Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0b0af8f8 | ||
|
|
7e13ca54fb | ||
|
|
e161c8c512 | ||
|
|
a7a6cb6e07 | ||
|
|
bba20e4561 | ||
|
|
1030a21f4e | ||
|
|
f07b5c1f91 | ||
|
|
8f619be0d7 | ||
|
|
771db6d752 | ||
|
|
3edf874ca3 |
No files matched your search
@@ -11,7 +11,7 @@ import { appWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. Compact tiles retain the familiar title, optional description, and centered app icon proportions.
|
||||
It also supports a status check to indicate whether Homarr can reach the application or website.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -23,6 +23,8 @@ This widget adapts its display based on the linked integration:
|
||||
|
||||
Only options relevant to your linked integration are shown in the configuration panel.
|
||||
|
||||
Compact mode keeps every configured statistic visible in the familiar filled tile layout, including a vertical stack for narrow Navidrome cards. Advanced mode retains the complete expanded statistics and stream details.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
|
||||
@@ -17,7 +17,7 @@ import beszelAlerts from "./img/beszel-alerts.png";
|
||||
|
||||
<WidgetHeader widget={beszelAlertsWidget} categories={["System Monitoring", "Alerts"]} />
|
||||
|
||||
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions.
|
||||
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Compact mode keeps configured OK alerts and descriptions visible alongside triggered alerts, and keeps history visible when the history option is enabled. Alerts are updated in real time via WebSocket subscriptions.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { coolifyIntegration } from "@site/docs/integrations/coolify";
|
||||
|
||||
<WidgetHeader widget={coolifyWidget} categories={["System monitoring", "Deployment"]} />
|
||||
|
||||
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version.
|
||||
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. Compact resource badges and footer details remain visible without height-based hiding.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
|
||||
|
||||
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
|
||||
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget's width and height. Bulk actions keep successful results visible and report the number of failures.
|
||||
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, with its familiar textual status control retained at compact heights. Bulk actions keep successful results visible and report the number of failures.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { dockerContainersWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
||||
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them. Compact mode keeps every configured table column and shows the complete CPU and memory totals whenever the footer is available.
|
||||
|
||||
:::info
|
||||
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
|
||||
|
||||
@@ -58,7 +58,7 @@ import filterPanelImg from "./img/filter-panel.png";
|
||||
|
||||
### Features
|
||||
|
||||
- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column
|
||||
- **Responsive compact table** — in compact mode, selected columns use the established width breakpoints; widget height does not remove configured columns or the footer
|
||||
- **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction
|
||||
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
|
||||
- **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration
|
||||
@@ -72,11 +72,10 @@ import filterPanelImg from "./img/filter-panel.png";
|
||||
|
||||
:::note
|
||||
|
||||
Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state
|
||||
columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also
|
||||
hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions,
|
||||
keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available
|
||||
from the footer.
|
||||
Compact layouts use their established width breakpoints: below 500px wide, speed columns are hidden; below 300px wide,
|
||||
the ETA and state columns are hidden too. Height does not remove configured columns or the footer. Advanced view bypasses
|
||||
the compact width reductions, keeps the selected column set, and always includes the stats bar and footer controls, with
|
||||
the filter panel available from the footer.
|
||||
|
||||
The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and
|
||||
state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the
|
||||
|
||||
@@ -16,6 +16,8 @@ import { opnsenseIntegration } from "../../integrations/opnsense";
|
||||
|
||||
<WidgetHeader widget={firewallWidget} categories={["Firewall"]} />
|
||||
|
||||
Compact mode keeps the configured firewall summary and interface section visible without dropping interfaces based on widget height.
|
||||
|
||||
### Screenshots
|
||||
|
||||

|
||||
|
||||
@@ -15,6 +15,8 @@ import { immichServerStatsWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} />
|
||||
|
||||
Compact mode presents every enabled statistic as a vertical icon, label, and value card.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import screen from "./img/screen.png";
|
||||
|
||||
@@ -14,6 +14,8 @@ import { indexerManagerWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} />
|
||||
|
||||
Compact mode keeps the titled indexer list, per-indexer status indicators, and full-width **Test all** action together.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import bigItem from "./img/big.png";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
|
||||
<WidgetHeader widget={mediaReleasesWidget} categories={["Releases", "Media"]} />
|
||||
|
||||
Provides a list of newly added media or upcoming releases from your media server.
|
||||
Provides a list of newly added media or upcoming releases from your media server. Compact rows retain their poster, full release date and time, and configured metadata.
|
||||
|
||||
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
|
||||
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Compact request cards retain their full artwork, request metadata, title, status, and permitted actions.
|
||||
|
||||
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
||||
|
||||
Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated.
|
||||
Provides an overview of media requests, including totals, availability, approvals, and rejections. Compact mode keeps every configured stat and section title visible; the user ranking remains scrollable instead of being truncated.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. Compact mode keeps the user, currently playing, and status columns together with the stream summary and integration names.
|
||||
|
||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||
|
||||
@@ -17,7 +17,7 @@ import { minecraftServerStatusWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={minecraftServerStatusWidget} categories={["Gaming", "Minecraft"]} />
|
||||
|
||||
Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us).
|
||||
Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, but only public servers accessible through the [mcsrvstat.us API](https://api.mcsrvstat.us) are supported.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { networkControllerStatusWidget } from ".";
|
||||
|
||||
Shows connected users and connected guest devices on wired or wireless networks.
|
||||
|
||||
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
|
||||
Compact view aggregates the configured controllers into one familiar card for the selected Wi-Fi or wired total. A failed refresh is shown as a small warning instead of replacing the totals card. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
|
||||
|
||||
Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view.
|
||||
Shows whether your WAN, internet, Wi-Fi, and VPN are available. The compact board view keeps the familiar status list, including internet latency and the connected VPN-user count.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
|
||||
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { gotifyIntegration } from "@site/docs/integrations/gotify";
|
||||
|
||||
<WidgetHeader widget={notificationsWidget} categories={["Notifications", "Messaging"]} />
|
||||
|
||||
The notifications widget allows you to display notifications from various integrations in a unified view.
|
||||
The notifications widget allows you to display notifications from various integrations in a unified view. Compact mode presents them as a vertical list of padded notification cards.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx";
|
||||
|
||||
<WidgetHeader widget={paperlessNgxWidget} categories={["Documents"]} />
|
||||
|
||||
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types.
|
||||
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. Compact mode keeps every configured statistic in the familiar filled tile layout and uses a vertical stack on narrow cards.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { releasesWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} />
|
||||
|
||||
The releases widget enables you to track a list of repositories and see their respective latest release.
|
||||
The releases widget enables you to track a list of repositories and see their respective latest release. Compact mode keeps repositories in the familiar full-width stack.
|
||||
It also supports importing releases from your local docker containers.
|
||||
|
||||
### Screenshots
|
||||
@@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
|
||||
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
|
||||
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
|
||||
|
||||
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
|
||||
Advanced view shows every configured repository in a roomier responsive layout. Repository names and details remain visible, and selecting a repository still expands its description and links.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { rssFeedWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={rssFeedWidget} categories={["Feeds"]} />
|
||||
|
||||
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
|
||||
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. Compact mode uses a vertical card list and keeps enclosure images as the familiar poster and blurred backdrop.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
|
||||
|
||||
<WidgetHeader widget={smartHomeEntityStateWidget} categories={["Smart home"]} />
|
||||
|
||||
The entity state enables you to display the current state of an entity.
|
||||
The entity state enables you to display the current state of an entity. Compact mode presents the friendly name first and its state beneath it.
|
||||
This works with most entities and enables you display almost any data.
|
||||
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
|
||||
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
|
||||
including the latest result, historical averages, and a live chart of the last 12 hours.
|
||||
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
|
||||
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
|
||||
|
||||
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
|
||||
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations. Compact mode uses a one-column card list with the configured primary value, available temperature, and solid health bar.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
|
||||
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
|
||||
|
||||
Displays CPU, RAM and network of your host in real time
|
||||
Displays CPU, RAM and network of your host in real time. Compact view keeps every configured chart in its familiar stacked layout, while advanced view adds the expanded per-source presentation.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
|
||||
|
||||
The Timetable widget provides real-time departure times for a selected station.
|
||||
The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible while adapting dense rows to the available space.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
|
||||
|
||||
<WidgetHeader widget={tracearrWidget} categories={["Media Monitoring"]} />
|
||||
|
||||
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
|
||||
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations. Compact mode renders every enabled section in that familiar order without hiding or truncating sections based on widget height.
|
||||
|
||||
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
|
||||
|
||||
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
|
||||
|
||||
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
|
||||
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps every configured protocol card and entry-point label visible and names each healthy source when multiple integrations are selected.
|
||||
|
||||
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { umamiIntegration } from "@site/docs/integrations/umami";
|
||||
|
||||
<WidgetHeader widget={umamiWidget} categories={["Analytics"]} />
|
||||
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists.
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. Compact mode keeps configured axes and secondary statistics visible regardless of widget height.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
|
||||
|
||||
<WidgetHeader widget={uptimeKumaWidget} categories={["Monitoring"]} />
|
||||
|
||||
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
|
||||
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. Compact mode shows every configured ring and statistic without dropping them based on widget height.
|
||||
|
||||
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
|
||||
|
||||
|
||||
@@ -334,6 +334,7 @@ const LoadedBoardItemContent = ({
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
displayScale={boardCanvasScale}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
displayMode={isAdvanced ? "advanced" : "compact"}
|
||||
@@ -436,6 +437,7 @@ interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
displayScale: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
displayMode: "compact" | "advanced";
|
||||
|
||||
@@ -30,11 +30,15 @@
|
||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
|
||||
|
||||
@@ -3179,7 +3179,8 @@
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch indexers status"
|
||||
"internalServerError": "Failed to fetch indexers status",
|
||||
"testAll": "The indexer test failed. Try again."
|
||||
}
|
||||
},
|
||||
"healthMonitoring": {
|
||||
@@ -4249,6 +4250,7 @@
|
||||
"failed": "Failed",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"unknown": "Unknown",
|
||||
"toBeDetermined": "TBD"
|
||||
},
|
||||
"mediaRequests-requestStats": {
|
||||
@@ -4961,6 +4963,10 @@
|
||||
"empty": "No alerts configured",
|
||||
"history": "History",
|
||||
"resolved": "Resolved",
|
||||
"alertDescription": {
|
||||
"statusDuration": "Down for {minutes} min",
|
||||
"threshold": "Exceeds {value} over {minutes} min"
|
||||
},
|
||||
"status": {
|
||||
"triggered": "Triggered",
|
||||
"ok": "OK"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Fragment, Suspense } from "react";
|
||||
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconMinus } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -59,19 +59,19 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
<Text
|
||||
className="app-title"
|
||||
fw={700}
|
||||
size={isTiny ? "xs" : "sm"}
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{options.descriptionDisplayMode === "normal" && !isTiny && (
|
||||
{options.descriptionDisplayMode === "normal" && (
|
||||
<Text
|
||||
className="app-description"
|
||||
size="sm"
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
c="dimmed"
|
||||
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
|
||||
lineClamp={4}
|
||||
>
|
||||
{app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -91,7 +91,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
minWidth: "20%",
|
||||
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
|
||||
maxWidth: isColumnLayout ? undefined : "50%",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -7,16 +7,10 @@ import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homar
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { getCompactStatLayout } from "../common/compact-stat-layout";
|
||||
import classes from "./component.module.css";
|
||||
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
|
||||
import {
|
||||
AUDIO_STATS_TRANSLATION_SCOPE,
|
||||
getGridCols,
|
||||
getIconSize,
|
||||
prioritizeVisibleStats,
|
||||
getVisibleStatLimit,
|
||||
getVisibleStats,
|
||||
} from "./shared";
|
||||
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
|
||||
|
||||
const rootClassByCompact = {
|
||||
true: classes.rootCompact,
|
||||
@@ -33,11 +27,49 @@ const statTileClassByCompact = {
|
||||
false: "",
|
||||
} as const;
|
||||
|
||||
const statTileClassByAdvanced = {
|
||||
true: classes.statTileAdvanced,
|
||||
false: "",
|
||||
} as const;
|
||||
|
||||
const rootClassByLayout = {
|
||||
default: "",
|
||||
short: classes.rootShort,
|
||||
narrowShort: classes.rootShort,
|
||||
} as const;
|
||||
|
||||
const gridClassByLayout = {
|
||||
default: "",
|
||||
short: classes.gridShort,
|
||||
narrowShort: classes.gridShort,
|
||||
} as const;
|
||||
|
||||
const statTileClassByLayout = {
|
||||
default: "",
|
||||
short: classes.statTileShort,
|
||||
narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`,
|
||||
} as const;
|
||||
|
||||
const statLabelClassByLayout = {
|
||||
default: "",
|
||||
short: classes.statLabelShort,
|
||||
narrowShort: classes.statLabelShort,
|
||||
} as const;
|
||||
|
||||
const statValueClassByLayout = {
|
||||
default: "",
|
||||
short: "",
|
||||
narrowShort: classes.statValueNarrowShort,
|
||||
} as const;
|
||||
|
||||
const COMPACT_SINGLE_COLUMN_BREAKPOINT = 220;
|
||||
|
||||
interface AudioStatsContentProps {
|
||||
backend: AudioStatsBackend;
|
||||
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
|
||||
options: AudioStatsDisplayOptions;
|
||||
showAllStats?: boolean;
|
||||
advanced?: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
@@ -47,29 +79,45 @@ export function AudioStatsContent({
|
||||
stats,
|
||||
options,
|
||||
showAllStats = false,
|
||||
advanced = false,
|
||||
width,
|
||||
height,
|
||||
}: AudioStatsContentProps) {
|
||||
const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE);
|
||||
const compact = options.compactMode ?? false;
|
||||
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact);
|
||||
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
|
||||
const visibleStats = getVisibleStats(backend, options, stats, showAllStats);
|
||||
const compactKey = String(compact) as keyof typeof rootClassByCompact;
|
||||
const gridCols = getGridCols(width, visibleStats.length, compact);
|
||||
const iconSize = getIconSize(width, compact);
|
||||
const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced;
|
||||
let defaultGridCols = getGridCols(width, visibleStats.length, compact);
|
||||
if (!advanced && width < COMPACT_SINGLE_COLUMN_BREAKPOINT) {
|
||||
defaultGridCols = 1;
|
||||
}
|
||||
const layout = getCompactStatLayout({
|
||||
width,
|
||||
height,
|
||||
visibleCount: visibleStats.length,
|
||||
compactDisplay: !advanced,
|
||||
defaultColumns: defaultGridCols,
|
||||
defaultIconSize: getIconSize(width, compact),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
|
||||
<div className={`${classes.root} ${rootClassByCompact[compactKey]} ${rootClassByLayout[layout.state]}`}>
|
||||
{visibleStats.length > 0 && (
|
||||
<div
|
||||
className={`${classes.grid} ${gridClassByCompact[compactKey]}`}
|
||||
style={{ "--stat-cols": gridCols } as CSSProperties}
|
||||
className={`${classes.grid} ${gridClassByCompact[compactKey]} ${gridClassByLayout[layout.state]}`}
|
||||
style={{ "--stat-cols": layout.columns } as CSSProperties}
|
||||
>
|
||||
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
|
||||
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
|
||||
<span className={classes.statValue}>{value}</span>
|
||||
<span className={classes.statLabel}>{t(statKey as never)}</span>
|
||||
<div
|
||||
key={optionKey}
|
||||
className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]} ${statTileClassByLayout[layout.state]}`}
|
||||
>
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
|
||||
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>{value}</span>
|
||||
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>
|
||||
{t(statKey as never)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared";
|
||||
import { getGridCols, getIconSize, getVisibleStats } from "./shared";
|
||||
|
||||
describe("getGridCols", () => {
|
||||
test("caps columns to visible stat count", () => {
|
||||
@@ -37,17 +37,6 @@ describe("getIconSize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleStatLimit", () => {
|
||||
test("keeps compact metrics legible when height is constrained", () => {
|
||||
expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1);
|
||||
expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3);
|
||||
});
|
||||
|
||||
test("preserves every configured metric outside compact mode", () => {
|
||||
expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleStats", () => {
|
||||
const navidromeStats: NavidromeDashboardData = {
|
||||
artistCount: 100,
|
||||
@@ -117,21 +106,3 @@ describe("getVisibleStats", () => {
|
||||
expect(typeof result[0]?.value).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prioritizeVisibleStats", () => {
|
||||
test("puts an active session first only on compact surfaces", () => {
|
||||
const stats = getVisibleStats(
|
||||
"audiobookshelf",
|
||||
{ showLibraryCount: true, showActiveSessions: true },
|
||||
{
|
||||
libraryCount: 3,
|
||||
totalAudiobooks: 150,
|
||||
totalPodcasts: 20,
|
||||
totalListeningTimeSeconds: 86400,
|
||||
activeSessions: 2,
|
||||
},
|
||||
);
|
||||
expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions");
|
||||
expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,11 @@
|
||||
gap: clamp(2px, 1cqi, 4px);
|
||||
}
|
||||
|
||||
.rootShort {
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
@@ -29,6 +34,10 @@
|
||||
gap: clamp(2px, 1cqi, 4px);
|
||||
}
|
||||
|
||||
.gridShort {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statTile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -40,16 +49,31 @@
|
||||
gap: clamp(2px, 1.5cqi, 4px);
|
||||
padding: clamp(4px, 2cqi, 8px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: transparent;
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statTileAdvanced {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.statTileCompact {
|
||||
gap: clamp(1px, 0.8cqi, 2px);
|
||||
padding: clamp(2px, 1.2cqi, 5px);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.statTileShort {
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.statTileNarrowShort {
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
@@ -57,7 +81,12 @@
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: clamp(8px, 2.5cqi, 11px);
|
||||
flex-shrink: 0;
|
||||
font-size: clamp(
|
||||
calc(8px * var(--board-canvas-ui-scale, 1)),
|
||||
calc(2.5cqi * var(--board-canvas-ui-scale, 1)),
|
||||
calc(11px * var(--board-canvas-ui-scale, 1))
|
||||
);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
@@ -68,11 +97,32 @@
|
||||
}
|
||||
|
||||
.statTileCompact .statLabel {
|
||||
font-size: clamp(7px, 2cqi, 9px);
|
||||
font-size: clamp(
|
||||
calc(7px * var(--board-canvas-ui-scale, 1)),
|
||||
calc(2cqi * var(--board-canvas-ui-scale, 1)),
|
||||
calc(9px * var(--board-canvas-ui-scale, 1))
|
||||
);
|
||||
}
|
||||
|
||||
.statLabelShort {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: clamp(13px, 5cqi, 20px);
|
||||
flex-shrink: 0;
|
||||
font-size: clamp(
|
||||
calc(13px * var(--board-canvas-ui-scale, 1)),
|
||||
calc(5cqi * var(--board-canvas-ui-scale, 1)),
|
||||
calc(20px * var(--board-canvas-ui-scale, 1))
|
||||
);
|
||||
font-weight: 700;
|
||||
color: var(--mantine-primary-color-filled);
|
||||
line-height: 1.1;
|
||||
@@ -84,7 +134,16 @@
|
||||
}
|
||||
|
||||
.statTileCompact .statValue {
|
||||
font-size: clamp(11px, 4cqi, 16px);
|
||||
font-size: clamp(
|
||||
calc(11px * var(--board-canvas-ui-scale, 1)),
|
||||
calc(4cqi * var(--board-canvas-ui-scale, 1)),
|
||||
calc(16px * var(--board-canvas-ui-scale, 1))
|
||||
);
|
||||
}
|
||||
|
||||
.statValueNarrowShort {
|
||||
font-size: calc(11px * var(--board-canvas-ui-scale, 1));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function AudioStatsWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayScale = 1,
|
||||
displayMode = "compact",
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"audioStats">) {
|
||||
@@ -39,14 +40,22 @@ export default function AudioStatsWidget({
|
||||
const currentStreams = streamResults ?? [];
|
||||
if (!response) return <WidgetEmptyState />;
|
||||
|
||||
let responsiveWidth = width;
|
||||
let responsiveHeight = height;
|
||||
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
|
||||
responsiveWidth *= displayScale;
|
||||
responsiveHeight *= displayScale;
|
||||
}
|
||||
|
||||
const summary = (
|
||||
<AudioStatsContent
|
||||
backend={response.kind}
|
||||
stats={response.data}
|
||||
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
|
||||
options={{ ...options, compactMode: displayMode === "compact" && options.compactMode }}
|
||||
showAllStats={displayMode === "advanced"}
|
||||
width={width}
|
||||
height={height}
|
||||
advanced={displayMode === "advanced"}
|
||||
width={responsiveWidth}
|
||||
height={responsiveHeight}
|
||||
/>
|
||||
);
|
||||
if (displayMode === "compact") {
|
||||
|
||||
@@ -156,23 +156,6 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea
|
||||
return Math.min(maxCols, Math.max(visibleCount, 1));
|
||||
};
|
||||
|
||||
export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => {
|
||||
if (!compact) return visibleCount;
|
||||
|
||||
const columns = getGridCols(width, visibleCount, true);
|
||||
const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44));
|
||||
return Math.min(visibleCount, columns * rows);
|
||||
};
|
||||
|
||||
export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => {
|
||||
if (!compact) return stats;
|
||||
return stats.toSorted(
|
||||
(left, right) =>
|
||||
Number(right.statKey === "activeSessions" && Number(right.value) > 0) -
|
||||
Number(left.statKey === "activeSessions" && Number(left.value) > 0),
|
||||
);
|
||||
};
|
||||
|
||||
const fallbackIconSize = { true: 14, false: 16 } as const;
|
||||
|
||||
export const getIconSize = (width: number, compact: boolean): number => {
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import dayjs from "dayjs";
|
||||
@@ -53,7 +40,6 @@ export default function BeszelAlertsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
height,
|
||||
displayMode,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"beszelAlerts">) {
|
||||
@@ -109,9 +95,7 @@ export default function BeszelAlertsWidget({
|
||||
|
||||
const triggeredAlerts = alerts.filter((a) => a.triggered);
|
||||
const okAlerts = alerts.filter((a) => !a.triggered);
|
||||
const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260;
|
||||
const showHistory = isAdvanced || (options.showHistory && height >= 360);
|
||||
const showAlertDescriptions = isAdvanced || height >= 190;
|
||||
const showHistory = isAdvanced || options.showHistory;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -145,7 +129,7 @@ export default function BeszelAlertsWidget({
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconFlame size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={600} c="red">
|
||||
<Text size="xs" fw={600} c="red" tt={isAdvanced ? undefined : "uppercase"}>
|
||||
{t("status.triggered")} ({triggeredAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -158,19 +142,19 @@ export default function BeszelAlertsWidget({
|
||||
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
|
||||
integrationName={showIntegrationName ? alert.integrationName : undefined}
|
||||
triggered
|
||||
showDescription={showAlertDescriptions}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && <Divider />}
|
||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||
|
||||
{showOkAlerts && okAlerts.length > 0 && (
|
||||
{okAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
<Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}>
|
||||
{t("status.ok")} ({okAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -183,7 +167,7 @@ export default function BeszelAlertsWidget({
|
||||
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
|
||||
integrationName={showIntegrationName ? alert.integrationName : undefined}
|
||||
triggered={false}
|
||||
showDescription={showAlertDescriptions}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -195,7 +179,7 @@ export default function BeszelAlertsWidget({
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconHistory size="var(--mantine-font-size-sm)" opacity={0.5} />
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
<Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}>
|
||||
{t("history")}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -250,7 +234,7 @@ interface AlertRowProps {
|
||||
systemName: string;
|
||||
integrationName?: string;
|
||||
triggered: boolean;
|
||||
showDescription: boolean;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
const unitSuffixMap: Record<string, string> = {
|
||||
@@ -268,49 +252,46 @@ const unitSuffixMap: Record<string, string> = {
|
||||
Status: "",
|
||||
};
|
||||
|
||||
function formatAlertDescription(name: string, value: number, min: number): string {
|
||||
const suffix = unitSuffixMap[name] ?? "";
|
||||
if (name === "Status") return `down for ${min} min`;
|
||||
return `exceeds ${value}${suffix} over ${min} min`;
|
||||
}
|
||||
|
||||
function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) {
|
||||
function AlertRow({ name, value, min, systemName, integrationName, triggered, isAdvanced }: AlertRowProps) {
|
||||
const t = useI18n("widget.beszelAlerts");
|
||||
const Icon = alertIconMap[name] ?? Server;
|
||||
const description = formatAlertDescription(name, value, min);
|
||||
const suffix = unitSuffixMap[name] ?? "";
|
||||
let description = t("alertDescription.threshold", { value: `${value}${suffix}`, minutes: min });
|
||||
if (name === "Status") {
|
||||
description = t("alertDescription.statusDuration", { minutes: min });
|
||||
}
|
||||
const accentColor = triggered ? "red" : "green";
|
||||
const borderColor = triggered ? "red" : "gray";
|
||||
const rowStyle = isAdvanced
|
||||
? {
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
border: `1px solid var(--mantine-color-${borderColor}-4)`,
|
||||
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
|
||||
}
|
||||
: {
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
borderLeft: `3px solid var(--mantine-color-${accentColor}-6)`,
|
||||
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
|
||||
};
|
||||
return (
|
||||
<Tooltip label={description} disabled={showDescription} withArrow>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
py={showDescription ? 4 : 2}
|
||||
px={8}
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
border: `1px solid var(--mantine-color-${triggered ? "red" : "gray"}-4)`,
|
||||
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
|
||||
}}
|
||||
>
|
||||
<Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{integrationName ? `${systemName} · ${integrationName}` : systemName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
{showDescription && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{!showDescription && <VisuallyHidden>{description}</VisuallyHidden>}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
<Group wrap="nowrap" gap="xs" py={4} pr={8} pl={isAdvanced ? 8 : 12} style={rowStyle}>
|
||||
<Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{integrationName ? `${systemName} · ${integrationName}` : systemName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
const SHORT_HEIGHT_BREAKPOINT = 120;
|
||||
const NARROW_SHORT_WIDTH_BREAKPOINT = 120;
|
||||
const NARROW_SHORT_ICON_SIZE = 12;
|
||||
const SHORT_GRID_HORIZONTAL_INSET = 8;
|
||||
const SHORT_GRID_GAP = 2;
|
||||
const SHORT_ICON_WIDTH_RATIO = 0.44;
|
||||
const SHORT_ICON_SIZE_CAP = 40;
|
||||
|
||||
export type CompactStatLayoutState = "default" | "short" | "narrowShort";
|
||||
|
||||
interface CompactStatLayoutInput {
|
||||
width: number;
|
||||
height: number;
|
||||
visibleCount: number;
|
||||
/** Compact board display mode, independent of widget-specific compact styling options. */
|
||||
compactDisplay: boolean;
|
||||
defaultColumns: number;
|
||||
defaultIconSize: number;
|
||||
}
|
||||
|
||||
interface CompactStatLayout {
|
||||
state: CompactStatLayoutState;
|
||||
columns: number;
|
||||
iconSize: number;
|
||||
}
|
||||
|
||||
export function getCompactStatLayout({
|
||||
width,
|
||||
height,
|
||||
visibleCount,
|
||||
compactDisplay,
|
||||
defaultColumns,
|
||||
defaultIconSize,
|
||||
}: CompactStatLayoutInput): CompactStatLayout {
|
||||
if (!compactDisplay) {
|
||||
return {
|
||||
state: "default",
|
||||
columns: defaultColumns,
|
||||
iconSize: defaultIconSize,
|
||||
};
|
||||
}
|
||||
|
||||
if (width < NARROW_SHORT_WIDTH_BREAKPOINT) {
|
||||
return {
|
||||
state: "narrowShort",
|
||||
columns: 1,
|
||||
iconSize: NARROW_SHORT_ICON_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
if (height >= SHORT_HEIGHT_BREAKPOINT) {
|
||||
return {
|
||||
state: "default",
|
||||
columns: defaultColumns,
|
||||
iconSize: defaultIconSize,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "short",
|
||||
columns: Math.max(visibleCount, 1),
|
||||
iconSize: getShortIconSize(width, visibleCount, defaultIconSize),
|
||||
};
|
||||
}
|
||||
|
||||
function getShortIconSize(width: number, visibleCount: number, defaultIconSize: number): number {
|
||||
const statCount = Math.max(visibleCount, 1);
|
||||
const totalGridGap = Math.max(statCount - 1, 0) * SHORT_GRID_GAP;
|
||||
const availableWidth = Math.max(width - SHORT_GRID_HORIZONTAL_INSET - totalGridGap, 0);
|
||||
const availableWidthPerStat = availableWidth / statCount;
|
||||
const proportionalIconSize = Math.round(availableWidthPerStat * SHORT_ICON_WIDTH_RATIO);
|
||||
const cappedIconSize = Math.min(proportionalIconSize, SHORT_ICON_SIZE_CAP);
|
||||
return Math.max(defaultIconSize, cappedIconSize);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export default function CoolifyWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"coolify">) {
|
||||
const t = useI18n("widget.coolify");
|
||||
@@ -38,7 +37,6 @@ export default function CoolifyWidget({
|
||||
integrationIds={integrationIds}
|
||||
options={{ ...options, ...sectionVisibility }}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
);
|
||||
@@ -48,11 +46,10 @@ interface CoolifyContentProps {
|
||||
integrationIds: string[];
|
||||
options: WidgetComponentProps<"coolify">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
|
||||
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
|
||||
const t = useI18n("common");
|
||||
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
|
||||
integrationIds,
|
||||
@@ -64,8 +61,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
|
||||
);
|
||||
const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null);
|
||||
|
||||
const isTiny = width < 256 || height < 144;
|
||||
const hideFooter = height < 112;
|
||||
const isTiny = width < 256;
|
||||
const [firstInstance] = successfulInstances;
|
||||
const widgetKey = createWidgetKey(integrationIds);
|
||||
|
||||
@@ -100,7 +96,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
|
||||
isTiny={!isAdvanced && isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
widgetKey={widgetKey}
|
||||
hideFooter={!isAdvanced && hideFooter}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
@@ -120,7 +115,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
|
||||
isTiny={!isAdvanced && isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
widgetKey={widgetKey}
|
||||
hideFooter={!isAdvanced && hideFooter}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
@@ -22,12 +22,10 @@ interface InstanceCardProps {
|
||||
isTiny: boolean;
|
||||
isAdvanced: boolean;
|
||||
widgetKey: string;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
|
||||
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey }: InstanceCardProps) {
|
||||
const t = useI18n("widget.coolify");
|
||||
const tCommon = useI18n("common");
|
||||
const cardKey = `${widgetKey}-${instance.integrationId}`;
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
key: `coolify-show-ip-${cardKey}`,
|
||||
@@ -53,32 +51,10 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
const runningServices = instance.instanceInfo.services.filter(
|
||||
(s) => parseStatus(s.status ?? "") === "running",
|
||||
).length;
|
||||
const healthyResources =
|
||||
(options.showServers ? onlineServers : 0) +
|
||||
(options.showApplications ? runningApps : 0) +
|
||||
(options.showServices ? runningServices : 0);
|
||||
const totalResources =
|
||||
(options.showServers ? instance.instanceInfo.servers.length : 0) +
|
||||
(options.showApplications ? instance.instanceInfo.applications.length : 0) +
|
||||
(options.showServices ? instance.instanceInfo.services.length : 0);
|
||||
const resourceSummary = [
|
||||
options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null,
|
||||
options.showApplications
|
||||
? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}`
|
||||
: null,
|
||||
options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<Card p={0} radius="sm" bg="transparent">
|
||||
<Group
|
||||
p="xs"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
className={classes.neutralDividerBottom}
|
||||
>
|
||||
<Group p="xs" justify="space-between" wrap="nowrap" className={classes.neutralDividerBottom}>
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
<Stack gap={0} miw={0}>
|
||||
@@ -102,19 +78,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{isTiny && totalResources > 0 ? (
|
||||
<Tooltip label={resourceSummary}>
|
||||
<Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs">
|
||||
{healthyResources}/{totalResources}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isTiny && options.showServers && (
|
||||
{options.showServers && (
|
||||
<Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs">
|
||||
{onlineServers}/{instance.instanceInfo.servers.length}
|
||||
</Badge>
|
||||
)}
|
||||
{!isTiny && options.showApplications && (
|
||||
{options.showApplications && (
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)}
|
||||
@@ -123,7 +92,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
{runningApps}/{instance.instanceInfo.applications.length}
|
||||
</Badge>
|
||||
)}
|
||||
{!isTiny && options.showServices && (
|
||||
{options.showServices && (
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={getBadgeColor(runningServices, instance.instanceInfo.services.length)}
|
||||
@@ -168,7 +137,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
|
||||
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
<IconLink size="var(--mantine-font-size-xs)" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isTiny && logsUrl && (
|
||||
{logsUrl && (
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
component="a"
|
||||
@@ -129,12 +129,10 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
<IconFileText size="var(--mantine-font-size-xs)" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isTiny && (
|
||||
<Text fz="10px" c="dimmed" lineClamp={1}>
|
||||
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
|
||||
</Text>
|
||||
)}
|
||||
{!isTiny && resourceTimestamp && (
|
||||
<Text fz="10px" c="dimmed" lineClamp={1}>
|
||||
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
|
||||
</Text>
|
||||
{resourceTimestamp && (
|
||||
<Text fz="10px" c="dimmed" ml="auto">
|
||||
{resourceTimestamp}
|
||||
</Text>
|
||||
|
||||
@@ -22,17 +22,9 @@ interface SingleInstanceLayoutProps {
|
||||
isTiny: boolean;
|
||||
isAdvanced: boolean;
|
||||
widgetKey: string;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
export function SingleInstanceLayout({
|
||||
instance,
|
||||
options,
|
||||
isTiny,
|
||||
isAdvanced,
|
||||
widgetKey,
|
||||
hideFooter,
|
||||
}: SingleInstanceLayoutProps) {
|
||||
export function SingleInstanceLayout({ instance, options, isTiny, isAdvanced, widgetKey }: SingleInstanceLayoutProps) {
|
||||
const t = useI18n("widget.coolify");
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
key: `coolify-show-ip-${widgetKey}`,
|
||||
@@ -115,7 +107,7 @@ export function SingleInstanceLayout({
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
|
||||
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -180,6 +180,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
|
||||
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
|
||||
width: number;
|
||||
height: number;
|
||||
displayScale?: number;
|
||||
widgetRuntimeRef?: WidgetRuntimeRef;
|
||||
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
removeItem?: () => void;
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function DnsHoleControlsWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const board = useRequiredBoard();
|
||||
// DnsHole integrations with interaction permissions
|
||||
@@ -236,7 +236,7 @@ export default function DnsHoleControlsWidget({
|
||||
t={t}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
actionsPending={actionsPending}
|
||||
/>
|
||||
))}
|
||||
@@ -275,7 +275,7 @@ interface ControlsCardProps {
|
||||
t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>;
|
||||
hasIconColor: boolean;
|
||||
rootWidth: number;
|
||||
rootHeight: number;
|
||||
isAdvanced: boolean;
|
||||
actionsPending: boolean;
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
t,
|
||||
hasIconColor,
|
||||
rootWidth,
|
||||
rootHeight,
|
||||
isAdvanced,
|
||||
actionsPending,
|
||||
}) => {
|
||||
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
|
||||
@@ -299,7 +299,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
|
||||
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md";
|
||||
const layout = !isAdvanced && rootWidth < 256 ? "sm" : "md";
|
||||
|
||||
return (
|
||||
<Indicator
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
@@ -111,33 +110,11 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
|
||||
const t = useI18n("docker.field.state.option");
|
||||
const label = t(state);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<Box component="span" style={{ display: "inline-flex", flexShrink: 0 }}>
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden="true"
|
||||
w={8}
|
||||
h={8}
|
||||
bg={`${containerStateColorMap[state]}.6`}
|
||||
style={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const createColumns = (
|
||||
t: ReturnType<typeof useI18n<"docker">>,
|
||||
tCommon: ReturnType<typeof useI18n<"common">>,
|
||||
handlers: ContainerActionHandlers,
|
||||
sortingEnabled: boolean,
|
||||
inlineState: boolean,
|
||||
): DataTableColumn<DockerContainer>[] => [
|
||||
{
|
||||
accessor: "name",
|
||||
@@ -147,7 +124,6 @@ const createColumns = (
|
||||
sortable: sortingEnabled,
|
||||
render: (container) => (
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
{inlineState && <ContainerStateDot state={container.state} />}
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
@@ -387,14 +363,12 @@ export default function DockerWidget({
|
||||
() => getDockerColumnVisibility(options.columns, width, isAdvanced),
|
||||
[isAdvanced, options.columns, width],
|
||||
);
|
||||
const inlineState =
|
||||
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
|
||||
const columns = useMemo(() => {
|
||||
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
|
||||
return createColumns(t, tCommon, actionHandlers, sortingEnabled, inlineState).filter(
|
||||
return createColumns(t, tCommon, actionHandlers, sortingEnabled).filter(
|
||||
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
|
||||
);
|
||||
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t, tCommon]);
|
||||
}, [actionHandlers, columnVisibility, isAdvanced, isEditMode, options.enableRowSorting, t, tCommon]);
|
||||
const { effectiveColumns, storeKey } = usePersistedTableLayout({
|
||||
columns,
|
||||
columnAccessors,
|
||||
@@ -435,7 +409,7 @@ export default function DockerWidget({
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
cellPadding="2px 8px"
|
||||
cellPadding={isAdvanced || width < 400 ? "2px 8px" : "4px 8px"}
|
||||
className="docker-table"
|
||||
rowCursor="default"
|
||||
fetching={isFetching && containers.length === 0}
|
||||
|
||||
@@ -5,27 +5,34 @@ import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
|
||||
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
|
||||
|
||||
describe("getDockerColumnVisibility", () => {
|
||||
test("keeps only essential columns in a narrow compact widget", () => {
|
||||
test("keeps every configured column in a narrow compact widget", () => {
|
||||
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
|
||||
name: true,
|
||||
state: false,
|
||||
host: false,
|
||||
cpuUsage: false,
|
||||
memoryUsage: false,
|
||||
state: true,
|
||||
host: true,
|
||||
cpuUsage: true,
|
||||
memoryUsage: true,
|
||||
actions: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals compact metrics as width becomes available", () => {
|
||||
expect(getDockerColumnVisibility(columns, 440, false)).toMatchObject({
|
||||
test("honors the configured compact column set at every width", () => {
|
||||
expect(getDockerColumnVisibility(["name", "memoryUsage"], 200, false)).toEqual({
|
||||
name: true,
|
||||
state: false,
|
||||
host: false,
|
||||
cpuUsage: true,
|
||||
memoryUsage: false,
|
||||
cpuUsage: false,
|
||||
memoryUsage: true,
|
||||
actions: false,
|
||||
});
|
||||
expect(getDockerColumnVisibility(["name", "memoryUsage"], 800, false)).toEqual({
|
||||
name: true,
|
||||
state: false,
|
||||
host: false,
|
||||
cpuUsage: false,
|
||||
memoryUsage: true,
|
||||
actions: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a configured state column when there is no name column for the inline dot", () => {
|
||||
expect(getDockerColumnVisibility(["state"], 240, false).state).toBe(true);
|
||||
});
|
||||
|
||||
test("uses every expert column in advanced mode", () => {
|
||||
@@ -41,9 +48,9 @@ describe("getDockerColumnVisibility", () => {
|
||||
});
|
||||
|
||||
describe("getDockerFooterVisibility", () => {
|
||||
test("reveals totals only when they fit", () => {
|
||||
expect(getDockerFooterVisibility(300, false)).toEqual({ footer: true, cpu: false, memory: false });
|
||||
expect(getDockerFooterVisibility(400, false)).toEqual({ footer: true, cpu: true, memory: false });
|
||||
test("shows the full compact footer above its threshold", () => {
|
||||
expect(getDockerFooterVisibility(256, false)).toEqual({ footer: false, cpu: false, memory: false });
|
||||
expect(getDockerFooterVisibility(257, false)).toEqual({ footer: true, cpu: true, memory: true });
|
||||
});
|
||||
|
||||
test("keeps every total in advanced mode", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ export type DockerColumn = "name" | "state" | "host" | "cpuUsage" | "memoryUsage
|
||||
|
||||
export const getDockerColumnVisibility = (
|
||||
configuredColumns: readonly DockerColumn[],
|
||||
width: number,
|
||||
_width: number,
|
||||
isAdvanced: boolean,
|
||||
): Record<DockerColumn, boolean> => {
|
||||
const configured = new Set(configuredColumns);
|
||||
@@ -10,16 +10,16 @@ export const getDockerColumnVisibility = (
|
||||
|
||||
return {
|
||||
name: isVisible("name"),
|
||||
state: isVisible("state") && (isAdvanced || width >= 340 || !configured.has("name")),
|
||||
host: isVisible("host") && (isAdvanced || width >= 640),
|
||||
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 420),
|
||||
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 520),
|
||||
state: isVisible("state"),
|
||||
host: isVisible("host"),
|
||||
cpuUsage: isVisible("cpuUsage"),
|
||||
memoryUsage: isVisible("memoryUsage"),
|
||||
actions: isVisible("actions"),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDockerFooterVisibility = (width: number, isAdvanced: boolean) => ({
|
||||
footer: isAdvanced || width > 256,
|
||||
cpu: isAdvanced || width >= 360,
|
||||
memory: isAdvanced || width >= 480,
|
||||
cpu: isAdvanced || width > 256,
|
||||
memory: isAdvanced || width > 256,
|
||||
});
|
||||
@@ -159,10 +159,8 @@ const speedColumnConfig = {
|
||||
up: { Icon: IconUpload, color: "green" as const },
|
||||
} as const;
|
||||
|
||||
export function getSizeConfig(width: number, height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
|
||||
export function getSizeConfig(width: number, _height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
|
||||
if (isAdvanced) return DEFAULT_SIZE_CONFIG;
|
||||
if (height < 120) return SIZE_BREAKPOINTS[0]?.config ?? DEFAULT_SIZE_CONFIG;
|
||||
if (height < 180) return SIZE_BREAKPOINTS[1]?.config ?? DEFAULT_SIZE_CONFIG;
|
||||
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
|
||||
if (width < maxWidth) return config;
|
||||
}
|
||||
@@ -796,25 +794,23 @@ export default function DownloadClientsWidget({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{(isAdvanced || height >= 96) && (
|
||||
<WidgetFooter
|
||||
key={`footer-${displayMode}`}
|
||||
clients={clients}
|
||||
totalSpeed={totalSpeed}
|
||||
totalUpSpeed={totalUpSpeed}
|
||||
globalRatio={globalRatio}
|
||||
hasTorrents={hasTorrents}
|
||||
clientFilter={clientFilter}
|
||||
setClientFilter={setClientFilter}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
availableStatuses={availableStatuses}
|
||||
pauseQueue={mutatePauseQueue}
|
||||
resumeQueue={mutateResumeQueue}
|
||||
showStats={statsDisplay.visible}
|
||||
toggleStats={statsDisplay.canToggle ? toggleStats : undefined}
|
||||
/>
|
||||
)}
|
||||
<WidgetFooter
|
||||
key={`footer-${displayMode}`}
|
||||
clients={clients}
|
||||
totalSpeed={totalSpeed}
|
||||
totalUpSpeed={totalUpSpeed}
|
||||
globalRatio={globalRatio}
|
||||
hasTorrents={hasTorrents}
|
||||
clientFilter={clientFilter}
|
||||
setClientFilter={setClientFilter}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
availableStatuses={availableStatuses}
|
||||
pauseQueue={mutatePauseQueue}
|
||||
resumeQueue={mutateResumeQueue}
|
||||
showStats={statsDisplay.visible}
|
||||
toggleStats={statsDisplay.canToggle ? toggleStats : undefined}
|
||||
/>
|
||||
|
||||
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />}
|
||||
</Stack>
|
||||
|
||||
@@ -68,7 +68,6 @@ export default function FirewallWidget({
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isTiny = !isAdvanced && (width < 256 || height < 180);
|
||||
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
|
||||
const showInterfaces = isAdvanced || (!isTiny && height >= 120);
|
||||
const t = useI18n("widget.firewall");
|
||||
const tCommon = useI18n("common");
|
||||
|
||||
@@ -179,7 +178,6 @@ export default function FirewallWidget({
|
||||
hasError={hasError}
|
||||
isAdvanced={isAdvanced}
|
||||
ringSize={ringSize}
|
||||
showInterfaces={showInterfaces}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={t("error.internalServerError")}
|
||||
@@ -210,7 +208,6 @@ interface FirewallPanelProps {
|
||||
hasError: boolean;
|
||||
isAdvanced: boolean;
|
||||
ringSize: number;
|
||||
showInterfaces: boolean;
|
||||
accordionValue: string | null;
|
||||
setAccordionValue: (value: string | null) => void;
|
||||
errorLabel: string;
|
||||
@@ -234,7 +231,6 @@ const FirewallPanel = ({
|
||||
hasError,
|
||||
isAdvanced,
|
||||
ringSize,
|
||||
showInterfaces,
|
||||
accordionValue,
|
||||
setAccordionValue,
|
||||
errorLabel,
|
||||
@@ -283,21 +279,19 @@ const FirewallPanel = ({
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{showInterfaces && (
|
||||
<InterfacesPanel
|
||||
summary={interfaces ?? []}
|
||||
hasResult={interfacesLoaded}
|
||||
hasError={interfacesError}
|
||||
isAdvanced={isAdvanced}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={errorLabel}
|
||||
errorBadgeLabel={errorBadgeLabel}
|
||||
noDataLabel={noDataLabel}
|
||||
loadingLabel={loadingLabel}
|
||||
label={interfacesLabel}
|
||||
/>
|
||||
)}
|
||||
<InterfacesPanel
|
||||
summary={interfaces ?? []}
|
||||
hasResult={interfacesLoaded}
|
||||
hasError={interfacesError}
|
||||
isAdvanced={isAdvanced}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={errorLabel}
|
||||
errorBadgeLabel={errorBadgeLabel}
|
||||
noDataLabel={noDataLabel}
|
||||
loadingLabel={loadingLabel}
|
||||
label={interfacesLabel}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -14,9 +14,21 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.statItem,
|
||||
.statItemDense {
|
||||
border-color: CanvasText;
|
||||
.statItemCompact {
|
||||
padding: 12px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.statItem,
|
||||
.statItemDense,
|
||||
.statItemCompact {
|
||||
border: 0.0625rem solid CanvasText;
|
||||
}
|
||||
}
|
||||
@@ -60,54 +60,57 @@ export default function ImmichServerStatsWidget({
|
||||
{
|
||||
key: "users",
|
||||
enabled: statVisibility.showUsers,
|
||||
icon: <IconUsers style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
|
||||
icon: <IconUsers style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
|
||||
label: t("users"),
|
||||
value: stats.userCount.toLocaleString(locale),
|
||||
},
|
||||
{
|
||||
key: "photos",
|
||||
enabled: statVisibility.showPhotos,
|
||||
icon: <IconPhoto style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
|
||||
icon: <IconPhoto style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
|
||||
label: t("photos"),
|
||||
value: stats.photoCount.toLocaleString(locale),
|
||||
},
|
||||
{
|
||||
key: "videos",
|
||||
enabled: statVisibility.showVideos,
|
||||
icon: <IconVideo style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
|
||||
icon: <IconVideo style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
|
||||
label: t("videos"),
|
||||
value: stats.videoCount.toLocaleString(locale),
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
enabled: statVisibility.showStorage,
|
||||
icon: <IconDatabase style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
|
||||
icon: <IconDatabase style={zoomCompensatedSize(isAdvanced ? 18 : 20)} />,
|
||||
label: t("storage"),
|
||||
value: formatBytes(stats.totalLibraryUsageInBytes),
|
||||
},
|
||||
]
|
||||
.filter((item) => item.enabled)
|
||||
.map(({ key, icon, label, value }) => (
|
||||
<StatItem key={key} icon={icon} label={label} value={value} dense={statsLayout.dense} />
|
||||
<StatItem
|
||||
key={key}
|
||||
icon={icon}
|
||||
label={label}
|
||||
value={value}
|
||||
dense={isAdvanced && statsLayout.dense}
|
||||
compact={!isAdvanced}
|
||||
/>
|
||||
));
|
||||
|
||||
const statsContent =
|
||||
isAdvanced || statsLayout.dense ? (
|
||||
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
|
||||
{statItems}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack gap="xs">{statItems}</Stack>
|
||||
);
|
||||
|
||||
if (!isAdvanced) {
|
||||
return (
|
||||
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
|
||||
{statsContent}
|
||||
<Stack gap="md" h="100%" p="md">
|
||||
{statItems}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const statsContent = (
|
||||
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
|
||||
{statItems}
|
||||
</SimpleGrid>
|
||||
);
|
||||
const sortedAlbums = albums;
|
||||
const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
|
||||
return (
|
||||
@@ -150,9 +153,10 @@ interface StatItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
dense: boolean;
|
||||
compact: boolean;
|
||||
}
|
||||
|
||||
function StatItem({ icon, label, value, dense }: StatItemProps) {
|
||||
function StatItem({ icon, label, value, dense, compact }: StatItemProps) {
|
||||
if (dense) {
|
||||
return (
|
||||
<Stack gap={1} align="center" justify="center" className={classes.statItemDense} title={`${label}: ${value}`}>
|
||||
@@ -167,7 +171,7 @@ function StatItem({ icon, label, value, dense }: StatItemProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Group justify="space-between" align="center" className={classes.statItem}>
|
||||
<Group justify="space-between" align="center" className={compact ? classes.statItemCompact : classes.statItem}>
|
||||
<Group gap="sm" align="center" wrap="nowrap" miw={0}>
|
||||
{icon}
|
||||
<Text size="sm" fw={500} truncate="end">
|
||||
@@ -181,16 +185,13 @@ function StatItem({ icon, label, value, dense }: StatItemProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const getImmichStatsLayout = (width: number, height: number, itemCount: number, isAdvanced = false) => {
|
||||
export const getImmichStatsLayout = (width: number, _height: number, itemCount: number, isAdvanced = false) => {
|
||||
const count = Math.max(1, itemCount);
|
||||
if (isAdvanced) {
|
||||
return { columns: width >= 720 ? Math.min(count, 4) : width >= 360 ? Math.min(count, 2) : 1, dense: false };
|
||||
}
|
||||
|
||||
const dense = width < 320 || height < 160;
|
||||
if (height < 140) return { columns: Math.min(count, width >= 360 ? 4 : 2), dense: true };
|
||||
if (height < 220) return { columns: Math.min(count, 2), dense: true };
|
||||
return { columns: Math.min(count, width >= 280 ? 2 : 1), dense };
|
||||
return { columns: 1, dense: false };
|
||||
};
|
||||
|
||||
export const getImmichStatVisibility = (
|
||||
|
||||
@@ -3,14 +3,11 @@ import { describe, expect, test } from "vitest";
|
||||
import { getImmichStatsLayout, getImmichStatVisibility } from "./component";
|
||||
|
||||
describe("Immich server statistics compact layout", () => {
|
||||
test("keeps four enabled stats legible in a short widget", () => {
|
||||
expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 2, dense: true });
|
||||
expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 4, dense: true });
|
||||
});
|
||||
|
||||
test("uses the roomier summary layout when space is available", () => {
|
||||
expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 2, dense: false });
|
||||
expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 2, dense: true });
|
||||
test("keeps the v1 one-column non-dense layout at every compact size", () => {
|
||||
expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 1, dense: false });
|
||||
expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 1, dense: false });
|
||||
expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 1, dense: false });
|
||||
expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 1, dense: false });
|
||||
});
|
||||
|
||||
test("advanced mode shows all four headline stats", () => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
.card {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.advancedCard {
|
||||
background-color: transparent;
|
||||
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Flex,
|
||||
@@ -22,7 +23,9 @@ import combineClasses from "clsx";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeActions } from "../runtime-hooks";
|
||||
@@ -60,6 +63,11 @@ export default function IndexerManagerWidget({
|
||||
isPending,
|
||||
error: testAllError,
|
||||
} = clientApi.widget.indexerManager.testAllIndexers.useMutation({
|
||||
onError: () =>
|
||||
showErrorNotification({
|
||||
title: tCommon("error"),
|
||||
message: t("error.testAll"),
|
||||
}),
|
||||
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
|
||||
});
|
||||
const interactIntegrationIds = new Set(useIntegrationsWithInteractAccess().map(({ id }) => id));
|
||||
@@ -67,8 +75,7 @@ export default function IndexerManagerWidget({
|
||||
const board = useRequiredBoard();
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const hasSmallWidth = !isAdvanced && width < 256;
|
||||
const isDense = !isAdvanced && (width < 280 || height < 180);
|
||||
const showHealthCounts = isAdvanced || (width >= 200 && height >= 100);
|
||||
const hasSmallHeight = !isAdvanced && height < 256;
|
||||
const allIndexers = indexersData.flatMap((entry) => entry.indexers);
|
||||
const hasSourceError = indexersData.some((entry) => Boolean(entry.error));
|
||||
const unavailableCount = allIndexers.filter(
|
||||
@@ -88,24 +95,32 @@ export default function IndexerManagerWidget({
|
||||
className={`indexer-manager-container ${classes.root}`}
|
||||
h="100%"
|
||||
direction="column"
|
||||
gap={isDense ? 4 : "xs"}
|
||||
p={isDense ? "xs" : "sm"}
|
||||
gap={isAdvanced ? "xs" : "sm"}
|
||||
p="sm"
|
||||
align={isAdvanced ? undefined : "center"}
|
||||
>
|
||||
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap" w="100%">
|
||||
<Group
|
||||
className="indexer-manager-title"
|
||||
align="center"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
w={isAdvanced ? "100%" : undefined}
|
||||
>
|
||||
{testAllError && <VisuallyHidden role="alert">{tCommon("error")}</VisuallyHidden>}
|
||||
<Tooltip label={t("title")} disabled={!hasSmallWidth}>
|
||||
<IconReportSearch
|
||||
className="indexer-manager-title-icon"
|
||||
size={hasSmallWidth ? 16 : 20}
|
||||
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
|
||||
style={hasSmallWidth ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!hasSmallWidth && (
|
||||
<Text size={isDense ? "xs" : "sm"} fw={600} truncate="end">
|
||||
{t("title")}
|
||||
</Text>
|
||||
)}
|
||||
{showHealthCounts && (
|
||||
<Text
|
||||
size={hasSmallWidth ? "xs" : isAdvanced ? "sm" : "md"}
|
||||
fw={isAdvanced ? 600 : "bold"}
|
||||
truncate={isAdvanced ? "end" : undefined}
|
||||
>
|
||||
{t("title")}
|
||||
</Text>
|
||||
{isAdvanced && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
{allIndexers.length - unavailableCount}
|
||||
@@ -117,29 +132,35 @@ export default function IndexerManagerWidget({
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
|
||||
<ActionIcon
|
||||
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
variant="light"
|
||||
color={testAllError ? "red" : undefined}
|
||||
loading={isPending}
|
||||
disabled={isEditMode || !canInteract}
|
||||
loaderProps={{ type: "dots" }}
|
||||
onClick={() => {
|
||||
testAllIndexers();
|
||||
}}
|
||||
aria-label={t("testAll")}
|
||||
>
|
||||
<IconTestPipe size="var(--mantine-font-size-sm)" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{(isAdvanced || hasSmallHeight) && (
|
||||
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
|
||||
<ActionIcon
|
||||
className={combineClasses(
|
||||
"indexer-manager-test-action-icon",
|
||||
isAdvanced && classes.testAction,
|
||||
actionTargetClasses.root,
|
||||
)}
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
variant="light"
|
||||
color={testAllError ? "red" : undefined}
|
||||
loading={isPending}
|
||||
disabled={isEditMode || !canInteract}
|
||||
loaderProps={{ type: "dots" }}
|
||||
onClick={() => {
|
||||
testAllIndexers();
|
||||
}}
|
||||
aria-label={t("testAll")}
|
||||
>
|
||||
<IconTestPipe style={isAdvanced ? iconSizes.sm : iconSizes.xs} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Card
|
||||
className={combineClasses("indexer-manager-list-container", classes.card)}
|
||||
className={combineClasses("indexer-manager-list-container", classes.card, isAdvanced && classes.advancedCard)}
|
||||
w="100%"
|
||||
p={isDense ? 4 : "xs"}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
flex={1}
|
||||
>
|
||||
@@ -153,7 +174,7 @@ export default function IndexerManagerWidget({
|
||||
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
|
||||
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
|
||||
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
|
||||
{(isAdvanced || indexersData.length > 1 || error) && (
|
||||
{(isAdvanced || error) && (
|
||||
<Group justify="space-between" wrap="nowrap" py={4}>
|
||||
<Text size="xs" fw={600} truncate="end">
|
||||
{integrationName}
|
||||
@@ -171,14 +192,18 @@ export default function IndexerManagerWidget({
|
||||
const presentation = statusPresentation[displayStatus];
|
||||
return (
|
||||
<Group
|
||||
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
|
||||
className={combineClasses(
|
||||
"indexer-manager-line",
|
||||
`indexer-manager-${indexer.name}`,
|
||||
isAdvanced && classes.indexerRow,
|
||||
)}
|
||||
key={indexer.id}
|
||||
justify="space-between"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Anchor
|
||||
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
|
||||
className={combineClasses("indexer-manager-line-anchor", isAdvanced && classes.indexerLink)}
|
||||
component={href ? "a" : "span"}
|
||||
href={href}
|
||||
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
|
||||
@@ -189,7 +214,7 @@ export default function IndexerManagerWidget({
|
||||
className="indexer-manager-line-anchor-text"
|
||||
c="dimmed"
|
||||
size={hasSmallWidth ? "xs" : "sm"}
|
||||
truncate="end"
|
||||
truncate={isAdvanced ? "end" : undefined}
|
||||
>
|
||||
{indexer.name}
|
||||
</Text>
|
||||
@@ -201,14 +226,14 @@ export default function IndexerManagerWidget({
|
||||
) : displayStatus === "healthy" ? (
|
||||
<IconCircleCheck
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
|
||||
color="var(--mantine-color-green-6)"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
color="#2ecc71"
|
||||
style={hasSmallWidth ? iconSizes.xs : iconSizes.md}
|
||||
/>
|
||||
) : (
|
||||
<IconCircleX
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
|
||||
color="var(--mantine-color-red-6)"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
color="#d9534f"
|
||||
style={hasSmallWidth ? iconSizes.xs : iconSizes.md}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@@ -219,6 +244,25 @@ export default function IndexerManagerWidget({
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Card>
|
||||
{!isAdvanced && !hasSmallHeight && (
|
||||
<Button
|
||||
className="indexer-manager-test-button"
|
||||
w="100%"
|
||||
size="xs"
|
||||
radius={board.itemRadius}
|
||||
variant="light"
|
||||
color={testAllError ? "red" : undefined}
|
||||
leftSection={<IconTestPipe style={iconSizes.md} />}
|
||||
loading={isPending}
|
||||
disabled={isEditMode || !canInteract}
|
||||
loaderProps={{ type: "dots" }}
|
||||
onClick={() => {
|
||||
testAllIndexers();
|
||||
}}
|
||||
>
|
||||
{t("testAll")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,6 @@ export default function MediaReleasesWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaReleases">) {
|
||||
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
|
||||
@@ -63,7 +62,7 @@ export default function MediaReleasesWidget({
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={`${item.integration.id}:${item.id}`}>
|
||||
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} />
|
||||
</Fragment>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -82,30 +81,27 @@ interface ItemProps {
|
||||
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number];
|
||||
options: WidgetComponentProps<"mediaReleases">["options"];
|
||||
isAdvanced: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => {
|
||||
const formatReleaseDate = (value: unknown, locale: string) => {
|
||||
const date = toValidDate(value);
|
||||
if (!date) return "—";
|
||||
return Intl.DateTimeFormat(locale, {
|
||||
month: compact ? "short" : "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
day: "2-digit",
|
||||
...(compact ? {} : { hour: "2-digit", minute: "2-digit", hour12: false }),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
const Item = ({ item, options, isAdvanced }: ItemProps) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useI18n("widget.mediaReleases");
|
||||
const length = formatLength(item.length, item.type, t);
|
||||
const isCompact = !isAdvanced && (width < 340 || height < 180);
|
||||
const isTiny = !isAdvanced && (width < 220 || height < 105);
|
||||
const showPoster = (isAdvanced || options.layout === "poster") && !isTiny;
|
||||
const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360);
|
||||
const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource));
|
||||
const showPoster = isAdvanced || options.layout === "poster";
|
||||
const showSide = isAdvanced || options.showType || options.showSource;
|
||||
const href = getSafeApplicationUrl(item.href);
|
||||
|
||||
return (
|
||||
@@ -129,7 +125,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
pos="relative"
|
||||
p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6}
|
||||
p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4}
|
||||
h="100%"
|
||||
title={item.title}
|
||||
style={
|
||||
@@ -158,57 +154,61 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Group justify="space-between" h="100%" wrap="nowrap" gap={isCompact ? "xs" : "sm"}>
|
||||
<Group justify="space-between" h="100%" wrap="nowrap" gap={isAdvanced ? "sm" : undefined}>
|
||||
<Group align="start" wrap="nowrap" style={{ zIndex: 0, minWidth: 0, flex: 1 }} gap="xs">
|
||||
{showPoster && (
|
||||
<Image
|
||||
w={isAdvanced ? 80 : isCompact ? 44 : 60}
|
||||
w={isAdvanced ? 80 : 60}
|
||||
src={item.imageUrls.poster}
|
||||
alt=""
|
||||
radius="xs"
|
||||
alt={isAdvanced ? "" : item.title}
|
||||
radius={isAdvanced ? "xs" : undefined}
|
||||
loading="lazy"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Stack gap={isCompact ? 2 : 4} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Stack gap={0}>
|
||||
<Text size={isTiny ? "xs" : "sm"} fw="bold" lineClamp={isCompact ? 1 : 2}>
|
||||
<Text size="sm" fw="bold" lineClamp={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.subtitle !== undefined && !isTiny && (
|
||||
<Text size={isCompact ? "xs" : "sm"} c={isCompact ? "dimmed" : undefined} lineClamp={1}>
|
||||
{item.subtitle !== undefined && (
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale, isCompact)} />
|
||||
{length !== undefined && !isTiny && (
|
||||
<Group
|
||||
gap={6}
|
||||
wrap={isAdvanced ? "nowrap" : "wrap"}
|
||||
style={isAdvanced ? { minWidth: 0 } : { minWidth: 0, rowGap: 0 }}
|
||||
>
|
||||
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale)} />
|
||||
{length !== undefined && (
|
||||
<>
|
||||
<InfoDivider />
|
||||
<Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} />
|
||||
</>
|
||||
)}
|
||||
{item.producer !== undefined && showExtendedMetadata && (
|
||||
{item.producer !== undefined && (
|
||||
<>
|
||||
<InfoDivider />
|
||||
<Info label={item.producer} />
|
||||
</>
|
||||
)}
|
||||
{item.rating !== undefined && showExtendedMetadata && (
|
||||
{item.rating !== undefined && (
|
||||
<>
|
||||
<InfoDivider />
|
||||
<Info icon={IconStarFilled} label={item.rating} />
|
||||
</>
|
||||
)}
|
||||
{item.price !== undefined && showExtendedMetadata && (
|
||||
{item.price !== undefined && (
|
||||
<>
|
||||
<InfoDivider />
|
||||
<Info label={`$${item.price.toFixed(2)}`} />
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
{item.tags.length > 0 && (isAdvanced || !isCompact) && (
|
||||
{item.tags.length > 0 && (
|
||||
<OverflowBadge
|
||||
size="xs"
|
||||
groupGap={4}
|
||||
|
||||
@@ -29,7 +29,6 @@ export default function MediaServerWidget({
|
||||
isEditMode,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||
const interactIntegrationIds = new Set(
|
||||
useIntegrationsWithInteractAccess()
|
||||
@@ -69,8 +68,7 @@ export default function MediaServerWidget({
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256 || height < 96}
|
||||
isDense={width < 340 || height < 150}
|
||||
isTiny={width <= 256}
|
||||
showIntegrationSource={showIntegrationSource}
|
||||
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
|
||||
options={options}
|
||||
@@ -103,20 +101,12 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
|
||||
interface MediaRequestCardProps {
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
|
||||
isTiny: boolean;
|
||||
isDense: boolean;
|
||||
showIntegrationSource: boolean;
|
||||
canInteract: boolean;
|
||||
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
|
||||
}
|
||||
|
||||
const MediaRequestCard = ({
|
||||
request,
|
||||
isTiny,
|
||||
isDense,
|
||||
showIntegrationSource,
|
||||
canInteract,
|
||||
options,
|
||||
}: MediaRequestCardProps) => {
|
||||
const MediaRequestCard = ({ request, isTiny, showIntegrationSource, canInteract, options }: MediaRequestCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n("widget.mediaRequests-requestList");
|
||||
const requestHref = getSafeApplicationUrl(request.href);
|
||||
@@ -124,11 +114,21 @@ const MediaRequestCard = ({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status} ${classes.card}`}
|
||||
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status}`}
|
||||
radius={board.itemRadius}
|
||||
p={isDense ? 6 : "xs"}
|
||||
withBorder
|
||||
p="xs"
|
||||
>
|
||||
<Image
|
||||
className="mediaRequests-list-item-background"
|
||||
src={request.backdropImageUrl}
|
||||
pos="absolute"
|
||||
w="100%"
|
||||
h="100%"
|
||||
opacity={0.2}
|
||||
top={0}
|
||||
left={0}
|
||||
alt=""
|
||||
/>
|
||||
<Group
|
||||
className="mediaRequests-list-item-contents"
|
||||
h="100%"
|
||||
@@ -137,56 +137,22 @@ const MediaRequestCard = ({
|
||||
wrap="nowrap"
|
||||
gap={0}
|
||||
>
|
||||
<Group
|
||||
className="mediaRequests-list-item-left-side"
|
||||
h="100%"
|
||||
gap={isDense ? "xs" : "md"}
|
||||
wrap="nowrap"
|
||||
flex={1}
|
||||
miw={0}
|
||||
>
|
||||
<Group className="mediaRequests-list-item-left-side" h="100%" gap="md" wrap="nowrap" flex={1} miw={0}>
|
||||
{!isTiny && (
|
||||
<Image
|
||||
className="mediaRequests-list-item-poster"
|
||||
src={request.posterImagePath}
|
||||
h={isDense ? 32 : 40}
|
||||
h={40}
|
||||
w="auto"
|
||||
radius="sm"
|
||||
radius="md"
|
||||
alt=""
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack gap={0} w="100%" miw={0}>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
|
||||
component={requestHref ? "a" : "span"}
|
||||
href={requestHref}
|
||||
c="var(--mantine-color-text)"
|
||||
target={requestHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={requestHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={600}
|
||||
title={request.name}
|
||||
truncate="end"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
{request.name || "unknown"}
|
||||
</Anchor>
|
||||
{request.status === "pending" ? (
|
||||
<DecisionButtons
|
||||
requestId={request.id}
|
||||
integrationId={request.integrationId}
|
||||
canInteract={canInteract}
|
||||
alwaysVisible={isTiny}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={request.status} />
|
||||
)}
|
||||
</Group>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap" className="mediaRequests-list-item-bottom-group">
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
<Group justify="space-between" gap="xs" className="mediaRequests-list-item-top-group">
|
||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||
<Text className="mediaRequests-list-item-media-year" size="xs">
|
||||
{toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")}
|
||||
</Text>
|
||||
@@ -206,27 +172,52 @@ const MediaRequestCard = ({
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{!isTiny && (
|
||||
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}>
|
||||
<Avatar
|
||||
className="mediaRequests-list-item-request-user-avatar"
|
||||
src={request.requestedBy?.avatar}
|
||||
size={18}
|
||||
/>
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-request-user-name"
|
||||
component={requestedByHref ? "a" : "span"}
|
||||
href={requestedByHref}
|
||||
c="dimmed"
|
||||
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz="xs"
|
||||
truncate="end"
|
||||
style={{ minWidth: 0, maxWidth: isDense ? 100 : 180 }}
|
||||
>
|
||||
{(request.requestedBy?.displayName ?? "") || "unknown"}
|
||||
</Anchor>
|
||||
</Group>
|
||||
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}>
|
||||
<Avatar
|
||||
className="mediaRequests-list-item-request-user-avatar"
|
||||
src={request.requestedBy?.avatar}
|
||||
size="xs"
|
||||
/>
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-request-user-name"
|
||||
component={requestedByHref ? "a" : "span"}
|
||||
href={requestedByHref}
|
||||
c="var(--mantine-color-text)"
|
||||
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz="xs"
|
||||
lineClamp={1}
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{(request.requestedBy?.displayName ?? "") || t("unknown")}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-bottom-group">
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
|
||||
component={requestHref ? "a" : "span"}
|
||||
href={requestHref}
|
||||
c="var(--mantine-color-text)"
|
||||
target={requestHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={requestHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw="bold"
|
||||
title={request.name}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{request.name || t("unknown")}
|
||||
</Anchor>
|
||||
{request.status === "pending" ? (
|
||||
<DecisionButtons
|
||||
requestId={request.id}
|
||||
integrationId={request.integrationId}
|
||||
canInteract={canInteract}
|
||||
alwaysVisible
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={request.status} />
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -100,9 +100,7 @@ export default function MediaServerWidget({
|
||||
},
|
||||
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
|
||||
|
||||
const isTiny = width < 256 || height < 180;
|
||||
const showSectionTitles = height >= 180;
|
||||
const visibleStats = data.slice(0, height < 120 ? 4 : 8);
|
||||
const isTiny = width < 256;
|
||||
const showIntegrationLegend = requestStats.integrations.length > 1 && height >= 240;
|
||||
|
||||
return (
|
||||
@@ -121,13 +119,11 @@ export default function MediaServerWidget({
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined, overflow: "hidden" }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
{showSectionTitles && (
|
||||
<Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
)}
|
||||
<Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{visibleStats.map((stat) => (
|
||||
{data.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
@@ -148,11 +144,9 @@ export default function MediaServerWidget({
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%" style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
{showSectionTitles && (
|
||||
<Text className="mediaRequests-stats-users-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
)}
|
||||
<Text className="mediaRequests-stats-users-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} mih={0} w="100%">
|
||||
<Stack gap={4}>
|
||||
{requestStats.users.map((user) => {
|
||||
|
||||
@@ -106,10 +106,10 @@ describe("SessionDetailsPopover", () => {
|
||||
});
|
||||
|
||||
describe("getMediaServerColumnVisibility", () => {
|
||||
test("keeps a stable responsive column model from the zero-sized first render", () => {
|
||||
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
|
||||
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
|
||||
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false });
|
||||
test("keeps every compact column visible at every width", () => {
|
||||
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: true, status: true });
|
||||
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: true });
|
||||
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
|
||||
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
|
||||
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -48,11 +49,9 @@ type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transc
|
||||
type SortColumn = "user" | "currentlyPlaying" | "status";
|
||||
type SortState = { column: SortColumn; descending: boolean } | null;
|
||||
|
||||
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
|
||||
user: isAdvanced || width >= 300,
|
||||
// The user (26%) and status (22%) columns together need a floor here, otherwise the
|
||||
// currentlyPlaying column - the primary content - gets squeezed to almost nothing.
|
||||
status: isAdvanced || width >= 540,
|
||||
export const getMediaServerColumnVisibility = (_width: number, _isAdvanced: boolean) => ({
|
||||
user: true,
|
||||
status: true,
|
||||
});
|
||||
|
||||
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
|
||||
@@ -171,7 +170,6 @@ export default function MediaServerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
isEditMode,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaServer">) {
|
||||
@@ -411,61 +409,76 @@ export default function MediaServerWidget({
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
{(isAdvanced || height >= 144) && (
|
||||
<Group
|
||||
gap="xs"
|
||||
h={30}
|
||||
px="xs"
|
||||
pr="md"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||
count: flatSessions.length,
|
||||
})}
|
||||
</Text>
|
||||
{totalBitrateLabel && (isAdvanced || width >= 300) && (
|
||||
<>
|
||||
<Group
|
||||
gap="xs"
|
||||
h={30}
|
||||
px="xs"
|
||||
pr="md"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<Group gap={isAdvanced ? 6 : 4} wrap="nowrap">
|
||||
<IconVideo
|
||||
size={isAdvanced ? "var(--mantine-font-size-xs)" : undefined}
|
||||
style={isAdvanced ? { flexShrink: 0 } : zoomCompensatedSize(16)}
|
||||
/>
|
||||
<Text size="sm" fw={isAdvanced ? 500 : undefined} style={{ whiteSpace: "nowrap" }}>
|
||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||
count: flatSessions.length,
|
||||
})}
|
||||
</Text>
|
||||
{totalBitrateLabel && (
|
||||
<>
|
||||
{isAdvanced && (
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
•
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
{uniqueIntegrations.map((integration) => (
|
||||
<Group
|
||||
key={integration.integrationId}
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
pl={2}
|
||||
pr={8}
|
||||
py={2}
|
||||
style={{
|
||||
backgroundColor: "var(--mantine-color-default-hover)",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
<Avatar className="media-server-icon" src={integration.integrationIcon} radius="xl" size={18} />
|
||||
{(isAdvanced || width >= 480) && (
|
||||
<Text className="media-server-name" size="xs" fw={500} truncate="end">
|
||||
{integration.integrationName}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Group gap={isAdvanced ? 6 : "xs"} wrap="nowrap">
|
||||
{uniqueIntegrations.map((integration) => (
|
||||
<Group
|
||||
key={integration.integrationId}
|
||||
gap={isAdvanced ? 6 : "xs"}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
pl={isAdvanced ? 2 : undefined}
|
||||
pr={isAdvanced ? 8 : undefined}
|
||||
py={isAdvanced ? 2 : undefined}
|
||||
style={
|
||||
isAdvanced
|
||||
? {
|
||||
backgroundColor: "var(--mantine-color-default-hover)",
|
||||
borderRadius: 999,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
className="media-server-icon"
|
||||
src={integration.integrationIcon}
|
||||
radius={isAdvanced ? "xl" : "xs"}
|
||||
size={isAdvanced ? 18 : "xs"}
|
||||
/>
|
||||
<Text
|
||||
className="media-server-name"
|
||||
size={isAdvanced ? "xs" : "sm"}
|
||||
fw={isAdvanced ? 500 : undefined}
|
||||
truncate={isAdvanced ? "end" : undefined}
|
||||
>
|
||||
{integration.integrationName}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function MinecraftServerStatusWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"minecraftServerStatus">) {
|
||||
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
|
||||
const t = useI18n("widget.minecraftServerStatus");
|
||||
@@ -34,10 +35,11 @@ export default function MinecraftServerStatusWidget({
|
||||
const { data } = result;
|
||||
|
||||
const title = options.title.trim().length > 0 ? options.title : options.domain;
|
||||
const isDense = width < 220 || height < 120;
|
||||
const showServerIcon = !isDense && height >= 144;
|
||||
const showMetadata = width >= 260 && height >= 180;
|
||||
const showCapacity = width >= 180 && height >= 140;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = isAdvanced && (width < 220 || height < 120);
|
||||
const showServerIcon = !isAdvanced || (!isDense && height >= 144);
|
||||
const showMetadata = isAdvanced && width >= 260 && height >= 180;
|
||||
const showCapacity = isAdvanced && width >= 180 && height >= 140;
|
||||
const iconSize = Math.max(40, Math.min(80, width * 0.45, height * 0.45));
|
||||
const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0;
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@ const WidgetEditPreview = ({
|
||||
integrationIds={state.integrationIds}
|
||||
width={sourceWidth}
|
||||
height={sourceHeight}
|
||||
displayScale={previewScale}
|
||||
isEditMode={isPendingCustomWidget}
|
||||
displayMode="compact"
|
||||
boardId={boardId}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
@@ -13,8 +12,7 @@ import { getNetworkControllerStatusLayout } from "./layout";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
import { WiredVariant } from "./variants/wired-variant";
|
||||
|
||||
const neutralBorderColor =
|
||||
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
const neutralBorderColor = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
export default function NetworkControllerNetworkStatusWidget({
|
||||
options,
|
||||
@@ -22,17 +20,18 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
displayMode,
|
||||
width,
|
||||
height,
|
||||
displayScale = 1,
|
||||
}: WidgetComponentProps<"networkControllerStatus">) {
|
||||
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const results = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const summaries = useMemo(
|
||||
() =>
|
||||
results.filter(
|
||||
(result): result is typeof result & { summary: NonNullable<typeof result.summary> } => result.summary !== null,
|
||||
),
|
||||
[results],
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
let results = summaryQuery.data ?? [];
|
||||
if (isAdvanced) {
|
||||
results = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
}
|
||||
const summaries = results.filter(
|
||||
(result): result is typeof result & { summary: NonNullable<typeof result.summary> } => result.summary !== null,
|
||||
);
|
||||
const tWidgetCommon = useI18n("widget.common");
|
||||
const tCommon = useI18n("common");
|
||||
@@ -42,14 +41,55 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0);
|
||||
const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0);
|
||||
const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0);
|
||||
const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content });
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
let responsiveWidth = width;
|
||||
let responsiveHeight = height;
|
||||
if (!isAdvanced && Number.isFinite(displayScale) && displayScale > 0) {
|
||||
responsiveWidth *= displayScale;
|
||||
responsiveHeight *= displayScale;
|
||||
}
|
||||
const layout = getNetworkControllerStatusLayout({
|
||||
width: responsiveWidth,
|
||||
height: responsiveHeight,
|
||||
displayMode,
|
||||
content: options.content,
|
||||
});
|
||||
const indicatorResults =
|
||||
results.length > 0 || !summaryQuery.error
|
||||
? results
|
||||
: integrationIds.map((integrationId) => ({ integrationId, error: "query" }));
|
||||
const queryIndicators = (
|
||||
<Group gap={0} justify="flex-end">
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={indicatorResults} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (!isAdvanced) {
|
||||
return (
|
||||
<Box h="100%" p={layout.padding} pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
{options.content === "wifi" ? (
|
||||
<WifiVariant
|
||||
countGuests={countWifiGuests}
|
||||
countUsers={countWifiUsers}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
inline={layout.inlineStats}
|
||||
/>
|
||||
) : (
|
||||
<WiredVariant
|
||||
countGuests={countLanGuests}
|
||||
countUsers={countLanUsers}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
inline={layout.inlineStats}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (summaryQuery.isPending || summaries.length === 0) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
@@ -63,92 +103,40 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<ScrollArea h="100%" p={layout.padding}>
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
|
||||
{summaries.map(({ integration, summary, updatedAt }) => (
|
||||
<Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
{integration.name}
|
||||
</Text>
|
||||
{updatedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Badge size="xs" variant="light">
|
||||
{integration.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid cols={layout.columns} spacing="sm">
|
||||
<Card
|
||||
p={layout.cardPadding}
|
||||
withBorder
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralBorderColor }}
|
||||
>
|
||||
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} />
|
||||
</Card>
|
||||
<Card
|
||||
p={layout.cardPadding}
|
||||
withBorder
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralBorderColor }}
|
||||
>
|
||||
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} />
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack p={layout.padding} h="100%" gap={0}>
|
||||
<ScrollArea h="100%" p={layout.padding}>
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={layout.columns} spacing="sm" style={{ flex: 1, alignItems: "stretch" }}>
|
||||
{layout.showWifi && (
|
||||
<Card
|
||||
p={layout.cardPadding}
|
||||
withBorder={layout.withBorder}
|
||||
bg="transparent"
|
||||
h="100%"
|
||||
style={{ borderColor: neutralBorderColor }}
|
||||
>
|
||||
<WifiVariant
|
||||
countGuests={countWifiGuests}
|
||||
countUsers={countWifiUsers}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
/>
|
||||
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
|
||||
{summaries.map(({ integration, summary, updatedAt }) => (
|
||||
<Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
{integration.name}
|
||||
</Text>
|
||||
{updatedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Badge size="xs" variant="light">
|
||||
{integration.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid cols={layout.columns} spacing="sm">
|
||||
<Card p={layout.cardPadding} withBorder bg="transparent" style={{ borderColor: neutralBorderColor }}>
|
||||
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} advanced />
|
||||
</Card>
|
||||
<Card p={layout.cardPadding} withBorder bg="transparent" style={{ borderColor: neutralBorderColor }}>
|
||||
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} advanced />
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
{layout.showWired && (
|
||||
<Card
|
||||
p={layout.cardPadding}
|
||||
withBorder={layout.withBorder}
|
||||
bg="transparent"
|
||||
h="100%"
|
||||
style={{ borderColor: neutralBorderColor }}
|
||||
>
|
||||
<WiredVariant
|
||||
countGuests={countLanGuests}
|
||||
countUsers={countLanUsers}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ describe("network controller status layout", () => {
|
||||
{
|
||||
name: "minimum compact",
|
||||
input: { width: 160, height: 90, displayMode: "compact" as const, content: "wifi" as const },
|
||||
expected: { padding: "xs", columns: 1, showWifi: true, showWired: false, horizontalStats: false },
|
||||
expected: { padding: 4, columns: 1, showWifi: true, showWired: false, horizontalStats: true },
|
||||
},
|
||||
{
|
||||
name: "short compact",
|
||||
@@ -58,7 +58,13 @@ describe("network controller status layout", () => {
|
||||
).toMatchObject({ padding: "sm", horizontalStats: false });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ padding: "xs", horizontalStats: false });
|
||||
).toMatchObject({ padding: 4, horizontalStats: true });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 199, height: 80, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ inlineStats: false });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 199, height: 79, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ inlineStats: true });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 960, height: 320, displayMode: "advanced", content: "wifi" }),
|
||||
).toMatchObject({ sourceColumns: 2 });
|
||||
|
||||
@@ -11,7 +11,7 @@ interface NetworkControllerStatusLayoutInput {
|
||||
}
|
||||
|
||||
export interface NetworkControllerStatusLayout {
|
||||
padding: "xs" | "sm" | "md";
|
||||
padding: 4 | "xs" | "sm" | "md";
|
||||
columns: 1 | 2;
|
||||
sourceColumns: 1 | 2;
|
||||
showWifi: boolean;
|
||||
@@ -20,6 +20,7 @@ export interface NetworkControllerStatusLayout {
|
||||
withBorder: boolean;
|
||||
compact: boolean;
|
||||
horizontalStats: boolean;
|
||||
inlineStats: boolean;
|
||||
}
|
||||
|
||||
export const getNetworkControllerStatusLayout = ({
|
||||
@@ -29,16 +30,18 @@ export const getNetworkControllerStatusLayout = ({
|
||||
content,
|
||||
}: NetworkControllerStatusLayoutInput): NetworkControllerStatusLayout => {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const compact = !isAdvanced && (width < 240 || height < 180);
|
||||
|
||||
return {
|
||||
padding: isAdvanced ? "md" : height < 120 ? "xs" : "sm",
|
||||
padding: isAdvanced ? "md" : height < 120 ? 4 : "sm",
|
||||
columns: isAdvanced && width >= 560 ? 2 : 1,
|
||||
sourceColumns: isAdvanced && width >= 960 ? 2 : 1,
|
||||
showWifi: isAdvanced || content === "wifi",
|
||||
showWired: isAdvanced || content === "wired",
|
||||
cardPadding: isAdvanced ? "md" : 0,
|
||||
withBorder: isAdvanced,
|
||||
compact: !isAdvanced,
|
||||
horizontalStats: !isAdvanced && height < 150 && width >= 200,
|
||||
compact,
|
||||
horizontalStats: compact && height < 150,
|
||||
inlineStats: compact && height < 80,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,44 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
export const StatRow = ({
|
||||
label,
|
||||
value,
|
||||
advanced = false,
|
||||
compact = false,
|
||||
inline = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
advanced?: boolean;
|
||||
compact?: boolean;
|
||||
inline?: boolean;
|
||||
}) => {
|
||||
if (!advanced && compact && inline) {
|
||||
return (
|
||||
<Group gap={2} align="baseline" justify="center" wrap="nowrap" miw={0}>
|
||||
<Text size="xl" fw={800} lh={1} style={{ flexShrink: 0 }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!advanced) {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size={compact ? "xl" : "2xl"} fw={compact ? 800 : 900} lh={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size={compact ? "sm" : "md"} c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={2} align="center" justify="center">
|
||||
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1} ta="center">
|
||||
|
||||
@@ -2,22 +2,50 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconWifi } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { StatRow } from "./stat-row";
|
||||
|
||||
export const WifiVariant = ({
|
||||
countGuests,
|
||||
countUsers,
|
||||
advanced = false,
|
||||
compact = false,
|
||||
horizontal = false,
|
||||
inline = false,
|
||||
}: {
|
||||
countUsers: number;
|
||||
countGuests: number;
|
||||
advanced?: boolean;
|
||||
compact?: boolean;
|
||||
horizontal?: boolean;
|
||||
inline?: boolean;
|
||||
}) => {
|
||||
const t = useI18n("widget.networkControllerStatus.card");
|
||||
if (!advanced) {
|
||||
const iconSize = horizontal ? 20 : 24;
|
||||
let titleMargin: 0 | "xs" | "md" = "md";
|
||||
if (compact) titleMargin = "xs";
|
||||
if (horizontal) titleMargin = 0;
|
||||
let statsSpacing: number | "xs" | "lg" = "lg";
|
||||
if (compact) statsSpacing = "xs";
|
||||
if (horizontal) statsSpacing = 4;
|
||||
return (
|
||||
<>
|
||||
<Group gap="xs" wrap="nowrap" mb={titleMargin}>
|
||||
<IconWifi style={zoomCompensatedSize(iconSize)} />
|
||||
<Text size="md" fw="bold" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{t("variants.wifi.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={statsSpacing}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} inline={inline} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} inline={inline} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
|
||||
<Group gap="xs" wrap="nowrap" justify="center">
|
||||
@@ -29,8 +57,8 @@ export const WifiVariant = ({
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} advanced />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} advanced />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -2,22 +2,50 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { StatRow } from "./stat-row";
|
||||
|
||||
export const WiredVariant = ({
|
||||
countGuests,
|
||||
countUsers,
|
||||
advanced = false,
|
||||
compact = false,
|
||||
horizontal = false,
|
||||
inline = false,
|
||||
}: {
|
||||
countUsers: number;
|
||||
countGuests: number;
|
||||
advanced?: boolean;
|
||||
compact?: boolean;
|
||||
horizontal?: boolean;
|
||||
inline?: boolean;
|
||||
}) => {
|
||||
const t = useI18n("widget.networkControllerStatus.card");
|
||||
if (!advanced) {
|
||||
const iconSize = horizontal ? 20 : 24;
|
||||
let titleMargin: 0 | "xs" | "md" = "md";
|
||||
if (compact) titleMargin = "xs";
|
||||
if (horizontal) titleMargin = 0;
|
||||
let statsSpacing: number | "xs" | "lg" = "lg";
|
||||
if (compact) statsSpacing = "xs";
|
||||
if (horizontal) statsSpacing = 4;
|
||||
return (
|
||||
<>
|
||||
<Group gap="xs" wrap="nowrap" mb={titleMargin}>
|
||||
<IconNetwork style={zoomCompensatedSize(iconSize)} />
|
||||
<Text size="md" fw="bold" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{t("variants.wired.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={statsSpacing}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} inline={inline} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} inline={inline} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
|
||||
<Group gap="xs" wrap="nowrap" justify="center">
|
||||
@@ -29,8 +57,8 @@ export const WiredVariant = ({
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} advanced />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} advanced />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { Badge, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Box, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
@@ -27,13 +27,16 @@ dayjs.extend(duration);
|
||||
export default function NetworkControllerSummaryWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"networkControllerSummary">) {
|
||||
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const results = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
let results = summaryQuery.data ?? [];
|
||||
if (isAdvanced) {
|
||||
results = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
}
|
||||
const summaries = results.filter(
|
||||
(result): result is typeof result & { summary: NonNullable<typeof result.summary>; updatedAt: Date } =>
|
||||
result.summary !== null && result.updatedAt !== undefined,
|
||||
@@ -51,17 +54,18 @@ export default function NetworkControllerSummaryWidget({
|
||||
unknown: tDns("status.unknown"),
|
||||
};
|
||||
|
||||
const isDense = height < 160;
|
||||
const showDetails = height >= 180 && width >= 240;
|
||||
const columns = width >= 720 ? 2 : 1;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const indicatorResults =
|
||||
results.length > 0 || !summaryQuery.error
|
||||
? results
|
||||
: integrationIds.map((integrationId) => ({ integrationId, error: "query" }));
|
||||
const queryIndicators = (
|
||||
<Group gap={0} justify="flex-end">
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={indicatorResults} />
|
||||
</Group>
|
||||
);
|
||||
const firstSummary = summaries[0];
|
||||
|
||||
if (isPending || summaries.length === 0) {
|
||||
if (isPending || !firstSummary) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
{queryIndicators}
|
||||
@@ -74,190 +78,152 @@ export default function NetworkControllerSummaryWidget({
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdvanced) {
|
||||
const controllerColumns = width >= 960 ? 2 : 1;
|
||||
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
|
||||
if (!isAdvanced) {
|
||||
const summary = firstSummary.summary;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p="sm" pos="relative">
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={controllerColumns} spacing="sm">
|
||||
{summaries.map(({ integration, summary, updatedAt }) => (
|
||||
<Card
|
||||
key={integration.id}
|
||||
p="sm"
|
||||
withBorder
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
<Box h="100%" p="sm" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<Center h="100%">
|
||||
<List spacing="xs" center>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.wanStatus}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
|
||||
style={iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{integration.kind}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("advanced.updated")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{new Date(updatedAt).toLocaleString(locale)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<SimpleGrid cols={matrixColumns} spacing="xs">
|
||||
{getNetworkControllerMatrix(summary).map((section) => (
|
||||
<Card
|
||||
key={section.key}
|
||||
p="xs"
|
||||
withBorder
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<StatusIcon
|
||||
status={section.status}
|
||||
label={statusLabels[getBinaryStatusKey(section.status)]}
|
||||
size="var(--mantine-font-size-md)"
|
||||
/>
|
||||
<Text size="sm" fw={600}>
|
||||
{getMatrixSectionLabel(section.key, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{statusLabels[getBinaryStatusKey(section.status)]}
|
||||
</Text>
|
||||
</Group>
|
||||
{section.metrics.length > 0 && (
|
||||
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
|
||||
{section.metrics.map((metric) => (
|
||||
<Stack key={metric.key} gap={0}>
|
||||
<Text size="10px" c="dimmed">
|
||||
{getMatrixMetricLabel(metric.key, t)}
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{formatMatrixMetric(metric.key, metric.value)}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
{t("card.wan")}
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.www.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
|
||||
style={iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
WWW
|
||||
<Text c="dimmed" size="md" ms="xs" span>
|
||||
{summary.www.latency}ms
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.wifi.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
|
||||
style={iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("card.wifi")}
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.vpn.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
|
||||
style={iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
{t("card.vpn.label")}
|
||||
<Text c="dimmed" size="md" ms="xs" span>
|
||||
{t("card.vpn.countConnected", { count: summary.vpn.users })}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const controllerColumns = width >= 960 ? 2 : 1;
|
||||
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
|
||||
|
||||
return (
|
||||
<Stack h="100%" p={isDense ? "xs" : "sm"} gap={0} style={{ overflow: "hidden" }}>
|
||||
<ScrollArea h="100%" p="sm" pos="relative">
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={columns} spacing="sm" style={{ flex: 1, minHeight: 0, alignContent: "center" }}>
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Card
|
||||
key={integration.id}
|
||||
p={isDense ? "xs" : "sm"}
|
||||
withBorder={summaries.length > 1}
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
>
|
||||
<Stack h="100%" gap={isDense ? 4 : 8} justify="center" align="center">
|
||||
{summaries.length > 1 && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<SimpleGrid cols={controllerColumns} spacing="sm">
|
||||
{summaries.map(({ integration, summary, updatedAt }) => (
|
||||
<Card key={integration.id} p="sm" withBorder bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
<Text size="xs" c="dimmed">
|
||||
{integration.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
<List spacing={isDense ? 2 : 4} center size={isDense ? "xs" : "sm"} w="fit-content" mx="auto">
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.wanStatus}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("card.wan")}
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.www.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
{t("card.web")}
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
{summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.wifi.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("card.wifi")}
|
||||
{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
|
||||
</List.Item>
|
||||
{showDetails && (
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.lan.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
</Stack>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("advanced.updated")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{new Date(updatedAt).toLocaleString(locale)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<SimpleGrid cols={matrixColumns} spacing="xs">
|
||||
{getNetworkControllerMatrix(summary).map((section) => (
|
||||
<Card
|
||||
key={section.key}
|
||||
p="xs"
|
||||
withBorder
|
||||
bg="transparent"
|
||||
style={{ borderColor: neutralSurfaceBorder }}
|
||||
>
|
||||
{t("card.lan")} · {summary.lan.users + summary.lan.guests}
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.vpn.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
{t("card.vpn.label")}
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
{t("card.vpn.countConnected", { count: summary.vpn.users })}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<StatusIcon
|
||||
status={section.status}
|
||||
label={statusLabels[getBinaryStatusKey(section.status)]}
|
||||
style={iconSizes.md}
|
||||
/>
|
||||
<Text size="sm" fw={600}>
|
||||
{getMatrixSectionLabel(section.key, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{statusLabels[getBinaryStatusKey(section.status)]}
|
||||
</Text>
|
||||
</Group>
|
||||
{section.metrics.length > 0 && (
|
||||
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
|
||||
{section.metrics.map((metric) => (
|
||||
<Stack key={metric.key} gap={0}>
|
||||
<Text size="10px" c="dimmed">
|
||||
{getMatrixMetricLabel(metric.key, t)}
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{formatMatrixMetric(metric.key, metric.value)}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,26 +246,23 @@ const getMatrixSectionLabel = (
|
||||
return t(`card.${key}` as never);
|
||||
};
|
||||
|
||||
const neutralSurfaceBorder =
|
||||
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
const neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
const StatusIcon = ({
|
||||
status,
|
||||
label,
|
||||
size,
|
||||
style,
|
||||
}: {
|
||||
status?: "enabled" | "disabled";
|
||||
label: string;
|
||||
size?: number | string;
|
||||
style?: CSSProperties;
|
||||
}) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
return <IconCircleCheckFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.green[6]} />;
|
||||
return <IconCircleCheckFilled aria-label={label} style={style} color={mantineTheme.colors.green[6]} />;
|
||||
}
|
||||
if (status === "disabled") {
|
||||
return <IconCircleXFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.red[6]} />;
|
||||
return <IconCircleXFilled aria-label={label} style={style} color={mantineTheme.colors.red[6]} />;
|
||||
}
|
||||
return <IconCircleXFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.gray[6]} />;
|
||||
return <IconCircleXFilled aria-label={label} style={style} color={mantineTheme.colors.gray[6]} />;
|
||||
};
|
||||
@@ -52,21 +52,22 @@ export default function NotificationsWidget({
|
||||
const failedIntegrations = notificationIntegrations.filter(
|
||||
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
|
||||
);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = width < 280 || height < 180;
|
||||
const isRoomy = width >= 360 && height >= 220;
|
||||
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
|
||||
const notificationDisplay = getNotificationDisplay({
|
||||
displayMode,
|
||||
hideLogos: options.hideLogos,
|
||||
isRoomy,
|
||||
bodyLineClamp,
|
||||
isRoomy: isAdvanced && isRoomy,
|
||||
bodyLineClamp: isAdvanced ? bodyLineClamp : 4,
|
||||
});
|
||||
const columns = width >= 720 ? 2 : 1;
|
||||
const spacing = isRoomy ? "sm" : "xs";
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
const spacing = !isAdvanced || isRoomy ? "sm" : "xs";
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
|
||||
<Stack w="100%" gap="xs">
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "xs" : "sm"}>
|
||||
<Stack w="100%" gap={isAdvanced ? "xs" : "sm"}>
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{failedIntegrations.map((integration) => (
|
||||
@@ -93,34 +94,39 @@ export default function NotificationsWidget({
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
className={columns > 1 ? classes.card : classes.row}
|
||||
className={isAdvanced ? (columns > 1 ? classes.card : classes.row) : undefined}
|
||||
radius={board.itemRadius}
|
||||
w="100%"
|
||||
p={isRoomy ? "sm" : isDense ? 6 : "xs"}
|
||||
p={!isAdvanced || isRoomy ? "sm" : isDense ? 6 : "xs"}
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
|
||||
<Flex gap={!isAdvanced || !isDense ? "sm" : "xs"} align="flex-start" w="100%">
|
||||
{notificationDisplay.showLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
size={isDense ? "xs" : "sm"}
|
||||
size={isAdvanced && isDense ? "xs" : "sm"}
|
||||
radius={board.itemRadius}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex gap={isRoomy ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
|
||||
<Flex gap={!isAdvanced || isRoomy ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
|
||||
{notification.title && (
|
||||
<Text fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={isDense ? 1 : 2}>
|
||||
<Text
|
||||
fz={isAdvanced && isRoomy ? "md" : "sm"}
|
||||
fw={isAdvanced ? 600 : undefined}
|
||||
lh={isAdvanced ? 1.25 : "sm"}
|
||||
lineClamp={isAdvanced && isDense ? 1 : 2}
|
||||
>
|
||||
{notification.title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
c="dimmed"
|
||||
size={isDense ? "xs" : "sm"}
|
||||
size={isAdvanced && isDense ? "xs" : "sm"}
|
||||
lineClamp={notificationDisplay.bodyLineClamp}
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
>
|
||||
@@ -134,7 +140,7 @@ export default function NotificationsWidget({
|
||||
? (notification.source?.name ?? notification.integrationName)
|
||||
: undefined
|
||||
}
|
||||
dense={isDense}
|
||||
dense={isAdvanced && isDense}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -159,11 +165,7 @@ const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; den
|
||||
|
||||
return (
|
||||
<Group gap={5} align="center" wrap="nowrap">
|
||||
<IconClock
|
||||
aria-hidden
|
||||
style={dense ? iconSizes.xs : iconSizes.md}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
<IconClock aria-hidden style={dense ? iconSizes.xs : iconSizes.md} color="var(--mantine-color-dimmed)" />
|
||||
<Text size={dense ? "xs" : "sm"} c="dimmed">
|
||||
{timeAgo}
|
||||
</Text>
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: clamp(6px, 3cqi, 14px);
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
padding: clamp(6px, 3cqi, 12px);
|
||||
gap: clamp(4px, 2cqi, 8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rootShort {
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -16,8 +21,7 @@
|
||||
gap: clamp(8px, 3cqi, 18px);
|
||||
flex-shrink: 0;
|
||||
padding-bottom: clamp(4px, 2cqi, 10px);
|
||||
border-bottom: 1px solid
|
||||
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.heroExpanded {
|
||||
@@ -81,13 +85,18 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
gap: clamp(4px, 2cqi, 8px);
|
||||
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.gridShort {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statTile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -95,22 +104,37 @@
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
gap: clamp(2px, 1.5cqi, 4px);
|
||||
padding: clamp(4px, 2cqi, 8px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 1px solid
|
||||
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
background-color: transparent;
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statTileShort {
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.statTileNarrowShort {
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: clamp(8px, 2.5cqi, 12px);
|
||||
flex-shrink: 0;
|
||||
font-size: clamp(
|
||||
calc(8px * var(--board-canvas-ui-scale, 1)),
|
||||
calc(2.5cqi * var(--board-canvas-ui-scale, 1)),
|
||||
calc(11px * var(--board-canvas-ui-scale, 1))
|
||||
);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
@@ -120,12 +144,38 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statLabelShort {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: clamp(14px, 5.5cqi, 24px);
|
||||
flex-shrink: 0;
|
||||
font-size: clamp(
|
||||
calc(13px * var(--board-canvas-ui-scale, 1)),
|
||||
calc(5cqi * var(--board-canvas-ui-scale, 1)),
|
||||
calc(20px * var(--board-canvas-ui-scale, 1))
|
||||
);
|
||||
font-weight: 700;
|
||||
color: var(--mantine-color-text);
|
||||
color: var(--mantine-primary-color-filled);
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statValueNarrowShort {
|
||||
font-size: calc(11px * var(--board-canvas-ui-scale, 1));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { getCompactStatLayout } from "../common/compact-stat-layout";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
@@ -32,11 +33,41 @@ const statIcons = {
|
||||
const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]);
|
||||
|
||||
const gridColsByWidth = [
|
||||
{ minWidth: 380, cols: 3 },
|
||||
{ minWidth: 300, cols: 3 },
|
||||
{ minWidth: 220, cols: 2 },
|
||||
{ minWidth: 0, cols: 1 },
|
||||
] as const;
|
||||
|
||||
const rootClassByLayout = {
|
||||
default: "",
|
||||
short: classes.rootShort,
|
||||
narrowShort: classes.rootShort,
|
||||
} as const;
|
||||
|
||||
const gridClassByLayout = {
|
||||
default: "",
|
||||
short: classes.gridShort,
|
||||
narrowShort: classes.gridShort,
|
||||
} as const;
|
||||
|
||||
const statTileClassByLayout = {
|
||||
default: "",
|
||||
short: classes.statTileShort,
|
||||
narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`,
|
||||
} as const;
|
||||
|
||||
const statLabelClassByLayout = {
|
||||
default: "",
|
||||
short: classes.statLabelShort,
|
||||
narrowShort: classes.statLabelShort,
|
||||
} as const;
|
||||
|
||||
const statValueClassByLayout = {
|
||||
default: "",
|
||||
short: "",
|
||||
narrowShort: classes.statValueNarrowShort,
|
||||
} as const;
|
||||
|
||||
const ringSizeByWidth = [
|
||||
{ minWidth: 400, size: 88 },
|
||||
{ minWidth: 320, size: 72 },
|
||||
@@ -76,6 +107,8 @@ export default function PaperlessNgxWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayScale = 1,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"paperlessNgx">) {
|
||||
const t = useI18n("widget.paperlessNgx");
|
||||
const tCommon = useI18n("common");
|
||||
@@ -116,19 +149,31 @@ export default function PaperlessNgxWidget({
|
||||
.map(([, statKey]) => statKey)
|
||||
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
|
||||
|
||||
const gridCols = Math.max(1, Math.min(visibleStatKeys.length, getGridCols(width, height)));
|
||||
const ringSize = getRingSize(Math.min(width, height * 2));
|
||||
const iconSize = getIconSize(width);
|
||||
let responsiveWidth = width;
|
||||
let responsiveHeight = height;
|
||||
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
|
||||
responsiveWidth *= displayScale;
|
||||
responsiveHeight *= displayScale;
|
||||
}
|
||||
const advanced = displayMode === "advanced";
|
||||
const layout = getCompactStatLayout({
|
||||
width: responsiveWidth,
|
||||
height: responsiveHeight,
|
||||
visibleCount: visibleStatKeys.length,
|
||||
compactDisplay: !advanced,
|
||||
defaultColumns: getGridCols(responsiveWidth),
|
||||
defaultIconSize: getIconSize(responsiveWidth),
|
||||
});
|
||||
const ringSize = getRingSize(responsiveWidth);
|
||||
const ringLabelSize = getRingLabelSize(ringSize);
|
||||
const hasContent = showHero || visibleStatKeys.length > 0;
|
||||
|
||||
const heroLayoutClass =
|
||||
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
|
||||
const showInboxRing = options.showInboxRing && height >= 120;
|
||||
const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
|
||||
const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing];
|
||||
|
||||
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
|
||||
([partKey, optionKey]) => options[optionKey as keyof typeof options] && (partKey !== "ring" || showInboxRing),
|
||||
([, optionKey]) => options[optionKey as keyof typeof options],
|
||||
);
|
||||
|
||||
const heroPartRenderers = {
|
||||
@@ -162,7 +207,7 @@ export default function PaperlessNgxWidget({
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={`${classes.root} ${rootClassByLayout[layout.state]}`}>
|
||||
{showHero && (
|
||||
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
|
||||
{visibleHeroParts.map(([partKey]) => (
|
||||
@@ -180,14 +225,19 @@ export default function PaperlessNgxWidget({
|
||||
)}
|
||||
|
||||
{visibleStatKeys.length > 0 && (
|
||||
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
|
||||
<div
|
||||
className={`${classes.grid} ${gridClassByLayout[layout.state]}`}
|
||||
style={{ "--stat-cols": layout.columns } as CSSProperties}
|
||||
>
|
||||
{visibleStatKeys.map((statKey) => {
|
||||
const Icon = statIcons[statKey];
|
||||
return (
|
||||
<div key={statKey} className={classes.statTile}>
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
|
||||
<span className={classes.statValue}>{statValues[statKey]}</span>
|
||||
<span className={classes.statLabel}>{t(statKey)}</span>
|
||||
<div key={statKey} className={`${classes.statTile} ${statTileClassByLayout[layout.state]}`}>
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
|
||||
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>
|
||||
{statValues[statKey]}
|
||||
</span>
|
||||
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>{t(statKey)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -205,9 +255,9 @@ function computeInboxProgress(total: number, inbox: number): number {
|
||||
return Math.round((inbox / total) * 100);
|
||||
}
|
||||
|
||||
function getGridCols(width: number, height: number): number {
|
||||
function getGridCols(width: number): number {
|
||||
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return Math.min(match?.cols ?? 1, height < 140 ? 2 : 5);
|
||||
return match?.cols ?? 1;
|
||||
}
|
||||
|
||||
function getRingSize(width: number): number {
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
|
||||
defaultValue: {},
|
||||
});
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const columns = Math.max(1, Math.floor(width / 420));
|
||||
const columns = isAdvanced ? Math.max(1, Math.floor(width / 420)) : 1;
|
||||
|
||||
const relativeDateOptions = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -24,6 +24,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backgroundImage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(5px);
|
||||
transform: scaleX(-1.05) scaleZ(-1.05);
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.entry {
|
||||
transition: none;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Text } from "@mantine/core";
|
||||
import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconClock, IconServerOff } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
@@ -104,9 +104,10 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
|
||||
descriptionLines,
|
||||
});
|
||||
const spacing = isRoomy ? "sm" : "xs";
|
||||
const isNarrow = width < 128 * 3;
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isTiny ? 4 : "xs"}>
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? (isTiny ? 4 : "xs") : "sm"}>
|
||||
{warning && (
|
||||
<Alert
|
||||
role="presentation"
|
||||
@@ -118,67 +119,109 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
|
||||
<output>{warning}</output>
|
||||
</Alert>
|
||||
)}
|
||||
<SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}>
|
||||
{feed.entries.map((feedEntry) => {
|
||||
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
|
||||
return (
|
||||
<Card
|
||||
key={feedEntry.id}
|
||||
className={classes.entry}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
radius={board.itemRadius}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
w="100%"
|
||||
p={isDense ? 6 : "xs"}
|
||||
title={feedEntry.title}
|
||||
>
|
||||
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
|
||||
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
|
||||
<Image
|
||||
loading="lazy"
|
||||
className={classes.poster}
|
||||
src={feedEntry.enclosure}
|
||||
alt=""
|
||||
w={isRoomy ? 140 : isDense ? 64 : 96}
|
||||
h={isRoomy ? 96 : isDense ? 64 : 96}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
|
||||
<Text
|
||||
dir={languageDir}
|
||||
fz={isRoomy ? "md" : "sm"}
|
||||
fw={600}
|
||||
lh={1.25}
|
||||
lineClamp={isAdvanced ? undefined : 2}
|
||||
>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{entryDisplay.showDescription && feedEntry.description && (
|
||||
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={entryDisplay.descriptionLineClamp}>
|
||||
{feedDescriptionToText(feedEntry.description)}
|
||||
</Text>
|
||||
{isAdvanced ? (
|
||||
<SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}>
|
||||
{feed.entries.map((feedEntry) => {
|
||||
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
|
||||
return (
|
||||
<Card
|
||||
key={feedEntry.id}
|
||||
className={classes.entry}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
radius={board.itemRadius}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
w="100%"
|
||||
p={isDense ? 6 : "xs"}
|
||||
title={feedEntry.title}
|
||||
>
|
||||
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
|
||||
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
|
||||
<Image
|
||||
loading="lazy"
|
||||
className={classes.poster}
|
||||
src={feedEntry.enclosure}
|
||||
alt=""
|
||||
w={isRoomy ? 140 : isDense ? 64 : 96}
|
||||
h={isRoomy ? 96 : isDense ? 64 : 96}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<InfoDisplay
|
||||
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
|
||||
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
|
||||
timestamp={
|
||||
isAdvanced && feedEntry.published
|
||||
? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
|
||||
<Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25}>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{entryDisplay.showDescription && feedEntry.description && (
|
||||
<Text dir={languageDir} c="dimmed" size="sm">
|
||||
{feedDescriptionToText(feedEntry.description)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<InfoDisplay
|
||||
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
|
||||
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
|
||||
timestamp={
|
||||
feedEntry.published ? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z") : undefined
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack w="100%" gap="sm">
|
||||
{feed.entries.map((feedEntry) => {
|
||||
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
|
||||
return (
|
||||
<Card
|
||||
key={feedEntry.id}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
radius={board.itemRadius}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
w="100%"
|
||||
p="sm"
|
||||
title={feedEntry.title}
|
||||
>
|
||||
{feedEntry.enclosure !== undefined && (
|
||||
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="" />
|
||||
)}
|
||||
<Group wrap="nowrap">
|
||||
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
|
||||
<Image
|
||||
loading="lazy"
|
||||
className={classes.poster}
|
||||
src={feedEntry.enclosure}
|
||||
alt={feedEntry.title}
|
||||
w={140}
|
||||
h={140}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
)}
|
||||
<Flex gap="sm" direction="column" w="100%" miw={0}>
|
||||
<Text dir={languageDir} fz="sm" lh="sm" lineClamp={2}>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{!options.hideDescription && feedEntry.description && (
|
||||
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={options.textLinesClamp}>
|
||||
{feedDescriptionToText(feedEntry.description)}
|
||||
</Text>
|
||||
)}
|
||||
{feedEntry.published && <InfoDisplay date={dayjs(feedEntry.published).fromNow()} />}
|
||||
</Flex>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,14 +98,15 @@ export default function SmartHomeEntityStateWidget({
|
||||
|
||||
if (entityError && entity === undefined) throw entityError;
|
||||
|
||||
const isTiny = isSmartHomeTiny(width, height);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isTiny = isAdvanced ? isSmartHomeTiny(width, height) : width < 128;
|
||||
const advancedAttributes = [
|
||||
{ key: "unit" as const, value: entity?.attributes.unit_of_measurement },
|
||||
{ key: "deviceClass" as const, value: entity?.attributes.device_class },
|
||||
{ key: "icon" as const, value: entity?.attributes.icon },
|
||||
].filter(({ value }) => typeof value === "string" && value.length > 0);
|
||||
const displayName =
|
||||
displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
|
||||
isAdvanced && typeof entity?.attributes.friendly_name === "string"
|
||||
? entity.attributes.friendly_name
|
||||
: options.displayName;
|
||||
const knownStates = {
|
||||
@@ -133,20 +134,34 @@ export default function SmartHomeEntityStateWidget({
|
||||
}}
|
||||
>
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap={isTiny ? 4 : "md"} p="xs" maw="100%">
|
||||
<Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%">
|
||||
{state}
|
||||
{attribute}
|
||||
</Text>
|
||||
<Text ta="center" c="dimmed" fw={500} size={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
|
||||
{displayName}
|
||||
</Text>
|
||||
<Stack align="center" gap={isAdvanced && isTiny ? 4 : "md"} p={isAdvanced ? "xs" : undefined} maw="100%">
|
||||
{isAdvanced ? (
|
||||
<>
|
||||
<Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%">
|
||||
{state}
|
||||
{attribute}
|
||||
</Text>
|
||||
<Text ta="center" c="dimmed" fw={500} size={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
|
||||
{displayName}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text ta="center" fw="bold" size={isTiny ? "sm" : "lg"} lineClamp={2} maw="100%">
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text ta="center" size={isTiny ? "xs" : "lg"} lineClamp={1} maw="100%">
|
||||
{state}
|
||||
{attribute}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{queryErrorLabel && (
|
||||
<Text size="xs" c="orange" ta="center" lineClamp={1} maw="100%" aria-live="polite">
|
||||
{queryErrorLabel}
|
||||
</Text>
|
||||
)}
|
||||
{displayMode === "advanced" && entity && (
|
||||
{isAdvanced && entity && (
|
||||
<>
|
||||
<Group justify="center" gap={4}>
|
||||
{advancedAttributes.map(({ key, value }) => (
|
||||
|
||||
@@ -10,12 +10,20 @@ import { formatStatsSpeed } from "./helpers";
|
||||
import { SectionLabel } from "./section-label";
|
||||
import { SpeedStatCard } from "./speed-stat-card";
|
||||
|
||||
export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTrackerStats; width?: number }) {
|
||||
export function AveragesSection({
|
||||
stats,
|
||||
width = 500,
|
||||
compactSurface = false,
|
||||
}: {
|
||||
stats: SpeedtestTrackerStats;
|
||||
width?: number;
|
||||
compactSurface?: boolean;
|
||||
}) {
|
||||
const t = useI18n("widget.speedtestTracker");
|
||||
|
||||
return (
|
||||
<Stack gap={6} h="100%">
|
||||
<SectionLabel>
|
||||
<SectionLabel uppercase={compactSurface}>
|
||||
{t("averages")} · {t("tests", { count: stats.total_results.toString() })}
|
||||
</SectionLabel>
|
||||
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
|
||||
@@ -25,6 +33,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
|
||||
value={formatStatsSpeed(stats.download)}
|
||||
label={t("avgDownload")}
|
||||
compact
|
||||
legacySurface={compactSurface}
|
||||
/>
|
||||
<SpeedStatCard
|
||||
icon={IconArrowUp}
|
||||
@@ -32,6 +41,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
|
||||
value={formatStatsSpeed(stats.upload)}
|
||||
label={t("avgUpload")}
|
||||
compact
|
||||
legacySurface={compactSurface}
|
||||
/>
|
||||
<SpeedStatCard
|
||||
icon={IconWaveSine}
|
||||
@@ -39,6 +49,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke
|
||||
value={`${stats.ping.avg.toFixed(1)} ms`}
|
||||
label={t("avgPing")}
|
||||
compact
|
||||
legacySurface={compactSurface}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { IntegrationErrorIndicator } from "../common/integration-error-indicator
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { AveragesSection } from "./averages";
|
||||
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
|
||||
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards } from "./helpers";
|
||||
import { LatestResultSection } from "./latest-result";
|
||||
import { RecentResultsSection } from "./recent-results";
|
||||
|
||||
@@ -51,16 +51,19 @@ export default function SpeedtestTrackerWidget({
|
||||
const advancedSectionWidth = width >= 720 ? width / 2 : width;
|
||||
|
||||
const latest = showLatestResult && combined.latestResult && (
|
||||
<LatestResultSection result={combined.latestResult} width={isAdvanced ? advancedSectionWidth : width} />
|
||||
<LatestResultSection
|
||||
result={combined.latestResult}
|
||||
width={isAdvanced ? advancedSectionWidth : width}
|
||||
compactSurface={!isAdvanced}
|
||||
/>
|
||||
);
|
||||
const averages = showStats && combined.stats && (
|
||||
<AveragesSection stats={combined.stats} width={isAdvanced ? advancedSectionWidth : width} />
|
||||
<AveragesSection
|
||||
stats={combined.stats}
|
||||
width={isAdvanced ? advancedSectionWidth : width}
|
||||
compactSurface={!isAdvanced}
|
||||
/>
|
||||
);
|
||||
const compactSections = getCompactSections(height, {
|
||||
latest: Boolean(latest),
|
||||
chart: hasChart,
|
||||
averages: Boolean(averages),
|
||||
});
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
|
||||
@@ -136,15 +139,15 @@ export default function SpeedtestTrackerWidget({
|
||||
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
{(compactSections.latest || compactSections.averages) && (
|
||||
{(latest || averages) && (
|
||||
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
|
||||
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
|
||||
{latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
|
||||
{averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
|
||||
</Stack>
|
||||
)}
|
||||
{compactSections.chart && (
|
||||
{hasChart && (
|
||||
<div style={{ flex: 2, minHeight: 0 }}>
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} />
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} uppercaseLabels />
|
||||
</div>
|
||||
)}
|
||||
{noSectionsEnabled && (
|
||||
|
||||
@@ -10,7 +10,15 @@ import { formatResultSpeed } from "./helpers";
|
||||
import { SectionLabel } from "./section-label";
|
||||
import { SpeedStatCard } from "./speed-stat-card";
|
||||
|
||||
export function LatestResultSection({ result, width = 500 }: { result: SpeedtestTrackerResult; width?: number }) {
|
||||
export function LatestResultSection({
|
||||
result,
|
||||
width = 500,
|
||||
compactSurface = false,
|
||||
}: {
|
||||
result: SpeedtestTrackerResult;
|
||||
width?: number;
|
||||
compactSurface?: boolean;
|
||||
}) {
|
||||
const t = useI18n("widget.speedtestTracker");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const timestamp = Intl.DateTimeFormat(locale, {
|
||||
@@ -25,7 +33,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
|
||||
return (
|
||||
<Stack gap={6} h="100%">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<SectionLabel>{t("latestResult")}</SectionLabel>
|
||||
<SectionLabel uppercase={compactSurface}>{t("latestResult")}</SectionLabel>
|
||||
<Text size="xs" c="dimmed" truncate title={timestamp} style={{ minWidth: 0 }}>
|
||||
{timestamp}
|
||||
</Text>
|
||||
@@ -36,18 +44,21 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
|
||||
color="blue"
|
||||
value={formatResultSpeed(result, "download")}
|
||||
label={t("download")}
|
||||
legacySurface={compactSurface}
|
||||
/>
|
||||
<SpeedStatCard
|
||||
icon={IconArrowUp}
|
||||
color="teal"
|
||||
value={formatResultSpeed(result, "upload")}
|
||||
label={t("upload")}
|
||||
legacySurface={compactSurface}
|
||||
/>
|
||||
<SpeedStatCard
|
||||
icon={IconWaveSine}
|
||||
color="orange"
|
||||
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
|
||||
label={t("ping")}
|
||||
legacySurface={compactSurface}
|
||||
/>
|
||||
{result.healthy !== null && (
|
||||
<SpeedStatCard
|
||||
@@ -55,6 +66,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
|
||||
color={result.healthy ? "green" : "red"}
|
||||
value={result.healthy ? t("healthy") : t("unhealthy")}
|
||||
label={t("status")}
|
||||
legacySurface={compactSurface}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -114,30 +114,40 @@ function formatTooltipDate(label: number | string | undefined, locale: string):
|
||||
export function RecentResultsSection({
|
||||
results,
|
||||
showPingGraph,
|
||||
uppercaseLabels = false,
|
||||
}: {
|
||||
results: SpeedtestTrackerResult[];
|
||||
showPingGraph: boolean;
|
||||
uppercaseLabels?: boolean;
|
||||
}) {
|
||||
const t = useI18n("widget.speedtestTracker");
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%" style={{ minHeight: 0 }}>
|
||||
<SectionLabel>{t("recentResults")}</SectionLabel>
|
||||
<SectionLabel uppercase={uppercaseLabels}>{t("recentResults")}</SectionLabel>
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<SpeedSubChart results={results} showLabel={showPingGraph} />
|
||||
{showPingGraph && <PingSubChart results={results} />}
|
||||
<SpeedSubChart results={results} showLabel={showPingGraph} uppercaseLabels={uppercaseLabels} />
|
||||
{showPingGraph && <PingSubChart results={results} uppercaseLabels={uppercaseLabels} />}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult[]; showLabel: boolean }) {
|
||||
function SpeedSubChart({
|
||||
results,
|
||||
showLabel,
|
||||
uppercaseLabels,
|
||||
}: {
|
||||
results: SpeedtestTrackerResult[];
|
||||
showLabel: boolean;
|
||||
uppercaseLabels: boolean;
|
||||
}) {
|
||||
const t = useI18n("widget.speedtestTracker");
|
||||
const { ref, height } = useElementSize<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div style={{ flex: 2, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{showLabel && <SectionLabel>{t("recentResultsSpeed")}</SectionLabel>}
|
||||
{showLabel && <SectionLabel uppercase={uppercaseLabels}>{t("recentResultsSpeed")}</SectionLabel>}
|
||||
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
|
||||
{height > 0 && <SpeedHistoryChart results={results} height={height} />}
|
||||
</div>
|
||||
@@ -145,13 +155,13 @@ function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult
|
||||
);
|
||||
}
|
||||
|
||||
function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) {
|
||||
function PingSubChart({ results, uppercaseLabels }: { results: SpeedtestTrackerResult[]; uppercaseLabels: boolean }) {
|
||||
const t = useI18n("widget.speedtestTracker");
|
||||
const { ref, height } = useElementSize<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<SectionLabel>{t("recentResultsPing")}</SectionLabel>
|
||||
<SectionLabel uppercase={uppercaseLabels}>{t("recentResultsPing")}</SectionLabel>
|
||||
<div ref={ref} style={{ flex: 1, minHeight: 0 }}>
|
||||
{height > 0 && <PingHistoryChart results={results} height={height} />}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
export function SectionLabel({ children }: { children: ReactNode }) {
|
||||
export function SectionLabel({ children, uppercase = false }: { children: ReactNode; uppercase?: boolean }) {
|
||||
return (
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="dimmed"
|
||||
tt={uppercase ? "uppercase" : undefined}
|
||||
style={{ letterSpacing: uppercase ? "0.05em" : undefined }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -13,9 +13,17 @@ export interface SpeedStatCardProps {
|
||||
value: string;
|
||||
label: string;
|
||||
compact?: boolean;
|
||||
legacySurface?: boolean;
|
||||
}
|
||||
|
||||
export function SpeedStatCard({ icon: Icon, color, value, label, compact = false }: SpeedStatCardProps) {
|
||||
export function SpeedStatCard({
|
||||
icon: Icon,
|
||||
color,
|
||||
value,
|
||||
label,
|
||||
compact = false,
|
||||
legacySurface = false,
|
||||
}: SpeedStatCardProps) {
|
||||
const { ref, height, width } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
const isWide = width > height + 20;
|
||||
@@ -23,7 +31,8 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
|
||||
const surfaceColor = `var(--mantine-color-${color}-filled)`;
|
||||
const accentColor = `var(--mantine-color-${color}-5)`;
|
||||
const surfaceAlpha = compact ? 0.1 : 0.14;
|
||||
const surfaceBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`;
|
||||
const modernBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`;
|
||||
const surfaceBackground = legacySurface ? `var(--mantine-color-${color}-light)` : modernBackground;
|
||||
const surfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
|
||||
return (
|
||||
@@ -32,7 +41,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
|
||||
ref={ref}
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
withBorder={!legacySurface}
|
||||
bg={surfaceBackground}
|
||||
h="100%"
|
||||
style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip } from "@mantine/core";
|
||||
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useComputedColorScheme } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -28,12 +28,12 @@ const advancedColumnBreakpoints = [
|
||||
{ minimumWidth: 0, columns: 1 },
|
||||
] as const;
|
||||
|
||||
const getSystemDisksLayout = (width: number, height: number, diskCount: number, isAdvanced: boolean) => {
|
||||
const getSystemDisksLayout = (width: number, diskCount: number, isAdvanced: boolean) => {
|
||||
if (!isAdvanced) {
|
||||
return {
|
||||
columns: 1,
|
||||
showSecondaryText: height >= 160 && width >= 260,
|
||||
showTemperature: height >= 100 && width >= 220,
|
||||
showSecondaryText: false,
|
||||
showTemperature: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ const SystemDiskCard = ({
|
||||
showTemperature,
|
||||
}: SystemDiskCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
const t = useI18n("widget.systemDisks");
|
||||
const valueRef = useRef<HTMLParagraphElement>(null);
|
||||
const [valueFits, setValueFits] = useState(true);
|
||||
@@ -149,6 +150,10 @@ const SystemDiskCard = ({
|
||||
.join(" · ");
|
||||
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
const legacyBackground = colorScheme === "dark" ? "dark.7" : "gray.1";
|
||||
const cardBackground = isAdvanced ? backgroundColor : legacyBackground;
|
||||
const progressBackground = healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)";
|
||||
const legacyProgressColor = healthy ? "green" : "red";
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -160,13 +165,13 @@ const SystemDiskCard = ({
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
py="xs"
|
||||
withBorder
|
||||
bg={backgroundColor}
|
||||
style={{ overflow: "hidden", position: "relative", borderColor }}
|
||||
withBorder={isAdvanced}
|
||||
bg={cardBackground}
|
||||
style={{ overflow: "hidden", position: "relative", borderColor: isAdvanced ? borderColor : undefined }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={700} size="sm" truncate="end">
|
||||
<Text fw={700} size={isAdvanced ? "sm" : undefined} truncate="end">
|
||||
{deviceName}
|
||||
</Text>
|
||||
{integrationName && (
|
||||
@@ -174,7 +179,11 @@ const SystemDiskCard = ({
|
||||
{integrationName}
|
||||
</Text>
|
||||
)}
|
||||
<Text ref={valueRef} size="sm" style={{ visibility: valueFits ? "visible" : "hidden" }}>
|
||||
<Text
|
||||
ref={valueRef}
|
||||
size={isAdvanced ? "sm" : undefined}
|
||||
style={{ visibility: valueFits ? "visible" : "hidden" }}
|
||||
>
|
||||
<span>{displayText}</span>
|
||||
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
|
||||
</Text>
|
||||
@@ -195,10 +204,13 @@ const SystemDiskCard = ({
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
{showTemperature && (hasTemperature || isAdvanced) ? <Text size="sm">{temperatureText}</Text> : null}
|
||||
{showTemperature && (hasTemperature || isAdvanced) ? (
|
||||
<Text size={isAdvanced ? "sm" : undefined}>{temperatureText}</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
<Box
|
||||
bg={isAdvanced ? undefined : legacyProgressColor}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -207,7 +219,7 @@ const SystemDiskCard = ({
|
||||
height: "100%",
|
||||
zIndex: 0,
|
||||
display: showBackgroundBar ? "block" : "none",
|
||||
backgroundColor: healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)",
|
||||
backgroundColor: isAdvanced ? progressBackground : undefined,
|
||||
}}
|
||||
></Box>
|
||||
</Card>
|
||||
@@ -219,7 +231,6 @@ export default function SystemResources({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"systemDisks">) {
|
||||
const t = useI18n("widget.systemDisks");
|
||||
@@ -247,8 +258,8 @@ export default function SystemResources({
|
||||
});
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const layout = useMemo(
|
||||
() => getSystemDisksLayout(width, height, disks.length, isAdvanced),
|
||||
[disks.length, height, isAdvanced, width],
|
||||
() => getSystemDisksLayout(width, disks.length, isAdvanced),
|
||||
[disks.length, isAdvanced, width],
|
||||
);
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
|
||||
@@ -11,6 +11,7 @@ export const CombinedNetworkTrafficChart = ({
|
||||
usageOverTime,
|
||||
hasShadow,
|
||||
labelDisplayMode,
|
||||
advanced = false,
|
||||
}: {
|
||||
usageOverTime: {
|
||||
up: number;
|
||||
@@ -18,6 +19,7 @@ export const CombinedNetworkTrafficChart = ({
|
||||
}[];
|
||||
hasShadow: boolean;
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
|
||||
const t = useI18n("widget.systemResources.card");
|
||||
@@ -35,6 +37,7 @@ export const CombinedNetworkTrafficChart = ({
|
||||
yAxisProps={{ domain: [0, "dataMax"] }}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { ReactNode } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type { AreaChartSeries } from "@mantine/charts";
|
||||
import { AreaChart, LineChart } from "@mantine/charts";
|
||||
import { Card, Center, Group, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
|
||||
import type { TooltipProps, YAxisProps } from "recharts";
|
||||
|
||||
@@ -23,6 +23,7 @@ export const CommonChart = ({
|
||||
yAxisProps,
|
||||
lastValue,
|
||||
chartType = "line",
|
||||
advanced = false,
|
||||
}: {
|
||||
data: Record<string, any>[];
|
||||
dataKey: string;
|
||||
@@ -34,30 +35,32 @@ export const CommonChart = ({
|
||||
yAxisProps?: Omit<YAxisProps, "ref">;
|
||||
lastValue?: string;
|
||||
chartType?: "line" | "area";
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const { ref: elementSizeRef, height } = useElementSize();
|
||||
const theme = useMantineTheme();
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
const board = useRequiredBoard();
|
||||
const { hovered, ref: hoverRef } = useHover();
|
||||
const ref = useMergedRef(elementSizeRef, hoverRef);
|
||||
|
||||
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
const opacity = board.opacity / 100;
|
||||
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
|
||||
const cardStyle: CSSProperties = { overflow: "hidden" };
|
||||
const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] };
|
||||
if (advanced) {
|
||||
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
cardStyle.border =
|
||||
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
chartRootStyle.backgroundColor = backgroundColor;
|
||||
}
|
||||
|
||||
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
|
||||
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
|
||||
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
h={"100%"}
|
||||
pos={"relative"}
|
||||
style={{ overflow: "hidden", border: `1px solid ${borderColor}` }}
|
||||
p={0}
|
||||
bg={backgroundColor}
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Card ref={ref} h={"100%"} pos={"relative"} style={cardStyle} p={0} bg={backgroundColor} radius={board.itemRadius}>
|
||||
{data.length > 1 && height > 40 && !hovered && (
|
||||
<Group
|
||||
pos={"absolute"}
|
||||
@@ -70,7 +73,13 @@ export const CommonChart = ({
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
align="center"
|
||||
>
|
||||
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} style={zoomCompensatedSize(height > 100 ? 20 : 14)} stroke={1.5} />}
|
||||
{showIcon && (
|
||||
<Icon
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
{showText && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
|
||||
{title}
|
||||
@@ -83,7 +92,7 @@ export const CommonChart = ({
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
|
||||
{advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
|
||||
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
|
||||
<Text size="xs" fw={600}>
|
||||
{lastValue}
|
||||
@@ -93,13 +102,19 @@ export const CommonChart = ({
|
||||
{data.length <= 1 ? (
|
||||
<Center pos="absolute" w="100%" h="100%">
|
||||
<Stack px={"xs"} align={"center"} gap={4}>
|
||||
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} style={zoomCompensatedSize(height > 100 ? 20 : 14)} stroke={1.5} />}
|
||||
{showIcon && (
|
||||
<Icon
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
{showText && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{lastValue && (
|
||||
{advanced && lastValue && (
|
||||
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
|
||||
{lastValue}
|
||||
</Text>
|
||||
@@ -119,13 +134,7 @@ export const CommonChart = ({
|
||||
withYAxis={false}
|
||||
withDots={false}
|
||||
bg={backgroundColor}
|
||||
styles={{
|
||||
root: {
|
||||
padding: 5,
|
||||
borderRadius: theme.radius[board.itemRadius],
|
||||
backgroundColor,
|
||||
},
|
||||
}}
|
||||
styles={{ root: chartRootStyle }}
|
||||
tooltipAnimationDuration={200}
|
||||
tooltipProps={{
|
||||
// Render via portal so the tooltip isn't clipped by the card's overflow:hidden
|
||||
|
||||
@@ -10,10 +10,12 @@ export const SystemResourceCPUChart = ({
|
||||
cpuUsageOverTime,
|
||||
hasShadow,
|
||||
labelDisplayMode,
|
||||
advanced = false,
|
||||
}: {
|
||||
cpuUsageOverTime: number[];
|
||||
hasShadow: boolean;
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
|
||||
|
||||
@@ -31,6 +33,7 @@ export const SystemResourceCPUChart = ({
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
yAxisProps={{ domain: [0, 100] }}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
|
||||
@@ -10,10 +10,12 @@ export const SystemResourceGPUChart = ({
|
||||
gpuUsageOverTime,
|
||||
hasShadow,
|
||||
labelDisplayMode,
|
||||
advanced = false,
|
||||
}: {
|
||||
gpuUsageOverTime: number[];
|
||||
hasShadow: boolean;
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage }));
|
||||
|
||||
@@ -31,6 +33,7 @@ export const SystemResourceGPUChart = ({
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
yAxisProps={{ domain: [0, 100] }}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
|
||||
@@ -12,11 +12,13 @@ export const SystemResourceMemoryChart = ({
|
||||
totalCapacityInBytes,
|
||||
hasShadow,
|
||||
labelDisplayMode,
|
||||
advanced = false,
|
||||
}: {
|
||||
memoryUsageOverTime: number[];
|
||||
totalCapacityInBytes: number;
|
||||
hasShadow: boolean;
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
|
||||
const t = useI18n("widget.systemResources.card");
|
||||
@@ -35,6 +37,7 @@ export const SystemResourceMemoryChart = ({
|
||||
title={t("memory")}
|
||||
icon={IconBrain}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
yAxisProps={{ domain: [0, totalCapacityInBytes] }}
|
||||
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
|
||||
@@ -12,11 +12,13 @@ export const NetworkTrafficChart = ({
|
||||
isUp,
|
||||
hasShadow,
|
||||
labelDisplayMode,
|
||||
advanced = false,
|
||||
}: {
|
||||
usageOverTime: number[];
|
||||
isUp: boolean;
|
||||
hasShadow: boolean;
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const chartData = usageOverTime.map((usage, index) => ({ index, usage }));
|
||||
const t = useI18n("widget.systemResources.card");
|
||||
@@ -36,6 +38,7 @@ export const NetworkTrafficChart = ({
|
||||
lastValue={formatByteRate(Math.round(latest))}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
|
||||
@@ -68,7 +68,7 @@ export const getVisibleSystemCharts = ({
|
||||
configuredCharts,
|
||||
hasGpu,
|
||||
hasNetwork,
|
||||
height,
|
||||
height: _height,
|
||||
isAdvanced,
|
||||
}: {
|
||||
configuredCharts: readonly SystemChart[];
|
||||
@@ -81,7 +81,7 @@ export const getVisibleSystemCharts = ({
|
||||
const availableCharts = selectedCharts.filter(
|
||||
(chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork),
|
||||
);
|
||||
return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height));
|
||||
return [...availableCharts];
|
||||
};
|
||||
|
||||
export default function SystemResources({
|
||||
@@ -235,10 +235,7 @@ const SystemCharts = ({
|
||||
const showNetwork = visibleCharts.includes("network");
|
||||
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
|
||||
const chartCount = visibleCharts.length;
|
||||
const chartColumns = useMemo(
|
||||
() => (isAdvanced ? getAdvancedChartColumns(width) : 1),
|
||||
[isAdvanced, width],
|
||||
);
|
||||
const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]);
|
||||
const compactChartGap = 8;
|
||||
const compactRowHeight = 56;
|
||||
const chartHeight = isAdvanced
|
||||
@@ -269,6 +266,7 @@ const SystemCharts = ({
|
||||
cpuUsageOverTime={items.map((item) => item.cpu)}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={isAdvanced}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -279,6 +277,7 @@ const SystemCharts = ({
|
||||
totalCapacityInBytes={memoryCapacityInBytes}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={isAdvanced}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -288,6 +287,7 @@ const SystemCharts = ({
|
||||
gpuUsageOverTime={items.map((item) => item.gpu)}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={isAdvanced}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -299,12 +299,14 @@ const SystemCharts = ({
|
||||
isUp={false}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={isAdvanced}
|
||||
/>
|
||||
<NetworkTrafficChart
|
||||
usageOverTime={networkItems.map((network) => network.up)}
|
||||
isUp
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={isAdvanced}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
@@ -313,6 +315,7 @@ const SystemCharts = ({
|
||||
usageOverTime={networkItems}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={isAdvanced}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -65,17 +65,12 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
|
||||
const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && (width < 300 || height < 160);
|
||||
const showTitle = isAdvanced || height >= 112;
|
||||
const showLine = isAdvanced || width >= 200;
|
||||
const showPlatform = isAdvanced || width >= 320;
|
||||
|
||||
return (
|
||||
<Stack w="100%" h="100%" gap={isDense ? 4 : "xs"} p={isDense ? "xs" : "sm"}>
|
||||
{showTitle && (
|
||||
<Text fw={600} size={isDense ? "sm" : undefined} truncate="end">
|
||||
{t("title", { station: station.label })}
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={600} size={isDense ? "sm" : undefined} truncate="end">
|
||||
{t("title", { station: station.label })}
|
||||
</Text>
|
||||
{compactStaleWarning && displayMode === "compact" && (
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<IconAlertTriangle
|
||||
@@ -113,8 +108,6 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
|
||||
key={`${entry.timestamp.toISOString()}-${entry.location}`}
|
||||
entry={entry}
|
||||
dense={isDense}
|
||||
showLine={showLine}
|
||||
showPlatform={showPlatform}
|
||||
locale={locale}
|
||||
/>
|
||||
))
|
||||
@@ -144,23 +137,11 @@ interface TimetableEntryView {
|
||||
platform: { name: string; hasChanged: boolean } | null;
|
||||
}
|
||||
|
||||
function DepartureRow({
|
||||
entry,
|
||||
dense,
|
||||
showLine,
|
||||
showPlatform,
|
||||
locale,
|
||||
}: {
|
||||
entry: TimetableEntryView;
|
||||
dense: boolean;
|
||||
showLine: boolean;
|
||||
showPlatform: boolean;
|
||||
locale: string;
|
||||
}) {
|
||||
function DepartureRow({ entry, dense, locale }: { entry: TimetableEntryView; dense: boolean; locale: string }) {
|
||||
return (
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
<Group gap={dense ? 6 : "sm"} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{showLine && entry.line && (
|
||||
{entry.line && (
|
||||
<Badge
|
||||
size="sm"
|
||||
color={entry.line.color ?? undefined}
|
||||
@@ -185,8 +166,14 @@ function DepartureRow({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{showPlatform && entry.platform && (
|
||||
<Text size={dense ? "xs" : "sm"} c={entry.platform.hasChanged ? "red" : undefined} style={{ flexShrink: 0 }}>
|
||||
{entry.platform && (
|
||||
<Text
|
||||
size={dense ? "xs" : "sm"}
|
||||
c={entry.platform.hasChanged ? "red" : undefined}
|
||||
maw="35%"
|
||||
truncate="end"
|
||||
style={{ flexShrink: 1, minWidth: 0 }}
|
||||
>
|
||||
{entry.platform.name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAdvancedColumnWidth, getCompactSectionVisibility, getTracearrSectionVisibility } from "./component";
|
||||
import { getAdvancedColumnWidth, getTracearrSectionVisibility } from "./component";
|
||||
import { attachTracearrSource } from "./source";
|
||||
|
||||
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", () => {
|
||||
it("shows all sections even when compact presentation toggles are disabled", () => {
|
||||
expect(
|
||||
|
||||
@@ -33,54 +33,27 @@ export const getTracearrSectionVisibility = (options: TracearrSectionOptions, is
|
||||
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({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"tracearr">) {
|
||||
if (integrationIds.length === 0) {
|
||||
throw new NoIntegrationDataError();
|
||||
}
|
||||
|
||||
return (
|
||||
<TracearrContent
|
||||
integrationIds={integrationIds}
|
||||
options={options}
|
||||
width={width}
|
||||
height={height}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
return <TracearrContent integrationIds={integrationIds} options={options} width={width} displayMode={displayMode} />;
|
||||
}
|
||||
|
||||
interface TracearrContentProps {
|
||||
integrationIds: string[];
|
||||
options: WidgetComponentProps<"tracearr">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
displayMode: "compact" | "advanced";
|
||||
}
|
||||
|
||||
function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) {
|
||||
function TracearrContent({ integrationIds, options, width, displayMode }: TracearrContentProps) {
|
||||
const t = useI18n("widget.tracearr");
|
||||
const dashboardQuery = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds });
|
||||
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? [];
|
||||
@@ -157,7 +130,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
||||
const sectionVisibility = getTracearrSectionVisibility(options, isAdvanced);
|
||||
const noSectionsEnabled = Object.values(sectionVisibility).every((isVisible) => !isVisible);
|
||||
|
||||
const compactLimit = height < 220 ? 2 : height < 360 ? 4 : 8;
|
||||
const sourcedStreams = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
attachTracearrSource(dashboard.streams.data, { integrationId, integrationName }),
|
||||
);
|
||||
@@ -167,15 +139,7 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
||||
const sourcedRecentActivity = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
attachTracearrSource(dashboard.recentActivity?.data ?? [], { integrationId, integrationName }),
|
||||
);
|
||||
const streams = isAdvanced ? sourcedStreams : sourcedStreams.slice(0, compactLimit);
|
||||
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) {
|
||||
const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT;
|
||||
@@ -196,18 +160,18 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
||||
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
|
||||
</Text>
|
||||
{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 && (
|
||||
<StreamsList streams={streams} width={columnWidth} showSource={showSource} />
|
||||
<StreamsList streams={sourcedStreams} width={columnWidth} showSource={showSource} transparent />
|
||||
)}
|
||||
</Stack>
|
||||
<Stack gap="sm">
|
||||
{sectionVisibility.violations && (
|
||||
<ViolationsList violations={sourcedViolations} showSource={showSource} />
|
||||
<ViolationsList violations={sourcedViolations} showSource={showSource} transparent />
|
||||
)}
|
||||
{sectionVisibility.recentActivity && (
|
||||
<RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} />
|
||||
<RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} transparent />
|
||||
)}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
@@ -231,12 +195,10 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
||||
{sectionVisibility.stats && (
|
||||
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={width} />
|
||||
)}
|
||||
{compactSections.violations && (
|
||||
<ViolationsList violations={sourcedViolations.slice(0, compactLimit)} showSource={showSource} />
|
||||
)}
|
||||
{sectionVisibility.streams && <StreamsList streams={streams} width={width} showSource={showSource} />}
|
||||
{compactSections.recentActivity && (
|
||||
<RecentActivityList sessions={sourcedRecentActivity.slice(0, compactLimit)} showSource={showSource} />
|
||||
{sectionVisibility.streams && <StreamsList streams={sourcedStreams} width={width} showSource={showSource} />}
|
||||
{sectionVisibility.violations && <ViolationsList violations={sourcedViolations} showSource={showSource} />}
|
||||
{sectionVisibility.recentActivity && (
|
||||
<RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} />
|
||||
)}
|
||||
{noSectionsEnabled && (
|
||||
<Text c="dimmed" ta="center">
|
||||
|
||||
@@ -10,16 +10,18 @@ type SourcedTracearrHistorySession = SourcedTracearrItem<TracearrHistorySession>
|
||||
export function RecentActivityList({
|
||||
sessions,
|
||||
showSource,
|
||||
transparent = false,
|
||||
}: {
|
||||
sessions: SourcedTracearrHistorySession[];
|
||||
showSource: boolean;
|
||||
transparent?: boolean;
|
||||
}) {
|
||||
const t = useI18n("widget.tracearr");
|
||||
const locale = useCurrentIntlLocale();
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
<Text size="xs" fw={600} c="dimmed" tt={transparent ? undefined : "uppercase"}>
|
||||
{t("recentActivity.title")}
|
||||
</Text>
|
||||
{sessions.length === 0 ? (
|
||||
@@ -35,7 +37,7 @@ export function RecentActivityList({
|
||||
: session.mediaTitle;
|
||||
|
||||
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 gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<Avatar src={session.user.avatarUrl} alt={session.user.username} radius="xl" size="sm" />
|
||||
|
||||
@@ -8,10 +8,12 @@ export function StatsBar({
|
||||
stats,
|
||||
summary,
|
||||
width,
|
||||
transparent = false,
|
||||
}: {
|
||||
stats: TracearrDashboardData["stats"];
|
||||
summary: TracearrDashboardData["streams"]["summary"];
|
||||
width: number;
|
||||
transparent?: boolean;
|
||||
}) {
|
||||
const t = useI18n("widget.tracearr");
|
||||
const cols = width > 400 ? 4 : width > 250 ? 2 : 1;
|
||||
@@ -22,29 +24,43 @@ export function StatsBar({
|
||||
icon={<IconVideo size="var(--mantine-font-size-md)" />}
|
||||
label={t("stats.activeStreams")}
|
||||
value={stats.activeStreams}
|
||||
transparent={transparent}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<IconUsers size="var(--mantine-font-size-md)" />}
|
||||
label={t("stats.totalUsers")}
|
||||
value={stats.totalUsers}
|
||||
transparent={transparent}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<IconDevices size="var(--mantine-font-size-md)" />}
|
||||
label={t("stats.transcodes")}
|
||||
value={`${summary.transcodes}/${summary.total}`}
|
||||
transparent={transparent}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<IconNetwork size="var(--mantine-font-size-md)" />}
|
||||
label={t("stats.bandwidth")}
|
||||
value={summary.totalBitrate}
|
||||
transparent={transparent}
|
||||
/>
|
||||
</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 (
|
||||
<Paper p="xs" radius="lg" bg="transparent">
|
||||
<Paper p="xs" radius="lg" bg={transparent ? "transparent" : undefined}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{icon}
|
||||
<Stack gap={0} style={{ overflow: "hidden" }}>
|
||||
|
||||
@@ -13,10 +13,12 @@ export function StreamsList({
|
||||
streams,
|
||||
width,
|
||||
showSource,
|
||||
transparent = false,
|
||||
}: {
|
||||
streams: SourcedTracearrStream[];
|
||||
width: number;
|
||||
showSource: boolean;
|
||||
transparent?: boolean;
|
||||
}) {
|
||||
const t = useI18n("widget.tracearr");
|
||||
|
||||
@@ -31,7 +33,13 @@ export function StreamsList({
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{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>
|
||||
);
|
||||
@@ -41,10 +49,12 @@ function StreamCard({
|
||||
stream,
|
||||
compact,
|
||||
showSource,
|
||||
transparent,
|
||||
}: {
|
||||
stream: SourcedTracearrStream;
|
||||
compact: boolean;
|
||||
showSource: boolean;
|
||||
transparent: boolean;
|
||||
}) {
|
||||
const t = useI18n("widget.tracearr");
|
||||
const progressPercent =
|
||||
@@ -65,7 +75,12 @@ function StreamCard({
|
||||
: null;
|
||||
|
||||
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 && (
|
||||
<Box
|
||||
style={{
|
||||
|
||||
@@ -11,16 +11,18 @@ type SourcedTracearrViolation = SourcedTracearrItem<TracearrViolation>;
|
||||
export function ViolationsList({
|
||||
violations,
|
||||
showSource,
|
||||
transparent = false,
|
||||
}: {
|
||||
violations: SourcedTracearrViolation[];
|
||||
showSource: boolean;
|
||||
transparent?: boolean;
|
||||
}) {
|
||||
const t = useI18n("widget.tracearr");
|
||||
const locale = useCurrentIntlLocale();
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
<Text size="xs" fw={600} c="dimmed" tt={transparent ? undefined : "uppercase"}>
|
||||
{t("violations.title")}
|
||||
</Text>
|
||||
{violations.length === 0 ? (
|
||||
@@ -30,7 +32,7 @@ export function ViolationsList({
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{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 gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<IconAlertTriangle
|
||||
|
||||
@@ -39,8 +39,8 @@ describe("Traefik source ownership", () => {
|
||||
});
|
||||
|
||||
describe("Traefik advanced disclosure", () => {
|
||||
test("shows every protocol regardless of compact toggles and height", () => {
|
||||
expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, 100, true)).toEqual(["http", "tcp", "udp"]);
|
||||
test("shows every protocol in advanced mode regardless of compact toggles", () => {
|
||||
expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, true)).toEqual(["http", "tcp", "udp"]);
|
||||
});
|
||||
|
||||
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
Reference in new issue
Block a user