Compare commits

..
Author SHA1 Message Date
ajnart dd0b0af8f8 fix(beszel): preserve widget description key 2026-08-27 09:25:48 +02:00
ajnart 7e13ca54fb fix(widgets): align tall compact stats 2026-08-27 09:18:27 +02:00
ajnart e161c8c512 fix(widgets): increase short stat density 2026-08-27 09:18:27 +02:00
ajnart a7a6cb6e07 fix(widgets): support compact stat sizes 2026-08-27 09:18:27 +02:00
ajnart bba20e4561 fix(widgets): address review feedback 2026-08-27 09:18:27 +02:00
ajnart 1030a21f4e chore: remove regression demo seed 2026-08-27 09:18:27 +02:00
ajnart f07b5c1f91 fix(widgets): preserve compact scaling 2026-08-27 09:18:27 +02:00
ajnart 8f619be0d7 fix(network-status): fit compact layouts 2026-08-27 09:18:11 +02:00
ajnart 771db6d752 fix(paperless): align stat scaling 2026-08-27 09:18:10 +02:00
ajnart 3edf874ca3 fix(widgets): restore compact v1 surfaces 2026-08-27 09:18:10 +02:00
Bergasha 1b2f1bd720 Merge pull request #6714 from homarr-labs/fix/v2-container-scale-fix
fix(V2 container): widgets clipping and label overlap
2026-08-27 14:39:16 +10:00
Bergasha c82965067c Floor containerContentScale above zero
logicalWidth/viewportHeight can go non-positive for a narrow container
on a heavily zoomed-out board, where outerCardInset (which grows as
canvasScale shrinks) can exceed the container's own un-inset size.
That non-positive scale then feeds into a division (Infinity/NaN into
--board-canvas-ui-scale) and is used as zoom directly (invalid or
collapsed content) - flooring it well above zero avoids both.
2026-08-27 14:17:17 +10:00
Bergasha 8abc9cae6c Fix unused logicalHeight variable left over from CodeRabbit fix
Removing it in favor of viewportHeight for the vertical fit ratio left
the declaration itself dead code, which the CI lint step flags as an
error rather than a warning.
2026-08-27 13:56:45 +10:00
Bergasha becc6a71a9 Address CodeRabbit review on container scale fix PR
- Guard canvasScale before dividing by it in outerCardInset, matching
  calculateBoardUiScale's own fallback.
- Fix containerContentScale's vertical fit ratio for scrollable
  containers: it was derived from the full (all-rows) content height,
  which drifts toward 1 as more rows are added regardless of the fixed
  inset, under-scaling relative to what the visible viewport actually
  needs and clipping the bottom of the visible rows. Use
  viewportHeight/viewportRowCount instead, which describe the real
  visible card size in both the scrollable and non-scrollable case.
2026-08-27 13:46:34 +10:00
Bergasha 78a4f0e513 Fix container widgets clipping and label overlap
A container's own visible card is inset from its allocated board cell
by the board's standard per-item gap, but items inside it are
positioned in a coordinate space sized to the un-inset column/row
count - so without correcting for that, items (not just the grid's
own box) spilled past the container's right/bottom edge by that
inset amount, worse the busier/more zoomed-out the board is.

Scales the container's inner grid content down to fit the actual card
size, using the same zoom mechanism the board's own top-level canvas
uses for its whole-board zoom, just one level deeper. Corrects
--board-canvas-ui-scale to compensate so icon/text/custom-CSS sizing
inside the container isn't affected - computed as a plain JS number
rather than a CSS calc() referencing the property's own inherited
value, since custom properties don't have a sequential/temporal order
within the same element and doing so is a circular reference that
silently invalidates the whole property for every descendant.

Also fixes the collapsible container's label bar poking above the
card's rounded top border via a negative offset - it now sits flush
inside it.
2026-08-27 13:34:57 +10:00
Bergasha 70e1b29913 Merge pull request #6713 from homarr-labs/fix/v2-system-resource-widget
fix(v2 system resources widget): overflow and initial chart render
2026-08-27 11:09:18 +10:00
Bergasha 73de83b48f Address CodeRabbit review on system resources widget PR
- Render the chart tooltip via a portal so it isn't clipped by the
  card's overflow:hidden (needed to stop the chart line itself
  bleeding past its rounded corners).
- Move chartColumns' width breakpoints into a lookup table and
  memoize the computed value, instead of an inline ternary.
2026-08-27 10:46:04 +10:00
Bergasha 26d12d9fad Fix system resources widget overflow and initial chart flicker
The chart card and its outer widget shell both had overflow:visible,
letting chart lines bleed past their rounded borders into neighboring
widgets. Compact-mode charts also stretched to fill the widget's full
resized height instead of staying compact like other widgets.

Additionally, the history used to build the chart needs 2+ points to
render a line - it started empty and only got its first real point on
the next poll (up to 30s later), so the chart would flash between a
single-value fallback and the actual graph. Both the initial render
and the first state update now seed with the current value duplicated,
so a flat line renders immediately with no flicker.
2026-08-27 09:07:05 +10:00
Thomas f0eddda573 fix: preserve assistant CSS and daily quota history (#6705)
* fix: route custom css through review form

* feat(workshop): retain daily assistant quotas
2026-08-26 14:48:08 +02:00
Thomas b2c72d54c2 fix: refine configurable header defaults (#6707) 2026-08-26 14:29:51 +02:00
117 changed files with 1777 additions and 1319 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. Compact tiles retain the familiar title, optional description, and centered app icon proportions.
It also supports a status check to indicate whether Homarr can reach the application or website.
### Screenshots
@@ -23,6 +23,8 @@ This widget adapts its display based on the linked integration:
Only options relevant to your linked integration are shown in the configuration panel.
Compact mode keeps every configured statistic visible in the familiar filled tile layout, including a vertical stack for narrow Navidrome cards. Advanced mode retains the complete expanded statistics and stream details.
### Supported Integrations
<WidgetIntegrations
@@ -17,7 +17,7 @@ import beszelAlerts from "./img/beszel-alerts.png";
<WidgetHeader widget={beszelAlertsWidget} categories={["System Monitoring", "Alerts"]} />
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions.
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Compact mode keeps configured OK alerts and descriptions visible alongside triggered alerts, and keeps history visible when the history option is enabled. Alerts are updated in real time via WebSocket subscriptions.
### Screenshots
+1 -1
View File
@@ -15,7 +15,7 @@ import { coolifyIntegration } from "@site/docs/integrations/coolify";
<WidgetHeader widget={coolifyWidget} categories={["System monitoring", "Deployment"]} />
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version.
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. Compact resource badges and footer details remain visible without height-based hiding.
### Supported Integrations
@@ -22,7 +22,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget&apos;s width and height. Bulk actions keep successful results visible and report the number of failures.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, with its familiar textual status control retained at compact heights. Bulk actions keep successful results visible and report the number of failures.
### Screenshots
@@ -12,7 +12,7 @@ import { dockerContainersWidget } from ".";
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
This widget displays the resource usage of your Docker containers and allows you to manage them.
This widget displays the resource usage of your Docker containers and allows you to manage them. Compact mode keeps every configured table column and shows the complete CPU and memory totals whenever the footer is available.
:::info
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
+5 -6
View File
@@ -58,7 +58,7 @@ import filterPanelImg from "./img/filter-panel.png";
### Features
- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column
- **Responsive compact table** — in compact mode, selected columns use the established width breakpoints; widget height does not remove configured columns or the footer
- **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration
@@ -72,11 +72,10 @@ import filterPanelImg from "./img/filter-panel.png";
:::note
Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state
columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also
hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions,
keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available
from the footer.
Compact layouts use their established width breakpoints: below 500px wide, speed columns are hidden; below 300px wide,
the ETA and state columns are hidden too. Height does not remove configured columns or the footer. Advanced view bypasses
the compact width reductions, keeps the selected column set, and always includes the stats bar and footer controls, with
the filter panel available from the footer.
The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and
state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the
@@ -16,6 +16,8 @@ import { opnsenseIntegration } from "../../integrations/opnsense";
<WidgetHeader widget={firewallWidget} categories={["Firewall"]} />
Compact mode keeps the configured firewall summary and interface section visible without dropping interfaces based on widget height.
### Screenshots
![Firewall Widget](img/firewall_widget.png)
@@ -15,6 +15,8 @@ import { immichServerStatsWidget } from ".";
<WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} />
Compact mode presents every enabled statistic as a vertical icon, label, and value card.
### Screenshots
import screen from "./img/screen.png";
@@ -14,6 +14,8 @@ import { indexerManagerWidget } from ".";
<WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} />
Compact mode keeps the titled indexer list, per-indexer status indicators, and full-width **Test all** action together.
### Screenshots
import bigItem from "./img/big.png";
@@ -17,7 +17,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaReleasesWidget} categories={["Releases", "Media"]} />
Provides a list of newly added media or upcoming releases from your media server.
Provides a list of newly added media or upcoming releases from your media server. Compact rows retain their poster, full release date and time, and configured metadata.
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Compact request cards retain their full artwork, request metadata, title, status, and permitted actions.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated.
Provides an overview of media requests, including totals, availability, approvals, and rejections. Compact mode keeps every configured stat and section title visible; the user ranking remains scrollable instead of being truncated.
### Supported Integrations
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. Compact mode keeps the user, currently playing, and status columns together with the stream summary and integration names.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
@@ -17,7 +17,7 @@ import { minecraftServerStatusWidget } from ".";
<WidgetHeader widget={minecraftServerStatusWidget} categories={["Gaming", "Minecraft"]} />
Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us).
Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, but only public servers accessible through the [mcsrvstat.us API](https://api.mcsrvstat.us) are supported.
### Screenshots
@@ -20,7 +20,7 @@ import { networkControllerStatusWidget } from ".";
Shows connected users and connected guest devices on wired or wireless networks.
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
Compact view aggregates the configured controllers into one familiar card for the selected Wi-Fi or wired total. A failed refresh is shown as a small warning instead of replacing the totals card. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
### Screenshots
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view.
Shows whether your WAN, internet, Wi-Fi, and VPN are available. The compact board view keeps the familiar status list, including internet latency and the connected VPN-user count.
### Screenshots
@@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png";
]}
/>
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users.
<WidgetInteractionGuide />
@@ -20,7 +20,7 @@ import { gotifyIntegration } from "@site/docs/integrations/gotify";
<WidgetHeader widget={notificationsWidget} categories={["Notifications", "Messaging"]} />
The notifications widget allows you to display notifications from various integrations in a unified view.
The notifications widget allows you to display notifications from various integrations in a unified view. Compact mode presents them as a vertical list of padded notification cards.
### Screenshots
@@ -15,7 +15,7 @@ import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx";
<WidgetHeader widget={paperlessNgxWidget} categories={["Documents"]} />
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types.
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. Compact mode keeps every configured statistic in the familiar filled tile layout and uses a vertical stack on narrow cards.
### Supported Integrations
+2 -2
View File
@@ -13,7 +13,7 @@ import { releasesWidget } from ".";
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} />
The releases widget enables you to track a list of repositories and see their respective latest release.
The releases widget enables you to track a list of repositories and see their respective latest release. Compact mode keeps repositories in the familiar full-width stack.
It also supports importing releases from your local docker containers.
### Screenshots
@@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
Advanced view shows every configured repository in a roomier responsive layout. Repository names and details remain visible, and selecting a repository still expands its description and links.
<WidgetInteractionGuide />
+1 -1
View File
@@ -13,7 +13,7 @@ import { rssFeedWidget } from ".";
<WidgetHeader widget={rssFeedWidget} categories={["Feeds"]} />
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. Compact mode uses a vertical card list and keeps enclosure images as the familiar poster and blurred backdrop.
### Screenshots
@@ -18,7 +18,7 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
<WidgetHeader widget={smartHomeEntityStateWidget} categories={["Smart home"]} />
The entity state enables you to display the current state of an entity.
The entity state enables you to display the current state of an entity. Compact mode presents the friendly name first and its state beneath it.
This works with most entities and enables you display almost any data.
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
@@ -19,7 +19,7 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
This widget displays filesystem and SMART disk usage for supported server and NAS integrations. Compact mode uses a one-column card list with the configured primary value, available temperature, and solid health bar.
### Supported Integrations
@@ -18,7 +18,7 @@ import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
Displays CPU, RAM and network of your host in real time
Displays CPU, RAM and network of your host in real time. Compact view keeps every configured chart in its familiar stacked layout, while advanced view adds the expanded per-source presentation.
### Screenshots
+1 -1
View File
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
The Timetable widget provides real-time departure times for a selected station.
The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible while adapting dense rows to the available space.
### Screenshots
+1 -1
View File
@@ -15,7 +15,7 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
<WidgetHeader widget={tracearrWidget} categories={["Media Monitoring"]} />
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations. Compact mode renders every enabled section in that familiar order without hiding or truncating sections based on widget height.
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
+1 -1
View File
@@ -17,7 +17,7 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps every configured protocol card and entry-point label visible and names each healthy source when multiple integrations are selected.
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
+1 -1
View File
@@ -15,7 +15,7 @@ import { umamiIntegration } from "@site/docs/integrations/umami";
<WidgetHeader widget={umamiWidget} categories={["Analytics"]} />
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists.
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. Compact mode keeps configured axes and secondary statistics visible regardless of widget height.
### Supported Integrations
+1 -1
View File
@@ -15,7 +15,7 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
<WidgetHeader widget={uptimeKumaWidget} categories={["Monitoring"]} />
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. Compact mode shows every configured ring and statistic without dropping them based on widget height.
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
+4 -2
View File
@@ -38,8 +38,10 @@ its server; changing the browser clock cannot bypass them.
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day
has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content.
OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to
train models.
We would rather become millionaires in a more ethical manner.
+3
View File
@@ -72,6 +72,9 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
limit as the final monetary backstop because request quotas are not dollar accounting.
PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private
collection for a daily usage breakdown; regular users cannot list or view its records.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
@@ -89,8 +89,9 @@ const repairIconSearchInput = <T extends AssistantToolCallInput>(toolCall: T): T
};
};
const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
if (!toolCall.toolName.startsWith("customWidget_")) return null;
const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
const isCustomWidgetTool = toolCall.toolName.startsWith("customWidget_");
if (!isCustomWidgetTool && toolCall.toolName !== "configure_board_settings") return null;
if (customWidgetNoInputToolNames.has(toolCall.toolName)) {
try {
const input = JSON.parse(toolCall.input) as unknown;
@@ -114,4 +115,4 @@ const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall:
};
export const repairAssistantToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null =>
repairIconSearchInput(toolCall) ?? repairCustomWidgetToolInput(toolCall);
repairIconSearchInput(toolCall) ?? repairMultilineToolInput(toolCall);
@@ -151,12 +151,12 @@ Homarr concepts:
Action rules:
- Prefer read-only tools before actions.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never use ask_user to confirm a board settings or custom CSS change before configure_board_settings, or to confirm another change whose inputs are already sufficient for its native review form or mutating tool. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears.
- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call.
- Do not retry a denied action.
- Before creating an app, call configure_app with the app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. If the user asked to place it on a board, call configure_widget afterward with kind "app" and options { appId: the returned appId }.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then immediately call configure_board_settings with only the requested proposed changes. For a custom CSS request, include the complete resulting stylesheet in changes.customCss so the native form is prefilled and previews the proposed CSS. The native form is the user's review and confirmation step, so never insert ask_user between these calls when the requested change is known. Use the form's returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before adding any widget to a board, call configure_widget with the target board, widget kind, proposed options, and any integration IDs selected from integration_all. Homarr's native widget editor applies defaults, validates the options, and restricts the picker to compatible integrations the user can access. Use the returned boardId, kind, options, and integrationIds exactly as the input to board_addItem. If configure_widget returns cancelled, do not add the widget.
- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols.
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
@@ -54,7 +54,7 @@ export const assistantBoardSettingsChangesSchema = z
export const browserToolContracts = {
ask_user: {
description:
"Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
"Pause and ask the user one concise structured question. Use this only for missing information or a meaningful choice, never to confirm details that are already sufficient for a native review form or mutating tool. In particular, do not use ask_user before configure_app, configure_board_settings, or configure_widget when their inputs are known. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
parameters: z.object({
question: z.string().trim().min(1).max(240),
description: z.string().trim().max(400).optional(),
@@ -82,7 +82,7 @@ export const browserToolContracts = {
},
configure_board_settings: {
description:
"Open Homarr's native board settings and custom CSS review form. Always call board_getBoardSettings first, then pass only the requested proposed changes here. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings; do not ask for confirmation in prose.",
"Open Homarr's native board settings and custom CSS review form. This form is the user's confirmation step. Always call board_getBoardSettings first, then call this tool directly with only the requested proposed changes, including the complete resulting stylesheet in changes.customCss for a CSS request. Do not call ask_user between those tools when the requested changes are known. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
@@ -334,6 +334,7 @@ const LoadedBoardItemContent = ({
item={item}
width={width}
height={height}
displayScale={boardCanvasScale}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"}
@@ -436,6 +437,7 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
displayScale: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
displayMode: "compact" | "advanced";
@@ -30,11 +30,15 @@
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
@@ -67,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
top={0}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
@@ -18,7 +18,7 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -138,9 +138,56 @@ export const SectionGrid = ({
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
const logicalWidth = getLogicalGridSize(columnCount);
const logicalHeight = getLogicalGridSize(rowCount);
const viewportHeight = getLogicalGridSize(viewportRowCount);
// A container's own visible card is inset from its allocated board cell by the board's
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
// any widget, so it gets that same gap). A widget's content just fills whatever size its
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
// the same column/row counts used to allocate the *outer*, uninset cell - so without
// subtracting that gap back out here, a container's inner grid renders larger than its own
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size.
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
// corrected to compensate so icon/text sizing inside the container isn't affected.
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
// describe the real visible card size in both cases (they equal the non-scrollable values when
// rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
// be left with a small amount of unfilled slack when width/height need different ratios to
// exactly fit - centered below so it's even on both sides rather than left-aligned.
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
// zero or negative - combinedUiScale divides by this value, and this value is also used as
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
: 1;
// Compute the combined value in JS rather than a CSS calc() referencing the existing
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
// sequential/temporal order the way normal variables do, so any calc() on this element that
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -249,7 +296,15 @@ export const SectionGrid = ({
>
<Box
className={classes.staticGrid}
style={{ width: logicalWidth, height: logicalHeight }}
style={
{
width: fullGridWidth,
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
} as CSSProperties
}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
+7 -5
View File
@@ -605,7 +605,12 @@ func (provider *homarrProvider) loadQuota(
user *core.Record,
now time.Time,
) (*core.Record, quotaSnapshot, bool, error) {
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
day := now.Format(time.DateOnly)
quota, err := app.FindFirstRecordByFilter(
"assistant_quotas",
"user = {:user} && day = {:day}",
dbx.Params{"user": user.Id, "day": day},
)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, quotaSnapshot{}, false, err
}
@@ -617,10 +622,7 @@ func (provider *homarrProvider) loadQuota(
}
quota = core.NewRecord(collection)
quota.Set("user", user.Id)
changed = true
}
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
quota.Set("day", day)
quota.Set("used", 0)
changed = true
}
@@ -0,0 +1,36 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user");
quotas.addIndex("idx_assistant_quotas_user_day", true, "user, day", "");
app.save(quotas);
},
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user_day");
// The previous schema can keep only one record per user. Retain the latest UTC day when rolling back.
app
.db()
.newQuery(`
DELETE FROM assistant_quotas
WHERE id NOT IN (
SELECT (
SELECT candidate.id
FROM assistant_quotas candidate
WHERE candidate.user = quota_user.user
ORDER BY candidate.day DESC, candidate.created DESC, candidate.id DESC
LIMIT 1
)
FROM assistant_quotas quota_user
GROUP BY quota_user.user
)
`)
.execute();
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
app.save(quotas);
},
);
@@ -82,6 +82,14 @@ for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activit
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
for (const required of [
'removeIndex("idx_assistant_quotas_user")',
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
]) {
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/model"',
+14 -2
View File
@@ -436,9 +436,21 @@ const resetUsage = await request("/api/ai/usage", { headers: authorSession.heade
if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) {
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
}
const resetQuota = (
const today = new Date().toISOString().slice(0, 10);
const resetQuotas = (
await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders })
).items.find((item) => item.user === author.id);
).items.filter((item) => item.user === author.id);
const historicalQuota = resetQuotas.find((item) => item.id === authorQuota.id);
const resetQuota = resetQuotas.find((item) => item.day === today);
if (
resetQuotas.length !== 2 ||
historicalQuota?.day !== "2000-01-01" ||
historicalQuota.used !== 49 ||
!resetQuota ||
resetQuota.used !== 0
) {
throw new Error(`Daily quota history was not preserved: ${JSON.stringify(resetQuotas)}`);
}
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
+7 -1
View File
@@ -3179,7 +3179,8 @@
"disabled": "Disabled"
},
"error": {
"internalServerError": "Failed to fetch indexers status"
"internalServerError": "Failed to fetch indexers status",
"testAll": "The indexer test failed. Try again."
}
},
"healthMonitoring": {
@@ -4249,6 +4250,7 @@
"failed": "Failed",
"completed": "Completed"
},
"unknown": "Unknown",
"toBeDetermined": "TBD"
},
"mediaRequests-requestStats": {
@@ -4961,6 +4963,10 @@
"empty": "No alerts configured",
"history": "History",
"resolved": "Resolved",
"alertDescription": {
"statusDuration": "Down for {minutes} min",
"threshold": "Exceeds {value} over {minutes} min"
},
"status": {
"triggered": "Triggered",
"ok": "OK"
+6 -6
View File
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -59,19 +59,19 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
<Text
className="app-title"
fw={700}
size={isTiny ? "xs" : "sm"}
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && !isTiny && (
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size="sm"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
lineClamp={4}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
@@ -91,7 +91,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
maxWidth: isColumnLayout ? undefined : "50%",
}}
/>
</Flex>
@@ -7,16 +7,10 @@ import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homar
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getCompactStatLayout } from "../common/compact-stat-layout";
import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
import {
AUDIO_STATS_TRANSLATION_SCOPE,
getGridCols,
getIconSize,
prioritizeVisibleStats,
getVisibleStatLimit,
getVisibleStats,
} from "./shared";
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
const rootClassByCompact = {
true: classes.rootCompact,
@@ -33,11 +27,49 @@ const statTileClassByCompact = {
false: "",
} as const;
const statTileClassByAdvanced = {
true: classes.statTileAdvanced,
false: "",
} as const;
const rootClassByLayout = {
default: "",
short: classes.rootShort,
narrowShort: classes.rootShort,
} as const;
const gridClassByLayout = {
default: "",
short: classes.gridShort,
narrowShort: classes.gridShort,
} as const;
const statTileClassByLayout = {
default: "",
short: classes.statTileShort,
narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`,
} as const;
const statLabelClassByLayout = {
default: "",
short: classes.statLabelShort,
narrowShort: classes.statLabelShort,
} as const;
const statValueClassByLayout = {
default: "",
short: "",
narrowShort: classes.statValueNarrowShort,
} as const;
const COMPACT_SINGLE_COLUMN_BREAKPOINT = 220;
interface AudioStatsContentProps {
backend: AudioStatsBackend;
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
options: AudioStatsDisplayOptions;
showAllStats?: boolean;
advanced?: boolean;
width: number;
height: number;
}
@@ -47,29 +79,45 @@ export function AudioStatsContent({
stats,
options,
showAllStats = false,
advanced = false,
width,
height,
}: AudioStatsContentProps) {
const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const compact = options.compactMode ?? false;
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact);
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
const visibleStats = getVisibleStats(backend, options, stats, showAllStats);
const compactKey = String(compact) as keyof typeof rootClassByCompact;
const gridCols = getGridCols(width, visibleStats.length, compact);
const iconSize = getIconSize(width, compact);
const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced;
let defaultGridCols = getGridCols(width, visibleStats.length, compact);
if (!advanced && width < COMPACT_SINGLE_COLUMN_BREAKPOINT) {
defaultGridCols = 1;
}
const layout = getCompactStatLayout({
width,
height,
visibleCount: visibleStats.length,
compactDisplay: !advanced,
defaultColumns: defaultGridCols,
defaultIconSize: getIconSize(width, compact),
});
return (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
<div className={`${classes.root} ${rootClassByCompact[compactKey]} ${rootClassByLayout[layout.state]}`}>
{visibleStats.length > 0 && (
<div
className={`${classes.grid} ${gridClassByCompact[compactKey]}`}
style={{ "--stat-cols": gridCols } as CSSProperties}
className={`${classes.grid} ${gridClassByCompact[compactKey]} ${gridClassByLayout[layout.state]}`}
style={{ "--stat-cols": layout.columns } as CSSProperties}
>
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
<span className={classes.statValue}>{value}</span>
<span className={classes.statLabel}>{t(statKey as never)}</span>
<div
key={optionKey}
className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]} ${statTileClassByLayout[layout.state]}`}
>
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>{value}</span>
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>
{t(statKey as never)}
</span>
</div>
))}
</div>
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared";
import { getGridCols, getIconSize, getVisibleStats } from "./shared";
describe("getGridCols", () => {
test("caps columns to visible stat count", () => {
@@ -37,17 +37,6 @@ describe("getIconSize", () => {
});
});
describe("getVisibleStatLimit", () => {
test("keeps compact metrics legible when height is constrained", () => {
expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1);
expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3);
});
test("preserves every configured metric outside compact mode", () => {
expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5);
});
});
describe("getVisibleStats", () => {
const navidromeStats: NavidromeDashboardData = {
artistCount: 100,
@@ -117,21 +106,3 @@ describe("getVisibleStats", () => {
expect(typeof result[0]?.value).toBe("string");
});
});
describe("prioritizeVisibleStats", () => {
test("puts an active session first only on compact surfaces", () => {
const stats = getVisibleStats(
"audiobookshelf",
{ showLibraryCount: true, showActiveSessions: true },
{
libraryCount: 3,
totalAudiobooks: 150,
totalPodcasts: 20,
totalListeningTimeSeconds: 86400,
activeSessions: 2,
},
);
expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions");
expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount");
});
});
@@ -14,6 +14,11 @@
gap: clamp(2px, 1cqi, 4px);
}
.rootShort {
padding: 4px;
gap: 2px;
}
.grid {
display: grid;
flex: 1;
@@ -29,6 +34,10 @@
gap: clamp(2px, 1cqi, 4px);
}
.gridShort {
gap: 2px;
}
.statTile {
display: flex;
flex-direction: column;
@@ -40,16 +49,31 @@
gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md);
background-color: transparent;
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
.statTileAdvanced {
background-color: transparent;
}
.statTileCompact {
gap: clamp(1px, 0.8cqi, 2px);
padding: clamp(2px, 1.2cqi, 5px);
border-radius: var(--mantine-radius-sm);
}
.statTileShort {
gap: 2px;
padding: 3px;
}
.statTileNarrowShort {
flex-direction: row;
gap: 2px;
padding: 1px 3px;
}
.statIcon {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
@@ -57,7 +81,12 @@
}
.statLabel {
font-size: clamp(8px, 2.5cqi, 11px);
flex-shrink: 0;
font-size: clamp(
calc(8px * var(--board-canvas-ui-scale, 1)),
calc(2.5cqi * var(--board-canvas-ui-scale, 1)),
calc(11px * var(--board-canvas-ui-scale, 1))
);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -68,11 +97,32 @@
}
.statTileCompact .statLabel {
font-size: clamp(7px, 2cqi, 9px);
font-size: clamp(
calc(7px * var(--board-canvas-ui-scale, 1)),
calc(2cqi * var(--board-canvas-ui-scale, 1)),
calc(9px * var(--board-canvas-ui-scale, 1))
);
}
.statLabelShort {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.statValue {
font-size: clamp(13px, 5cqi, 20px);
flex-shrink: 0;
font-size: clamp(
calc(13px * var(--board-canvas-ui-scale, 1)),
calc(5cqi * var(--board-canvas-ui-scale, 1)),
calc(20px * var(--board-canvas-ui-scale, 1))
);
font-weight: 700;
color: var(--mantine-primary-color-filled);
line-height: 1.1;
@@ -84,7 +134,16 @@
}
.statTileCompact .statValue {
font-size: clamp(11px, 4cqi, 16px);
font-size: clamp(
calc(11px * var(--board-canvas-ui-scale, 1)),
calc(4cqi * var(--board-canvas-ui-scale, 1)),
calc(16px * var(--board-canvas-ui-scale, 1))
);
}
.statValueNarrowShort {
font-size: calc(11px * var(--board-canvas-ui-scale, 1));
line-height: 1;
}
.emptyState {
+12 -3
View File
@@ -18,6 +18,7 @@ export default function AudioStatsWidget({
options,
width,
height,
displayScale = 1,
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
@@ -39,14 +40,22 @@ export default function AudioStatsWidget({
const currentStreams = streamResults ?? [];
if (!response) return <WidgetEmptyState />;
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const summary = (
<AudioStatsContent
backend={response.kind}
stats={response.data}
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
options={{ ...options, compactMode: displayMode === "compact" && options.compactMode }}
showAllStats={displayMode === "advanced"}
width={width}
height={height}
advanced={displayMode === "advanced"}
width={responsiveWidth}
height={responsiveHeight}
/>
);
if (displayMode === "compact") {
@@ -156,23 +156,6 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea
return Math.min(maxCols, Math.max(visibleCount, 1));
};
export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => {
if (!compact) return visibleCount;
const columns = getGridCols(width, visibleCount, true);
const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44));
return Math.min(visibleCount, columns * rows);
};
export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => {
if (!compact) return stats;
return stats.toSorted(
(left, right) =>
Number(right.statKey === "activeSessions" && Number(right.value) > 0) -
Number(left.statKey === "activeSessions" && Number(left.value) > 0),
);
};
const fallbackIconSize = { true: 14, false: 16 } as const;
export const getIconSize = (width: number, compact: boolean): number => {
@@ -1,20 +1,7 @@
"use client";
import { useMemo } from "react";
import {
Badge,
Box,
Center,
Divider,
Group,
Loader,
ScrollArea,
Stack,
Text,
ThemeIcon,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import dayjs from "dayjs";
@@ -53,7 +40,6 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
height,
displayMode,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelAlerts">) {
@@ -109,9 +95,7 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260;
const showHistory = isAdvanced || (options.showHistory && height >= 360);
const showAlertDescriptions = isAdvanced || height >= 190;
const showHistory = isAdvanced || options.showHistory;
if (isPending) {
return (
@@ -145,7 +129,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}>
<Group gap={6}>
<IconFlame size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} c="red">
<Text size="xs" fw={600} c="red" tt={isAdvanced ? undefined : "uppercase"}>
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
</Group>
@@ -158,19 +142,19 @@ export default function BeszelAlertsWidget({
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered
showDescription={showAlertDescriptions}
isAdvanced={isAdvanced}
/>
))}
</Stack>
)}
{triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && <Divider />}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
{showOkAlerts && okAlerts.length > 0 && (
{okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} c="dimmed">
<Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}>
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
@@ -183,7 +167,7 @@ export default function BeszelAlertsWidget({
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered={false}
showDescription={showAlertDescriptions}
isAdvanced={isAdvanced}
/>
))}
</Stack>
@@ -195,7 +179,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}>
<Group gap={6}>
<IconHistory size="var(--mantine-font-size-sm)" opacity={0.5} />
<Text size="xs" fw={600} c="dimmed">
<Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}>
{t("history")}
</Text>
</Group>
@@ -250,7 +234,7 @@ interface AlertRowProps {
systemName: string;
integrationName?: string;
triggered: boolean;
showDescription: boolean;
isAdvanced: boolean;
}
const unitSuffixMap: Record<string, string> = {
@@ -268,49 +252,46 @@ const unitSuffixMap: Record<string, string> = {
Status: "",
};
function formatAlertDescription(name: string, value: number, min: number): string {
const suffix = unitSuffixMap[name] ?? "";
if (name === "Status") return `down for ${min} min`;
return `exceeds ${value}${suffix} over ${min} min`;
}
function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) {
function AlertRow({ name, value, min, systemName, integrationName, triggered, isAdvanced }: AlertRowProps) {
const t = useI18n("widget.beszelAlerts");
const Icon = alertIconMap[name] ?? Server;
const description = formatAlertDescription(name, value, min);
const suffix = unitSuffixMap[name] ?? "";
let description = t("alertDescription.threshold", { value: `${value}${suffix}`, minutes: min });
if (name === "Status") {
description = t("alertDescription.statusDuration", { minutes: min });
}
const accentColor = triggered ? "red" : "green";
const borderColor = triggered ? "red" : "gray";
const rowStyle = isAdvanced
? {
borderRadius: "var(--mantine-radius-sm)",
border: `1px solid var(--mantine-color-${borderColor}-4)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}
: {
borderRadius: "var(--mantine-radius-sm)",
borderLeft: `3px solid var(--mantine-color-${accentColor}-6)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
};
return (
<Tooltip label={description} disabled={showDescription} withArrow>
<Group
wrap="nowrap"
gap="xs"
py={showDescription ? 4 : 2}
px={8}
style={{
borderRadius: "var(--mantine-radius-sm)",
border: `1px solid var(--mantine-color-${triggered ? "red" : "gray"}-4)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{integrationName ? `${systemName} · ${integrationName}` : systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
{showDescription && (
<Text size="xs" c="dimmed" truncate>
{description}
</Text>
)}
{!showDescription && <VisuallyHidden>{description}</VisuallyHidden>}
</Stack>
</Group>
</Tooltip>
<Group wrap="nowrap" gap="xs" py={4} pr={8} pl={isAdvanced ? 8 : 12} style={rowStyle}>
<Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{integrationName ? `${systemName} · ${integrationName}` : systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
{description}
</Text>
</Stack>
</Group>
);
}
@@ -0,0 +1,74 @@
const SHORT_HEIGHT_BREAKPOINT = 120;
const NARROW_SHORT_WIDTH_BREAKPOINT = 120;
const NARROW_SHORT_ICON_SIZE = 12;
const SHORT_GRID_HORIZONTAL_INSET = 8;
const SHORT_GRID_GAP = 2;
const SHORT_ICON_WIDTH_RATIO = 0.44;
const SHORT_ICON_SIZE_CAP = 40;
export type CompactStatLayoutState = "default" | "short" | "narrowShort";
interface CompactStatLayoutInput {
width: number;
height: number;
visibleCount: number;
/** Compact board display mode, independent of widget-specific compact styling options. */
compactDisplay: boolean;
defaultColumns: number;
defaultIconSize: number;
}
interface CompactStatLayout {
state: CompactStatLayoutState;
columns: number;
iconSize: number;
}
export function getCompactStatLayout({
width,
height,
visibleCount,
compactDisplay,
defaultColumns,
defaultIconSize,
}: CompactStatLayoutInput): CompactStatLayout {
if (!compactDisplay) {
return {
state: "default",
columns: defaultColumns,
iconSize: defaultIconSize,
};
}
if (width < NARROW_SHORT_WIDTH_BREAKPOINT) {
return {
state: "narrowShort",
columns: 1,
iconSize: NARROW_SHORT_ICON_SIZE,
};
}
if (height >= SHORT_HEIGHT_BREAKPOINT) {
return {
state: "default",
columns: defaultColumns,
iconSize: defaultIconSize,
};
}
return {
state: "short",
columns: Math.max(visibleCount, 1),
iconSize: getShortIconSize(width, visibleCount, defaultIconSize),
};
}
function getShortIconSize(width: number, visibleCount: number, defaultIconSize: number): number {
const statCount = Math.max(visibleCount, 1);
const totalGridGap = Math.max(statCount - 1, 0) * SHORT_GRID_GAP;
const availableWidth = Math.max(width - SHORT_GRID_HORIZONTAL_INSET - totalGridGap, 0);
const availableWidthPerStat = availableWidth / statCount;
const proportionalIconSize = Math.round(availableWidthPerStat * SHORT_ICON_WIDTH_RATIO);
const cappedIconSize = Math.min(proportionalIconSize, SHORT_ICON_SIZE_CAP);
return Math.max(defaultIconSize, cappedIconSize);
}
+2 -8
View File
@@ -17,7 +17,6 @@ export default function CoolifyWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useI18n("widget.coolify");
@@ -38,7 +37,6 @@ export default function CoolifyWidget({
integrationIds={integrationIds}
options={{ ...options, ...sectionVisibility }}
width={width}
height={height}
isAdvanced={isAdvanced}
/>
);
@@ -48,11 +46,10 @@ interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
height: number;
isAdvanced: boolean;
}
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
const t = useI18n("common");
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds,
@@ -64,8 +61,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
);
const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null);
const isTiny = width < 256 || height < 144;
const hideFooter = height < 112;
const isTiny = width < 256;
const [firstInstance] = successfulInstances;
const widgetKey = createWidgetKey(integrationIds);
@@ -100,7 +96,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/>
</div>
</Stack>
@@ -120,7 +115,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/>
))}
</SimpleGrid>
+7 -38
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
@@ -22,12 +22,10 @@ interface InstanceCardProps {
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey }: InstanceCardProps) {
const t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${cardKey}`,
@@ -53,32 +51,10 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
const runningServices = instance.instanceInfo.services.filter(
(s) => parseStatus(s.status ?? "") === "running",
).length;
const healthyResources =
(options.showServers ? onlineServers : 0) +
(options.showApplications ? runningApps : 0) +
(options.showServices ? runningServices : 0);
const totalResources =
(options.showServers ? instance.instanceInfo.servers.length : 0) +
(options.showApplications ? instance.instanceInfo.applications.length : 0) +
(options.showServices ? instance.instanceInfo.services.length : 0);
const resourceSummary = [
options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null,
options.showApplications
? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}`
: null,
options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null,
]
.filter(Boolean)
.join(" · ");
return (
<Card p={0} radius="sm" bg="transparent">
<Group
p="xs"
justify="space-between"
wrap="nowrap"
className={classes.neutralDividerBottom}
>
<Group p="xs" justify="space-between" wrap="nowrap" className={classes.neutralDividerBottom}>
<Group gap={4} wrap="nowrap" miw={0}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Stack gap={0} miw={0}>
@@ -102,19 +78,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
</Stack>
</Group>
<Group gap={4} wrap="nowrap">
{isTiny && totalResources > 0 ? (
<Tooltip label={resourceSummary}>
<Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs">
{healthyResources}/{totalResources}
</Badge>
</Tooltip>
) : null}
{!isTiny && options.showServers && (
{options.showServers && (
<Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs">
{onlineServers}/{instance.instanceInfo.servers.length}
</Badge>
)}
{!isTiny && options.showApplications && (
{options.showApplications && (
<Badge
variant="dot"
color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)}
@@ -123,7 +92,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
{runningApps}/{instance.instanceInfo.applications.length}
</Badge>
)}
{!isTiny && options.showServices && (
{options.showServices && (
<Badge
variant="dot"
color={getBadgeColor(runningServices, instance.instanceInfo.services.length)}
@@ -168,7 +137,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
)}
</Accordion>
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
</Card>
);
}
@@ -114,7 +114,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
<IconLink size="var(--mantine-font-size-xs)" />
</ActionIcon>
)}
{!isTiny && logsUrl && (
{logsUrl && (
<ActionIcon
className={actionTargetClasses.root}
component="a"
@@ -129,12 +129,10 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
<IconFileText size="var(--mantine-font-size-xs)" />
</ActionIcon>
)}
{!isTiny && (
<Text fz="10px" c="dimmed" lineClamp={1}>
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
)}
{!isTiny && resourceTimestamp && (
<Text fz="10px" c="dimmed" lineClamp={1}>
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
{resourceTimestamp && (
<Text fz="10px" c="dimmed" ml="auto">
{resourceTimestamp}
</Text>
@@ -22,17 +22,9 @@ interface SingleInstanceLayoutProps {
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function SingleInstanceLayout({
instance,
options,
isTiny,
isAdvanced,
widgetKey,
hideFooter,
}: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({ instance, options, isTiny, isAdvanced, widgetKey }: SingleInstanceLayoutProps) {
const t = useI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -115,7 +107,7 @@ export function SingleInstanceLayout({
)}
</Accordion>
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
</Stack>
</ScrollArea>
);
+1
View File
@@ -180,6 +180,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
displayScale?: number;
widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
removeItem?: () => void;
@@ -57,7 +57,7 @@ export default function DnsHoleControlsWidget({
integrationIds,
isEditMode,
width,
height,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
@@ -236,7 +236,7 @@ export default function DnsHoleControlsWidget({
t={t}
hasIconColor={board.iconColor !== null}
rootWidth={width}
rootHeight={height}
isAdvanced={displayMode === "advanced"}
actionsPending={actionsPending}
/>
))}
@@ -275,7 +275,7 @@ interface ControlsCardProps {
t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>;
hasIconColor: boolean;
rootWidth: number;
rootHeight: number;
isAdvanced: boolean;
actionsPending: boolean;
}
@@ -288,7 +288,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
t,
hasIconColor,
rootWidth,
rootHeight,
isAdvanced,
actionsPending,
}) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
@@ -299,7 +299,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md";
const layout = !isAdvanced && rootWidth < 256 ? "sm" : "md";
return (
<Indicator
+3 -29
View File
@@ -16,7 +16,6 @@ import {
Stack,
Text,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import {
IconBrandDocker,
@@ -111,33 +110,11 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
);
};
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
const t = useI18n("docker.field.state.option");
const label = t(state);
return (
<Tooltip label={label} withArrow>
<Box component="span" style={{ display: "inline-flex", flexShrink: 0 }}>
<Box
component="span"
aria-hidden="true"
w={8}
h={8}
bg={`${containerStateColorMap[state]}.6`}
style={{ borderRadius: "50%" }}
/>
<VisuallyHidden>{label}</VisuallyHidden>
</Box>
</Tooltip>
);
};
const createColumns = (
t: ReturnType<typeof useI18n<"docker">>,
tCommon: ReturnType<typeof useI18n<"common">>,
handlers: ContainerActionHandlers,
sortingEnabled: boolean,
inlineState: boolean,
): DataTableColumn<DockerContainer>[] => [
{
accessor: "name",
@@ -147,7 +124,6 @@ const createColumns = (
sortable: sortingEnabled,
render: (container) => (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar
variant="outline"
radius="sm"
@@ -387,14 +363,12 @@ export default function DockerWidget({
() => getDockerColumnVisibility(options.columns, width, isAdvanced),
[isAdvanced, options.columns, width],
);
const inlineState =
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
const columns = useMemo(() => {
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
return createColumns(t, tCommon, actionHandlers, sortingEnabled, inlineState).filter(
return createColumns(t, tCommon, actionHandlers, sortingEnabled).filter(
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
);
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t, tCommon]);
}, [actionHandlers, columnVisibility, isAdvanced, isEditMode, options.enableRowSorting, t, tCommon]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
@@ -435,7 +409,7 @@ export default function DockerWidget({
<Box style={{ flex: 1, minHeight: 0 }}>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding="2px 8px"
cellPadding={isAdvanced || width < 400 ? "2px 8px" : "4px 8px"}
className="docker-table"
rowCursor="default"
fetching={isFetching && containers.length === 0}
+23 -16
View File
@@ -5,27 +5,34 @@ import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
describe("getDockerColumnVisibility", () => {
test("keeps only essential columns in a narrow compact widget", () => {
test("keeps every configured column in a narrow compact widget", () => {
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
name: true,
state: false,
host: false,
cpuUsage: false,
memoryUsage: false,
state: true,
host: true,
cpuUsage: true,
memoryUsage: true,
actions: true,
});
});
test("reveals compact metrics as width becomes available", () => {
expect(getDockerColumnVisibility(columns, 440, false)).toMatchObject({
test("honors the configured compact column set at every width", () => {
expect(getDockerColumnVisibility(["name", "memoryUsage"], 200, false)).toEqual({
name: true,
state: false,
host: false,
cpuUsage: true,
memoryUsage: false,
cpuUsage: false,
memoryUsage: true,
actions: false,
});
expect(getDockerColumnVisibility(["name", "memoryUsage"], 800, false)).toEqual({
name: true,
state: false,
host: false,
cpuUsage: false,
memoryUsage: true,
actions: false,
});
});
test("keeps a configured state column when there is no name column for the inline dot", () => {
expect(getDockerColumnVisibility(["state"], 240, false).state).toBe(true);
});
test("uses every expert column in advanced mode", () => {
@@ -41,9 +48,9 @@ describe("getDockerColumnVisibility", () => {
});
describe("getDockerFooterVisibility", () => {
test("reveals totals only when they fit", () => {
expect(getDockerFooterVisibility(300, false)).toEqual({ footer: true, cpu: false, memory: false });
expect(getDockerFooterVisibility(400, false)).toEqual({ footer: true, cpu: true, memory: false });
test("shows the full compact footer above its threshold", () => {
expect(getDockerFooterVisibility(256, false)).toEqual({ footer: false, cpu: false, memory: false });
expect(getDockerFooterVisibility(257, false)).toEqual({ footer: true, cpu: true, memory: true });
});
test("keeps every total in advanced mode", () => {
+7 -7
View File
@@ -2,7 +2,7 @@ export type DockerColumn = "name" | "state" | "host" | "cpuUsage" | "memoryUsage
export const getDockerColumnVisibility = (
configuredColumns: readonly DockerColumn[],
width: number,
_width: number,
isAdvanced: boolean,
): Record<DockerColumn, boolean> => {
const configured = new Set(configuredColumns);
@@ -10,16 +10,16 @@ export const getDockerColumnVisibility = (
return {
name: isVisible("name"),
state: isVisible("state") && (isAdvanced || width >= 340 || !configured.has("name")),
host: isVisible("host") && (isAdvanced || width >= 640),
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 420),
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 520),
state: isVisible("state"),
host: isVisible("host"),
cpuUsage: isVisible("cpuUsage"),
memoryUsage: isVisible("memoryUsage"),
actions: isVisible("actions"),
};
};
export const getDockerFooterVisibility = (width: number, isAdvanced: boolean) => ({
footer: isAdvanced || width > 256,
cpu: isAdvanced || width >= 360,
memory: isAdvanced || width >= 480,
cpu: isAdvanced || width > 256,
memory: isAdvanced || width > 256,
});
+18 -22
View File
@@ -159,10 +159,8 @@ const speedColumnConfig = {
up: { Icon: IconUpload, color: "green" as const },
} as const;
export function getSizeConfig(width: number, height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
export function getSizeConfig(width: number, _height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
if (isAdvanced) return DEFAULT_SIZE_CONFIG;
if (height < 120) return SIZE_BREAKPOINTS[0]?.config ?? DEFAULT_SIZE_CONFIG;
if (height < 180) return SIZE_BREAKPOINTS[1]?.config ?? DEFAULT_SIZE_CONFIG;
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
if (width < maxWidth) return config;
}
@@ -796,25 +794,23 @@ export default function DownloadClientsWidget({
/>
</Box>
{(isAdvanced || height >= 96) && (
<WidgetFooter
key={`footer-${displayMode}`}
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clientFilter={clientFilter}
setClientFilter={setClientFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
availableStatuses={availableStatuses}
pauseQueue={mutatePauseQueue}
resumeQueue={mutateResumeQueue}
showStats={statsDisplay.visible}
toggleStats={statsDisplay.canToggle ? toggleStats : undefined}
/>
)}
<WidgetFooter
key={`footer-${displayMode}`}
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clientFilter={clientFilter}
setClientFilter={setClientFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
availableStatuses={availableStatuses}
pauseQueue={mutatePauseQueue}
resumeQueue={mutateResumeQueue}
showStats={statsDisplay.visible}
toggleStats={statsDisplay.canToggle ? toggleStats : undefined}
/>
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />}
</Stack>
+13 -19
View File
@@ -68,7 +68,6 @@ export default function FirewallWidget({
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && (width < 256 || height < 180);
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
const showInterfaces = isAdvanced || (!isTiny && height >= 120);
const t = useI18n("widget.firewall");
const tCommon = useI18n("common");
@@ -179,7 +178,6 @@ export default function FirewallWidget({
hasError={hasError}
isAdvanced={isAdvanced}
ringSize={ringSize}
showInterfaces={showInterfaces}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={t("error.internalServerError")}
@@ -210,7 +208,6 @@ interface FirewallPanelProps {
hasError: boolean;
isAdvanced: boolean;
ringSize: number;
showInterfaces: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
errorLabel: string;
@@ -234,7 +231,6 @@ const FirewallPanel = ({
hasError,
isAdvanced,
ringSize,
showInterfaces,
accordionValue,
setAccordionValue,
errorLabel,
@@ -283,21 +279,19 @@ const FirewallPanel = ({
)}
</Flex>
{showInterfaces && (
<InterfacesPanel
summary={interfaces ?? []}
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
errorBadgeLabel={errorBadgeLabel}
noDataLabel={noDataLabel}
loadingLabel={loadingLabel}
label={interfacesLabel}
/>
)}
<InterfacesPanel
summary={interfaces ?? []}
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
errorBadgeLabel={errorBadgeLabel}
noDataLabel={noDataLabel}
loadingLabel={loadingLabel}
label={interfacesLabel}
/>
</Stack>
</Paper>
);
@@ -14,9 +14,21 @@
background-color: transparent;
}
@media (forced-colors: active) {
.statItem,
.statItemDense {
border-color: CanvasText;
.statItemCompact {
padding: 12px;
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
transition: background-color 0.2s ease;
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
@media (forced-colors: active) {
.statItem,
.statItemDense,
.statItemCompact {
border: 0.0625rem solid CanvasText;
}
}
@@ -60,54 +60,57 @@ export default function ImmichServerStatsWidget({
{
key: "users",
enabled: statVisibility.showUsers,
icon: <IconUsers style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
icon: <IconUsers style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("users"),
value: stats.userCount.toLocaleString(locale),
},
{
key: "photos",
enabled: statVisibility.showPhotos,
icon: <IconPhoto style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
icon: <IconPhoto style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("photos"),
value: stats.photoCount.toLocaleString(locale),
},
{
key: "videos",
enabled: statVisibility.showVideos,
icon: <IconVideo style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
icon: <IconVideo style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("videos"),
value: stats.videoCount.toLocaleString(locale),
},
{
key: "storage",
enabled: statVisibility.showStorage,
icon: <IconDatabase style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
icon: <IconDatabase style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("storage"),
value: formatBytes(stats.totalLibraryUsageInBytes),
},
]
.filter((item) => item.enabled)
.map(({ key, icon, label, value }) => (
<StatItem key={key} icon={icon} label={label} value={value} dense={statsLayout.dense} />
<StatItem
key={key}
icon={icon}
label={label}
value={value}
dense={isAdvanced && statsLayout.dense}
compact={!isAdvanced}
/>
));
const statsContent =
isAdvanced || statsLayout.dense ? (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{statItems}
</SimpleGrid>
) : (
<Stack gap="xs">{statItems}</Stack>
);
if (!isAdvanced) {
return (
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
{statsContent}
<Stack gap="md" h="100%" p="md">
{statItems}
</Stack>
);
}
const statsContent = (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{statItems}
</SimpleGrid>
);
const sortedAlbums = albums;
const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
return (
@@ -150,9 +153,10 @@ interface StatItemProps {
label: string;
value: string | number;
dense: boolean;
compact: boolean;
}
function StatItem({ icon, label, value, dense }: StatItemProps) {
function StatItem({ icon, label, value, dense, compact }: StatItemProps) {
if (dense) {
return (
<Stack gap={1} align="center" justify="center" className={classes.statItemDense} title={`${label}: ${value}`}>
@@ -167,7 +171,7 @@ function StatItem({ icon, label, value, dense }: StatItemProps) {
}
return (
<Group justify="space-between" align="center" className={classes.statItem}>
<Group justify="space-between" align="center" className={compact ? classes.statItemCompact : classes.statItem}>
<Group gap="sm" align="center" wrap="nowrap" miw={0}>
{icon}
<Text size="sm" fw={500} truncate="end">
@@ -181,16 +185,13 @@ function StatItem({ icon, label, value, dense }: StatItemProps) {
);
}
export const getImmichStatsLayout = (width: number, height: number, itemCount: number, isAdvanced = false) => {
export const getImmichStatsLayout = (width: number, _height: number, itemCount: number, isAdvanced = false) => {
const count = Math.max(1, itemCount);
if (isAdvanced) {
return { columns: width >= 720 ? Math.min(count, 4) : width >= 360 ? Math.min(count, 2) : 1, dense: false };
}
const dense = width < 320 || height < 160;
if (height < 140) return { columns: Math.min(count, width >= 360 ? 4 : 2), dense: true };
if (height < 220) return { columns: Math.min(count, 2), dense: true };
return { columns: Math.min(count, width >= 280 ? 2 : 1), dense };
return { columns: 1, dense: false };
};
export const getImmichStatVisibility = (
@@ -3,14 +3,11 @@ import { describe, expect, test } from "vitest";
import { getImmichStatsLayout, getImmichStatVisibility } from "./component";
describe("Immich server statistics compact layout", () => {
test("keeps four enabled stats legible in a short widget", () => {
expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 2, dense: true });
expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 4, dense: true });
});
test("uses the roomier summary layout when space is available", () => {
expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 2, dense: false });
expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 2, dense: true });
test("keeps the v1 one-column non-dense layout at every compact size", () => {
expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 1, dense: false });
expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 1, dense: false });
expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 1, dense: false });
expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 1, dense: false });
});
test("advanced mode shows all four headline stats", () => {
@@ -1,4 +1,8 @@
.card {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
.advancedCard {
background-color: transparent;
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
}
@@ -5,6 +5,7 @@ import {
ActionIcon,
Anchor,
Badge,
Button,
Card,
Center,
Flex,
@@ -22,7 +23,9 @@ import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks";
@@ -60,6 +63,11 @@ export default function IndexerManagerWidget({
isPending,
error: testAllError,
} = clientApi.widget.indexerManager.testAllIndexers.useMutation({
onError: () =>
showErrorNotification({
title: tCommon("error"),
message: t("error.testAll"),
}),
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
});
const interactIntegrationIds = new Set(useIntegrationsWithInteractAccess().map(({ id }) => id));
@@ -67,8 +75,7 @@ export default function IndexerManagerWidget({
const board = useRequiredBoard();
const isAdvanced = displayMode === "advanced";
const hasSmallWidth = !isAdvanced && width < 256;
const isDense = !isAdvanced && (width < 280 || height < 180);
const showHealthCounts = isAdvanced || (width >= 200 && height >= 100);
const hasSmallHeight = !isAdvanced && height < 256;
const allIndexers = indexersData.flatMap((entry) => entry.indexers);
const hasSourceError = indexersData.some((entry) => Boolean(entry.error));
const unavailableCount = allIndexers.filter(
@@ -88,24 +95,32 @@ export default function IndexerManagerWidget({
className={`indexer-manager-container ${classes.root}`}
h="100%"
direction="column"
gap={isDense ? 4 : "xs"}
p={isDense ? "xs" : "sm"}
gap={isAdvanced ? "xs" : "sm"}
p="sm"
align={isAdvanced ? undefined : "center"}
>
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap" w="100%">
<Group
className="indexer-manager-title"
align="center"
gap="xs"
wrap="nowrap"
w={isAdvanced ? "100%" : undefined}
>
{testAllError && <VisuallyHidden role="alert">{tCommon("error")}</VisuallyHidden>}
<Tooltip label={t("title")} disabled={!hasSmallWidth}>
<IconReportSearch
className="indexer-manager-title-icon"
size={hasSmallWidth ? 16 : 20}
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
style={hasSmallWidth ? iconSizes.md : iconSizes.xl}
/>
</Tooltip>
{!hasSmallWidth && (
<Text size={isDense ? "xs" : "sm"} fw={600} truncate="end">
{t("title")}
</Text>
)}
{showHealthCounts && (
<Text
size={hasSmallWidth ? "xs" : isAdvanced ? "sm" : "md"}
fw={isAdvanced ? 600 : "bold"}
truncate={isAdvanced ? "end" : undefined}
>
{t("title")}
</Text>
{isAdvanced && (
<Group gap={4} wrap="nowrap">
<Badge size="xs" color="green" variant="light">
{allIndexers.length - unavailableCount}
@@ -117,29 +132,35 @@ export default function IndexerManagerWidget({
)}
</Group>
)}
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
<ActionIcon
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
size="sm"
radius={board.itemRadius}
variant="light"
color={testAllError ? "red" : undefined}
loading={isPending}
disabled={isEditMode || !canInteract}
loaderProps={{ type: "dots" }}
onClick={() => {
testAllIndexers();
}}
aria-label={t("testAll")}
>
<IconTestPipe size="var(--mantine-font-size-sm)" />
</ActionIcon>
</Tooltip>
{(isAdvanced || hasSmallHeight) && (
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
<ActionIcon
className={combineClasses(
"indexer-manager-test-action-icon",
isAdvanced && classes.testAction,
actionTargetClasses.root,
)}
size="sm"
radius={board.itemRadius}
variant="light"
color={testAllError ? "red" : undefined}
loading={isPending}
disabled={isEditMode || !canInteract}
loaderProps={{ type: "dots" }}
onClick={() => {
testAllIndexers();
}}
aria-label={t("testAll")}
>
<IconTestPipe style={isAdvanced ? iconSizes.sm : iconSizes.xs} />
</ActionIcon>
</Tooltip>
)}
</Group>
<Card
className={combineClasses("indexer-manager-list-container", classes.card)}
className={combineClasses("indexer-manager-list-container", classes.card, isAdvanced && classes.advancedCard)}
w="100%"
p={isDense ? 4 : "xs"}
p="xs"
radius={board.itemRadius}
flex={1}
>
@@ -153,7 +174,7 @@ export default function IndexerManagerWidget({
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
{(isAdvanced || indexersData.length > 1 || error) && (
{(isAdvanced || error) && (
<Group justify="space-between" wrap="nowrap" py={4}>
<Text size="xs" fw={600} truncate="end">
{integrationName}
@@ -171,14 +192,18 @@ export default function IndexerManagerWidget({
const presentation = statusPresentation[displayStatus];
return (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
className={combineClasses(
"indexer-manager-line",
`indexer-manager-${indexer.name}`,
isAdvanced && classes.indexerRow,
)}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Anchor
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
className={combineClasses("indexer-manager-line-anchor", isAdvanced && classes.indexerLink)}
component={href ? "a" : "span"}
href={href}
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
@@ -189,7 +214,7 @@ export default function IndexerManagerWidget({
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
truncate={isAdvanced ? "end" : undefined}
>
{indexer.name}
</Text>
@@ -201,14 +226,14 @@ export default function IndexerManagerWidget({
) : displayStatus === "healthy" ? (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="var(--mantine-color-green-6)"
size={hasSmallWidth ? 12 : 16}
color="#2ecc71"
style={hasSmallWidth ? iconSizes.xs : iconSizes.md}
/>
) : (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="var(--mantine-color-red-6)"
size={hasSmallWidth ? 12 : 16}
color="#d9534f"
style={hasSmallWidth ? iconSizes.xs : iconSizes.md}
/>
)}
</Group>
@@ -219,6 +244,25 @@ export default function IndexerManagerWidget({
</ScrollArea>
)}
</Card>
{!isAdvanced && !hasSmallHeight && (
<Button
className="indexer-manager-test-button"
w="100%"
size="xs"
radius={board.itemRadius}
variant="light"
color={testAllError ? "red" : undefined}
leftSection={<IconTestPipe style={iconSizes.md} />}
loading={isPending}
disabled={isEditMode || !canInteract}
loaderProps={{ type: "dots" }}
onClick={() => {
testAllIndexers();
}}
>
{t("testAll")}
</Button>
)}
</Flex>
);
}
@@ -40,7 +40,6 @@ export default function MediaReleasesWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
@@ -63,7 +62,7 @@ export default function MediaReleasesWidget({
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
<Item item={item} options={options} isAdvanced={isAdvanced} />
</Fragment>
))}
</SimpleGrid>
@@ -82,30 +81,27 @@ interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number];
options: WidgetComponentProps<"mediaReleases">["options"];
isAdvanced: boolean;
width: number;
height: number;
}
const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => {
const formatReleaseDate = (value: unknown, locale: string) => {
const date = toValidDate(value);
if (!date) return "—";
return Intl.DateTimeFormat(locale, {
month: compact ? "short" : "2-digit",
month: "2-digit",
year: "numeric",
day: "2-digit",
...(compact ? {} : { hour: "2-digit", minute: "2-digit", hour12: false }),
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
};
const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
const Item = ({ item, options, isAdvanced }: ItemProps) => {
const locale = useCurrentIntlLocale();
const t = useI18n("widget.mediaReleases");
const length = formatLength(item.length, item.type, t);
const isCompact = !isAdvanced && (width < 340 || height < 180);
const isTiny = !isAdvanced && (width < 220 || height < 105);
const showPoster = (isAdvanced || options.layout === "poster") && !isTiny;
const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360);
const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource));
const showPoster = isAdvanced || options.layout === "poster";
const showSide = isAdvanced || options.showType || options.showSource;
const href = getSafeApplicationUrl(item.href);
return (
@@ -129,7 +125,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
pos="relative"
p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6}
p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4}
h="100%"
title={item.title}
style={
@@ -158,57 +154,61 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
}}
/>
)}
<Group justify="space-between" h="100%" wrap="nowrap" gap={isCompact ? "xs" : "sm"}>
<Group justify="space-between" h="100%" wrap="nowrap" gap={isAdvanced ? "sm" : undefined}>
<Group align="start" wrap="nowrap" style={{ zIndex: 0, minWidth: 0, flex: 1 }} gap="xs">
{showPoster && (
<Image
w={isAdvanced ? 80 : isCompact ? 44 : 60}
w={isAdvanced ? 80 : 60}
src={item.imageUrls.poster}
alt=""
radius="xs"
alt={isAdvanced ? "" : item.title}
radius={isAdvanced ? "xs" : undefined}
loading="lazy"
style={{ flexShrink: 0 }}
/>
)}
<Stack gap={isCompact ? 2 : 4} style={{ minWidth: 0, flex: 1 }}>
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
<Stack gap={0}>
<Text size={isTiny ? "xs" : "sm"} fw="bold" lineClamp={isCompact ? 1 : 2}>
<Text size="sm" fw="bold" lineClamp={2}>
{item.title}
</Text>
{item.subtitle !== undefined && !isTiny && (
<Text size={isCompact ? "xs" : "sm"} c={isCompact ? "dimmed" : undefined} lineClamp={1}>
{item.subtitle !== undefined && (
<Text size="sm" lineClamp={1}>
{item.subtitle}
</Text>
)}
</Stack>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale, isCompact)} />
{length !== undefined && !isTiny && (
<Group
gap={6}
wrap={isAdvanced ? "nowrap" : "wrap"}
style={isAdvanced ? { minWidth: 0 } : { minWidth: 0, rowGap: 0 }}
>
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale)} />
{length !== undefined && (
<>
<InfoDivider />
<Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} />
</>
)}
{item.producer !== undefined && showExtendedMetadata && (
{item.producer !== undefined && (
<>
<InfoDivider />
<Info label={item.producer} />
</>
)}
{item.rating !== undefined && showExtendedMetadata && (
{item.rating !== undefined && (
<>
<InfoDivider />
<Info icon={IconStarFilled} label={item.rating} />
</>
)}
{item.price !== undefined && showExtendedMetadata && (
{item.price !== undefined && (
<>
<InfoDivider />
<Info label={`$${item.price.toFixed(2)}`} />
</>
)}
</Group>
{item.tags.length > 0 && (isAdvanced || !isCompact) && (
{item.tags.length > 0 && (
<OverflowBadge
size="xs"
groupGap={4}
@@ -29,7 +29,6 @@ export default function MediaServerWidget({
isEditMode,
options,
width,
height,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
@@ -69,8 +68,7 @@ export default function MediaServerWidget({
<MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
isTiny={width <= 256 || height < 96}
isDense={width < 340 || height < 150}
isTiny={width <= 256}
showIntegrationSource={showIntegrationSource}
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
options={options}
@@ -103,20 +101,12 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
isTiny: boolean;
isDense: boolean;
showIntegrationSource: boolean;
canInteract: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
}
const MediaRequestCard = ({
request,
isTiny,
isDense,
showIntegrationSource,
canInteract,
options,
}: MediaRequestCardProps) => {
const MediaRequestCard = ({ request, isTiny, showIntegrationSource, canInteract, options }: MediaRequestCardProps) => {
const board = useRequiredBoard();
const t = useI18n("widget.mediaRequests-requestList");
const requestHref = getSafeApplicationUrl(request.href);
@@ -124,11 +114,21 @@ const MediaRequestCard = ({
return (
<Card
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status} ${classes.card}`}
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status}`}
radius={board.itemRadius}
p={isDense ? 6 : "xs"}
withBorder
p="xs"
>
<Image
className="mediaRequests-list-item-background"
src={request.backdropImageUrl}
pos="absolute"
w="100%"
h="100%"
opacity={0.2}
top={0}
left={0}
alt=""
/>
<Group
className="mediaRequests-list-item-contents"
h="100%"
@@ -137,56 +137,22 @@ const MediaRequestCard = ({
wrap="nowrap"
gap={0}
>
<Group
className="mediaRequests-list-item-left-side"
h="100%"
gap={isDense ? "xs" : "md"}
wrap="nowrap"
flex={1}
miw={0}
>
<Group className="mediaRequests-list-item-left-side" h="100%" gap="md" wrap="nowrap" flex={1} miw={0}>
{!isTiny && (
<Image
className="mediaRequests-list-item-poster"
src={request.posterImagePath}
h={isDense ? 32 : 40}
h={40}
w="auto"
radius="sm"
radius="md"
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Stack gap={0} w="100%" miw={0}>
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
component={requestHref ? "a" : "span"}
href={requestHref}
c="var(--mantine-color-text)"
target={requestHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={600}
title={request.name}
truncate="end"
style={{ minWidth: 0, flex: 1 }}
>
{request.name || "unknown"}
</Anchor>
{request.status === "pending" ? (
<DecisionButtons
requestId={request.id}
integrationId={request.integrationId}
canInteract={canInteract}
alwaysVisible={isTiny}
/>
) : (
<StatusBadge status={request.status} />
)}
</Group>
<Group justify="space-between" gap="xs" wrap="nowrap" className="mediaRequests-list-item-bottom-group">
<Group gap={4} wrap="nowrap" miw={0}>
<Group justify="space-between" gap="xs" className="mediaRequests-list-item-top-group">
<Group gap="xs" wrap="nowrap" miw={0}>
<Text className="mediaRequests-list-item-media-year" size="xs">
{toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")}
</Text>
@@ -206,27 +172,52 @@ const MediaRequestCard = ({
</Badge>
)}
</Group>
{!isTiny && (
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}>
<Avatar
className="mediaRequests-list-item-request-user-avatar"
src={request.requestedBy?.avatar}
size={18}
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
component={requestedByHref ? "a" : "span"}
href={requestedByHref}
c="dimmed"
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
truncate="end"
style={{ minWidth: 0, maxWidth: isDense ? 100 : 180 }}
>
{(request.requestedBy?.displayName ?? "") || "unknown"}
</Anchor>
</Group>
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}>
<Avatar
className="mediaRequests-list-item-request-user-avatar"
src={request.requestedBy?.avatar}
size="xs"
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
component={requestedByHref ? "a" : "span"}
href={requestedByHref}
c="var(--mantine-color-text)"
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
lineClamp={1}
style={{ wordBreak: "break-all" }}
>
{(request.requestedBy?.displayName ?? "") || t("unknown")}
</Anchor>
</Group>
</Group>
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-bottom-group">
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
component={requestHref ? "a" : "span"}
href={requestHref}
c="var(--mantine-color-text)"
target={requestHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw="bold"
title={request.name}
lineClamp={1}
style={{ minWidth: 0 }}
>
{request.name || t("unknown")}
</Anchor>
{request.status === "pending" ? (
<DecisionButtons
requestId={request.id}
integrationId={request.integrationId}
canInteract={canInteract}
alwaysVisible
/>
) : (
<StatusBadge status={request.status} />
)}
</Group>
</Stack>
@@ -100,9 +100,7 @@ export default function MediaServerWidget({
},
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
const isTiny = width < 256 || height < 180;
const showSectionTitles = height >= 180;
const visibleStats = data.slice(0, height < 120 ? 4 : 8);
const isTiny = width < 256;
const showIntegrationLegend = requestStats.integrations.length > 1 && height >= 240;
return (
@@ -121,13 +119,11 @@ export default function MediaServerWidget({
style={{ pointerEvents: isEditMode ? "none" : undefined, overflow: "hidden" }}
>
<Stack gap={4} w="100%">
{showSectionTitles && (
<Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.stats.main")}
</Text>
)}
<Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.stats.main")}
</Text>
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
{visibleStats.map((stat) => (
{data.map((stat) => (
<Grid.Col
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
key={stat.name}
@@ -148,11 +144,9 @@ export default function MediaServerWidget({
</Grid>
</Stack>
<Stack gap={4} w="100%" style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
{showSectionTitles && (
<Text className="mediaRequests-stats-users-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
)}
<Text className="mediaRequests-stats-users-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} mih={0} w="100%">
<Stack gap={4}>
{requestStats.users.map((user) => {
@@ -106,10 +106,10 @@ describe("SessionDetailsPopover", () => {
});
describe("getMediaServerColumnVisibility", () => {
test("keeps a stable responsive column model from the zero-sized first render", () => {
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false });
test("keeps every compact column visible at every width", () => {
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
});
+70 -57
View File
@@ -35,6 +35,7 @@ import { objectEntries } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
@@ -48,11 +49,9 @@ type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transc
type SortColumn = "user" | "currentlyPlaying" | "status";
type SortState = { column: SortColumn; descending: boolean } | null;
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
user: isAdvanced || width >= 300,
// The user (26%) and status (22%) columns together need a floor here, otherwise the
// currentlyPlaying column - the primary content - gets squeezed to almost nothing.
status: isAdvanced || width >= 540,
export const getMediaServerColumnVisibility = (_width: number, _isAdvanced: boolean) => ({
user: true,
status: true,
});
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
@@ -171,7 +170,6 @@ export default function MediaServerWidget({
options,
integrationIds,
width,
height,
isEditMode,
displayMode,
}: WidgetComponentProps<"mediaServer">) {
@@ -411,61 +409,76 @@ export default function MediaServerWidget({
</Table.Tbody>
</Table>
</div>
{(isAdvanced || height >= 144) && (
<Group
gap="xs"
h={30}
px="xs"
pr="md"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
<Group gap={6} wrap="nowrap">
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
</Text>
{totalBitrateLabel && (isAdvanced || width >= 300) && (
<>
<Group
gap="xs"
h={30}
px="xs"
pr="md"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
<Group gap={isAdvanced ? 6 : 4} wrap="nowrap">
<IconVideo
size={isAdvanced ? "var(--mantine-font-size-xs)" : undefined}
style={isAdvanced ? { flexShrink: 0 } : zoomCompensatedSize(16)}
/>
<Text size="sm" fw={isAdvanced ? 500 : undefined} style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
</Text>
{totalBitrateLabel && (
<>
{isAdvanced && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
•
</Text>
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</>
)}
</Group>
<Group gap={6}>
{uniqueIntegrations.map((integration) => (
<Group
key={integration.integrationId}
gap={6}
align="center"
wrap="nowrap"
pl={2}
pr={8}
py={2}
style={{
backgroundColor: "var(--mantine-color-default-hover)",
borderRadius: 999,
}}
>
<Avatar className="media-server-icon" src={integration.integrationIcon} radius="xl" size={18} />
{(isAdvanced || width >= 480) && (
<Text className="media-server-name" size="xs" fw={500} truncate="end">
{integration.integrationName}
</Text>
)}
</Group>
))}
</Group>
)}
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</>
)}
</Group>
)}
<Group gap={isAdvanced ? 6 : "xs"} wrap="nowrap">
{uniqueIntegrations.map((integration) => (
<Group
key={integration.integrationId}
gap={isAdvanced ? 6 : "xs"}
align="center"
wrap="nowrap"
pl={isAdvanced ? 2 : undefined}
pr={isAdvanced ? 8 : undefined}
py={isAdvanced ? 2 : undefined}
style={
isAdvanced
? {
backgroundColor: "var(--mantine-color-default-hover)",
borderRadius: 999,
}
: undefined
}
>
<Avatar
className="media-server-icon"
src={integration.integrationIcon}
radius={isAdvanced ? "xl" : "xs"}
size={isAdvanced ? 18 : "xs"}
/>
<Text
className="media-server-name"
size={isAdvanced ? "xs" : "sm"}
fw={isAdvanced ? 500 : undefined}
truncate={isAdvanced ? "end" : undefined}
>
{integration.integrationName}
</Text>
</Group>
))}
</Group>
</Group>
</Stack>
);
}
@@ -15,6 +15,7 @@ export default function MinecraftServerStatusWidget({
options,
width,
height,
displayMode,
}: WidgetComponentProps<"minecraftServerStatus">) {
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n("widget.minecraftServerStatus");
@@ -34,10 +35,11 @@ export default function MinecraftServerStatusWidget({
const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain;
const isDense = width < 220 || height < 120;
const showServerIcon = !isDense && height >= 144;
const showMetadata = width >= 260 && height >= 180;
const showCapacity = width >= 180 && height >= 140;
const isAdvanced = displayMode === "advanced";
const isDense = isAdvanced && (width < 220 || height < 120);
const showServerIcon = !isAdvanced || (!isDense && height >= 144);
const showMetadata = isAdvanced && width >= 260 && height >= 180;
const showCapacity = isAdvanced && width >= 180 && height >= 140;
const iconSize = Math.max(40, Math.min(80, width * 0.45, height * 0.45));
const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0;
@@ -281,6 +281,7 @@ const WidgetEditPreview = ({
integrationIds={state.integrationIds}
width={sourceWidth}
height={sourceHeight}
displayScale={previewScale}
isEditMode={isPendingCustomWidget}
displayMode="compact"
boardId={boardId}
@@ -1,7 +1,6 @@
"use client";
import { useMemo } from "react";
import { Badge, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
@@ -13,8 +12,7 @@ import { getNetworkControllerStatusLayout } from "./layout";
import { WifiVariant } from "./variants/wifi-variant";
import { WiredVariant } from "./variants/wired-variant";
const neutralBorderColor =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const neutralBorderColor = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
export default function NetworkControllerNetworkStatusWidget({
options,
@@ -22,17 +20,18 @@ export default function NetworkControllerNetworkStatusWidget({
displayMode,
width,
height,
displayScale = 1,
}: WidgetComponentProps<"networkControllerStatus">) {
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const results = getUsableWidgetQueryData(summaryQuery) ?? [];
const summaries = useMemo(
() =>
results.filter(
(result): result is typeof result & { summary: NonNullable<typeof result.summary> } => result.summary !== null,
),
[results],
const isAdvanced = displayMode === "advanced";
let results = summaryQuery.data ?? [];
if (isAdvanced) {
results = getUsableWidgetQueryData(summaryQuery) ?? [];
}
const summaries = results.filter(
(result): result is typeof result & { summary: NonNullable<typeof result.summary> } => result.summary !== null,
);
const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("common");
@@ -42,14 +41,55 @@ export default function NetworkControllerNetworkStatusWidget({
const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0);
const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0);
const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0);
const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content });
const isAdvanced = displayMode === "advanced";
let responsiveWidth = width;
let responsiveHeight = height;
if (!isAdvanced && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const layout = getNetworkControllerStatusLayout({
width: responsiveWidth,
height: responsiveHeight,
displayMode,
content: options.content,
});
const indicatorResults =
results.length > 0 || !summaryQuery.error
? results
: integrationIds.map((integrationId) => ({ integrationId, error: "query" }));
const queryIndicators = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={indicatorResults} />
</Group>
);
if (!isAdvanced) {
return (
<Box h="100%" p={layout.padding} pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
{options.content === "wifi" ? (
<WifiVariant
countGuests={countWifiGuests}
countUsers={countWifiUsers}
compact={layout.compact}
horizontal={layout.horizontalStats}
inline={layout.inlineStats}
/>
) : (
<WiredVariant
countGuests={countLanGuests}
countUsers={countLanUsers}
compact={layout.compact}
horizontal={layout.horizontalStats}
inline={layout.inlineStats}
/>
)}
</Box>
);
}
if (summaryQuery.isPending || summaries.length === 0) {
return (
<Stack h="100%" gap={0}>
@@ -63,92 +103,40 @@ export default function NetworkControllerNetworkStatusWidget({
);
}
if (isAdvanced) {
return (
<ScrollArea h="100%" p={layout.padding}>
{queryIndicators}
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
{updatedAt && (
<Text size="xs" c="dimmed">
{tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
</Text>
)}
</Stack>
<Badge size="xs" variant="light">
{integration.kind}
</Badge>
</Group>
<SimpleGrid cols={layout.columns} spacing="sm">
<Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} />
</Card>
<Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} />
</Card>
</SimpleGrid>
</Stack>
</Card>
))}
</SimpleGrid>
</ScrollArea>
);
}
return (
<Stack p={layout.padding} h="100%" gap={0}>
<ScrollArea h="100%" p={layout.padding}>
{queryIndicators}
<SimpleGrid cols={layout.columns} spacing="sm" style={{ flex: 1, alignItems: "stretch" }}>
{layout.showWifi && (
<Card
p={layout.cardPadding}
withBorder={layout.withBorder}
bg="transparent"
h="100%"
style={{ borderColor: neutralBorderColor }}
>
<WifiVariant
countGuests={countWifiGuests}
countUsers={countWifiUsers}
compact={layout.compact}
horizontal={layout.horizontalStats}
/>
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
{updatedAt && (
<Text size="xs" c="dimmed">
{tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
</Text>
)}
</Stack>
<Badge size="xs" variant="light">
{integration.kind}
</Badge>
</Group>
<SimpleGrid cols={layout.columns} spacing="sm">
<Card p={layout.cardPadding} withBorder bg="transparent" style={{ borderColor: neutralBorderColor }}>
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} advanced />
</Card>
<Card p={layout.cardPadding} withBorder bg="transparent" style={{ borderColor: neutralBorderColor }}>
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} advanced />
</Card>
</SimpleGrid>
</Stack>
</Card>
)}
{layout.showWired && (
<Card
p={layout.cardPadding}
withBorder={layout.withBorder}
bg="transparent"
h="100%"
style={{ borderColor: neutralBorderColor }}
>
<WiredVariant
countGuests={countLanGuests}
countUsers={countLanUsers}
compact={layout.compact}
horizontal={layout.horizontalStats}
/>
</Card>
)}
))}
</SimpleGrid>
</Stack>
</ScrollArea>
);
}
@@ -7,7 +7,7 @@ describe("network controller status layout", () => {
{
name: "minimum compact",
input: { width: 160, height: 90, displayMode: "compact" as const, content: "wifi" as const },
expected: { padding: "xs", columns: 1, showWifi: true, showWired: false, horizontalStats: false },
expected: { padding: 4, columns: 1, showWifi: true, showWired: false, horizontalStats: true },
},
{
name: "short compact",
@@ -58,7 +58,13 @@ describe("network controller status layout", () => {
).toMatchObject({ padding: "sm", horizontalStats: false });
expect(
getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }),
).toMatchObject({ padding: "xs", horizontalStats: false });
).toMatchObject({ padding: 4, horizontalStats: true });
expect(
getNetworkControllerStatusLayout({ width: 199, height: 80, displayMode: "compact", content: "wifi" }),
).toMatchObject({ inlineStats: false });
expect(
getNetworkControllerStatusLayout({ width: 199, height: 79, displayMode: "compact", content: "wifi" }),
).toMatchObject({ inlineStats: true });
expect(
getNetworkControllerStatusLayout({ width: 960, height: 320, displayMode: "advanced", content: "wifi" }),
).toMatchObject({ sourceColumns: 2 });
@@ -11,7 +11,7 @@ interface NetworkControllerStatusLayoutInput {
}
export interface NetworkControllerStatusLayout {
padding: "xs" | "sm" | "md";
padding: 4 | "xs" | "sm" | "md";
columns: 1 | 2;
sourceColumns: 1 | 2;
showWifi: boolean;
@@ -20,6 +20,7 @@ export interface NetworkControllerStatusLayout {
withBorder: boolean;
compact: boolean;
horizontalStats: boolean;
inlineStats: boolean;
}
export const getNetworkControllerStatusLayout = ({
@@ -29,16 +30,18 @@ export const getNetworkControllerStatusLayout = ({
content,
}: NetworkControllerStatusLayoutInput): NetworkControllerStatusLayout => {
const isAdvanced = displayMode === "advanced";
const compact = !isAdvanced && (width < 240 || height < 180);
return {
padding: isAdvanced ? "md" : height < 120 ? "xs" : "sm",
padding: isAdvanced ? "md" : height < 120 ? 4 : "sm",
columns: isAdvanced && width >= 560 ? 2 : 1,
sourceColumns: isAdvanced && width >= 960 ? 2 : 1,
showWifi: isAdvanced || content === "wifi",
showWired: isAdvanced || content === "wired",
cardPadding: isAdvanced ? "md" : 0,
withBorder: isAdvanced,
compact: !isAdvanced,
horizontalStats: !isAdvanced && height < 150 && width >= 200,
compact,
horizontalStats: compact && height < 150,
inlineStats: compact && height < 80,
};
};
@@ -1,14 +1,44 @@
import { Stack, Text } from "@mantine/core";
import { Group, Stack, Text } from "@mantine/core";
export const StatRow = ({
label,
value,
advanced = false,
compact = false,
inline = false,
}: {
label: string;
value: string | number;
advanced?: boolean;
compact?: boolean;
inline?: boolean;
}) => {
if (!advanced && compact && inline) {
return (
<Group gap={2} align="baseline" justify="center" wrap="nowrap" miw={0}>
<Text size="xl" fw={800} lh={1} style={{ flexShrink: 0 }}>
{value}
</Text>
<Text size="sm" c="dimmed" truncate>
{label}
</Text>
</Group>
);
}
if (!advanced) {
return (
<Stack gap={0}>
<Text size={compact ? "xl" : "2xl"} fw={compact ? 800 : 900} lh={1}>
{value}
</Text>
<Text size={compact ? "sm" : "md"} c="dimmed">
{label}
</Text>
</Stack>
);
}
return (
<Stack gap={2} align="center" justify="center">
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1} ta="center">
@@ -2,22 +2,50 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconWifi } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { StatRow } from "./stat-row";
export const WifiVariant = ({
countGuests,
countUsers,
advanced = false,
compact = false,
horizontal = false,
inline = false,
}: {
countUsers: number;
countGuests: number;
advanced?: boolean;
compact?: boolean;
horizontal?: boolean;
inline?: boolean;
}) => {
const t = useI18n("widget.networkControllerStatus.card");
if (!advanced) {
const iconSize = horizontal ? 20 : 24;
let titleMargin: 0 | "xs" | "md" = "md";
if (compact) titleMargin = "xs";
if (horizontal) titleMargin = 0;
let statsSpacing: number | "xs" | "lg" = "lg";
if (compact) statsSpacing = "xs";
if (horizontal) statsSpacing = 4;
return (
<>
<Group gap="xs" wrap="nowrap" mb={titleMargin}>
<IconWifi style={zoomCompensatedSize(iconSize)} />
<Text size="md" fw="bold" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("variants.wifi.name")}
</Text>
</Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={statsSpacing}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} inline={inline} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} inline={inline} />
</SimpleGrid>
</>
);
}
return (
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" justify="center">
@@ -29,8 +57,8 @@ export const WifiVariant = ({
</Text>
</Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
<StatRow label={t("users.label")} value={countUsers} compact={compact} advanced />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} advanced />
</SimpleGrid>
</Stack>
);
@@ -2,22 +2,50 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { StatRow } from "./stat-row";
export const WiredVariant = ({
countGuests,
countUsers,
advanced = false,
compact = false,
horizontal = false,
inline = false,
}: {
countUsers: number;
countGuests: number;
advanced?: boolean;
compact?: boolean;
horizontal?: boolean;
inline?: boolean;
}) => {
const t = useI18n("widget.networkControllerStatus.card");
if (!advanced) {
const iconSize = horizontal ? 20 : 24;
let titleMargin: 0 | "xs" | "md" = "md";
if (compact) titleMargin = "xs";
if (horizontal) titleMargin = 0;
let statsSpacing: number | "xs" | "lg" = "lg";
if (compact) statsSpacing = "xs";
if (horizontal) statsSpacing = 4;
return (
<>
<Group gap="xs" wrap="nowrap" mb={titleMargin}>
<IconNetwork style={zoomCompensatedSize(iconSize)} />
<Text size="md" fw="bold" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("variants.wired.name")}
</Text>
</Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={statsSpacing}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} inline={inline} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} inline={inline} />
</SimpleGrid>
</>
);
}
return (
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" justify="center">
@@ -29,8 +57,8 @@ export const WiredVariant = ({
</Text>
</Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
<StatRow label={t("users.label")} value={countUsers} compact={compact} advanced />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} advanced />
</SimpleGrid>
</Stack>
);
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties } from "react";
import { Badge, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
import { Box, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
@@ -27,13 +27,16 @@ dayjs.extend(duration);
export default function NetworkControllerSummaryWidget({
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"networkControllerSummary">) {
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const results = getUsableWidgetQueryData(summaryQuery) ?? [];
const isAdvanced = displayMode === "advanced";
let results = summaryQuery.data ?? [];
if (isAdvanced) {
results = getUsableWidgetQueryData(summaryQuery) ?? [];
}
const summaries = results.filter(
(result): result is typeof result & { summary: NonNullable<typeof result.summary>; updatedAt: Date } =>
result.summary !== null && result.updatedAt !== undefined,
@@ -51,17 +54,18 @@ export default function NetworkControllerSummaryWidget({
unknown: tDns("status.unknown"),
};
const isDense = height < 160;
const showDetails = height >= 180 && width >= 240;
const columns = width >= 720 ? 2 : 1;
const isAdvanced = displayMode === "advanced";
const indicatorResults =
results.length > 0 || !summaryQuery.error
? results
: integrationIds.map((integrationId) => ({ integrationId, error: "query" }));
const queryIndicators = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={indicatorResults} />
</Group>
);
const firstSummary = summaries[0];
if (isPending || summaries.length === 0) {
if (isPending || !firstSummary) {
return (
<Stack h="100%" gap={0}>
{queryIndicators}
@@ -74,190 +78,152 @@ export default function NetworkControllerSummaryWidget({
);
}
if (isAdvanced) {
const controllerColumns = width >= 960 ? 2 : 1;
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
if (!isAdvanced) {
const summary = firstSummary.summary;
return (
<ScrollArea h="100%" p="sm" pos="relative">
{queryIndicators}
<SimpleGrid cols={controllerColumns} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card
key={integration.id}
p="sm"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
<Box h="100%" p="sm" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Center h="100%">
<List spacing="xs" center>
<List.Item
icon={
<StatusIcon
status={summary.wanStatus}
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
style={iconSizes.xl}
/>
}
>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{integration.kind}
</Text>
</Stack>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Text size="10px" c="dimmed">
{t("advanced.updated")}
</Text>
<Text size="xs" c="dimmed" ta="right">
{new Date(updatedAt).toLocaleString(locale)}
</Text>
</Stack>
</Group>
<SimpleGrid cols={matrixColumns} spacing="xs">
{getNetworkControllerMatrix(summary).map((section) => (
<Card
key={section.key}
p="xs"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<StatusIcon
status={section.status}
label={statusLabels[getBinaryStatusKey(section.status)]}
size="var(--mantine-font-size-md)"
/>
<Text size="sm" fw={600}>
{getMatrixSectionLabel(section.key, t)}
</Text>
</Group>
<Text size="xs" c="dimmed">
{statusLabels[getBinaryStatusKey(section.status)]}
</Text>
</Group>
{section.metrics.length > 0 && (
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
{section.metrics.map((metric) => (
<Stack key={metric.key} gap={0}>
<Text size="10px" c="dimmed">
{getMatrixMetricLabel(metric.key, t)}
</Text>
<Text size="xs" fw={500}>
{formatMatrixMetric(metric.key, metric.value)}
</Text>
</Stack>
))}
</SimpleGrid>
)}
</Stack>
</Card>
))}
</SimpleGrid>
</Stack>
</Card>
))}
</SimpleGrid>
</ScrollArea>
{t("card.wan")}
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary.www.status}
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
style={iconSizes.xl}
/>
}
>
<Text>
WWW
<Text c="dimmed" size="md" ms="xs" span>
{summary.www.latency}ms
</Text>
</Text>
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary.wifi.status}
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
style={iconSizes.xl}
/>
}
>
{t("card.wifi")}
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary.vpn.status}
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
style={iconSizes.xl}
/>
}
>
<Text>
{t("card.vpn.label")}
<Text c="dimmed" size="md" ms="xs" span>
{t("card.vpn.countConnected", { count: summary.vpn.users })}
</Text>
</Text>
</List.Item>
</List>
</Center>
</Box>
);
}
const controllerColumns = width >= 960 ? 2 : 1;
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
return (
<Stack h="100%" p={isDense ? "xs" : "sm"} gap={0} style={{ overflow: "hidden" }}>
<ScrollArea h="100%" p="sm" pos="relative">
{queryIndicators}
<SimpleGrid cols={columns} spacing="sm" style={{ flex: 1, minHeight: 0, alignContent: "center" }}>
{summaries.map(({ integration, summary }) => (
<Card
key={integration.id}
p={isDense ? "xs" : "sm"}
withBorder={summaries.length > 1}
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Stack h="100%" gap={isDense ? 4 : 8} justify="center" align="center">
{summaries.length > 1 && (
<Group justify="space-between" wrap="nowrap">
<SimpleGrid cols={controllerColumns} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} p="sm" withBorder bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
<Badge size="xs" variant="light">
<Text size="xs" c="dimmed">
{integration.kind}
</Badge>
</Group>
)}
<List spacing={isDense ? 2 : 4} center size={isDense ? "xs" : "sm"} w="fit-content" mx="auto">
<List.Item
icon={
<StatusIcon
status={summary.wanStatus}
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
>
{t("card.wan")}
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary.www.status}
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
>
<Text>
{t("card.web")}
<Text c="dimmed" size="sm" ms="xs" span>
{summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""}
</Text>
</Text>
</List.Item>
<List.Item
icon={
<StatusIcon
status={summary.wifi.status}
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
>
{t("card.wifi")}
{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
</List.Item>
{showDetails && (
<List.Item
icon={
<StatusIcon
status={summary.lan.status}
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
</Stack>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Text size="10px" c="dimmed">
{t("advanced.updated")}
</Text>
<Text size="xs" c="dimmed" ta="right">
{new Date(updatedAt).toLocaleString(locale)}
</Text>
</Stack>
</Group>
<SimpleGrid cols={matrixColumns} spacing="xs">
{getNetworkControllerMatrix(summary).map((section) => (
<Card
key={section.key}
p="xs"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
{t("card.lan")} · {summary.lan.users + summary.lan.guests}
</List.Item>
)}
<List.Item
icon={
<StatusIcon
status={summary.vpn.status}
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
>
<Text>
{t("card.vpn.label")}
<Text c="dimmed" size="sm" ms="xs" span>
{t("card.vpn.countConnected", { count: summary.vpn.users })}
</Text>
</Text>
</List.Item>
</List>
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<StatusIcon
status={section.status}
label={statusLabels[getBinaryStatusKey(section.status)]}
style={iconSizes.md}
/>
<Text size="sm" fw={600}>
{getMatrixSectionLabel(section.key, t)}
</Text>
</Group>
<Text size="xs" c="dimmed">
{statusLabels[getBinaryStatusKey(section.status)]}
</Text>
</Group>
{section.metrics.length > 0 && (
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
{section.metrics.map((metric) => (
<Stack key={metric.key} gap={0}>
<Text size="10px" c="dimmed">
{getMatrixMetricLabel(metric.key, t)}
</Text>
<Text size="xs" fw={500}>
{formatMatrixMetric(metric.key, metric.value)}
</Text>
</Stack>
))}
</SimpleGrid>
)}
</Stack>
</Card>
))}
</SimpleGrid>
</Stack>
</Card>
))}
</SimpleGrid>
</Stack>
</ScrollArea>
);
}
@@ -280,26 +246,23 @@ const getMatrixSectionLabel = (
return t(`card.${key}` as never);
};
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const StatusIcon = ({
status,
label,
size,
style,
}: {
status?: "enabled" | "disabled";
label: string;
size?: number | string;
style?: CSSProperties;
}) => {
const mantineTheme = useMantineTheme();
if (status === "enabled") {
return <IconCircleCheckFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.green[6]} />;
return <IconCircleCheckFilled aria-label={label} style={style} color={mantineTheme.colors.green[6]} />;
}
if (status === "disabled") {
return <IconCircleXFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.red[6]} />;
return <IconCircleXFilled aria-label={label} style={style} color={mantineTheme.colors.red[6]} />;
}
return <IconCircleXFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.gray[6]} />;
return <IconCircleXFilled aria-label={label} style={style} color={mantineTheme.colors.gray[6]} />;
};
@@ -52,21 +52,22 @@ export default function NotificationsWidget({
const failedIntegrations = notificationIntegrations.filter(
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
);
const isAdvanced = displayMode === "advanced";
const isDense = width < 280 || height < 180;
const isRoomy = width >= 360 && height >= 220;
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
const notificationDisplay = getNotificationDisplay({
displayMode,
hideLogos: options.hideLogos,
isRoomy,
bodyLineClamp,
isRoomy: isAdvanced && isRoomy,
bodyLineClamp: isAdvanced ? bodyLineClamp : 4,
});
const columns = width >= 720 ? 2 : 1;
const spacing = isRoomy ? "sm" : "xs";
const columns = isAdvanced && width >= 720 ? 2 : 1;
const spacing = !isAdvanced || isRoomy ? "sm" : "xs";
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
<Stack w="100%" gap="xs">
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "xs" : "sm"}>
<Stack w="100%" gap={isAdvanced ? "xs" : "sm"}>
{failedIntegrations.length > 0 && (
<Group gap={4} wrap="wrap">
{failedIntegrations.map((integration) => (
@@ -93,34 +94,39 @@ export default function NotificationsWidget({
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
className={columns > 1 ? classes.card : classes.row}
className={isAdvanced ? (columns > 1 ? classes.card : classes.row) : undefined}
radius={board.itemRadius}
w="100%"
p={isRoomy ? "sm" : isDense ? 6 : "xs"}
p={!isAdvanced || isRoomy ? "sm" : isDense ? 6 : "xs"}
style={{
color: "inherit",
textDecoration: "none",
}}
>
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
<Flex gap={!isAdvanced || !isDense ? "sm" : "xs"} align="flex-start" w="100%">
{notificationDisplay.showLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
size={isDense ? "xs" : "sm"}
size={isAdvanced && isDense ? "xs" : "sm"}
radius={board.itemRadius}
/>
)}
<Flex gap={isRoomy ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
<Flex gap={!isAdvanced || isRoomy ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
{notification.title && (
<Text fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={isDense ? 1 : 2}>
<Text
fz={isAdvanced && isRoomy ? "md" : "sm"}
fw={isAdvanced ? 600 : undefined}
lh={isAdvanced ? 1.25 : "sm"}
lineClamp={isAdvanced && isDense ? 1 : 2}
>
{notification.title}
</Text>
)}
<Text
c="dimmed"
size={isDense ? "xs" : "sm"}
size={isAdvanced && isDense ? "xs" : "sm"}
lineClamp={notificationDisplay.bodyLineClamp}
style={{ whiteSpace: "pre-line" }}
>
@@ -134,7 +140,7 @@ export default function NotificationsWidget({
? (notification.source?.name ?? notification.integrationName)
: undefined
}
dense={isDense}
dense={isAdvanced && isDense}
/>
</Flex>
</Flex>
@@ -159,11 +165,7 @@ const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; den
return (
<Group gap={5} align="center" wrap="nowrap">
<IconClock
aria-hidden
style={dense ? iconSizes.xs : iconSizes.md}
color="var(--mantine-color-dimmed)"
/>
<IconClock aria-hidden style={dense ? iconSizes.xs : iconSizes.md} color="var(--mantine-color-dimmed)" />
<Text size={dense ? "xs" : "sm"} c="dimmed">
{timeAgo}
</Text>
@@ -4,11 +4,16 @@
flex-direction: column;
height: 100%;
min-height: 0;
padding: clamp(6px, 3cqi, 14px);
gap: clamp(4px, 2cqi, 10px);
padding: clamp(6px, 3cqi, 12px);
gap: clamp(4px, 2cqi, 8px);
overflow: hidden;
}
.rootShort {
padding: 4px;
gap: 2px;
}
.hero {
display: flex;
align-items: center;
@@ -16,8 +21,7 @@
gap: clamp(8px, 3cqi, 18px);
flex-shrink: 0;
padding-bottom: clamp(4px, 2cqi, 10px);
border-bottom: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.heroExpanded {
@@ -81,13 +85,18 @@
.grid {
display: grid;
flex: 1;
width: 100%;
min-height: 0;
gap: clamp(4px, 2cqi, 10px);
gap: clamp(4px, 2cqi, 8px);
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr;
align-content: stretch;
}
.gridShort {
gap: 2px;
}
.statTile {
display: flex;
flex-direction: column;
@@ -95,22 +104,37 @@
justify-content: center;
min-height: 0;
min-width: 0;
gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 10px);
gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md);
border: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
.statTileShort {
gap: 2px;
padding: 3px;
}
.statTileNarrowShort {
flex-direction: row;
gap: 2px;
padding: 1px 3px;
}
.statIcon {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
line-height: 0;
}
.statLabel {
font-size: clamp(8px, 2.5cqi, 12px);
flex-shrink: 0;
font-size: clamp(
calc(8px * var(--board-canvas-ui-scale, 1)),
calc(2.5cqi * var(--board-canvas-ui-scale, 1)),
calc(11px * var(--board-canvas-ui-scale, 1))
);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -120,12 +144,38 @@
white-space: nowrap;
}
.statLabelShort {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.statValue {
font-size: clamp(14px, 5.5cqi, 24px);
flex-shrink: 0;
font-size: clamp(
calc(13px * var(--board-canvas-ui-scale, 1)),
calc(5cqi * var(--board-canvas-ui-scale, 1)),
calc(20px * var(--board-canvas-ui-scale, 1))
);
font-weight: 700;
color: var(--mantine-color-text);
color: var(--mantine-primary-color-filled);
line-height: 1.1;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statValueNarrowShort {
font-size: calc(11px * var(--board-canvas-ui-scale, 1));
line-height: 1;
}
.emptyState {
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getCompactStatLayout } from "../common/compact-stat-layout";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
@@ -32,11 +33,41 @@ const statIcons = {
const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]);
const gridColsByWidth = [
{ minWidth: 380, cols: 3 },
{ minWidth: 300, cols: 3 },
{ minWidth: 220, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
const rootClassByLayout = {
default: "",
short: classes.rootShort,
narrowShort: classes.rootShort,
} as const;
const gridClassByLayout = {
default: "",
short: classes.gridShort,
narrowShort: classes.gridShort,
} as const;
const statTileClassByLayout = {
default: "",
short: classes.statTileShort,
narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`,
} as const;
const statLabelClassByLayout = {
default: "",
short: classes.statLabelShort,
narrowShort: classes.statLabelShort,
} as const;
const statValueClassByLayout = {
default: "",
short: "",
narrowShort: classes.statValueNarrowShort,
} as const;
const ringSizeByWidth = [
{ minWidth: 400, size: 88 },
{ minWidth: 320, size: 72 },
@@ -76,6 +107,8 @@ export default function PaperlessNgxWidget({
options,
width,
height,
displayScale = 1,
displayMode = "compact",
}: WidgetComponentProps<"paperlessNgx">) {
const t = useI18n("widget.paperlessNgx");
const tCommon = useI18n("common");
@@ -116,19 +149,31 @@ export default function PaperlessNgxWidget({
.map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
const gridCols = Math.max(1, Math.min(visibleStatKeys.length, getGridCols(width, height)));
const ringSize = getRingSize(Math.min(width, height * 2));
const iconSize = getIconSize(width);
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const advanced = displayMode === "advanced";
const layout = getCompactStatLayout({
width: responsiveWidth,
height: responsiveHeight,
visibleCount: visibleStatKeys.length,
compactDisplay: !advanced,
defaultColumns: getGridCols(responsiveWidth),
defaultIconSize: getIconSize(responsiveWidth),
});
const ringSize = getRingSize(responsiveWidth);
const ringLabelSize = getRingLabelSize(ringSize);
const hasContent = showHero || visibleStatKeys.length > 0;
const heroLayoutClass =
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
const showInboxRing = options.showInboxRing && height >= 120;
const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing];
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
([partKey, optionKey]) => options[optionKey as keyof typeof options] && (partKey !== "ring" || showInboxRing),
([, optionKey]) => options[optionKey as keyof typeof options],
);
const heroPartRenderers = {
@@ -162,7 +207,7 @@ export default function PaperlessNgxWidget({
} as const;
return (
<div className={classes.root}>
<div className={`${classes.root} ${rootClassByLayout[layout.state]}`}>
{showHero && (
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
{visibleHeroParts.map(([partKey]) => (
@@ -180,14 +225,19 @@ export default function PaperlessNgxWidget({
)}
{visibleStatKeys.length > 0 && (
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
<div
className={`${classes.grid} ${gridClassByLayout[layout.state]}`}
style={{ "--stat-cols": layout.columns } as CSSProperties}
>
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
return (
<div key={statKey} className={classes.statTile}>
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
<span className={classes.statValue}>{statValues[statKey]}</span>
<span className={classes.statLabel}>{t(statKey)}</span>
<div key={statKey} className={`${classes.statTile} ${statTileClassByLayout[layout.state]}`}>
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>
{statValues[statKey]}
</span>
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>{t(statKey)}</span>
</div>
);
})}
@@ -205,9 +255,9 @@ function computeInboxProgress(total: number, inbox: number): number {
return Math.round((inbox / total) * 100);
}
function getGridCols(width: number, height: number): number {
function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return Math.min(match?.cols ?? 1, height < 140 ? 2 : 5);
return match?.cols ?? 1;
}
function getRingSize(width: number): number {
+1 -1
View File
@@ -65,7 +65,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
defaultValue: {},
});
const isAdvanced = displayMode === "advanced";
const columns = Math.max(1, Math.floor(width / 420));
const columns = isAdvanced ? Math.max(1, Math.floor(width / 420)) : 1;
const relativeDateOptions = useMemo(
() => ({
@@ -24,6 +24,18 @@
flex-shrink: 0;
}
.backgroundImage {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(5px);
transform: scaleX(-1.05) scaleZ(-1.05);
opacity: 0.25;
}
@media (prefers-reduced-motion: reduce) {
.entry {
transition: none;
+104 -61
View File
@@ -1,6 +1,6 @@
"use client";
import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Text } from "@mantine/core";
import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconClock, IconServerOff } from "@tabler/icons-react";
import dayjs from "dayjs";
@@ -104,9 +104,10 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
descriptionLines,
});
const spacing = isRoomy ? "sm" : "xs";
const isNarrow = width < 128 * 3;
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isTiny ? 4 : "xs"}>
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? (isTiny ? 4 : "xs") : "sm"}>
{warning && (
<Alert
role="presentation"
@@ -118,67 +119,109 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
<output>{warning}</output>
</Alert>
)}
<SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}>
{feed.entries.map((feedEntry) => {
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
return (
<Card
key={feedEntry.id}
className={classes.entry}
component={href ? "a" : "div"}
href={href}
radius={board.itemRadius}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
w="100%"
p={isDense ? 6 : "xs"}
title={feedEntry.title}
>
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
<Image
loading="lazy"
className={classes.poster}
src={feedEntry.enclosure}
alt=""
w={isRoomy ? 140 : isDense ? 64 : 96}
h={isRoomy ? 96 : isDense ? 64 : 96}
radius="sm"
fit="cover"
/>
)}
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
<Text
dir={languageDir}
fz={isRoomy ? "md" : "sm"}
fw={600}
lh={1.25}
lineClamp={isAdvanced ? undefined : 2}
>
{feedEntry.title}
</Text>
{entryDisplay.showDescription && feedEntry.description && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={entryDisplay.descriptionLineClamp}>
{feedDescriptionToText(feedEntry.description)}
</Text>
{isAdvanced ? (
<SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}>
{feed.entries.map((feedEntry) => {
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
return (
<Card
key={feedEntry.id}
className={classes.entry}
component={href ? "a" : "div"}
href={href}
radius={board.itemRadius}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
w="100%"
p={isDense ? 6 : "xs"}
title={feedEntry.title}
>
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
<Image
loading="lazy"
className={classes.poster}
src={feedEntry.enclosure}
alt=""
w={isRoomy ? 140 : isDense ? 64 : 96}
h={isRoomy ? 96 : isDense ? 64 : 96}
radius="sm"
fit="cover"
/>
)}
<InfoDisplay
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
timestamp={
isAdvanced && feedEntry.published
? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z")
: undefined
}
/>
</Flex>
</Group>
</Card>
);
})}
</SimpleGrid>
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
<Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25}>
{feedEntry.title}
</Text>
{entryDisplay.showDescription && feedEntry.description && (
<Text dir={languageDir} c="dimmed" size="sm">
{feedDescriptionToText(feedEntry.description)}
</Text>
)}
<InfoDisplay
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
timestamp={
feedEntry.published ? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z") : undefined
}
/>
</Flex>
</Group>
</Card>
);
})}
</SimpleGrid>
) : (
<Stack w="100%" gap="sm">
{feed.entries.map((feedEntry) => {
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
return (
<Card
key={feedEntry.id}
component={href ? "a" : "div"}
href={href}
radius={board.itemRadius}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
w="100%"
p="sm"
title={feedEntry.title}
>
{feedEntry.enclosure !== undefined && (
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="" />
)}
<Group wrap="nowrap">
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
<Image
loading="lazy"
className={classes.poster}
src={feedEntry.enclosure}
alt={feedEntry.title}
w={140}
h={140}
radius="sm"
fit="cover"
/>
)}
<Flex gap="sm" direction="column" w="100%" miw={0}>
<Text dir={languageDir} fz="sm" lh="sm" lineClamp={2}>
{feedEntry.title}
</Text>
{!options.hideDescription && feedEntry.description && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={options.textLinesClamp}>
{feedDescriptionToText(feedEntry.description)}
</Text>
)}
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
</Flex>
</Group>
</Card>
);
})}
</Stack>
)}
</ScrollArea>
);
}
@@ -98,14 +98,15 @@ export default function SmartHomeEntityStateWidget({
if (entityError && entity === undefined) throw entityError;
const isTiny = isSmartHomeTiny(width, height);
const isAdvanced = displayMode === "advanced";
const isTiny = isAdvanced ? isSmartHomeTiny(width, height) : width < 128;
const advancedAttributes = [
{ key: "unit" as const, value: entity?.attributes.unit_of_measurement },
{ key: "deviceClass" as const, value: entity?.attributes.device_class },
{ key: "icon" as const, value: entity?.attributes.icon },
].filter(({ value }) => typeof value === "string" && value.length > 0);
const displayName =
displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
isAdvanced && typeof entity?.attributes.friendly_name === "string"
? entity.attributes.friendly_name
: options.displayName;
const knownStates = {
@@ -133,20 +134,34 @@ export default function SmartHomeEntityStateWidget({
}}
>
<Center h="100%" w="100%">
<Stack align="center" gap={isTiny ? 4 : "md"} p="xs" maw="100%">
<Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%">
{state}
{attribute}
</Text>
<Text ta="center" c="dimmed" fw={500} size={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
{displayName}
</Text>
<Stack align="center" gap={isAdvanced && isTiny ? 4 : "md"} p={isAdvanced ? "xs" : undefined} maw="100%">
{isAdvanced ? (
<>
<Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%">
{state}
{attribute}
</Text>
<Text ta="center" c="dimmed" fw={500} size={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
{displayName}
</Text>
</>
) : (
<>
<Text ta="center" fw="bold" size={isTiny ? "sm" : "lg"} lineClamp={2} maw="100%">
{displayName}
</Text>
<Text ta="center" size={isTiny ? "xs" : "lg"} lineClamp={1} maw="100%">
{state}
{attribute}
</Text>
</>
)}
{queryErrorLabel && (
<Text size="xs" c="orange" ta="center" lineClamp={1} maw="100%" aria-live="polite">
{queryErrorLabel}
</Text>
)}
{displayMode === "advanced" && entity && (
{isAdvanced && entity && (
<>
<Group justify="center" gap={4}>
{advancedAttributes.map(({ key, value }) => (
@@ -10,12 +10,20 @@ import { formatStatsSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTrackerStats; width?: number }) {
export function AveragesSection({
stats,
width = 500,
compactSurface = false,
}: {
stats: SpeedtestTrackerStats;
width?: number;
compactSurface?: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
return (
<Stack gap={6} h="100%">
<SectionLabel>
<SectionLabel uppercase={compactSurface}>
{t("averages")} · {t("tests", { count: stats.total_results.toString() })}
</SectionLabel>
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
@@ -25,6 +33,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
value={formatStatsSpeed(stats.download)}
label={t("avgDownload")}
compact
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconArrowUp}
@@ -32,6 +41,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
value={formatStatsSpeed(stats.upload)}
label={t("avgUpload")}
compact
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconWaveSine}
@@ -39,6 +49,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
value={`${stats.ping.avg.toFixed(1)} ms`}
label={t("avgPing")}
compact
legacySurface={compactSurface}
/>
</SimpleGrid>
</Stack>
@@ -11,7 +11,7 @@ import { IntegrationErrorIndicator } from "../common/integration-error-indicator
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards } from "./helpers";
import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results";
@@ -51,16 +51,19 @@ export default function SpeedtestTrackerWidget({
const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={isAdvanced ? advancedSectionWidth : width} />
<LatestResultSection
result={combined.latestResult}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
);
const averages = showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={isAdvanced ? advancedSectionWidth : width} />
<AveragesSection
stats={combined.stats}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
);
const compactSections = getCompactSections(height, {
latest: Boolean(latest),
chart: hasChart,
averages: Boolean(averages),
});
if (displayMode === "advanced") {
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
@@ -136,15 +139,15 @@ export default function SpeedtestTrackerWidget({
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
</Text>
)}
{(compactSections.latest || compactSections.averages) && (
{(latest || averages) && (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
{latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
</Stack>
)}
{compactSections.chart && (
{hasChart && (
<div style={{ flex: 2, minHeight: 0 }}>
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} />
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} uppercaseLabels />
</div>
)}
{noSectionsEnabled && (
@@ -10,7 +10,15 @@ import { formatResultSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
export function LatestResultSection({ result, width = 500 }: { result: SpeedtestTrackerResult; width?: number }) {
export function LatestResultSection({
result,
width = 500,
compactSurface = false,
}: {
result: SpeedtestTrackerResult;
width?: number;
compactSurface?: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale();
const timestamp = Intl.DateTimeFormat(locale, {
@@ -25,7 +33,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
return (
<Stack gap={6} h="100%">
<Group justify="space-between" align="center" wrap="nowrap">
<SectionLabel>{t("latestResult")}</SectionLabel>
<SectionLabel uppercase={compactSurface}>{t("latestResult")}</SectionLabel>
<Text size="xs" c="dimmed" truncate title={timestamp} style={{ minWidth: 0 }}>
{timestamp}
</Text>
@@ -36,18 +44,21 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
color="blue"
value={formatResultSpeed(result, "download")}
label={t("download")}
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconArrowUp}
color="teal"
value={formatResultSpeed(result, "upload")}
label={t("upload")}
legacySurface={compactSurface}
/>
<SpeedStatCard
icon={IconWaveSine}
color="orange"
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
label={t("ping")}
legacySurface={compactSurface}
/>
{result.healthy !== null && (
<SpeedStatCard
@@ -55,6 +66,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
color={result.healthy ? "green" : "red"}
value={result.healthy ? t("healthy") : t("unhealthy")}
label={t("status")}
legacySurface={compactSurface}
/>
)}
</SimpleGrid>
@@ -114,30 +114,40 @@ function formatTooltipDate(label: number | string | undefined, locale: string):
export function RecentResultsSection({
results,
showPingGraph,
uppercaseLabels = false,
}: {
results: SpeedtestTrackerResult[];
showPingGraph: boolean;
uppercaseLabels?: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
return (
<Stack gap={4} h="100%" style={{ minHeight: 0 }}>
<SectionLabel>{t("recentResults")}</SectionLabel>
<SectionLabel uppercase={uppercaseLabels}>{t("recentResults")}</SectionLabel>
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<SpeedSubChart results={results} showLabel={showPingGraph} />
{showPingGraph && <PingSubChart results={results} />}
<SpeedSubChart results={results} showLabel={showPingGraph} uppercaseLabels={uppercaseLabels} />
{showPingGraph && <PingSubChart results={results} uppercaseLabels={uppercaseLabels} />}
</div>
</Stack>
);
}
function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult[]; showLabel: boolean }) {
function SpeedSubChart({
results,
showLabel,
uppercaseLabels,
}: {
results: SpeedtestTrackerResult[];
showLabel: boolean;
uppercaseLabels: boolean;
}) {
const t = useI18n("widget.speedtestTracker");
const { ref, height } = useElementSize<HTMLDivElement>();
return (
<div style={{ flex: 2, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
{showLabel && <SectionLabel>{t("recentResultsSpeed")}</SectionLabel>}
{showLabel && <SectionLabel uppercase={uppercaseLabels}>{t("recentResultsSpeed")}</SectionLabel>}
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <SpeedHistoryChart results={results} height={height} />}
</div>
@@ -145,13 +155,13 @@ function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult
);
}
function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) {
function PingSubChart({ results, uppercaseLabels }: { results: SpeedtestTrackerResult[]; uppercaseLabels: boolean }) {
const t = useI18n("widget.speedtestTracker");
const { ref, height } = useElementSize<HTMLDivElement>();
return (
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
<SectionLabel>{t("recentResultsPing")}</SectionLabel>
<SectionLabel uppercase={uppercaseLabels}>{t("recentResultsPing")}</SectionLabel>
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <PingHistoryChart results={results} height={height} />}
</div>
@@ -1,9 +1,15 @@
import type { ReactNode } from "react";
import { Text } from "@mantine/core";
export function SectionLabel({ children }: { children: ReactNode }) {
export function SectionLabel({ children, uppercase = false }: { children: ReactNode; uppercase?: boolean }) {
return (
<Text size="xs" fw={600} c="dimmed">
<Text
size="xs"
fw={600}
c="dimmed"
tt={uppercase ? "uppercase" : undefined}
style={{ letterSpacing: uppercase ? "0.05em" : undefined }}
>
{children}
</Text>
);
@@ -13,9 +13,17 @@ export interface SpeedStatCardProps {
value: string;
label: string;
compact?: boolean;
legacySurface?: boolean;
}
export function SpeedStatCard({ icon: Icon, color, value, label, compact = false }: SpeedStatCardProps) {
export function SpeedStatCard({
icon: Icon,
color,
value,
label,
compact = false,
legacySurface = false,
}: SpeedStatCardProps) {
const { ref, height, width } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const isWide = width > height + 20;
@@ -23,7 +31,8 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
const surfaceColor = `var(--mantine-color-${color}-filled)`;
const accentColor = `var(--mantine-color-${color}-5)`;
const surfaceAlpha = compact ? 0.1 : 0.14;
const surfaceBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`;
const modernBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`;
const surfaceBackground = legacySurface ? `var(--mantine-color-${color}-light)` : modernBackground;
const surfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
return (
@@ -32,7 +41,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
ref={ref}
p={compact ? "xs" : "sm"}
radius={board.itemRadius}
withBorder
withBorder={!legacySurface}
bg={surfaceBackground}
h="100%"
style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }}
+25 -14
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip } from "@mantine/core";
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useComputedColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -28,12 +28,12 @@ const advancedColumnBreakpoints = [
{ minimumWidth: 0, columns: 1 },
] as const;
const getSystemDisksLayout = (width: number, height: number, diskCount: number, isAdvanced: boolean) => {
const getSystemDisksLayout = (width: number, diskCount: number, isAdvanced: boolean) => {
if (!isAdvanced) {
return {
columns: 1,
showSecondaryText: height >= 160 && width >= 260,
showTemperature: height >= 100 && width >= 220,
showSecondaryText: false,
showTemperature: true,
};
}
@@ -113,6 +113,7 @@ const SystemDiskCard = ({
showTemperature,
}: SystemDiskCardProps) => {
const board = useRequiredBoard();
const colorScheme = useComputedColorScheme("light");
const t = useI18n("widget.systemDisks");
const valueRef = useRef<HTMLParagraphElement>(null);
const [valueFits, setValueFits] = useState(true);
@@ -149,6 +150,10 @@ const SystemDiskCard = ({
.join(" · ");
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
const legacyBackground = colorScheme === "dark" ? "dark.7" : "gray.1";
const cardBackground = isAdvanced ? backgroundColor : legacyBackground;
const progressBackground = healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)";
const legacyProgressColor = healthy ? "green" : "red";
return (
<Tooltip
@@ -160,13 +165,13 @@ const SystemDiskCard = ({
<Card
radius={board.itemRadius}
py="xs"
withBorder
bg={backgroundColor}
style={{ overflow: "hidden", position: "relative", borderColor }}
withBorder={isAdvanced}
bg={cardBackground}
style={{ overflow: "hidden", position: "relative", borderColor: isAdvanced ? borderColor : undefined }}
>
<Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<Text fw={700} size="sm" truncate="end">
<Text fw={700} size={isAdvanced ? "sm" : undefined} truncate="end">
{deviceName}
</Text>
{integrationName && (
@@ -174,7 +179,11 @@ const SystemDiskCard = ({
{integrationName}
</Text>
)}
<Text ref={valueRef} size="sm" style={{ visibility: valueFits ? "visible" : "hidden" }}>
<Text
ref={valueRef}
size={isAdvanced ? "sm" : undefined}
style={{ visibility: valueFits ? "visible" : "hidden" }}
>
<span>{displayText}</span>
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
</Text>
@@ -195,10 +204,13 @@ const SystemDiskCard = ({
)}
</div>
<div style={{ flexShrink: 0 }}>
{showTemperature && (hasTemperature || isAdvanced) ? <Text size="sm">{temperatureText}</Text> : null}
{showTemperature && (hasTemperature || isAdvanced) ? (
<Text size={isAdvanced ? "sm" : undefined}>{temperatureText}</Text>
) : null}
</div>
</Group>
<Box
bg={isAdvanced ? undefined : legacyProgressColor}
style={{
position: "absolute",
top: 0,
@@ -207,7 +219,7 @@ const SystemDiskCard = ({
height: "100%",
zIndex: 0,
display: showBackgroundBar ? "block" : "none",
backgroundColor: healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)",
backgroundColor: isAdvanced ? progressBackground : undefined,
}}
></Box>
</Card>
@@ -219,7 +231,6 @@ export default function SystemResources({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"systemDisks">) {
const t = useI18n("widget.systemDisks");
@@ -247,8 +258,8 @@ export default function SystemResources({
});
const isAdvanced = displayMode === "advanced";
const layout = useMemo(
() => getSystemDisksLayout(width, height, disks.length, isAdvanced),
[disks.length, height, isAdvanced, width],
() => getSystemDisksLayout(width, disks.length, isAdvanced),
[disks.length, isAdvanced, width],
);
const queryIndicators = (
<Group gap={0}>
@@ -11,6 +11,7 @@ export const CombinedNetworkTrafficChart = ({
usageOverTime,
hasShadow,
labelDisplayMode,
advanced = false,
}: {
usageOverTime: {
up: number;
@@ -18,6 +19,7 @@ export const CombinedNetworkTrafficChart = ({
}[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => {
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
const t = useI18n("widget.systemResources.card");
@@ -35,6 +37,7 @@ export const CombinedNetworkTrafficChart = ({
yAxisProps={{ domain: [0, "dataMax"] }}
chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{
content: ({ payload }) => {
return (
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import { Card, Center, Group, Stack, Text, useMantineTheme } from "@mantine/core";
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts";
@@ -23,6 +23,7 @@ export const CommonChart = ({
yAxisProps,
lastValue,
chartType = "line",
advanced = false,
}: {
data: Record<string, any>[];
dataKey: string;
@@ -34,30 +35,32 @@ export const CommonChart = ({
yAxisProps?: Omit<YAxisProps, "ref">;
lastValue?: string;
chartType?: "line" | "area";
advanced?: boolean;
}) => {
const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard();
const { hovered, ref: hoverRef } = useHover();
const ref = useMergedRef(elementSizeRef, hoverRef);
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
const opacity = board.opacity / 100;
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const cardStyle: CSSProperties = { overflow: "hidden" };
const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] };
if (advanced) {
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
cardStyle.border =
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
chartRootStyle.backgroundColor = backgroundColor;
}
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
return (
<Card
ref={ref}
h={"100%"}
pos={"relative"}
style={{ overflow: "visible", border: `1px solid ${borderColor}` }}
p={0}
bg={backgroundColor}
radius={board.itemRadius}
>
<Card ref={ref} h={"100%"} pos={"relative"} style={cardStyle} p={0} bg={backgroundColor} radius={board.itemRadius}>
{data.length > 1 && height > 40 && !hovered && (
<Group
pos={"absolute"}
@@ -70,7 +73,13 @@ export const CommonChart = ({
style={{ zIndex: 2, pointerEvents: "none" }}
align="center"
>
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} style={zoomCompensatedSize(height > 100 ? 20 : 14)} stroke={1.5} />}
{showIcon && (
<Icon
color="var(--mantine-color-dimmed)"
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
stroke={1.5}
/>
)}
{showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
{title}
@@ -83,7 +92,7 @@ export const CommonChart = ({
)}
</Group>
)}
{data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
{advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
<Text size="xs" fw={600}>
{lastValue}
@@ -93,13 +102,19 @@ export const CommonChart = ({
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}>
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} style={zoomCompensatedSize(height > 100 ? 20 : 14)} stroke={1.5} />}
{showIcon && (
<Icon
color="var(--mantine-color-dimmed)"
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
stroke={1.5}
/>
)}
{showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
{title}
</Text>
)}
{lastValue && (
{advanced && lastValue && (
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
{lastValue}
</Text>
@@ -119,15 +134,14 @@ export const CommonChart = ({
withYAxis={false}
withDots={false}
bg={backgroundColor}
styles={{
root: {
padding: 5,
borderRadius: theme.radius[board.itemRadius],
backgroundColor,
},
}}
styles={{ root: chartRootStyle }}
tooltipAnimationDuration={200}
tooltipProps={tooltipProps}
tooltipProps={{
// Render via portal so the tooltip isn't clipped by the card's overflow:hidden
// (needed to stop the chart line itself bleeding past its rounded corners).
portal: typeof document !== "undefined" ? document.body : undefined,
...tooltipProps,
}}
withTooltip={height >= 64}
yAxisProps={yAxisProps}
fillOpacity={chartType === "area" ? 0.3 : undefined}
@@ -10,10 +10,12 @@ export const SystemResourceCPUChart = ({
cpuUsageOverTime,
hasShadow,
labelDisplayMode,
advanced = false,
}: {
cpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => {
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
@@ -31,6 +33,7 @@ export const SystemResourceCPUChart = ({
chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
@@ -10,10 +10,12 @@ export const SystemResourceGPUChart = ({
gpuUsageOverTime,
hasShadow,
labelDisplayMode,
advanced = false,
}: {
gpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => {
const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage }));
@@ -31,6 +33,7 @@ export const SystemResourceGPUChart = ({
chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more