Compare commits

...
105 changed files with 1603 additions and 1285 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} /> <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. It also supports a status check to indicate whether Homarr can reach the application or website.
### Screenshots ### 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. 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 ### Supported Integrations
<WidgetIntegrations <WidgetIntegrations
@@ -17,7 +17,7 @@ import beszelAlerts from "./img/beszel-alerts.png";
<WidgetHeader widget={beszelAlertsWidget} categories={["System Monitoring", "Alerts"]} /> <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 ### Screenshots
+1 -1
View File
@@ -15,7 +15,7 @@ import { coolifyIntegration } from "@site/docs/integrations/coolify";
<WidgetHeader widget={coolifyWidget} categories={["System monitoring", "Deployment"]} /> <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 ### 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. 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 ### Screenshots
@@ -12,7 +12,7 @@ import { dockerContainersWidget } from ".";
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} /> <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 :::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. 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 ### 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 - **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 - **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 - **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 :::note
Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state Compact layouts use their established width breakpoints: below 500px wide, speed columns are hidden; below 300px wide,
columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also the ETA and state columns are hidden too. Height does not remove configured columns or the footer. Advanced view bypasses
hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions, the compact width reductions, keeps the selected column set, and always includes the stats bar and footer controls, with
keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available the filter panel available from the footer.
from the footer.
The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and 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 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"]} /> <WidgetHeader widget={firewallWidget} categories={["Firewall"]} />
Compact mode keeps the configured firewall summary and interface section visible without dropping interfaces based on widget height.
### Screenshots ### Screenshots
![Firewall Widget](img/firewall_widget.png) ![Firewall Widget](img/firewall_widget.png)
@@ -15,6 +15,8 @@ import { immichServerStatsWidget } from ".";
<WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} /> <WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} />
Compact mode presents every enabled statistic as a vertical icon, label, and value card.
### Screenshots ### Screenshots
import screen from "./img/screen.png"; import screen from "./img/screen.png";
@@ -14,6 +14,8 @@ import { indexerManagerWidget } from ".";
<WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} /> <WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} />
Compact mode keeps the titled indexer list, per-indexer status indicators, and full-width **Test all** action together.
### Screenshots ### Screenshots
import bigItem from "./img/big.png"; import bigItem from "./img/big.png";
@@ -17,7 +17,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaReleasesWidget} categories={["Releases", "Media"]} /> <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. 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"]} /> <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. 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"]} /> <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 ### Supported Integrations
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} /> <WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget shows active sessions across your media servers. 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. 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. 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"]} /> <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 ### Screenshots
@@ -20,7 +20,7 @@ import { networkControllerStatusWidget } from ".";
Shows connected users and connected guest devices on wired or wireless networks. 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 ### Screenshots
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} /> <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 ### 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 /> <WidgetInteractionGuide />
@@ -20,7 +20,7 @@ import { gotifyIntegration } from "@site/docs/integrations/gotify";
<WidgetHeader widget={notificationsWidget} categories={["Notifications", "Messaging"]} /> <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 ### Screenshots
@@ -15,7 +15,7 @@ import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx";
<WidgetHeader widget={paperlessNgxWidget} categories={["Documents"]} /> <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 ### Supported Integrations
+2 -2
View File
@@ -13,7 +13,7 @@ import { releasesWidget } from ".";
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} /> <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. It also supports importing releases from your local docker containers.
### Screenshots ### 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`. 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. 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 /> <WidgetInteractionGuide />
+1 -1
View File
@@ -13,7 +13,7 @@ import { rssFeedWidget } from ".";
<WidgetHeader widget={rssFeedWidget} categories={["Feeds"]} /> <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 ### Screenshots
@@ -18,7 +18,7 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
<WidgetHeader widget={smartHomeEntityStateWidget} categories={["Smart home"]} /> <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. 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. 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, 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. 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. 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"]} /> <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 ### Supported Integrations
@@ -18,7 +18,7 @@ import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} /> <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 ### Screenshots
+1 -1
View File
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} /> <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 ### Screenshots
+1 -1
View File
@@ -15,7 +15,7 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
<WidgetHeader widget={tracearrWidget} categories={["Media Monitoring"]} /> <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. 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. 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. 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"]} /> <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 ### Supported Integrations
+1 -1
View File
@@ -15,7 +15,7 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
<WidgetHeader widget={uptimeKumaWidget} categories={["Monitoring"]} /> <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. Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
@@ -334,6 +334,7 @@ const LoadedBoardItemContent = ({
item={item} item={item}
width={width} width={width}
height={height} height={height}
displayScale={boardCanvasScale}
widgetStateRef={widgetStateRef} widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef} widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"} displayMode={isAdvanced ? "advanced" : "compact"}
@@ -436,6 +437,7 @@ interface InnerContentProps {
item: SectionItem; item: SectionItem;
width: number; width: number;
height: number; height: number;
displayScale: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef; widgetRuntimeRef: WidgetRuntimeRef;
displayMode: "compact" | "advanced"; displayMode: "compact" | "advanced";
@@ -30,11 +30,15 @@
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1)); --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-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-xl: calc(2rem * 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-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-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-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-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-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-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-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)); --mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
+7 -1
View File
@@ -3179,7 +3179,8 @@
"disabled": "Disabled" "disabled": "Disabled"
}, },
"error": { "error": {
"internalServerError": "Failed to fetch indexers status" "internalServerError": "Failed to fetch indexers status",
"testAll": "The indexer test failed. Try again."
} }
}, },
"healthMonitoring": { "healthMonitoring": {
@@ -4249,6 +4250,7 @@
"failed": "Failed", "failed": "Failed",
"completed": "Completed" "completed": "Completed"
}, },
"unknown": "Unknown",
"toBeDetermined": "TBD" "toBeDetermined": "TBD"
}, },
"mediaRequests-requestStats": { "mediaRequests-requestStats": {
@@ -4961,6 +4963,10 @@
"empty": "No alerts configured", "empty": "No alerts configured",
"history": "History", "history": "History",
"resolved": "Resolved", "resolved": "Resolved",
"alertDescription": {
"statusDuration": "Down for {minutes} min",
"threshold": "Exceeds {value} over {minutes} min"
},
"status": { "status": {
"triggered": "Triggered", "triggered": "Triggered",
"ok": "OK" "ok": "OK"
+6 -6
View File
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { Fragment, Suspense } 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 { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx"; import combineClasses from "clsx";
@@ -59,19 +59,19 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
<Text <Text
className="app-title" className="app-title"
fw={700} fw={700}
size={isTiny ? "xs" : "sm"} size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined} ta={isColumnLayout ? "center" : undefined}
> >
{app.name} {app.name}
</Text> </Text>
)} )}
{options.descriptionDisplayMode === "normal" && !isTiny && ( {options.descriptionDisplayMode === "normal" && (
<Text <Text
className="app-description" className="app-description"
size="sm" size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined} ta={isColumnLayout ? "center" : undefined}
c="dimmed" c="dimmed"
lineClamp={Math.max(1, Math.floor((height - 48) / 18))} lineClamp={4}
> >
{app.description?.split("\n").map((line, index) => ( {app.description?.split("\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
@@ -91,7 +91,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
height: "100%", height: "100%",
width: "100%", width: "100%",
minWidth: "20%", minWidth: "20%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%", maxWidth: isColumnLayout ? undefined : "50%",
}} }}
/> />
</Flex> </Flex>
@@ -7,16 +7,10 @@ import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homar
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getCompactStatLayout } from "../common/compact-stat-layout";
import classes from "./component.module.css"; import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared"; import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
import { import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
AUDIO_STATS_TRANSLATION_SCOPE,
getGridCols,
getIconSize,
prioritizeVisibleStats,
getVisibleStatLimit,
getVisibleStats,
} from "./shared";
const rootClassByCompact = { const rootClassByCompact = {
true: classes.rootCompact, true: classes.rootCompact,
@@ -33,11 +27,49 @@ const statTileClassByCompact = {
false: "", false: "",
} as const; } 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 { interface AudioStatsContentProps {
backend: AudioStatsBackend; backend: AudioStatsBackend;
stats: NavidromeDashboardData | AudiobookshelfDashboardData; stats: NavidromeDashboardData | AudiobookshelfDashboardData;
options: AudioStatsDisplayOptions; options: AudioStatsDisplayOptions;
showAllStats?: boolean; showAllStats?: boolean;
advanced?: boolean;
width: number; width: number;
height: number; height: number;
} }
@@ -47,29 +79,45 @@ export function AudioStatsContent({
stats, stats,
options, options,
showAllStats = false, showAllStats = false,
advanced = false,
width, width,
height, height,
}: AudioStatsContentProps) { }: AudioStatsContentProps) {
const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE); const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const compact = options.compactMode ?? false; const compact = options.compactMode ?? false;
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact); const visibleStats = getVisibleStats(backend, options, stats, showAllStats);
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
const compactKey = String(compact) as keyof typeof rootClassByCompact; const compactKey = String(compact) as keyof typeof rootClassByCompact;
const gridCols = getGridCols(width, visibleStats.length, compact); const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced;
const iconSize = getIconSize(width, compact); 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 ( return (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}> <div className={`${classes.root} ${rootClassByCompact[compactKey]} ${rootClassByLayout[layout.state]}`}>
{visibleStats.length > 0 && ( {visibleStats.length > 0 && (
<div <div
className={`${classes.grid} ${gridClassByCompact[compactKey]}`} className={`${classes.grid} ${gridClassByCompact[compactKey]} ${gridClassByLayout[layout.state]}`}
style={{ "--stat-cols": gridCols } as CSSProperties} style={{ "--stat-cols": layout.columns } as CSSProperties}
> >
{visibleStats.map(({ optionKey, statKey, value, Icon }) => ( {visibleStats.map(({ optionKey, statKey, value, Icon }) => (
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}> <div
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} /> key={optionKey}
<span className={classes.statValue}>{value}</span> className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]} ${statTileClassByLayout[layout.state]}`}
<span className={classes.statLabel}>{t(statKey as never)}</span> >
<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>
))} ))}
</div> </div>
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared"; import { getGridCols, getIconSize, getVisibleStats } from "./shared";
describe("getGridCols", () => { describe("getGridCols", () => {
test("caps columns to visible stat count", () => { 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", () => { describe("getVisibleStats", () => {
const navidromeStats: NavidromeDashboardData = { const navidromeStats: NavidromeDashboardData = {
artistCount: 100, artistCount: 100,
@@ -117,21 +106,3 @@ describe("getVisibleStats", () => {
expect(typeof result[0]?.value).toBe("string"); 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); gap: clamp(2px, 1cqi, 4px);
} }
.rootShort {
padding: 4px;
gap: 2px;
}
.grid { .grid {
display: grid; display: grid;
flex: 1; flex: 1;
@@ -29,6 +34,10 @@
gap: clamp(2px, 1cqi, 4px); gap: clamp(2px, 1cqi, 4px);
} }
.gridShort {
gap: 2px;
}
.statTile { .statTile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -40,16 +49,31 @@
gap: clamp(2px, 1.5cqi, 4px); gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px); padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md); 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; overflow: hidden;
} }
.statTileAdvanced {
background-color: transparent;
}
.statTileCompact { .statTileCompact {
gap: clamp(1px, 0.8cqi, 2px); gap: clamp(1px, 0.8cqi, 2px);
padding: clamp(2px, 1.2cqi, 5px); padding: clamp(2px, 1.2cqi, 5px);
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
} }
.statTileShort {
gap: 2px;
padding: 3px;
}
.statTileNarrowShort {
flex-direction: row;
gap: 2px;
padding: 1px 3px;
}
.statIcon { .statIcon {
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
flex-shrink: 0; flex-shrink: 0;
@@ -57,7 +81,12 @@
} }
.statLabel { .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); color: var(--mantine-color-dimmed);
line-height: 1.2; line-height: 1.2;
text-align: center; text-align: center;
@@ -68,11 +97,32 @@
} }
.statTileCompact .statLabel { .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 { .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; font-weight: 700;
color: var(--mantine-primary-color-filled); color: var(--mantine-primary-color-filled);
line-height: 1.1; line-height: 1.1;
@@ -84,7 +134,16 @@
} }
.statTileCompact .statValue { .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 { .emptyState {
+12 -3
View File
@@ -18,6 +18,7 @@ export default function AudioStatsWidget({
options, options,
width, width,
height, height,
displayScale = 1,
displayMode = "compact", displayMode = "compact",
widgetRuntimeRef, widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) { }: WidgetComponentProps<"audioStats">) {
@@ -39,14 +40,22 @@ export default function AudioStatsWidget({
const currentStreams = streamResults ?? []; const currentStreams = streamResults ?? [];
if (!response) return <WidgetEmptyState />; if (!response) return <WidgetEmptyState />;
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const summary = ( const summary = (
<AudioStatsContent <AudioStatsContent
backend={response.kind} backend={response.kind}
stats={response.data} stats={response.data}
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }} options={{ ...options, compactMode: displayMode === "compact" && options.compactMode }}
showAllStats={displayMode === "advanced"} showAllStats={displayMode === "advanced"}
width={width} advanced={displayMode === "advanced"}
height={height} width={responsiveWidth}
height={responsiveHeight}
/> />
); );
if (displayMode === "compact") { if (displayMode === "compact") {
@@ -156,23 +156,6 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea
return Math.min(maxCols, Math.max(visibleCount, 1)); 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; const fallbackIconSize = { true: 14, false: 16 } as const;
export const getIconSize = (width: number, compact: boolean): number => { export const getIconSize = (width: number, compact: boolean): number => {
@@ -1,20 +1,7 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
Badge,
Box,
Center,
Divider,
Group,
Loader,
ScrollArea,
Stack,
Text,
ThemeIcon,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react"; import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query"; import { getQueryKey } from "@trpc/react-query";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -53,7 +40,6 @@ export default function BeszelAlertsWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
height,
displayMode, displayMode,
widgetRuntimeRef, widgetRuntimeRef,
}: WidgetComponentProps<"beszelAlerts">) { }: WidgetComponentProps<"beszelAlerts">) {
@@ -109,9 +95,7 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered); const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = 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;
const showHistory = isAdvanced || (options.showHistory && height >= 360);
const showAlertDescriptions = isAdvanced || height >= 190;
if (isPending) { if (isPending) {
return ( return (
@@ -145,7 +129,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}> <Stack gap={6}>
<Group gap={6}> <Group gap={6}>
<IconFlame size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-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}) {t("status.triggered")} ({triggeredAlerts.length})
</Text> </Text>
</Group> </Group>
@@ -158,19 +142,19 @@ export default function BeszelAlertsWidget({
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)} systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined} integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered triggered
showDescription={showAlertDescriptions} isAdvanced={isAdvanced}
/> />
))} ))}
</Stack> </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}> <Stack gap={6}>
<Group gap={6}> <Group gap={6}>
<IconCircleCheck size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-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}) {t("status.ok")} ({okAlerts.length})
</Text> </Text>
</Group> </Group>
@@ -183,7 +167,7 @@ export default function BeszelAlertsWidget({
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)} systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined} integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered={false} triggered={false}
showDescription={showAlertDescriptions} isAdvanced={isAdvanced}
/> />
))} ))}
</Stack> </Stack>
@@ -195,7 +179,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}> <Stack gap={6}>
<Group gap={6}> <Group gap={6}>
<IconHistory size="var(--mantine-font-size-sm)" opacity={0.5} /> <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")} {t("history")}
</Text> </Text>
</Group> </Group>
@@ -250,7 +234,7 @@ interface AlertRowProps {
systemName: string; systemName: string;
integrationName?: string; integrationName?: string;
triggered: boolean; triggered: boolean;
showDescription: boolean; isAdvanced: boolean;
} }
const unitSuffixMap: Record<string, string> = { const unitSuffixMap: Record<string, string> = {
@@ -268,49 +252,46 @@ const unitSuffixMap: Record<string, string> = {
Status: "", Status: "",
}; };
function formatAlertDescription(name: string, value: number, min: number): string { function AlertRow({ name, value, min, systemName, integrationName, triggered, isAdvanced }: AlertRowProps) {
const suffix = unitSuffixMap[name] ?? ""; const t = useI18n("widget.beszelAlerts");
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) {
const Icon = alertIconMap[name] ?? Server; 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 ( return (
<Tooltip label={description} disabled={showDescription} withArrow> <Group wrap="nowrap" gap="xs" py={4} pr={8} pl={isAdvanced ? 8 : 12} style={rowStyle}>
<Group <Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} />
wrap="nowrap" <Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
gap="xs" <Group gap={6} wrap="nowrap">
py={showDescription ? 4 : 2} <Text size="xs" fw={600} truncate>
px={8} {integrationName ? `${systemName} · ${integrationName}` : systemName}
style={{ </Text>
borderRadius: "var(--mantine-radius-sm)", <Text size="xs" c="dimmed">
border: `1px solid var(--mantine-color-${triggered ? "red" : "gray"}-4)`, ·
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)", </Text>
}} <Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
> {name}
<Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} /> </Text>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}> </Group>
<Group gap={6} wrap="nowrap"> <Text size="xs" c="dimmed" truncate>
<Text size="xs" fw={600} truncate> {description}
{integrationName ? `${systemName} · ${integrationName}` : systemName} </Text>
</Text> </Stack>
<Text size="xs" c="dimmed"> </Group>
·
</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>
); );
} }
@@ -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, options,
integrationIds, integrationIds,
width, width,
height,
displayMode, displayMode,
}: WidgetComponentProps<"coolify">) { }: WidgetComponentProps<"coolify">) {
const t = useI18n("widget.coolify"); const t = useI18n("widget.coolify");
@@ -38,7 +37,6 @@ export default function CoolifyWidget({
integrationIds={integrationIds} integrationIds={integrationIds}
options={{ ...options, ...sectionVisibility }} options={{ ...options, ...sectionVisibility }}
width={width} width={width}
height={height}
isAdvanced={isAdvanced} isAdvanced={isAdvanced}
/> />
); );
@@ -48,11 +46,10 @@ interface CoolifyContentProps {
integrationIds: string[]; integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"]; options: WidgetComponentProps<"coolify">["options"];
width: number; width: number;
height: number;
isAdvanced: boolean; isAdvanced: boolean;
} }
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) { function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
const t = useI18n("common"); const t = useI18n("common");
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({ const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds, integrationIds,
@@ -64,8 +61,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
); );
const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null); const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null);
const isTiny = width < 256 || height < 144; const isTiny = width < 256;
const hideFooter = height < 112;
const [firstInstance] = successfulInstances; const [firstInstance] = successfulInstances;
const widgetKey = createWidgetKey(integrationIds); const widgetKey = createWidgetKey(integrationIds);
@@ -100,7 +96,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
isTiny={!isAdvanced && isTiny} isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced} isAdvanced={isAdvanced}
widgetKey={widgetKey} widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/> />
</div> </div>
</Stack> </Stack>
@@ -120,7 +115,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
isTiny={!isAdvanced && isTiny} isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced} isAdvanced={isAdvanced}
widgetKey={widgetKey} widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/> />
))} ))}
</SimpleGrid> </SimpleGrid>
+7 -38
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; 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 { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common"; import { useTimeAgo } from "@homarr/common";
@@ -22,12 +22,10 @@ interface InstanceCardProps {
isTiny: boolean; isTiny: boolean;
isAdvanced: boolean; isAdvanced: boolean;
widgetKey: string; 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 t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const cardKey = `${widgetKey}-${instance.integrationId}`; const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({ const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${cardKey}`, key: `coolify-show-ip-${cardKey}`,
@@ -53,32 +51,10 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
const runningServices = instance.instanceInfo.services.filter( const runningServices = instance.instanceInfo.services.filter(
(s) => parseStatus(s.status ?? "") === "running", (s) => parseStatus(s.status ?? "") === "running",
).length; ).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 ( return (
<Card p={0} radius="sm" bg="transparent"> <Card p={0} radius="sm" bg="transparent">
<Group <Group p="xs" justify="space-between" wrap="nowrap" className={classes.neutralDividerBottom}>
p="xs"
justify="space-between"
wrap="nowrap"
className={classes.neutralDividerBottom}
>
<Group gap={4} wrap="nowrap" miw={0}> <Group gap={4} wrap="nowrap" miw={0}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} /> <Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Stack gap={0} miw={0}> <Stack gap={0} miw={0}>
@@ -102,19 +78,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
</Stack> </Stack>
</Group> </Group>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
{isTiny && totalResources > 0 ? ( {options.showServers && (
<Tooltip label={resourceSummary}>
<Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs">
{healthyResources}/{totalResources}
</Badge>
</Tooltip>
) : null}
{!isTiny && options.showServers && (
<Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs"> <Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs">
{onlineServers}/{instance.instanceInfo.servers.length} {onlineServers}/{instance.instanceInfo.servers.length}
</Badge> </Badge>
)} )}
{!isTiny && options.showApplications && ( {options.showApplications && (
<Badge <Badge
variant="dot" variant="dot"
color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)} color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)}
@@ -123,7 +92,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
{runningApps}/{instance.instanceInfo.applications.length} {runningApps}/{instance.instanceInfo.applications.length}
</Badge> </Badge>
)} )}
{!isTiny && options.showServices && ( {options.showServices && (
<Badge <Badge
variant="dot" variant="dot"
color={getBadgeColor(runningServices, instance.instanceInfo.services.length)} color={getBadgeColor(runningServices, instance.instanceInfo.services.length)}
@@ -168,7 +137,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
)} )}
</Accordion> </Accordion>
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />} <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
</Card> </Card>
); );
} }
@@ -114,7 +114,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
<IconLink size="var(--mantine-font-size-xs)" /> <IconLink size="var(--mantine-font-size-xs)" />
</ActionIcon> </ActionIcon>
)} )}
{!isTiny && logsUrl && ( {logsUrl && (
<ActionIcon <ActionIcon
className={actionTargetClasses.root} className={actionTargetClasses.root}
component="a" component="a"
@@ -129,12 +129,10 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
<IconFileText size="var(--mantine-font-size-xs)" /> <IconFileText size="var(--mantine-font-size-xs)" />
</ActionIcon> </ActionIcon>
)} )}
{!isTiny && ( <Text fz="10px" c="dimmed" lineClamp={1}>
<Text fz="10px" c="dimmed" lineClamp={1}> {item.projectName ?? "-"} / {item.environmentName ?? "-"}
{item.projectName ?? "-"} / {item.environmentName ?? "-"} </Text>
</Text> {resourceTimestamp && (
)}
{!isTiny && resourceTimestamp && (
<Text fz="10px" c="dimmed" ml="auto"> <Text fz="10px" c="dimmed" ml="auto">
{resourceTimestamp} {resourceTimestamp}
</Text> </Text>
@@ -22,17 +22,9 @@ interface SingleInstanceLayoutProps {
isTiny: boolean; isTiny: boolean;
isAdvanced: boolean; isAdvanced: boolean;
widgetKey: string; widgetKey: string;
hideFooter: boolean;
} }
export function SingleInstanceLayout({ export function SingleInstanceLayout({ instance, options, isTiny, isAdvanced, widgetKey }: SingleInstanceLayoutProps) {
instance,
options,
isTiny,
isAdvanced,
widgetKey,
hideFooter,
}: SingleInstanceLayoutProps) {
const t = useI18n("widget.coolify"); const t = useI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({ const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`, key: `coolify-show-ip-${widgetKey}`,
@@ -115,7 +107,7 @@ export function SingleInstanceLayout({
)} )}
</Accordion> </Accordion>
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />} <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
</Stack> </Stack>
</ScrollArea> </ScrollArea>
); );
+1
View File
@@ -180,6 +180,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void; setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number; width: number;
height: number; height: number;
displayScale?: number;
widgetRuntimeRef?: WidgetRuntimeRef; widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>; widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
removeItem?: () => void; removeItem?: () => void;
@@ -57,7 +57,7 @@ export default function DnsHoleControlsWidget({
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width,
height, displayMode,
}: WidgetComponentProps<typeof widgetKind>) { }: WidgetComponentProps<typeof widgetKind>) {
const board = useRequiredBoard(); const board = useRequiredBoard();
// DnsHole integrations with interaction permissions // DnsHole integrations with interaction permissions
@@ -236,7 +236,7 @@ export default function DnsHoleControlsWidget({
t={t} t={t}
hasIconColor={board.iconColor !== null} hasIconColor={board.iconColor !== null}
rootWidth={width} rootWidth={width}
rootHeight={height} isAdvanced={displayMode === "advanced"}
actionsPending={actionsPending} actionsPending={actionsPending}
/> />
))} ))}
@@ -275,7 +275,7 @@ interface ControlsCardProps {
t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>; t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>;
hasIconColor: boolean; hasIconColor: boolean;
rootWidth: number; rootWidth: number;
rootHeight: number; isAdvanced: boolean;
actionsPending: boolean; actionsPending: boolean;
} }
@@ -288,7 +288,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
t, t,
hasIconColor, hasIconColor,
rootWidth, rootWidth,
rootHeight, isAdvanced,
actionsPending, actionsPending,
}) => { }) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 }); const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
@@ -299,7 +299,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
const board = useRequiredBoard(); const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl; const iconUrl = integrationDefs[data.integration.kind].iconUrl;
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md"; const layout = !isAdvanced && rootWidth < 256 ? "sm" : "md";
return ( return (
<Indicator <Indicator
+3 -29
View File
@@ -16,7 +16,6 @@ import {
Stack, Stack,
Text, Text,
Tooltip, Tooltip,
VisuallyHidden,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconBrandDocker, 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 = ( const createColumns = (
t: ReturnType<typeof useI18n<"docker">>, t: ReturnType<typeof useI18n<"docker">>,
tCommon: ReturnType<typeof useI18n<"common">>, tCommon: ReturnType<typeof useI18n<"common">>,
handlers: ContainerActionHandlers, handlers: ContainerActionHandlers,
sortingEnabled: boolean, sortingEnabled: boolean,
inlineState: boolean,
): DataTableColumn<DockerContainer>[] => [ ): DataTableColumn<DockerContainer>[] => [
{ {
accessor: "name", accessor: "name",
@@ -147,7 +124,6 @@ const createColumns = (
sortable: sortingEnabled, sortable: sortingEnabled,
render: (container) => ( render: (container) => (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}> <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar <Avatar
variant="outline" variant="outline"
radius="sm" radius="sm"
@@ -387,14 +363,12 @@ export default function DockerWidget({
() => getDockerColumnVisibility(options.columns, width, isAdvanced), () => getDockerColumnVisibility(options.columns, width, isAdvanced),
[isAdvanced, options.columns, width], [isAdvanced, options.columns, width],
); );
const inlineState =
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
const columns = useMemo(() => { const columns = useMemo(() => {
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode; 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], ({ 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({ const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns, columns,
columnAccessors, columnAccessors,
@@ -435,7 +409,7 @@ export default function DockerWidget({
<Box style={{ flex: 1, minHeight: 0 }}> <Box style={{ flex: 1, minHeight: 0 }}>
<HomarrDataTable <HomarrDataTable
isEditMode={isEditMode} isEditMode={isEditMode}
cellPadding="2px 8px" cellPadding={isAdvanced || width < 400 ? "2px 8px" : "4px 8px"}
className="docker-table" className="docker-table"
rowCursor="default" rowCursor="default"
fetching={isFetching && containers.length === 0} 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; const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
describe("getDockerColumnVisibility", () => { 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({ expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
name: true, name: true,
state: false, state: true,
host: false, host: true,
cpuUsage: false, cpuUsage: true,
memoryUsage: false, memoryUsage: true,
actions: true, actions: true,
}); });
}); });
test("reveals compact metrics as width becomes available", () => { test("honors the configured compact column set at every width", () => {
expect(getDockerColumnVisibility(columns, 440, false)).toMatchObject({ expect(getDockerColumnVisibility(["name", "memoryUsage"], 200, false)).toEqual({
name: true,
state: false,
host: false, host: false,
cpuUsage: true, cpuUsage: false,
memoryUsage: 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", () => { test("uses every expert column in advanced mode", () => {
@@ -41,9 +48,9 @@ describe("getDockerColumnVisibility", () => {
}); });
describe("getDockerFooterVisibility", () => { describe("getDockerFooterVisibility", () => {
test("reveals totals only when they fit", () => { test("shows the full compact footer above its threshold", () => {
expect(getDockerFooterVisibility(300, false)).toEqual({ footer: true, cpu: false, memory: false }); expect(getDockerFooterVisibility(256, false)).toEqual({ footer: false, cpu: false, memory: false });
expect(getDockerFooterVisibility(400, false)).toEqual({ footer: true, cpu: true, memory: false }); expect(getDockerFooterVisibility(257, false)).toEqual({ footer: true, cpu: true, memory: true });
}); });
test("keeps every total in advanced mode", () => { 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 = ( export const getDockerColumnVisibility = (
configuredColumns: readonly DockerColumn[], configuredColumns: readonly DockerColumn[],
width: number, _width: number,
isAdvanced: boolean, isAdvanced: boolean,
): Record<DockerColumn, boolean> => { ): Record<DockerColumn, boolean> => {
const configured = new Set(configuredColumns); const configured = new Set(configuredColumns);
@@ -10,16 +10,16 @@ export const getDockerColumnVisibility = (
return { return {
name: isVisible("name"), name: isVisible("name"),
state: isVisible("state") && (isAdvanced || width >= 340 || !configured.has("name")), state: isVisible("state"),
host: isVisible("host") && (isAdvanced || width >= 640), host: isVisible("host"),
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 420), cpuUsage: isVisible("cpuUsage"),
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 520), memoryUsage: isVisible("memoryUsage"),
actions: isVisible("actions"), actions: isVisible("actions"),
}; };
}; };
export const getDockerFooterVisibility = (width: number, isAdvanced: boolean) => ({ export const getDockerFooterVisibility = (width: number, isAdvanced: boolean) => ({
footer: isAdvanced || width > 256, footer: isAdvanced || width > 256,
cpu: isAdvanced || width >= 360, cpu: isAdvanced || width > 256,
memory: isAdvanced || width >= 480, memory: isAdvanced || width > 256,
}); });
+18 -22
View File
@@ -159,10 +159,8 @@ const speedColumnConfig = {
up: { Icon: IconUpload, color: "green" as const }, up: { Icon: IconUpload, color: "green" as const },
} 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 (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) { for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
if (width < maxWidth) return config; if (width < maxWidth) return config;
} }
@@ -796,25 +794,23 @@ export default function DownloadClientsWidget({
/> />
</Box> </Box>
{(isAdvanced || height >= 96) && ( <WidgetFooter
<WidgetFooter key={`footer-${displayMode}`}
key={`footer-${displayMode}`} clients={clients}
clients={clients} totalSpeed={totalSpeed}
totalSpeed={totalSpeed} totalUpSpeed={totalUpSpeed}
totalUpSpeed={totalUpSpeed} globalRatio={globalRatio}
globalRatio={globalRatio} hasTorrents={hasTorrents}
hasTorrents={hasTorrents} clientFilter={clientFilter}
clientFilter={clientFilter} setClientFilter={setClientFilter}
setClientFilter={setClientFilter} statusFilter={statusFilter}
statusFilter={statusFilter} setStatusFilter={setStatusFilter}
setStatusFilter={setStatusFilter} availableStatuses={availableStatuses}
availableStatuses={availableStatuses} pauseQueue={mutatePauseQueue}
pauseQueue={mutatePauseQueue} resumeQueue={mutateResumeQueue}
resumeQueue={mutateResumeQueue} showStats={statsDisplay.visible}
showStats={statsDisplay.visible} toggleStats={statsDisplay.canToggle ? toggleStats : undefined}
toggleStats={statsDisplay.canToggle ? toggleStats : undefined} />
/>
)}
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />} {contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />}
</Stack> </Stack>
+13 -19
View File
@@ -68,7 +68,6 @@ export default function FirewallWidget({
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && (width < 256 || height < 180); const isTiny = !isAdvanced && (width < 256 || height < 180);
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100; const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
const showInterfaces = isAdvanced || (!isTiny && height >= 120);
const t = useI18n("widget.firewall"); const t = useI18n("widget.firewall");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
@@ -179,7 +178,6 @@ export default function FirewallWidget({
hasError={hasError} hasError={hasError}
isAdvanced={isAdvanced} isAdvanced={isAdvanced}
ringSize={ringSize} ringSize={ringSize}
showInterfaces={showInterfaces}
accordionValue={accordionValue} accordionValue={accordionValue}
setAccordionValue={setAccordionValue} setAccordionValue={setAccordionValue}
errorLabel={t("error.internalServerError")} errorLabel={t("error.internalServerError")}
@@ -210,7 +208,6 @@ interface FirewallPanelProps {
hasError: boolean; hasError: boolean;
isAdvanced: boolean; isAdvanced: boolean;
ringSize: number; ringSize: number;
showInterfaces: boolean;
accordionValue: string | null; accordionValue: string | null;
setAccordionValue: (value: string | null) => void; setAccordionValue: (value: string | null) => void;
errorLabel: string; errorLabel: string;
@@ -234,7 +231,6 @@ const FirewallPanel = ({
hasError, hasError,
isAdvanced, isAdvanced,
ringSize, ringSize,
showInterfaces,
accordionValue, accordionValue,
setAccordionValue, setAccordionValue,
errorLabel, errorLabel,
@@ -283,21 +279,19 @@ const FirewallPanel = ({
)} )}
</Flex> </Flex>
{showInterfaces && ( <InterfacesPanel
<InterfacesPanel summary={interfaces ?? []}
summary={interfaces ?? []} hasResult={interfacesLoaded}
hasResult={interfacesLoaded} hasError={interfacesError}
hasError={interfacesError} isAdvanced={isAdvanced}
isAdvanced={isAdvanced} accordionValue={accordionValue}
accordionValue={accordionValue} setAccordionValue={setAccordionValue}
setAccordionValue={setAccordionValue} errorLabel={errorLabel}
errorLabel={errorLabel} errorBadgeLabel={errorBadgeLabel}
errorBadgeLabel={errorBadgeLabel} noDataLabel={noDataLabel}
noDataLabel={noDataLabel} loadingLabel={loadingLabel}
loadingLabel={loadingLabel} label={interfacesLabel}
label={interfacesLabel} />
/>
)}
</Stack> </Stack>
</Paper> </Paper>
); );
@@ -14,9 +14,21 @@
background-color: transparent; background-color: transparent;
} }
@media (forced-colors: active) { .statItemCompact {
.statItem, padding: 12px;
.statItemDense { border-radius: var(--mantine-radius-md);
border-color: CanvasText; 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", key: "users",
enabled: statVisibility.showUsers, enabled: statVisibility.showUsers,
icon: <IconUsers style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />, icon: <IconUsers style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("users"), label: t("users"),
value: stats.userCount.toLocaleString(locale), value: stats.userCount.toLocaleString(locale),
}, },
{ {
key: "photos", key: "photos",
enabled: statVisibility.showPhotos, enabled: statVisibility.showPhotos,
icon: <IconPhoto style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />, icon: <IconPhoto style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("photos"), label: t("photos"),
value: stats.photoCount.toLocaleString(locale), value: stats.photoCount.toLocaleString(locale),
}, },
{ {
key: "videos", key: "videos",
enabled: statVisibility.showVideos, enabled: statVisibility.showVideos,
icon: <IconVideo style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />, icon: <IconVideo style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("videos"), label: t("videos"),
value: stats.videoCount.toLocaleString(locale), value: stats.videoCount.toLocaleString(locale),
}, },
{ {
key: "storage", key: "storage",
enabled: statVisibility.showStorage, enabled: statVisibility.showStorage,
icon: <IconDatabase style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />, icon: <IconDatabase style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
label: t("storage"), label: t("storage"),
value: formatBytes(stats.totalLibraryUsageInBytes), value: formatBytes(stats.totalLibraryUsageInBytes),
}, },
] ]
.filter((item) => item.enabled) .filter((item) => item.enabled)
.map(({ key, icon, label, value }) => ( .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) { if (!isAdvanced) {
return ( return (
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative"> <Stack gap="md" h="100%" p="md">
{statsContent} {statItems}
</Stack> </Stack>
); );
} }
const statsContent = (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{statItems}
</SimpleGrid>
);
const sortedAlbums = albums; const sortedAlbums = albums;
const maxAssets = sortedAlbums[0]?.assetCount ?? 1; const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
return ( return (
@@ -150,9 +153,10 @@ interface StatItemProps {
label: string; label: string;
value: string | number; value: string | number;
dense: boolean; dense: boolean;
compact: boolean;
} }
function StatItem({ icon, label, value, dense }: StatItemProps) { function StatItem({ icon, label, value, dense, compact }: StatItemProps) {
if (dense) { if (dense) {
return ( return (
<Stack gap={1} align="center" justify="center" className={classes.statItemDense} title={`${label}: ${value}`}> <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 ( 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}> <Group gap="sm" align="center" wrap="nowrap" miw={0}>
{icon} {icon}
<Text size="sm" fw={500} truncate="end"> <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); const count = Math.max(1, itemCount);
if (isAdvanced) { if (isAdvanced) {
return { columns: width >= 720 ? Math.min(count, 4) : width >= 360 ? Math.min(count, 2) : 1, dense: false }; return { columns: width >= 720 ? Math.min(count, 4) : width >= 360 ? Math.min(count, 2) : 1, dense: false };
} }
const dense = width < 320 || height < 160; return { columns: 1, dense: false };
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 };
}; };
export const getImmichStatVisibility = ( export const getImmichStatVisibility = (
@@ -3,14 +3,11 @@ import { describe, expect, test } from "vitest";
import { getImmichStatsLayout, getImmichStatVisibility } from "./component"; import { getImmichStatsLayout, getImmichStatVisibility } from "./component";
describe("Immich server statistics compact layout", () => { describe("Immich server statistics compact layout", () => {
test("keeps four enabled stats legible in a short widget", () => { test("keeps the v1 one-column non-dense layout at every compact size", () => {
expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 2, dense: true }); expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 1, dense: false });
expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 4, dense: true }); 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("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("advanced mode shows all four headline stats", () => { test("advanced mode shows all four headline stats", () => {
@@ -1,4 +1,8 @@
.card { .card {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
.advancedCard {
background-color: transparent; background-color: transparent;
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38)); 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, ActionIcon,
Anchor, Anchor,
Badge, Badge,
Button,
Card, Card,
Center, Center,
Flex, Flex,
@@ -22,7 +23,9 @@ import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks"; import { useWidgetRuntimeActions } from "../runtime-hooks";
@@ -60,6 +63,11 @@ export default function IndexerManagerWidget({
isPending, isPending,
error: testAllError, error: testAllError,
} = clientApi.widget.indexerManager.testAllIndexers.useMutation({ } = clientApi.widget.indexerManager.testAllIndexers.useMutation({
onError: () =>
showErrorNotification({
title: tCommon("error"),
message: t("error.testAll"),
}),
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(), onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
}); });
const interactIntegrationIds = new Set(useIntegrationsWithInteractAccess().map(({ id }) => id)); const interactIntegrationIds = new Set(useIntegrationsWithInteractAccess().map(({ id }) => id));
@@ -67,8 +75,7 @@ export default function IndexerManagerWidget({
const board = useRequiredBoard(); const board = useRequiredBoard();
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const hasSmallWidth = !isAdvanced && width < 256; const hasSmallWidth = !isAdvanced && width < 256;
const isDense = !isAdvanced && (width < 280 || height < 180); const hasSmallHeight = !isAdvanced && height < 256;
const showHealthCounts = isAdvanced || (width >= 200 && height >= 100);
const allIndexers = indexersData.flatMap((entry) => entry.indexers); const allIndexers = indexersData.flatMap((entry) => entry.indexers);
const hasSourceError = indexersData.some((entry) => Boolean(entry.error)); const hasSourceError = indexersData.some((entry) => Boolean(entry.error));
const unavailableCount = allIndexers.filter( const unavailableCount = allIndexers.filter(
@@ -88,24 +95,32 @@ export default function IndexerManagerWidget({
className={`indexer-manager-container ${classes.root}`} className={`indexer-manager-container ${classes.root}`}
h="100%" h="100%"
direction="column" direction="column"
gap={isDense ? 4 : "xs"} gap={isAdvanced ? "xs" : "sm"}
p={isDense ? "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>} {testAllError && <VisuallyHidden role="alert">{tCommon("error")}</VisuallyHidden>}
<Tooltip label={t("title")} disabled={!hasSmallWidth}> <Tooltip label={t("title")} disabled={!hasSmallWidth}>
<IconReportSearch <IconReportSearch
className="indexer-manager-title-icon" className="indexer-manager-title-icon"
size={hasSmallWidth ? 16 : 20} style={hasSmallWidth ? iconSizes.md : iconSizes.xl}
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
/> />
</Tooltip> </Tooltip>
{!hasSmallWidth && ( <Text
<Text size={isDense ? "xs" : "sm"} fw={600} truncate="end"> size={hasSmallWidth ? "xs" : isAdvanced ? "sm" : "md"}
{t("title")} fw={isAdvanced ? 600 : "bold"}
</Text> truncate={isAdvanced ? "end" : undefined}
)} >
{showHealthCounts && ( {t("title")}
</Text>
{isAdvanced && (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Badge size="xs" color="green" variant="light"> <Badge size="xs" color="green" variant="light">
{allIndexers.length - unavailableCount} {allIndexers.length - unavailableCount}
@@ -117,29 +132,35 @@ export default function IndexerManagerWidget({
)} )}
</Group> </Group>
)} )}
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}> {(isAdvanced || hasSmallHeight) && (
<ActionIcon <Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)} <ActionIcon
size="sm" className={combineClasses(
radius={board.itemRadius} "indexer-manager-test-action-icon",
variant="light" isAdvanced && classes.testAction,
color={testAllError ? "red" : undefined} actionTargetClasses.root,
loading={isPending} )}
disabled={isEditMode || !canInteract} size="sm"
loaderProps={{ type: "dots" }} radius={board.itemRadius}
onClick={() => { variant="light"
testAllIndexers(); color={testAllError ? "red" : undefined}
}} loading={isPending}
aria-label={t("testAll")} disabled={isEditMode || !canInteract}
> loaderProps={{ type: "dots" }}
<IconTestPipe size="var(--mantine-font-size-sm)" /> onClick={() => {
</ActionIcon> testAllIndexers();
</Tooltip> }}
aria-label={t("testAll")}
>
<IconTestPipe style={isAdvanced ? iconSizes.sm : iconSizes.xs} />
</ActionIcon>
</Tooltip>
)}
</Group> </Group>
<Card <Card
className={combineClasses("indexer-manager-list-container", classes.card)} className={combineClasses("indexer-manager-list-container", classes.card, isAdvanced && classes.advancedCard)}
w="100%" w="100%"
p={isDense ? 4 : "xs"} p="xs"
radius={board.itemRadius} radius={board.itemRadius}
flex={1} flex={1}
> >
@@ -153,7 +174,7 @@ export default function IndexerManagerWidget({
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y"> <ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
{indexersData.map(({ integrationId, integrationName, indexers, error }) => ( {indexersData.map(({ integrationId, integrationName, indexers, error }) => (
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}> <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}> <Group justify="space-between" wrap="nowrap" py={4}>
<Text size="xs" fw={600} truncate="end"> <Text size="xs" fw={600} truncate="end">
{integrationName} {integrationName}
@@ -171,14 +192,18 @@ export default function IndexerManagerWidget({
const presentation = statusPresentation[displayStatus]; const presentation = statusPresentation[displayStatus];
return ( return (
<Group <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} key={indexer.id}
justify="space-between" justify="space-between"
gap="xs" gap="xs"
wrap="nowrap" wrap="nowrap"
> >
<Anchor <Anchor
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)} className={combineClasses("indexer-manager-line-anchor", isAdvanced && classes.indexerLink)}
component={href ? "a" : "span"} component={href ? "a" : "span"}
href={href} href={href}
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined} target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
@@ -189,7 +214,7 @@ export default function IndexerManagerWidget({
className="indexer-manager-line-anchor-text" className="indexer-manager-line-anchor-text"
c="dimmed" c="dimmed"
size={hasSmallWidth ? "xs" : "sm"} size={hasSmallWidth ? "xs" : "sm"}
truncate="end" truncate={isAdvanced ? "end" : undefined}
> >
{indexer.name} {indexer.name}
</Text> </Text>
@@ -201,14 +226,14 @@ export default function IndexerManagerWidget({
) : displayStatus === "healthy" ? ( ) : displayStatus === "healthy" ? (
<IconCircleCheck <IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled" className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="var(--mantine-color-green-6)" color="#2ecc71"
size={hasSmallWidth ? 12 : 16} style={hasSmallWidth ? iconSizes.xs : iconSizes.md}
/> />
) : ( ) : (
<IconCircleX <IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled" className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="var(--mantine-color-red-6)" color="#d9534f"
size={hasSmallWidth ? 12 : 16} style={hasSmallWidth ? iconSizes.xs : iconSizes.md}
/> />
)} )}
</Group> </Group>
@@ -219,6 +244,25 @@ export default function IndexerManagerWidget({
</ScrollArea> </ScrollArea>
)} )}
</Card> </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> </Flex>
); );
} }
@@ -40,7 +40,6 @@ export default function MediaReleasesWidget({
options, options,
integrationIds, integrationIds,
width, width,
height,
displayMode, displayMode,
}: WidgetComponentProps<"mediaReleases">) { }: WidgetComponentProps<"mediaReleases">) {
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds }); const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
@@ -63,7 +62,7 @@ export default function MediaReleasesWidget({
{releases.map((item, index) => ( {releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}> <Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />} {!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> </Fragment>
))} ))}
</SimpleGrid> </SimpleGrid>
@@ -82,30 +81,27 @@ interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number]; item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number];
options: WidgetComponentProps<"mediaReleases">["options"]; options: WidgetComponentProps<"mediaReleases">["options"];
isAdvanced: boolean; isAdvanced: boolean;
width: number;
height: number;
} }
const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => { const formatReleaseDate = (value: unknown, locale: string) => {
const date = toValidDate(value); const date = toValidDate(value);
if (!date) return "—"; if (!date) return "—";
return Intl.DateTimeFormat(locale, { return Intl.DateTimeFormat(locale, {
month: compact ? "short" : "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
day: "2-digit", day: "2-digit",
...(compact ? {} : { hour: "2-digit", minute: "2-digit", hour12: false }), hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date); }).format(date);
}; };
const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { const Item = ({ item, options, isAdvanced }: ItemProps) => {
const locale = useCurrentIntlLocale(); const locale = useCurrentIntlLocale();
const t = useI18n("widget.mediaReleases"); const t = useI18n("widget.mediaReleases");
const length = formatLength(item.length, item.type, t); const length = formatLength(item.length, item.type, t);
const isCompact = !isAdvanced && (width < 340 || height < 180); const showPoster = isAdvanced || options.layout === "poster";
const isTiny = !isAdvanced && (width < 220 || height < 105); const showSide = isAdvanced || options.showType || options.showSource;
const showPoster = (isAdvanced || options.layout === "poster") && !isTiny;
const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360);
const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource));
const href = getSafeApplicationUrl(item.href); const href = getSafeApplicationUrl(item.href);
return ( return (
@@ -129,7 +125,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
target={href ? "_blank" : undefined} target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined} rel={href ? SAFE_NEW_TAB_REL : undefined}
pos="relative" pos="relative"
p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6} p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4}
h="100%" h="100%"
title={item.title} title={item.title}
style={ 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"> <Group align="start" wrap="nowrap" style={{ zIndex: 0, minWidth: 0, flex: 1 }} gap="xs">
{showPoster && ( {showPoster && (
<Image <Image
w={isAdvanced ? 80 : isCompact ? 44 : 60} w={isAdvanced ? 80 : 60}
src={item.imageUrls.poster} src={item.imageUrls.poster}
alt="" alt={isAdvanced ? "" : item.title}
radius="xs" radius={isAdvanced ? "xs" : undefined}
loading="lazy" loading="lazy"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
/> />
)} )}
<Stack gap={isCompact ? 2 : 4} style={{ minWidth: 0, flex: 1 }}> <Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
<Stack gap={0}> <Stack gap={0}>
<Text size={isTiny ? "xs" : "sm"} fw="bold" lineClamp={isCompact ? 1 : 2}> <Text size="sm" fw="bold" lineClamp={2}>
{item.title} {item.title}
</Text> </Text>
{item.subtitle !== undefined && !isTiny && ( {item.subtitle !== undefined && (
<Text size={isCompact ? "xs" : "sm"} c={isCompact ? "dimmed" : undefined} lineClamp={1}> <Text size="sm" lineClamp={1}>
{item.subtitle} {item.subtitle}
</Text> </Text>
)} )}
</Stack> </Stack>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}> <Group
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale, isCompact)} /> gap={6}
{length !== undefined && !isTiny && ( wrap={isAdvanced ? "nowrap" : "wrap"}
style={isAdvanced ? { minWidth: 0 } : { minWidth: 0, rowGap: 0 }}
>
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale)} />
{length !== undefined && (
<> <>
<InfoDivider /> <InfoDivider />
<Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} /> <Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} />
</> </>
)} )}
{item.producer !== undefined && showExtendedMetadata && ( {item.producer !== undefined && (
<> <>
<InfoDivider /> <InfoDivider />
<Info label={item.producer} /> <Info label={item.producer} />
</> </>
)} )}
{item.rating !== undefined && showExtendedMetadata && ( {item.rating !== undefined && (
<> <>
<InfoDivider /> <InfoDivider />
<Info icon={IconStarFilled} label={item.rating} /> <Info icon={IconStarFilled} label={item.rating} />
</> </>
)} )}
{item.price !== undefined && showExtendedMetadata && ( {item.price !== undefined && (
<> <>
<InfoDivider /> <InfoDivider />
<Info label={`$${item.price.toFixed(2)}`} /> <Info label={`$${item.price.toFixed(2)}`} />
</> </>
)} )}
</Group> </Group>
{item.tags.length > 0 && (isAdvanced || !isCompact) && ( {item.tags.length > 0 && (
<OverflowBadge <OverflowBadge
size="xs" size="xs"
groupGap={4} groupGap={4}
@@ -29,7 +29,6 @@ export default function MediaServerWidget({
isEditMode, isEditMode,
options, options,
width, width,
height,
}: WidgetComponentProps<"mediaRequests-requestList">) { }: WidgetComponentProps<"mediaRequests-requestList">) {
const interactIntegrationIds = new Set( const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess() useIntegrationsWithInteractAccess()
@@ -69,8 +68,7 @@ export default function MediaServerWidget({
<MediaRequestCard <MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`} key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest} request={mediaRequest}
isTiny={width <= 256 || height < 96} isTiny={width <= 256}
isDense={width < 340 || height < 150}
showIntegrationSource={showIntegrationSource} showIntegrationSource={showIntegrationSource}
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)} canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
options={options} options={options}
@@ -103,20 +101,12 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
interface MediaRequestCardProps { interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number]; request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
isTiny: boolean; isTiny: boolean;
isDense: boolean;
showIntegrationSource: boolean; showIntegrationSource: boolean;
canInteract: boolean; canInteract: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"]; options: WidgetComponentProps<"mediaRequests-requestList">["options"];
} }
const MediaRequestCard = ({ const MediaRequestCard = ({ request, isTiny, showIntegrationSource, canInteract, options }: MediaRequestCardProps) => {
request,
isTiny,
isDense,
showIntegrationSource,
canInteract,
options,
}: MediaRequestCardProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useI18n("widget.mediaRequests-requestList"); const t = useI18n("widget.mediaRequests-requestList");
const requestHref = getSafeApplicationUrl(request.href); const requestHref = getSafeApplicationUrl(request.href);
@@ -124,11 +114,21 @@ const MediaRequestCard = ({
return ( return (
<Card <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} radius={board.itemRadius}
p={isDense ? 6 : "xs"} p="xs"
withBorder
> >
<Image
className="mediaRequests-list-item-background"
src={request.backdropImageUrl}
pos="absolute"
w="100%"
h="100%"
opacity={0.2}
top={0}
left={0}
alt=""
/>
<Group <Group
className="mediaRequests-list-item-contents" className="mediaRequests-list-item-contents"
h="100%" h="100%"
@@ -137,56 +137,22 @@ const MediaRequestCard = ({
wrap="nowrap" wrap="nowrap"
gap={0} gap={0}
> >
<Group <Group className="mediaRequests-list-item-left-side" h="100%" gap="md" wrap="nowrap" flex={1} miw={0}>
className="mediaRequests-list-item-left-side"
h="100%"
gap={isDense ? "xs" : "md"}
wrap="nowrap"
flex={1}
miw={0}
>
{!isTiny && ( {!isTiny && (
<Image <Image
className="mediaRequests-list-item-poster" className="mediaRequests-list-item-poster"
src={request.posterImagePath} src={request.posterImagePath}
h={isDense ? 32 : 40} h={40}
w="auto" w="auto"
radius="sm" radius="md"
alt="" alt=""
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
/> />
)} )}
<Stack gap={0} w="100%" miw={0}> <Stack gap={0} w="100%" miw={0}>
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group"> <Group justify="space-between" gap="xs" className="mediaRequests-list-item-top-group">
<Anchor <Group gap="xs" wrap="nowrap" miw={0}>
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}>
<Text className="mediaRequests-list-item-media-year" size="xs"> <Text className="mediaRequests-list-item-media-year" size="xs">
{toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")} {toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")}
</Text> </Text>
@@ -206,27 +172,52 @@ const MediaRequestCard = ({
</Badge> </Badge>
)} )}
</Group> </Group>
{!isTiny && ( <Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}>
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}> <Avatar
<Avatar className="mediaRequests-list-item-request-user-avatar"
className="mediaRequests-list-item-request-user-avatar" src={request.requestedBy?.avatar}
src={request.requestedBy?.avatar} size="xs"
size={18} />
/> <Anchor
<Anchor className="mediaRequests-list-item-request-user-name"
className="mediaRequests-list-item-request-user-name" component={requestedByHref ? "a" : "span"}
component={requestedByHref ? "a" : "span"} href={requestedByHref}
href={requestedByHref} c="var(--mantine-color-text)"
c="dimmed" target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined} rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined} fz="xs"
fz="xs" lineClamp={1}
truncate="end" style={{ wordBreak: "break-all" }}
style={{ minWidth: 0, maxWidth: isDense ? 100 : 180 }} >
> {(request.requestedBy?.displayName ?? "") || t("unknown")}
{(request.requestedBy?.displayName ?? "") || "unknown"} </Anchor>
</Anchor> </Group>
</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> </Group>
</Stack> </Stack>
@@ -100,9 +100,7 @@ export default function MediaServerWidget({
}, },
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[]; ] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
const isTiny = width < 256 || height < 180; const isTiny = width < 256;
const showSectionTitles = height >= 180;
const visibleStats = data.slice(0, height < 120 ? 4 : 8);
const showIntegrationLegend = requestStats.integrations.length > 1 && height >= 240; const showIntegrationLegend = requestStats.integrations.length > 1 && height >= 240;
return ( return (
@@ -121,13 +119,11 @@ export default function MediaServerWidget({
style={{ pointerEvents: isEditMode ? "none" : undefined, overflow: "hidden" }} style={{ pointerEvents: isEditMode ? "none" : undefined, overflow: "hidden" }}
> >
<Stack gap={4} w="100%"> <Stack gap={4} w="100%">
{showSectionTitles && ( <Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
<Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}> {t("titles.stats.main")}
{t("titles.stats.main")} </Text>
</Text>
)}
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%"> <Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
{visibleStats.map((stat) => ( {data.map((stat) => (
<Grid.Col <Grid.Col
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)} className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
key={stat.name} key={stat.name}
@@ -148,11 +144,9 @@ export default function MediaServerWidget({
</Grid> </Grid>
</Stack> </Stack>
<Stack gap={4} w="100%" style={{ flex: 1, minHeight: 0, overflow: "hidden" }}> <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"}>
<Text className="mediaRequests-stats-users-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}> {t("titles.users.main")} ({t("titles.users.requests")})
{t("titles.users.main")} ({t("titles.users.requests")}) </Text>
</Text>
)}
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} mih={0} w="100%"> <ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} mih={0} w="100%">
<Stack gap={4}> <Stack gap={4}>
{requestStats.users.map((user) => { {requestStats.users.map((user) => {
@@ -106,10 +106,10 @@ describe("SessionDetailsPopover", () => {
}); });
describe("getMediaServerColumnVisibility", () => { describe("getMediaServerColumnVisibility", () => {
test("keeps a stable responsive column model from the zero-sized first render", () => { test("keeps every compact column visible at every width", () => {
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false }); expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false }); expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false }); expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true }); expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(0, true)).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 { getIconUrl } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations"; import type { StreamSession } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -48,11 +49,9 @@ type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transc
type SortColumn = "user" | "currentlyPlaying" | "status"; type SortColumn = "user" | "currentlyPlaying" | "status";
type SortState = { column: SortColumn; descending: boolean } | null; type SortState = { column: SortColumn; descending: boolean } | null;
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({ export const getMediaServerColumnVisibility = (_width: number, _isAdvanced: boolean) => ({
user: isAdvanced || width >= 300, user: true,
// The user (26%) and status (22%) columns together need a floor here, otherwise the status: true,
// currentlyPlaying column - the primary content - gets squeezed to almost nothing.
status: isAdvanced || width >= 540,
}); });
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus { function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
@@ -171,7 +170,6 @@ export default function MediaServerWidget({
options, options,
integrationIds, integrationIds,
width, width,
height,
isEditMode, isEditMode,
displayMode, displayMode,
}: WidgetComponentProps<"mediaServer">) { }: WidgetComponentProps<"mediaServer">) {
@@ -411,61 +409,76 @@ export default function MediaServerWidget({
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</div> </div>
{(isAdvanced || height >= 144) && ( <Group
<Group gap="xs"
gap="xs" h={30}
h={30} px="xs"
px="xs" pr="md"
pr="md" justify="space-between"
justify="space-between" style={{
style={{ borderTop: "1px solid var(--border-color)",
borderTop: "1px solid var(--border-color)", }}
}} >
> <Group gap={isAdvanced ? 6 : 4} wrap="nowrap">
<Group gap={6} wrap="nowrap"> <IconVideo
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} /> size={isAdvanced ? "var(--mantine-font-size-xs)" : undefined}
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}> style={isAdvanced ? { flexShrink: 0 } : zoomCompensatedSize(16)}
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { />
count: flatSessions.length, <Text size="sm" fw={isAdvanced ? 500 : undefined} style={{ whiteSpace: "nowrap" }}>
})} {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
</Text> count: flatSessions.length,
{totalBitrateLabel && (isAdvanced || width >= 300) && ( })}
<> </Text>
{totalBitrateLabel && (
<>
{isAdvanced && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}> <Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
• •
</Text> </Text>
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}> )}
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })} <Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
</Text> {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>
</Group> </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> </Stack>
); );
} }
@@ -15,6 +15,7 @@ export default function MinecraftServerStatusWidget({
options, options,
width, width,
height, height,
displayMode,
}: WidgetComponentProps<"minecraftServerStatus">) { }: WidgetComponentProps<"minecraftServerStatus">) {
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options); const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n("widget.minecraftServerStatus"); const t = useI18n("widget.minecraftServerStatus");
@@ -34,10 +35,11 @@ export default function MinecraftServerStatusWidget({
const { data } = result; const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain; const title = options.title.trim().length > 0 ? options.title : options.domain;
const isDense = width < 220 || height < 120; const isAdvanced = displayMode === "advanced";
const showServerIcon = !isDense && height >= 144; const isDense = isAdvanced && (width < 220 || height < 120);
const showMetadata = width >= 260 && height >= 180; const showServerIcon = !isAdvanced || (!isDense && height >= 144);
const showCapacity = width >= 180 && height >= 140; 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 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; 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} integrationIds={state.integrationIds}
width={sourceWidth} width={sourceWidth}
height={sourceHeight} height={sourceHeight}
displayScale={previewScale}
isEditMode={isPendingCustomWidget} isEditMode={isPendingCustomWidget}
displayMode="compact" displayMode="compact"
boardId={boardId} boardId={boardId}
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useMemo } from "react"; import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Badge, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
@@ -13,8 +12,7 @@ import { getNetworkControllerStatusLayout } from "./layout";
import { WifiVariant } from "./variants/wifi-variant"; import { WifiVariant } from "./variants/wifi-variant";
import { WiredVariant } from "./variants/wired-variant"; import { WiredVariant } from "./variants/wired-variant";
const neutralBorderColor = const neutralBorderColor = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
export default function NetworkControllerNetworkStatusWidget({ export default function NetworkControllerNetworkStatusWidget({
options, options,
@@ -22,17 +20,18 @@ export default function NetworkControllerNetworkStatusWidget({
displayMode, displayMode,
width, width,
height, height,
displayScale = 1,
}: WidgetComponentProps<"networkControllerStatus">) { }: WidgetComponentProps<"networkControllerStatus">) {
const summaryQuery = clientApi.widget.networkController.summary.useQuery({ const summaryQuery = clientApi.widget.networkController.summary.useQuery({
integrationIds, integrationIds,
}); });
const results = getUsableWidgetQueryData(summaryQuery) ?? []; const isAdvanced = displayMode === "advanced";
const summaries = useMemo( let results = summaryQuery.data ?? [];
() => if (isAdvanced) {
results.filter( results = getUsableWidgetQueryData(summaryQuery) ?? [];
(result): result is typeof result & { summary: NonNullable<typeof result.summary> } => result.summary !== null, }
), const summaries = results.filter(
[results], (result): result is typeof result & { summary: NonNullable<typeof result.summary> } => result.summary !== null,
); );
const tWidgetCommon = useI18n("widget.common"); const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("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 countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0);
const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0); const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0);
const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0); const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0);
const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content }); let responsiveWidth = width;
const isAdvanced = displayMode === "advanced"; 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 = ( const queryIndicators = (
<Group gap={0} justify="flex-end"> <Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} /> <IntegrationErrorIndicator results={indicatorResults} />
</Group> </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) { if (summaryQuery.isPending || summaries.length === 0) {
return ( return (
<Stack h="100%" gap={0}> <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 ( return (
<Stack p={layout.padding} h="100%" gap={0}> <ScrollArea h="100%" p={layout.padding}>
{queryIndicators} {queryIndicators}
<SimpleGrid cols={layout.columns} spacing="sm" style={{ flex: 1, alignItems: "stretch" }}> <SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
{layout.showWifi && ( {summaries.map(({ integration, summary, updatedAt }) => (
<Card <Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
p={layout.cardPadding} <Stack gap="sm">
withBorder={layout.withBorder} <Group justify="space-between" wrap="nowrap">
bg="transparent" <Stack gap={0} miw={0}>
h="100%" <Text fw={600} size="sm" truncate="end">
style={{ borderColor: neutralBorderColor }} {integration.name}
> </Text>
<WifiVariant {updatedAt && (
countGuests={countWifiGuests} <Text size="xs" c="dimmed">
countUsers={countWifiUsers} {tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
compact={layout.compact} </Text>
horizontal={layout.horizontalStats} )}
/> </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> </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> </SimpleGrid>
</Stack> </ScrollArea>
); );
} }
@@ -7,7 +7,7 @@ describe("network controller status layout", () => {
{ {
name: "minimum compact", name: "minimum compact",
input: { width: 160, height: 90, displayMode: "compact" as const, content: "wifi" as const }, 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", name: "short compact",
@@ -58,7 +58,13 @@ describe("network controller status layout", () => {
).toMatchObject({ padding: "sm", horizontalStats: false }); ).toMatchObject({ padding: "sm", horizontalStats: false });
expect( expect(
getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }), 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( expect(
getNetworkControllerStatusLayout({ width: 960, height: 320, displayMode: "advanced", content: "wifi" }), getNetworkControllerStatusLayout({ width: 960, height: 320, displayMode: "advanced", content: "wifi" }),
).toMatchObject({ sourceColumns: 2 }); ).toMatchObject({ sourceColumns: 2 });
@@ -11,7 +11,7 @@ interface NetworkControllerStatusLayoutInput {
} }
export interface NetworkControllerStatusLayout { export interface NetworkControllerStatusLayout {
padding: "xs" | "sm" | "md"; padding: 4 | "xs" | "sm" | "md";
columns: 1 | 2; columns: 1 | 2;
sourceColumns: 1 | 2; sourceColumns: 1 | 2;
showWifi: boolean; showWifi: boolean;
@@ -20,6 +20,7 @@ export interface NetworkControllerStatusLayout {
withBorder: boolean; withBorder: boolean;
compact: boolean; compact: boolean;
horizontalStats: boolean; horizontalStats: boolean;
inlineStats: boolean;
} }
export const getNetworkControllerStatusLayout = ({ export const getNetworkControllerStatusLayout = ({
@@ -29,16 +30,18 @@ export const getNetworkControllerStatusLayout = ({
content, content,
}: NetworkControllerStatusLayoutInput): NetworkControllerStatusLayout => { }: NetworkControllerStatusLayoutInput): NetworkControllerStatusLayout => {
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const compact = !isAdvanced && (width < 240 || height < 180);
return { return {
padding: isAdvanced ? "md" : height < 120 ? "xs" : "sm", padding: isAdvanced ? "md" : height < 120 ? 4 : "sm",
columns: isAdvanced && width >= 560 ? 2 : 1, columns: isAdvanced && width >= 560 ? 2 : 1,
sourceColumns: isAdvanced && width >= 960 ? 2 : 1, sourceColumns: isAdvanced && width >= 960 ? 2 : 1,
showWifi: isAdvanced || content === "wifi", showWifi: isAdvanced || content === "wifi",
showWired: isAdvanced || content === "wired", showWired: isAdvanced || content === "wired",
cardPadding: isAdvanced ? "md" : 0, cardPadding: isAdvanced ? "md" : 0,
withBorder: isAdvanced, withBorder: isAdvanced,
compact: !isAdvanced, compact,
horizontalStats: !isAdvanced && height < 150 && width >= 200, 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 = ({ export const StatRow = ({
label, label,
value, value,
advanced = false,
compact = false, compact = false,
inline = false,
}: { }: {
label: string; label: string;
value: string | number; value: string | number;
advanced?: boolean;
compact?: 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 ( return (
<Stack gap={2} align="center" justify="center"> <Stack gap={2} align="center" justify="center">
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1} ta="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 { IconWifi } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui"; import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { StatRow } from "./stat-row"; import { StatRow } from "./stat-row";
export const WifiVariant = ({ export const WifiVariant = ({
countGuests, countGuests,
countUsers, countUsers,
advanced = false,
compact = false, compact = false,
horizontal = false, horizontal = false,
inline = false,
}: { }: {
countUsers: number; countUsers: number;
countGuests: number; countGuests: number;
advanced?: boolean;
compact?: boolean; compact?: boolean;
horizontal?: boolean; horizontal?: boolean;
inline?: boolean;
}) => { }) => {
const t = useI18n("widget.networkControllerStatus.card"); 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 ( return (
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}> <Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" justify="center"> <Group gap="xs" wrap="nowrap" justify="center">
@@ -29,8 +57,8 @@ export const WifiVariant = ({
</Text> </Text>
</Group> </Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}> <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("users.label")} value={countUsers} compact={compact} advanced />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} /> <StatRow label={t("guests.label")} value={countGuests} compact={compact} advanced />
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
); );
@@ -2,22 +2,50 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react"; import { IconNetwork } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui"; import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { StatRow } from "./stat-row"; import { StatRow } from "./stat-row";
export const WiredVariant = ({ export const WiredVariant = ({
countGuests, countGuests,
countUsers, countUsers,
advanced = false,
compact = false, compact = false,
horizontal = false, horizontal = false,
inline = false,
}: { }: {
countUsers: number; countUsers: number;
countGuests: number; countGuests: number;
advanced?: boolean;
compact?: boolean; compact?: boolean;
horizontal?: boolean; horizontal?: boolean;
inline?: boolean;
}) => { }) => {
const t = useI18n("widget.networkControllerStatus.card"); 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 ( return (
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}> <Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" justify="center"> <Group gap="xs" wrap="nowrap" justify="center">
@@ -29,8 +57,8 @@ export const WiredVariant = ({
</Text> </Text>
</Group> </Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}> <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("users.label")} value={countUsers} compact={compact} advanced />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} /> <StatRow label={t("guests.label")} value={countGuests} compact={compact} advanced />
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
); );
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { CSSProperties } from "react"; 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 { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import duration from "dayjs/plugin/duration"; import duration from "dayjs/plugin/duration";
@@ -27,13 +27,16 @@ dayjs.extend(duration);
export default function NetworkControllerSummaryWidget({ export default function NetworkControllerSummaryWidget({
integrationIds, integrationIds,
width, width,
height,
displayMode, displayMode,
}: WidgetComponentProps<"networkControllerSummary">) { }: WidgetComponentProps<"networkControllerSummary">) {
const summaryQuery = clientApi.widget.networkController.summary.useQuery({ const summaryQuery = clientApi.widget.networkController.summary.useQuery({
integrationIds, integrationIds,
}); });
const results = getUsableWidgetQueryData(summaryQuery) ?? []; const isAdvanced = displayMode === "advanced";
let results = summaryQuery.data ?? [];
if (isAdvanced) {
results = getUsableWidgetQueryData(summaryQuery) ?? [];
}
const summaries = results.filter( const summaries = results.filter(
(result): result is typeof result & { summary: NonNullable<typeof result.summary>; updatedAt: Date } => (result): result is typeof result & { summary: NonNullable<typeof result.summary>; updatedAt: Date } =>
result.summary !== null && result.updatedAt !== undefined, result.summary !== null && result.updatedAt !== undefined,
@@ -51,17 +54,18 @@ export default function NetworkControllerSummaryWidget({
unknown: tDns("status.unknown"), unknown: tDns("status.unknown"),
}; };
const isDense = height < 160; const indicatorResults =
const showDetails = height >= 180 && width >= 240; results.length > 0 || !summaryQuery.error
const columns = width >= 720 ? 2 : 1; ? results
const isAdvanced = displayMode === "advanced"; : integrationIds.map((integrationId) => ({ integrationId, error: "query" }));
const queryIndicators = ( const queryIndicators = (
<Group gap={0} justify="flex-end"> <Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} /> <IntegrationErrorIndicator results={indicatorResults} />
</Group> </Group>
); );
const firstSummary = summaries[0];
if (isPending || summaries.length === 0) { if (isPending || !firstSummary) {
return ( return (
<Stack h="100%" gap={0}> <Stack h="100%" gap={0}>
{queryIndicators} {queryIndicators}
@@ -74,190 +78,152 @@ export default function NetworkControllerSummaryWidget({
); );
} }
if (isAdvanced) { if (!isAdvanced) {
const controllerColumns = width >= 960 ? 2 : 1; const summary = firstSummary.summary;
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
return ( return (
<ScrollArea h="100%" p="sm" pos="relative"> <Box h="100%" p="sm" pos="relative">
{queryIndicators} <Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<SimpleGrid cols={controllerColumns} spacing="sm"> {queryIndicators}
{summaries.map(({ integration, summary, updatedAt }) => ( </Box>
<Card <Center h="100%">
key={integration.id} <List spacing="xs" center>
p="sm" <List.Item
withBorder icon={
bg="transparent" <StatusIcon
style={{ borderColor: neutralSurfaceBorder }} status={summary.wanStatus}
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
style={iconSizes.xl}
/>
}
> >
<Stack gap="sm"> {t("card.wan")}
<Group justify="space-between" align="flex-start" wrap="nowrap"> </List.Item>
<Stack gap={0} style={{ minWidth: 0 }}> <List.Item
<Text fw={600} size="sm" truncate="end"> icon={
{integration.name} <StatusIcon
</Text> status={summary.www.status}
<Text size="xs" c="dimmed"> label={statusLabels[getBinaryStatusKey(summary.www.status)]}
{integration.kind} style={iconSizes.xl}
</Text> />
</Stack> }
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}> >
<Text size="10px" c="dimmed"> <Text>
{t("advanced.updated")} WWW
</Text> <Text c="dimmed" size="md" ms="xs" span>
<Text size="xs" c="dimmed" ta="right"> {summary.www.latency}ms
{new Date(updatedAt).toLocaleString(locale)} </Text>
</Text> </Text>
</Stack> </List.Item>
</Group> <List.Item
<SimpleGrid cols={matrixColumns} spacing="xs"> icon={
{getNetworkControllerMatrix(summary).map((section) => ( <StatusIcon
<Card status={summary.wifi.status}
key={section.key} label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
p="xs" style={iconSizes.xl}
withBorder />
bg="transparent" }
style={{ borderColor: neutralSurfaceBorder }} >
> {t("card.wifi")}
<Stack gap="xs"> </List.Item>
<Group justify="space-between" wrap="nowrap"> <List.Item
<Group gap={6} wrap="nowrap"> icon={
<StatusIcon <StatusIcon
status={section.status} status={summary.vpn.status}
label={statusLabels[getBinaryStatusKey(section.status)]} label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
size="var(--mantine-font-size-md)" style={iconSizes.xl}
/> />
<Text size="sm" fw={600}> }
{getMatrixSectionLabel(section.key, t)} >
</Text> <Text>
</Group> {t("card.vpn.label")}
<Text size="xs" c="dimmed"> <Text c="dimmed" size="md" ms="xs" span>
{statusLabels[getBinaryStatusKey(section.status)]} {t("card.vpn.countConnected", { count: summary.vpn.users })}
</Text> </Text>
</Group> </Text>
{section.metrics.length > 0 && ( </List.Item>
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}> </List>
{section.metrics.map((metric) => ( </Center>
<Stack key={metric.key} gap={0}> </Box>
<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>
); );
} }
const controllerColumns = width >= 960 ? 2 : 1;
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
return ( return (
<Stack h="100%" p={isDense ? "xs" : "sm"} gap={0} style={{ overflow: "hidden" }}> <ScrollArea h="100%" p="sm" pos="relative">
{queryIndicators} {queryIndicators}
<SimpleGrid cols={columns} spacing="sm" style={{ flex: 1, minHeight: 0, alignContent: "center" }}> <SimpleGrid cols={controllerColumns} spacing="sm">
{summaries.map(({ integration, summary }) => ( {summaries.map(({ integration, summary, updatedAt }) => (
<Card <Card key={integration.id} p="sm" withBorder bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
key={integration.id} <Stack gap="sm">
p={isDense ? "xs" : "sm"} <Group justify="space-between" align="flex-start" wrap="nowrap">
withBorder={summaries.length > 1} <Stack gap={0} style={{ minWidth: 0 }}>
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">
<Text fw={600} size="sm" truncate="end"> <Text fw={600} size="sm" truncate="end">
{integration.name} {integration.name}
</Text> </Text>
<Badge size="xs" variant="light"> <Text size="xs" c="dimmed">
{integration.kind} {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> </Text>
</List.Item> </Stack>
<List.Item <Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
icon={ <Text size="10px" c="dimmed">
<StatusIcon {t("advanced.updated")}
status={summary.wifi.status} </Text>
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]} <Text size="xs" c="dimmed" ta="right">
style={isDense ? iconSizes.md : iconSizes.xl} {new Date(updatedAt).toLocaleString(locale)}
/> </Text>
} </Stack>
> </Group>
{t("card.wifi")} <SimpleGrid cols={matrixColumns} spacing="xs">
{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""} {getNetworkControllerMatrix(summary).map((section) => (
</List.Item> <Card
{showDetails && ( key={section.key}
<List.Item p="xs"
icon={ withBorder
<StatusIcon bg="transparent"
status={summary.lan.status} style={{ borderColor: neutralSurfaceBorder }}
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
style={isDense ? iconSizes.md : iconSizes.xl}
/>
}
> >
{t("card.lan")} · {summary.lan.users + summary.lan.guests} <Stack gap="xs">
</List.Item> <Group justify="space-between" wrap="nowrap">
)} <Group gap={6} wrap="nowrap">
<List.Item <StatusIcon
icon={ status={section.status}
<StatusIcon label={statusLabels[getBinaryStatusKey(section.status)]}
status={summary.vpn.status} style={iconSizes.md}
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]} />
style={isDense ? iconSizes.md : iconSizes.xl} <Text size="sm" fw={600}>
/> {getMatrixSectionLabel(section.key, t)}
} </Text>
> </Group>
<Text> <Text size="xs" c="dimmed">
{t("card.vpn.label")} {statusLabels[getBinaryStatusKey(section.status)]}
<Text c="dimmed" size="sm" ms="xs" span> </Text>
{t("card.vpn.countConnected", { count: summary.vpn.users })} </Group>
</Text> {section.metrics.length > 0 && (
</Text> <SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
</List.Item> {section.metrics.map((metric) => (
</List> <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> </Stack>
</Card> </Card>
))} ))}
</SimpleGrid> </SimpleGrid>
</Stack> </ScrollArea>
); );
} }
@@ -280,26 +246,23 @@ const getMatrixSectionLabel = (
return t(`card.${key}` as never); return t(`card.${key}` as never);
}; };
const neutralSurfaceBorder = const neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const StatusIcon = ({ const StatusIcon = ({
status, status,
label, label,
size,
style, style,
}: { }: {
status?: "enabled" | "disabled"; status?: "enabled" | "disabled";
label: string; label: string;
size?: number | string;
style?: CSSProperties; style?: CSSProperties;
}) => { }) => {
const mantineTheme = useMantineTheme(); const mantineTheme = useMantineTheme();
if (status === "enabled") { 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") { 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( const failedIntegrations = notificationIntegrations.filter(
(integration): integration is typeof integration & { error: string } => Boolean(integration.error), (integration): integration is typeof integration & { error: string } => Boolean(integration.error),
); );
const isAdvanced = displayMode === "advanced";
const isDense = width < 280 || height < 180; const isDense = width < 280 || height < 180;
const isRoomy = width >= 360 && height >= 220; const isRoomy = width >= 360 && height >= 220;
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4; const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
const notificationDisplay = getNotificationDisplay({ const notificationDisplay = getNotificationDisplay({
displayMode, displayMode,
hideLogos: options.hideLogos, hideLogos: options.hideLogos,
isRoomy, isRoomy: isAdvanced && isRoomy,
bodyLineClamp, bodyLineClamp: isAdvanced ? bodyLineClamp : 4,
}); });
const columns = width >= 720 ? 2 : 1; const columns = isAdvanced && width >= 720 ? 2 : 1;
const spacing = isRoomy ? "sm" : "xs"; const spacing = !isAdvanced || isRoomy ? "sm" : "xs";
return ( return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs"> <ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "xs" : "sm"}>
<Stack w="100%" gap="xs"> <Stack w="100%" gap={isAdvanced ? "xs" : "sm"}>
{failedIntegrations.length > 0 && ( {failedIntegrations.length > 0 && (
<Group gap={4} wrap="wrap"> <Group gap={4} wrap="wrap">
{failedIntegrations.map((integration) => ( {failedIntegrations.map((integration) => (
@@ -93,34 +94,39 @@ export default function NotificationsWidget({
href={href} href={href}
target={href ? "_blank" : undefined} target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : 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} radius={board.itemRadius}
w="100%" w="100%"
p={isRoomy ? "sm" : isDense ? 6 : "xs"} p={!isAdvanced || isRoomy ? "sm" : isDense ? 6 : "xs"}
style={{ style={{
color: "inherit", color: "inherit",
textDecoration: "none", 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 && ( {notificationDisplay.showLogos && notification.source?.iconUrl && (
<Avatar <Avatar
src={notification.source.iconUrl} src={notification.source.iconUrl}
alt={notification.source.name} alt={notification.source.name}
size={isDense ? "xs" : "sm"} size={isAdvanced && isDense ? "xs" : "sm"}
radius={board.itemRadius} 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 && ( {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} {notification.title}
</Text> </Text>
)} )}
<Text <Text
c="dimmed" c="dimmed"
size={isDense ? "xs" : "sm"} size={isAdvanced && isDense ? "xs" : "sm"}
lineClamp={notificationDisplay.bodyLineClamp} lineClamp={notificationDisplay.bodyLineClamp}
style={{ whiteSpace: "pre-line" }} style={{ whiteSpace: "pre-line" }}
> >
@@ -134,7 +140,7 @@ export default function NotificationsWidget({
? (notification.source?.name ?? notification.integrationName) ? (notification.source?.name ?? notification.integrationName)
: undefined : undefined
} }
dense={isDense} dense={isAdvanced && isDense}
/> />
</Flex> </Flex>
</Flex> </Flex>
@@ -159,11 +165,7 @@ const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; den
return ( return (
<Group gap={5} align="center" wrap="nowrap"> <Group gap={5} align="center" wrap="nowrap">
<IconClock <IconClock aria-hidden style={dense ? iconSizes.xs : iconSizes.md} color="var(--mantine-color-dimmed)" />
aria-hidden
style={dense ? iconSizes.xs : iconSizes.md}
color="var(--mantine-color-dimmed)"
/>
<Text size={dense ? "xs" : "sm"} c="dimmed"> <Text size={dense ? "xs" : "sm"} c="dimmed">
{timeAgo} {timeAgo}
</Text> </Text>
@@ -4,11 +4,16 @@
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
padding: clamp(6px, 3cqi, 14px); padding: clamp(6px, 3cqi, 12px);
gap: clamp(4px, 2cqi, 10px); gap: clamp(4px, 2cqi, 8px);
overflow: hidden; overflow: hidden;
} }
.rootShort {
padding: 4px;
gap: 2px;
}
.hero { .hero {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -16,8 +21,7 @@
gap: clamp(8px, 3cqi, 18px); gap: clamp(8px, 3cqi, 18px);
flex-shrink: 0; flex-shrink: 0;
padding-bottom: clamp(4px, 2cqi, 10px); padding-bottom: clamp(4px, 2cqi, 10px);
border-bottom: 1px solid border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
} }
.heroExpanded { .heroExpanded {
@@ -81,13 +85,18 @@
.grid { .grid {
display: grid; display: grid;
flex: 1; flex: 1;
width: 100%;
min-height: 0; 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-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
align-content: stretch; align-content: stretch;
} }
.gridShort {
gap: 2px;
}
.statTile { .statTile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -95,22 +104,37 @@
justify-content: center; justify-content: center;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
gap: clamp(2px, 1.5cqi, 5px); gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2.5cqi, 10px); padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
border: 1px solid background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
overflow: hidden; overflow: hidden;
} }
.statTileShort {
gap: 2px;
padding: 3px;
}
.statTileNarrowShort {
flex-direction: row;
gap: 2px;
padding: 1px 3px;
}
.statIcon { .statIcon {
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
flex-shrink: 0; flex-shrink: 0;
line-height: 0;
} }
.statLabel { .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); color: var(--mantine-color-dimmed);
line-height: 1.2; line-height: 1.2;
text-align: center; text-align: center;
@@ -120,12 +144,38 @@
white-space: nowrap; 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 { .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; font-weight: 700;
color: var(--mantine-color-text); color: var(--mantine-primary-color-filled);
line-height: 1.1; line-height: 1.1;
text-align: center; 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 { .emptyState {
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
import { getCompactStatLayout } from "../common/compact-stat-layout";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css"; import classes from "./component.module.css";
@@ -32,11 +33,41 @@ const statIcons = {
const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]); const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]);
const gridColsByWidth = [ const gridColsByWidth = [
{ minWidth: 380, cols: 3 }, { minWidth: 300, cols: 3 },
{ minWidth: 220, cols: 2 }, { minWidth: 220, cols: 2 },
{ minWidth: 0, cols: 1 }, { minWidth: 0, cols: 1 },
] as const; ] 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 = [ const ringSizeByWidth = [
{ minWidth: 400, size: 88 }, { minWidth: 400, size: 88 },
{ minWidth: 320, size: 72 }, { minWidth: 320, size: 72 },
@@ -76,6 +107,8 @@ export default function PaperlessNgxWidget({
options, options,
width, width,
height, height,
displayScale = 1,
displayMode = "compact",
}: WidgetComponentProps<"paperlessNgx">) { }: WidgetComponentProps<"paperlessNgx">) {
const t = useI18n("widget.paperlessNgx"); const t = useI18n("widget.paperlessNgx");
const tCommon = useI18n("common"); const tCommon = useI18n("common");
@@ -116,19 +149,31 @@ export default function PaperlessNgxWidget({
.map(([, statKey]) => statKey) .map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey))); .filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
const gridCols = Math.max(1, Math.min(visibleStatKeys.length, getGridCols(width, height))); let responsiveWidth = width;
const ringSize = getRingSize(Math.min(width, height * 2)); let responsiveHeight = height;
const iconSize = getIconSize(width); 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 ringLabelSize = getRingLabelSize(ringSize);
const hasContent = showHero || visibleStatKeys.length > 0; const hasContent = showHero || visibleStatKeys.length > 0;
const heroLayoutClass = const heroLayoutClass =
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats]; heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
const showInboxRing = options.showInboxRing && height >= 120; const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing];
const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
const visibleHeroParts = Object.entries(heroPartVisibility).filter( 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 = { const heroPartRenderers = {
@@ -162,7 +207,7 @@ export default function PaperlessNgxWidget({
} as const; } as const;
return ( return (
<div className={classes.root}> <div className={`${classes.root} ${rootClassByLayout[layout.state]}`}>
{showHero && ( {showHero && (
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}> <div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
{visibleHeroParts.map(([partKey]) => ( {visibleHeroParts.map(([partKey]) => (
@@ -180,14 +225,19 @@ export default function PaperlessNgxWidget({
)} )}
{visibleStatKeys.length > 0 && ( {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) => { {visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey]; const Icon = statIcons[statKey];
return ( return (
<div key={statKey} className={classes.statTile}> <div key={statKey} className={`${classes.statTile} ${statTileClassByLayout[layout.state]}`}>
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} /> <Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
<span className={classes.statValue}>{statValues[statKey]}</span> <span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>
<span className={classes.statLabel}>{t(statKey)}</span> {statValues[statKey]}
</span>
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>{t(statKey)}</span>
</div> </div>
); );
})} })}
@@ -205,9 +255,9 @@ function computeInboxProgress(total: number, inbox: number): number {
return Math.round((inbox / total) * 100); 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); 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 { function getRingSize(width: number): number {
+1 -1
View File
@@ -65,7 +65,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
defaultValue: {}, defaultValue: {},
}); });
const isAdvanced = displayMode === "advanced"; 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( const relativeDateOptions = useMemo(
() => ({ () => ({
@@ -24,6 +24,18 @@
flex-shrink: 0; 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) { @media (prefers-reduced-motion: reduce) {
.entry { .entry {
transition: none; transition: none;
+104 -61
View File
@@ -1,6 +1,6 @@
"use client"; "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 { IconAlertTriangle, IconClock, IconServerOff } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -104,9 +104,10 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
descriptionLines, descriptionLines,
}); });
const spacing = isRoomy ? "sm" : "xs"; const spacing = isRoomy ? "sm" : "xs";
const isNarrow = width < 128 * 3;
return ( 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 && ( {warning && (
<Alert <Alert
role="presentation" role="presentation"
@@ -118,67 +119,109 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
<output>{warning}</output> <output>{warning}</output>
</Alert> </Alert>
)} )}
<SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}> {isAdvanced ? (
{feed.entries.map((feedEntry) => { <SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}>
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl }); {feed.entries.map((feedEntry) => {
return ( const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
<Card return (
key={feedEntry.id} <Card
className={classes.entry} key={feedEntry.id}
component={href ? "a" : "div"} className={classes.entry}
href={href} component={href ? "a" : "div"}
radius={board.itemRadius} href={href}
target={href ? "_blank" : undefined} radius={board.itemRadius}
rel={href ? SAFE_NEW_TAB_REL : undefined} target={href ? "_blank" : undefined}
w="100%" rel={href ? SAFE_NEW_TAB_REL : undefined}
p={isDense ? 6 : "xs"} w="100%"
title={feedEntry.title} p={isDense ? 6 : "xs"}
> title={feedEntry.title}
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}> >
{feedEntry.enclosure !== undefined && entryDisplay.showImage && ( <Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
<Image {feedEntry.enclosure !== undefined && entryDisplay.showImage && (
loading="lazy" <Image
className={classes.poster} loading="lazy"
src={feedEntry.enclosure} className={classes.poster}
alt="" src={feedEntry.enclosure}
w={isRoomy ? 140 : isDense ? 64 : 96} alt=""
h={isRoomy ? 96 : isDense ? 64 : 96} w={isRoomy ? 140 : isDense ? 64 : 96}
radius="sm" h={isRoomy ? 96 : isDense ? 64 : 96}
fit="cover" 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>
)} )}
<InfoDisplay <Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined} <Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25}>
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined} {feedEntry.title}
timestamp={ </Text>
isAdvanced && feedEntry.published {entryDisplay.showDescription && feedEntry.description && (
? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z") <Text dir={languageDir} c="dimmed" size="sm">
: undefined {feedDescriptionToText(feedEntry.description)}
} </Text>
/> )}
</Flex>
</Group> <InfoDisplay
</Card> source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
); date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
})} timestamp={
</SimpleGrid> 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> </ScrollArea>
); );
} }
@@ -98,14 +98,15 @@ export default function SmartHomeEntityStateWidget({
if (entityError && entity === undefined) throw entityError; 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 = [ const advancedAttributes = [
{ key: "unit" as const, value: entity?.attributes.unit_of_measurement }, { key: "unit" as const, value: entity?.attributes.unit_of_measurement },
{ key: "deviceClass" as const, value: entity?.attributes.device_class }, { key: "deviceClass" as const, value: entity?.attributes.device_class },
{ key: "icon" as const, value: entity?.attributes.icon }, { key: "icon" as const, value: entity?.attributes.icon },
].filter(({ value }) => typeof value === "string" && value.length > 0); ].filter(({ value }) => typeof value === "string" && value.length > 0);
const displayName = const displayName =
displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string" isAdvanced && typeof entity?.attributes.friendly_name === "string"
? entity.attributes.friendly_name ? entity.attributes.friendly_name
: options.displayName; : options.displayName;
const knownStates = { const knownStates = {
@@ -133,20 +134,34 @@ export default function SmartHomeEntityStateWidget({
}} }}
> >
<Center h="100%" w="100%"> <Center h="100%" w="100%">
<Stack align="center" gap={isTiny ? 4 : "md"} p="xs" maw="100%"> <Stack align="center" gap={isAdvanced && isTiny ? 4 : "md"} p={isAdvanced ? "xs" : undefined} maw="100%">
<Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%"> {isAdvanced ? (
{state} <>
{attribute} <Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%">
</Text> {state}
<Text ta="center" c="dimmed" fw={500} size={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%"> {attribute}
{displayName} </Text>
</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 && ( {queryErrorLabel && (
<Text size="xs" c="orange" ta="center" lineClamp={1} maw="100%" aria-live="polite"> <Text size="xs" c="orange" ta="center" lineClamp={1} maw="100%" aria-live="polite">
{queryErrorLabel} {queryErrorLabel}
</Text> </Text>
)} )}
{displayMode === "advanced" && entity && ( {isAdvanced && entity && (
<> <>
<Group justify="center" gap={4}> <Group justify="center" gap={4}>
{advancedAttributes.map(({ key, value }) => ( {advancedAttributes.map(({ key, value }) => (
@@ -10,12 +10,20 @@ import { formatStatsSpeed } from "./helpers";
import { SectionLabel } from "./section-label"; import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card"; 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"); const t = useI18n("widget.speedtestTracker");
return ( return (
<Stack gap={6} h="100%"> <Stack gap={6} h="100%">
<SectionLabel> <SectionLabel uppercase={compactSurface}>
{t("averages")} · {t("tests", { count: stats.total_results.toString() })} {t("averages")} · {t("tests", { count: stats.total_results.toString() })}
</SectionLabel> </SectionLabel>
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}> <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)} value={formatStatsSpeed(stats.download)}
label={t("avgDownload")} label={t("avgDownload")}
compact compact
legacySurface={compactSurface}
/> />
<SpeedStatCard <SpeedStatCard
icon={IconArrowUp} icon={IconArrowUp}
@@ -32,6 +41,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
value={formatStatsSpeed(stats.upload)} value={formatStatsSpeed(stats.upload)}
label={t("avgUpload")} label={t("avgUpload")}
compact compact
legacySurface={compactSurface}
/> />
<SpeedStatCard <SpeedStatCard
icon={IconWaveSine} icon={IconWaveSine}
@@ -39,6 +49,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
value={`${stats.ping.avg.toFixed(1)} ms`} value={`${stats.ping.avg.toFixed(1)} ms`}
label={t("avgPing")} label={t("avgPing")}
compact compact
legacySurface={compactSurface}
/> />
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
@@ -11,7 +11,7 @@ import { IntegrationErrorIndicator } from "../common/integration-error-indicator
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages"; import { AveragesSection } from "./averages";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers"; import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards } from "./helpers";
import { LatestResultSection } from "./latest-result"; import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results"; import { RecentResultsSection } from "./recent-results";
@@ -51,16 +51,19 @@ export default function SpeedtestTrackerWidget({
const advancedSectionWidth = width >= 720 ? width / 2 : width; const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = showLatestResult && combined.latestResult && ( 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 && ( 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") { if (displayMode === "advanced") {
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1; const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
@@ -136,15 +139,15 @@ export default function SpeedtestTrackerWidget({
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")} {successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
</Text> </Text>
)} )}
{(compactSections.latest || compactSections.averages) && ( {(latest || averages) && (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}> <Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>} {latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>} {averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
</Stack> </Stack>
)} )}
{compactSections.chart && ( {hasChart && (
<div style={{ flex: 2, minHeight: 0 }}> <div style={{ flex: 2, minHeight: 0 }}>
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} /> <RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} uppercaseLabels />
</div> </div>
)} )}
{noSectionsEnabled && ( {noSectionsEnabled && (
@@ -10,7 +10,15 @@ import { formatResultSpeed } from "./helpers";
import { SectionLabel } from "./section-label"; import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card"; 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 t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale(); const locale = useCurrentIntlLocale();
const timestamp = Intl.DateTimeFormat(locale, { const timestamp = Intl.DateTimeFormat(locale, {
@@ -25,7 +33,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
return ( return (
<Stack gap={6} h="100%"> <Stack gap={6} h="100%">
<Group justify="space-between" align="center" wrap="nowrap"> <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 }}> <Text size="xs" c="dimmed" truncate title={timestamp} style={{ minWidth: 0 }}>
{timestamp} {timestamp}
</Text> </Text>
@@ -36,18 +44,21 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
color="blue" color="blue"
value={formatResultSpeed(result, "download")} value={formatResultSpeed(result, "download")}
label={t("download")} label={t("download")}
legacySurface={compactSurface}
/> />
<SpeedStatCard <SpeedStatCard
icon={IconArrowUp} icon={IconArrowUp}
color="teal" color="teal"
value={formatResultSpeed(result, "upload")} value={formatResultSpeed(result, "upload")}
label={t("upload")} label={t("upload")}
legacySurface={compactSurface}
/> />
<SpeedStatCard <SpeedStatCard
icon={IconWaveSine} icon={IconWaveSine}
color="orange" color="orange"
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"} value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
label={t("ping")} label={t("ping")}
legacySurface={compactSurface}
/> />
{result.healthy !== null && ( {result.healthy !== null && (
<SpeedStatCard <SpeedStatCard
@@ -55,6 +66,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
color={result.healthy ? "green" : "red"} color={result.healthy ? "green" : "red"}
value={result.healthy ? t("healthy") : t("unhealthy")} value={result.healthy ? t("healthy") : t("unhealthy")}
label={t("status")} label={t("status")}
legacySurface={compactSurface}
/> />
)} )}
</SimpleGrid> </SimpleGrid>
@@ -114,30 +114,40 @@ function formatTooltipDate(label: number | string | undefined, locale: string):
export function RecentResultsSection({ export function RecentResultsSection({
results, results,
showPingGraph, showPingGraph,
uppercaseLabels = false,
}: { }: {
results: SpeedtestTrackerResult[]; results: SpeedtestTrackerResult[];
showPingGraph: boolean; showPingGraph: boolean;
uppercaseLabels?: boolean;
}) { }) {
const t = useI18n("widget.speedtestTracker"); const t = useI18n("widget.speedtestTracker");
return ( return (
<Stack gap={4} h="100%" style={{ minHeight: 0 }}> <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 }}> <div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<SpeedSubChart results={results} showLabel={showPingGraph} /> <SpeedSubChart results={results} showLabel={showPingGraph} uppercaseLabels={uppercaseLabels} />
{showPingGraph && <PingSubChart results={results} />} {showPingGraph && <PingSubChart results={results} uppercaseLabels={uppercaseLabels} />}
</div> </div>
</Stack> </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 t = useI18n("widget.speedtestTracker");
const { ref, height } = useElementSize<HTMLDivElement>(); const { ref, height } = useElementSize<HTMLDivElement>();
return ( return (
<div style={{ flex: 2, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}> <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 }}> <div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <SpeedHistoryChart results={results} height={height} />} {height > 0 && <SpeedHistoryChart results={results} height={height} />}
</div> </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 t = useI18n("widget.speedtestTracker");
const { ref, height } = useElementSize<HTMLDivElement>(); const { ref, height } = useElementSize<HTMLDivElement>();
return ( return (
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}> <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 }}> <div ref={ref} style={{ flex: 1, minHeight: 0 }}>
{height > 0 && <PingHistoryChart results={results} height={height} />} {height > 0 && <PingHistoryChart results={results} height={height} />}
</div> </div>
@@ -1,9 +1,15 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
export function SectionLabel({ children }: { children: ReactNode }) { export function SectionLabel({ children, uppercase = false }: { children: ReactNode; uppercase?: boolean }) {
return ( 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} {children}
</Text> </Text>
); );
@@ -13,9 +13,17 @@ export interface SpeedStatCardProps {
value: string; value: string;
label: string; label: string;
compact?: boolean; 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 { ref, height, width } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard(); const board = useRequiredBoard();
const isWide = width > height + 20; 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 surfaceColor = `var(--mantine-color-${color}-filled)`;
const accentColor = `var(--mantine-color-${color}-5)`; const accentColor = `var(--mantine-color-${color}-5)`;
const surfaceAlpha = compact ? 0.1 : 0.14; 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))"; const surfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
return ( return (
@@ -32,7 +41,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
ref={ref} ref={ref}
p={compact ? "xs" : "sm"} p={compact ? "xs" : "sm"}
radius={board.itemRadius} radius={board.itemRadius}
withBorder withBorder={!legacySurface}
bg={surfaceBackground} bg={surfaceBackground}
h="100%" h="100%"
style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }} style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }}
+25 -14
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; 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 { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -28,12 +28,12 @@ const advancedColumnBreakpoints = [
{ minimumWidth: 0, columns: 1 }, { minimumWidth: 0, columns: 1 },
] as const; ] as const;
const getSystemDisksLayout = (width: number, height: number, diskCount: number, isAdvanced: boolean) => { const getSystemDisksLayout = (width: number, diskCount: number, isAdvanced: boolean) => {
if (!isAdvanced) { if (!isAdvanced) {
return { return {
columns: 1, columns: 1,
showSecondaryText: height >= 160 && width >= 260, showSecondaryText: false,
showTemperature: height >= 100 && width >= 220, showTemperature: true,
}; };
} }
@@ -113,6 +113,7 @@ const SystemDiskCard = ({
showTemperature, showTemperature,
}: SystemDiskCardProps) => { }: SystemDiskCardProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const colorScheme = useComputedColorScheme("light");
const t = useI18n("widget.systemDisks"); const t = useI18n("widget.systemDisks");
const valueRef = useRef<HTMLParagraphElement>(null); const valueRef = useRef<HTMLParagraphElement>(null);
const [valueFits, setValueFits] = useState(true); const [valueFits, setValueFits] = useState(true);
@@ -149,6 +150,10 @@ const SystemDiskCard = ({
.join(" · "); .join(" · ");
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; 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 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 ( return (
<Tooltip <Tooltip
@@ -160,13 +165,13 @@ const SystemDiskCard = ({
<Card <Card
radius={board.itemRadius} radius={board.itemRadius}
py="xs" py="xs"
withBorder withBorder={isAdvanced}
bg={backgroundColor} bg={cardBackground}
style={{ overflow: "hidden", position: "relative", borderColor }} style={{ overflow: "hidden", position: "relative", borderColor: isAdvanced ? borderColor : undefined }}
> >
<Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}> <Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}>
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<Text fw={700} size="sm" truncate="end"> <Text fw={700} size={isAdvanced ? "sm" : undefined} truncate="end">
{deviceName} {deviceName}
</Text> </Text>
{integrationName && ( {integrationName && (
@@ -174,7 +179,11 @@ const SystemDiskCard = ({
{integrationName} {integrationName}
</Text> </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> <span>{displayText}</span>
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>} {!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
</Text> </Text>
@@ -195,10 +204,13 @@ const SystemDiskCard = ({
)} )}
</div> </div>
<div style={{ flexShrink: 0 }}> <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> </div>
</Group> </Group>
<Box <Box
bg={isAdvanced ? undefined : legacyProgressColor}
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
@@ -207,7 +219,7 @@ const SystemDiskCard = ({
height: "100%", height: "100%",
zIndex: 0, zIndex: 0,
display: showBackgroundBar ? "block" : "none", display: showBackgroundBar ? "block" : "none",
backgroundColor: healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)", backgroundColor: isAdvanced ? progressBackground : undefined,
}} }}
></Box> ></Box>
</Card> </Card>
@@ -219,7 +231,6 @@ export default function SystemResources({
integrationIds, integrationIds,
options, options,
width, width,
height,
displayMode, displayMode,
}: WidgetComponentProps<"systemDisks">) { }: WidgetComponentProps<"systemDisks">) {
const t = useI18n("widget.systemDisks"); const t = useI18n("widget.systemDisks");
@@ -247,8 +258,8 @@ export default function SystemResources({
}); });
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const layout = useMemo( const layout = useMemo(
() => getSystemDisksLayout(width, height, disks.length, isAdvanced), () => getSystemDisksLayout(width, disks.length, isAdvanced),
[disks.length, height, isAdvanced, width], [disks.length, isAdvanced, width],
); );
const queryIndicators = ( const queryIndicators = (
<Group gap={0}> <Group gap={0}>
@@ -11,6 +11,7 @@ export const CombinedNetworkTrafficChart = ({
usageOverTime, usageOverTime,
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false,
}: { }: {
usageOverTime: { usageOverTime: {
up: number; up: number;
@@ -18,6 +19,7 @@ export const CombinedNetworkTrafficChart = ({
}[]; }[];
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => { }) => {
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down })); const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
const t = useI18n("widget.systemResources.card"); const t = useI18n("widget.systemResources.card");
@@ -35,6 +37,7 @@ export const CombinedNetworkTrafficChart = ({
yAxisProps={{ domain: [0, "dataMax"] }} yAxisProps={{ domain: [0, "dataMax"] }}
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{ tooltipProps={{
content: ({ payload }) => { content: ({ payload }) => {
return ( return (
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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 type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } 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 { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts"; import type { TooltipProps, YAxisProps } from "recharts";
@@ -23,6 +23,7 @@ export const CommonChart = ({
yAxisProps, yAxisProps,
lastValue, lastValue,
chartType = "line", chartType = "line",
advanced = false,
}: { }: {
data: Record<string, any>[]; data: Record<string, any>[];
dataKey: string; dataKey: string;
@@ -34,30 +35,32 @@ export const CommonChart = ({
yAxisProps?: Omit<YAxisProps, "ref">; yAxisProps?: Omit<YAxisProps, "ref">;
lastValue?: string; lastValue?: string;
chartType?: "line" | "area"; chartType?: "line" | "area";
advanced?: boolean;
}) => { }) => {
const { ref: elementSizeRef, height } = useElementSize(); const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme(); const theme = useMantineTheme();
const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hovered, ref: hoverRef } = useHover(); const { hovered, ref: hoverRef } = useHover();
const ref = useMergedRef(elementSizeRef, hoverRef); const ref = useMergedRef(elementSizeRef, hoverRef);
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; const opacity = board.opacity / 100;
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; 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 ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon"; const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
return ( return (
<Card <Card ref={ref} h={"100%"} pos={"relative"} style={cardStyle} p={0} bg={backgroundColor} radius={board.itemRadius}>
ref={ref}
h={"100%"}
pos={"relative"}
style={{ overflow: "hidden", border: `1px solid ${borderColor}` }}
p={0}
bg={backgroundColor}
radius={board.itemRadius}
>
{data.length > 1 && height > 40 && !hovered && ( {data.length > 1 && height > 40 && !hovered && (
<Group <Group
pos={"absolute"} pos={"absolute"}
@@ -70,7 +73,13 @@ export const CommonChart = ({
style={{ zIndex: 2, pointerEvents: "none" }} style={{ zIndex: 2, pointerEvents: "none" }}
align="center" 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 && ( {showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}> <Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
{title} {title}
@@ -83,7 +92,7 @@ export const CommonChart = ({
)} )}
</Group> </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" }}> <Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
<Text size="xs" fw={600}> <Text size="xs" fw={600}>
{lastValue} {lastValue}
@@ -93,13 +102,19 @@ export const CommonChart = ({
{data.length <= 1 ? ( {data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%"> <Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}> <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 && ( {showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center"> <Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
{title} {title}
</Text> </Text>
)} )}
{lastValue && ( {advanced && lastValue && (
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center"> <Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
{lastValue} {lastValue}
</Text> </Text>
@@ -119,13 +134,7 @@ export const CommonChart = ({
withYAxis={false} withYAxis={false}
withDots={false} withDots={false}
bg={backgroundColor} bg={backgroundColor}
styles={{ styles={{ root: chartRootStyle }}
root: {
padding: 5,
borderRadius: theme.radius[board.itemRadius],
backgroundColor,
},
}}
tooltipAnimationDuration={200} tooltipAnimationDuration={200}
tooltipProps={{ tooltipProps={{
// Render via portal so the tooltip isn't clipped by the card's overflow:hidden // Render via portal so the tooltip isn't clipped by the card's overflow:hidden
@@ -10,10 +10,12 @@ export const SystemResourceCPUChart = ({
cpuUsageOverTime, cpuUsageOverTime,
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false,
}: { }: {
cpuUsageOverTime: number[]; cpuUsageOverTime: number[];
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => { }) => {
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage })); const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
@@ -31,6 +33,7 @@ export const SystemResourceCPUChart = ({
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }} yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{ tooltipProps={{
content: ({ payload }) => { content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0; const value = payload[0] ? Number(payload[0].value) : 0;
@@ -10,10 +10,12 @@ export const SystemResourceGPUChart = ({
gpuUsageOverTime, gpuUsageOverTime,
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false,
}: { }: {
gpuUsageOverTime: number[]; gpuUsageOverTime: number[];
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => { }) => {
const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage })); const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage }));
@@ -31,6 +33,7 @@ export const SystemResourceGPUChart = ({
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }} yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{ tooltipProps={{
content: ({ payload }) => { content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0; const value = payload[0] ? Number(payload[0].value) : 0;
@@ -12,11 +12,13 @@ export const SystemResourceMemoryChart = ({
totalCapacityInBytes, totalCapacityInBytes,
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false,
}: { }: {
memoryUsageOverTime: number[]; memoryUsageOverTime: number[];
totalCapacityInBytes: number; totalCapacityInBytes: number;
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => { }) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage })); const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
const t = useI18n("widget.systemResources.card"); const t = useI18n("widget.systemResources.card");
@@ -35,6 +37,7 @@ export const SystemResourceMemoryChart = ({
title={t("memory")} title={t("memory")}
icon={IconBrain} icon={IconBrain}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced}
yAxisProps={{ domain: [0, totalCapacityInBytes] }} yAxisProps={{ domain: [0, totalCapacityInBytes] }}
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined} lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
@@ -12,11 +12,13 @@ export const NetworkTrafficChart = ({
isUp, isUp,
hasShadow, hasShadow,
labelDisplayMode, labelDisplayMode,
advanced = false,
}: { }: {
usageOverTime: number[]; usageOverTime: number[];
isUp: boolean; isUp: boolean;
hasShadow: boolean; hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
}) => { }) => {
const chartData = usageOverTime.map((usage, index) => ({ index, usage })); const chartData = usageOverTime.map((usage, index) => ({ index, usage }));
const t = useI18n("widget.systemResources.card"); const t = useI18n("widget.systemResources.card");
@@ -36,6 +38,7 @@ export const NetworkTrafficChart = ({
lastValue={formatByteRate(Math.round(latest))} lastValue={formatByteRate(Math.round(latest))}
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced}
tooltipProps={{ tooltipProps={{
content: ({ payload }) => { content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0; const value = payload[0] ? Number(payload[0].value) : 0;
@@ -68,7 +68,7 @@ export const getVisibleSystemCharts = ({
configuredCharts, configuredCharts,
hasGpu, hasGpu,
hasNetwork, hasNetwork,
height, height: _height,
isAdvanced, isAdvanced,
}: { }: {
configuredCharts: readonly SystemChart[]; configuredCharts: readonly SystemChart[];
@@ -81,7 +81,7 @@ export const getVisibleSystemCharts = ({
const availableCharts = selectedCharts.filter( const availableCharts = selectedCharts.filter(
(chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork), (chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork),
); );
return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height)); return [...availableCharts];
}; };
export default function SystemResources({ export default function SystemResources({
@@ -235,10 +235,7 @@ const SystemCharts = ({
const showNetwork = visibleCharts.includes("network"); const showNetwork = visibleCharts.includes("network");
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode; const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
const chartCount = visibleCharts.length; const chartCount = visibleCharts.length;
const chartColumns = useMemo( const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]);
() => (isAdvanced ? getAdvancedChartColumns(width) : 1),
[isAdvanced, width],
);
const compactChartGap = 8; const compactChartGap = 8;
const compactRowHeight = 56; const compactRowHeight = 56;
const chartHeight = isAdvanced const chartHeight = isAdvanced
@@ -269,6 +266,7 @@ const SystemCharts = ({
cpuUsageOverTime={items.map((item) => item.cpu)} cpuUsageOverTime={items.map((item) => item.cpu)}
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/> />
</Box> </Box>
)} )}
@@ -279,6 +277,7 @@ const SystemCharts = ({
totalCapacityInBytes={memoryCapacityInBytes} totalCapacityInBytes={memoryCapacityInBytes}
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/> />
</Box> </Box>
)} )}
@@ -288,6 +287,7 @@ const SystemCharts = ({
gpuUsageOverTime={items.map((item) => item.gpu)} gpuUsageOverTime={items.map((item) => item.gpu)}
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/> />
</Box> </Box>
)} )}
@@ -299,12 +299,14 @@ const SystemCharts = ({
isUp={false} isUp={false}
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/> />
<NetworkTrafficChart <NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.up)} usageOverTime={networkItems.map((network) => network.up)}
isUp isUp
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/> />
</Group> </Group>
) : ( ) : (
@@ -313,6 +315,7 @@ const SystemCharts = ({
usageOverTime={networkItems} usageOverTime={networkItems}
hasShadow={options.hasShadow} hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
/> />
</Box> </Box>
))} ))}
+13 -26
View File
@@ -65,17 +65,12 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined; const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined;
const isAdvanced = displayMode === "advanced"; const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && (width < 300 || height < 160); const isDense = !isAdvanced && (width < 300 || height < 160);
const showTitle = isAdvanced || height >= 112;
const showLine = isAdvanced || width >= 200;
const showPlatform = isAdvanced || width >= 320;
return ( return (
<Stack w="100%" h="100%" gap={isDense ? 4 : "xs"} p={isDense ? "xs" : "sm"}> <Stack w="100%" h="100%" gap={isDense ? 4 : "xs"} p={isDense ? "xs" : "sm"}>
{showTitle && ( <Text fw={600} size={isDense ? "sm" : undefined} truncate="end">
<Text fw={600} size={isDense ? "sm" : undefined} truncate="end"> {t("title", { station: station.label })}
{t("title", { station: station.label })} </Text>
</Text>
)}
{compactStaleWarning && displayMode === "compact" && ( {compactStaleWarning && displayMode === "compact" && (
<Group gap={2} wrap="nowrap"> <Group gap={2} wrap="nowrap">
<IconAlertTriangle <IconAlertTriangle
@@ -113,8 +108,6 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
key={`${entry.timestamp.toISOString()}-${entry.location}`} key={`${entry.timestamp.toISOString()}-${entry.location}`}
entry={entry} entry={entry}
dense={isDense} dense={isDense}
showLine={showLine}
showPlatform={showPlatform}
locale={locale} locale={locale}
/> />
)) ))
@@ -144,23 +137,11 @@ interface TimetableEntryView {
platform: { name: string; hasChanged: boolean } | null; platform: { name: string; hasChanged: boolean } | null;
} }
function DepartureRow({ function DepartureRow({ entry, dense, locale }: { entry: TimetableEntryView; dense: boolean; locale: string }) {
entry,
dense,
showLine,
showPlatform,
locale,
}: {
entry: TimetableEntryView;
dense: boolean;
showLine: boolean;
showPlatform: boolean;
locale: string;
}) {
return ( return (
<Group justify="space-between" w="100%" wrap="nowrap"> <Group justify="space-between" w="100%" wrap="nowrap">
<Group gap={dense ? 6 : "sm"} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={dense ? 6 : "sm"} wrap="nowrap" style={{ minWidth: 0 }}>
{showLine && entry.line && ( {entry.line && (
<Badge <Badge
size="sm" size="sm"
color={entry.line.color ?? undefined} color={entry.line.color ?? undefined}
@@ -185,8 +166,14 @@ function DepartureRow({
</Text> </Text>
</Group> </Group>
{showPlatform && entry.platform && ( {entry.platform && (
<Text size={dense ? "xs" : "sm"} c={entry.platform.hasChanged ? "red" : undefined} style={{ flexShrink: 0 }}> <Text
size={dense ? "xs" : "sm"}
c={entry.platform.hasChanged ? "red" : undefined}
maw="35%"
truncate="end"
style={{ flexShrink: 1, minWidth: 0 }}
>
{entry.platform.name} {entry.platform.name}
</Text> </Text>
)} )}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getAdvancedColumnWidth, getCompactSectionVisibility, getTracearrSectionVisibility } from "./component"; import { getAdvancedColumnWidth, getTracearrSectionVisibility } from "./component";
import { attachTracearrSource } from "./source"; import { attachTracearrSource } from "./source";
describe("Tracearr source ownership", () => { describe("Tracearr source ownership", () => {
@@ -30,32 +30,6 @@ describe("Tracearr advanced layout", () => {
}); });
}); });
describe("Tracearr compact disclosure", () => {
it("keeps urgent violations visible in a short widget", () => {
expect(
getCompactSectionVisibility({
height: 160,
showStreams: true,
showViolations: true,
hasViolations: true,
showRecentActivity: true,
}),
).toEqual({ violations: true, recentActivity: false });
});
it("reveals secondary history as height becomes available", () => {
expect(
getCompactSectionVisibility({
height: 400,
showStreams: true,
showViolations: true,
hasViolations: false,
showRecentActivity: true,
}),
).toEqual({ violations: true, recentActivity: true });
});
});
describe("Tracearr advanced disclosure", () => { describe("Tracearr advanced disclosure", () => {
it("shows all sections even when compact presentation toggles are disabled", () => { it("shows all sections even when compact presentation toggles are disabled", () => {
expect( expect(
+10 -48
View File
@@ -33,54 +33,27 @@ export const getTracearrSectionVisibility = (options: TracearrSectionOptions, is
recentActivity: isAdvanced || options.showRecentActivity, recentActivity: isAdvanced || options.showRecentActivity,
}); });
export const getCompactSectionVisibility = ({
height,
showStreams,
showViolations,
hasViolations,
showRecentActivity,
}: {
height: number;
showStreams: boolean;
showViolations: boolean;
hasViolations: boolean;
showRecentActivity: boolean;
}) => ({
violations: showViolations && (hasViolations || height >= 240 || !showStreams),
recentActivity: showRecentActivity && (height >= 360 || (!showStreams && (!showViolations || !hasViolations))),
});
export default function TracearrWidget({ export default function TracearrWidget({
options, options,
integrationIds, integrationIds,
width, width,
height,
displayMode = "compact", displayMode = "compact",
}: WidgetComponentProps<"tracearr">) { }: WidgetComponentProps<"tracearr">) {
if (integrationIds.length === 0) { if (integrationIds.length === 0) {
throw new NoIntegrationDataError(); throw new NoIntegrationDataError();
} }
return ( return <TracearrContent integrationIds={integrationIds} options={options} width={width} displayMode={displayMode} />;
<TracearrContent
integrationIds={integrationIds}
options={options}
width={width}
height={height}
displayMode={displayMode}
/>
);
} }
interface TracearrContentProps { interface TracearrContentProps {
integrationIds: string[]; integrationIds: string[];
options: WidgetComponentProps<"tracearr">["options"]; options: WidgetComponentProps<"tracearr">["options"];
width: number; width: number;
height: number;
displayMode: "compact" | "advanced"; displayMode: "compact" | "advanced";
} }
function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) { function TracearrContent({ integrationIds, options, width, displayMode }: TracearrContentProps) {
const t = useI18n("widget.tracearr"); const t = useI18n("widget.tracearr");
const dashboardQuery = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds }); const dashboardQuery = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? []; const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? [];
@@ -157,7 +130,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
const sectionVisibility = getTracearrSectionVisibility(options, isAdvanced); const sectionVisibility = getTracearrSectionVisibility(options, isAdvanced);
const noSectionsEnabled = Object.values(sectionVisibility).every((isVisible) => !isVisible); const noSectionsEnabled = Object.values(sectionVisibility).every((isVisible) => !isVisible);
const compactLimit = height < 220 ? 2 : height < 360 ? 4 : 8;
const sourcedStreams = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) => const sourcedStreams = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) =>
attachTracearrSource(dashboard.streams.data, { integrationId, integrationName }), attachTracearrSource(dashboard.streams.data, { integrationId, integrationName }),
); );
@@ -167,15 +139,7 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
const sourcedRecentActivity = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) => const sourcedRecentActivity = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) =>
attachTracearrSource(dashboard.recentActivity?.data ?? [], { integrationId, integrationName }), attachTracearrSource(dashboard.recentActivity?.data ?? [], { integrationId, integrationName }),
); );
const streams = isAdvanced ? sourcedStreams : sourcedStreams.slice(0, compactLimit);
const showSource = isAdvanced || dashboardData.length > 1; const showSource = isAdvanced || dashboardData.length > 1;
const compactSections = getCompactSectionVisibility({
height,
showStreams: sectionVisibility.streams,
showViolations: sectionVisibility.violations,
hasViolations: sourcedViolations.length > 0,
showRecentActivity: sectionVisibility.recentActivity,
});
if (isAdvanced) { if (isAdvanced) {
const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT; const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT;
@@ -196,18 +160,18 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")} {successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
</Text> </Text>
{sectionVisibility.stats && ( {sectionVisibility.stats && (
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={columnWidth} /> <StatsBar stats={combined.stats} summary={combined.streams.summary} width={columnWidth} transparent />
)} )}
{sectionVisibility.streams && ( {sectionVisibility.streams && (
<StreamsList streams={streams} width={columnWidth} showSource={showSource} /> <StreamsList streams={sourcedStreams} width={columnWidth} showSource={showSource} transparent />
)} )}
</Stack> </Stack>
<Stack gap="sm"> <Stack gap="sm">
{sectionVisibility.violations && ( {sectionVisibility.violations && (
<ViolationsList violations={sourcedViolations} showSource={showSource} /> <ViolationsList violations={sourcedViolations} showSource={showSource} transparent />
)} )}
{sectionVisibility.recentActivity && ( {sectionVisibility.recentActivity && (
<RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} /> <RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} transparent />
)} )}
</Stack> </Stack>
</SimpleGrid> </SimpleGrid>
@@ -231,12 +195,10 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
{sectionVisibility.stats && ( {sectionVisibility.stats && (
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={width} /> <StatsBar stats={combined.stats} summary={combined.streams.summary} width={width} />
)} )}
{compactSections.violations && ( {sectionVisibility.streams && <StreamsList streams={sourcedStreams} width={width} showSource={showSource} />}
<ViolationsList violations={sourcedViolations.slice(0, compactLimit)} showSource={showSource} /> {sectionVisibility.violations && <ViolationsList violations={sourcedViolations} showSource={showSource} />}
)} {sectionVisibility.recentActivity && (
{sectionVisibility.streams && <StreamsList streams={streams} width={width} showSource={showSource} />} <RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} />
{compactSections.recentActivity && (
<RecentActivityList sessions={sourcedRecentActivity.slice(0, compactLimit)} showSource={showSource} />
)} )}
{noSectionsEnabled && ( {noSectionsEnabled && (
<Text c="dimmed" ta="center"> <Text c="dimmed" ta="center">
@@ -10,16 +10,18 @@ type SourcedTracearrHistorySession = SourcedTracearrItem<TracearrHistorySession>
export function RecentActivityList({ export function RecentActivityList({
sessions, sessions,
showSource, showSource,
transparent = false,
}: { }: {
sessions: SourcedTracearrHistorySession[]; sessions: SourcedTracearrHistorySession[];
showSource: boolean; showSource: boolean;
transparent?: boolean;
}) { }) {
const t = useI18n("widget.tracearr"); const t = useI18n("widget.tracearr");
const locale = useCurrentIntlLocale(); const locale = useCurrentIntlLocale();
return ( return (
<Stack gap={4}> <Stack gap={4}>
<Text size="xs" fw={600} c="dimmed"> <Text size="xs" fw={600} c="dimmed" tt={transparent ? undefined : "uppercase"}>
{t("recentActivity.title")} {t("recentActivity.title")}
</Text> </Text>
{sessions.length === 0 ? ( {sessions.length === 0 ? (
@@ -35,7 +37,7 @@ export function RecentActivityList({
: session.mediaTitle; : session.mediaTitle;
return ( return (
<Paper key={session.key} p="sm" radius="lg" bg="transparent"> <Paper key={session.key} p="sm" radius="lg" bg={transparent ? "transparent" : undefined}>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}> <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<Avatar src={session.user.avatarUrl} alt={session.user.username} radius="xl" size="sm" /> <Avatar src={session.user.avatarUrl} alt={session.user.username} radius="xl" size="sm" />
@@ -8,10 +8,12 @@ export function StatsBar({
stats, stats,
summary, summary,
width, width,
transparent = false,
}: { }: {
stats: TracearrDashboardData["stats"]; stats: TracearrDashboardData["stats"];
summary: TracearrDashboardData["streams"]["summary"]; summary: TracearrDashboardData["streams"]["summary"];
width: number; width: number;
transparent?: boolean;
}) { }) {
const t = useI18n("widget.tracearr"); const t = useI18n("widget.tracearr");
const cols = width > 400 ? 4 : width > 250 ? 2 : 1; const cols = width > 400 ? 4 : width > 250 ? 2 : 1;
@@ -22,29 +24,43 @@ export function StatsBar({
icon={<IconVideo size="var(--mantine-font-size-md)" />} icon={<IconVideo size="var(--mantine-font-size-md)" />}
label={t("stats.activeStreams")} label={t("stats.activeStreams")}
value={stats.activeStreams} value={stats.activeStreams}
transparent={transparent}
/> />
<StatCard <StatCard
icon={<IconUsers size="var(--mantine-font-size-md)" />} icon={<IconUsers size="var(--mantine-font-size-md)" />}
label={t("stats.totalUsers")} label={t("stats.totalUsers")}
value={stats.totalUsers} value={stats.totalUsers}
transparent={transparent}
/> />
<StatCard <StatCard
icon={<IconDevices size="var(--mantine-font-size-md)" />} icon={<IconDevices size="var(--mantine-font-size-md)" />}
label={t("stats.transcodes")} label={t("stats.transcodes")}
value={`${summary.transcodes}/${summary.total}`} value={`${summary.transcodes}/${summary.total}`}
transparent={transparent}
/> />
<StatCard <StatCard
icon={<IconNetwork size="var(--mantine-font-size-md)" />} icon={<IconNetwork size="var(--mantine-font-size-md)" />}
label={t("stats.bandwidth")} label={t("stats.bandwidth")}
value={summary.totalBitrate} value={summary.totalBitrate}
transparent={transparent}
/> />
</SimpleGrid> </SimpleGrid>
); );
} }
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) { function StatCard({
icon,
label,
value,
transparent,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
transparent: boolean;
}) {
return ( return (
<Paper p="xs" radius="lg" bg="transparent"> <Paper p="xs" radius="lg" bg={transparent ? "transparent" : undefined}>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
{icon} {icon}
<Stack gap={0} style={{ overflow: "hidden" }}> <Stack gap={0} style={{ overflow: "hidden" }}>
@@ -13,10 +13,12 @@ export function StreamsList({
streams, streams,
width, width,
showSource, showSource,
transparent = false,
}: { }: {
streams: SourcedTracearrStream[]; streams: SourcedTracearrStream[];
width: number; width: number;
showSource: boolean; showSource: boolean;
transparent?: boolean;
}) { }) {
const t = useI18n("widget.tracearr"); const t = useI18n("widget.tracearr");
@@ -31,7 +33,13 @@ export function StreamsList({
return ( return (
<Stack gap="xs"> <Stack gap="xs">
{streams.map((stream) => ( {streams.map((stream) => (
<StreamCard key={stream.key} stream={stream} compact={width < 300} showSource={showSource} /> <StreamCard
key={stream.key}
stream={stream}
compact={width < 300}
showSource={showSource}
transparent={transparent}
/>
))} ))}
</Stack> </Stack>
); );
@@ -41,10 +49,12 @@ function StreamCard({
stream, stream,
compact, compact,
showSource, showSource,
transparent,
}: { }: {
stream: SourcedTracearrStream; stream: SourcedTracearrStream;
compact: boolean; compact: boolean;
showSource: boolean; showSource: boolean;
transparent: boolean;
}) { }) {
const t = useI18n("widget.tracearr"); const t = useI18n("widget.tracearr");
const progressPercent = const progressPercent =
@@ -65,7 +75,12 @@ function StreamCard({
: null; : null;
return ( return (
<Paper p="xs" radius="lg" bg="transparent" style={{ position: "relative", overflow: "hidden" }}> <Paper
p="xs"
radius="lg"
bg={transparent ? "transparent" : undefined}
style={{ position: "relative", overflow: "hidden" }}
>
{stream.posterUrl && ( {stream.posterUrl && (
<Box <Box
style={{ style={{
@@ -11,16 +11,18 @@ type SourcedTracearrViolation = SourcedTracearrItem<TracearrViolation>;
export function ViolationsList({ export function ViolationsList({
violations, violations,
showSource, showSource,
transparent = false,
}: { }: {
violations: SourcedTracearrViolation[]; violations: SourcedTracearrViolation[];
showSource: boolean; showSource: boolean;
transparent?: boolean;
}) { }) {
const t = useI18n("widget.tracearr"); const t = useI18n("widget.tracearr");
const locale = useCurrentIntlLocale(); const locale = useCurrentIntlLocale();
return ( return (
<Stack gap={4}> <Stack gap={4}>
<Text size="xs" fw={600} c="dimmed"> <Text size="xs" fw={600} c="dimmed" tt={transparent ? undefined : "uppercase"}>
{t("violations.title")} {t("violations.title")}
</Text> </Text>
{violations.length === 0 ? ( {violations.length === 0 ? (
@@ -30,7 +32,7 @@ export function ViolationsList({
) : ( ) : (
<Stack gap="xs"> <Stack gap="xs">
{violations.map((violation) => ( {violations.map((violation) => (
<Paper key={violation.key} p="xs" radius="lg" bg="transparent"> <Paper key={violation.key} p="xs" radius="lg" bg={transparent ? "transparent" : undefined}>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}> <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<IconAlertTriangle <IconAlertTriangle
@@ -39,8 +39,8 @@ describe("Traefik source ownership", () => {
}); });
describe("Traefik advanced disclosure", () => { describe("Traefik advanced disclosure", () => {
test("shows every protocol regardless of compact toggles and height", () => { test("shows every protocol in advanced mode regardless of compact toggles", () => {
expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, 100, true)).toEqual(["http", "tcp", "udp"]); expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, true)).toEqual(["http", "tcp", "udp"]);
}); });
test("shows every unique entry point regardless of compact width limits", () => { test("shows every unique entry point regardless of compact width limits", () => {
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more