From c099a8eef6afd0309afab996ce8fb4f8d20bbb69 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 13 Aug 2026 12:31:32 +0200 Subject: [PATCH] feat: expand advanced widget experiences --- .../widgets/archive-team-warrior/index.mdx | 2 +- apps/docs/docs/widgets/clock/index.mdx | 2 + .../docs/widgets/health-monitoring/index.mdx | 2 + .../docs/widgets/indexer-manager/index.mdx | 4 +- apps/docs/docs/widgets/notebook/index.mdx | 6 +- apps/docs/docs/widgets/notebook/index.ts | 3 +- apps/docs/docs/widgets/patchmon/index.mdx | 2 +- apps/docs/docs/widgets/releases/index.mdx | 4 +- apps/docs/docs/widgets/system-disks/index.mdx | 4 +- apps/docs/docs/widgets/traefik/index.mdx | 4 + .../docs/widgets/whats-up-docker/index.mdx | 5 + .../router/docker/docker-operation-results.ts | 10 + .../api/src/router/docker/docker-router.ts | 13 +- .../docker/docker-operation-results.spec.ts | 26 ++ .../partial-integration-failures.spec.ts | 338 +++++++++++++++++- .../__tests__/media-release-result.spec.ts | 68 ++++ packages/api/src/router/widgets/coolify.ts | 48 ++- packages/api/src/router/widgets/dns-hole.ts | 46 ++- packages/api/src/router/widgets/downloads.ts | 63 +++- .../src/router/widgets/health-monitoring.ts | 41 ++- .../router/widgets/media-release-result.ts | 61 ++++ .../api/src/router/widgets/media-release.ts | 17 +- .../src/router/widgets/network-controller.ts | 54 ++- .../src/router/widgets/speedtest-tracker.ts | 39 +- packages/api/src/router/widgets/tracearr.ts | 39 +- packages/api/src/router/widgets/traefik.ts | 39 +- packages/api/src/router/widgets/ups.ts | 39 +- .../api/src/router/widgets/uptime-kuma.ts | 39 +- .../src/advanced-focus-support.spec.ts | 15 + packages/widgets/src/app/component.tsx | 5 +- .../src/archive-team-warrior/component.tsx | 32 ++ .../src/audio-stats/audio-stats-content.tsx | 12 +- .../audio-stats/audio-stats-shared.spec.ts | 15 + packages/widgets/src/audio-stats/shared.ts | 3 +- .../widgets/src/bazarr/component.module.css | 2 + packages/widgets/src/bazarr/component.tsx | 64 +++- packages/widgets/src/bazarr/index.ts | 2 +- packages/widgets/src/bazarr/layout.spec.ts | 16 +- .../widgets/src/beszel-alerts/component.tsx | 95 +++-- .../widgets/src/beszel-alerts/display.spec.ts | 26 ++ packages/widgets/src/beszel-alerts/display.ts | 22 ++ packages/widgets/src/beszel-alerts/index.ts | 14 +- .../src/beszel-alerts/system-name-map.spec.ts | 19 + .../src/beszel-alerts/system-name-map.ts | 24 ++ .../src/beszel-system-grid/component.tsx | 114 ++++-- .../src/beszel-system-grid/display.spec.ts | 15 + .../widgets/src/beszel-system-grid/display.ts | 2 + .../widgets/src/beszel-system-grid/index.ts | 2 +- .../src/beszel-system-stats/component.tsx | 14 +- .../src/beszel-system-table/component.spec.ts | 21 ++ .../src/beszel-system-table/display.ts | 28 ++ .../widgets/src/beszel-system-table/index.ts | 2 +- packages/widgets/src/beszel/_shared/hooks.ts | 4 +- packages/widgets/src/coolify/component.tsx | 129 +++++-- .../widgets/src/coolify/coolify-utils.spec.ts | 56 ++- packages/widgets/src/coolify/coolify-utils.ts | 23 ++ packages/widgets/src/coolify/index.ts | 2 +- .../widgets/src/coolify/servers-section.tsx | 54 ++- .../widgets/src/coolify/services-section.tsx | 65 +++- .../src/dns-hole/controls/component.tsx | 26 +- .../src/dns-hole/summary/component.tsx | 24 +- .../widgets/src/downloads/helpers.spec.ts | 17 +- packages/widgets/src/downloads/helpers.ts | 27 ++ packages/widgets/src/downloads/index.ts | 24 +- .../src/health-monitoring/system-health.tsx | 63 +++- .../src/immich/server-stats/layout.spec.ts | 14 +- .../widgets/src/indexer-manager/component.tsx | 203 +++++++---- packages/widgets/src/indexer-manager/index.ts | 2 +- .../src/indexer-manager/status.spec.ts | 12 + .../widgets/src/indexer-manager/status.ts | 12 + .../widgets/src/media-releases/component.tsx | 56 +-- .../network-status/component.tsx | 8 +- .../network-controller/summary/component.tsx | 126 ++++++- .../summary/display.spec.ts | 45 +++ .../src/network-controller/summary/display.ts | 40 +++ .../src/network-controller/summary/index.ts | 2 +- packages/widgets/src/notebook/display.spec.ts | 18 + packages/widgets/src/notebook/display.ts | 13 + packages/widgets/src/notebook/index.ts | 2 +- packages/widgets/src/notebook/notebook.tsx | 49 ++- .../widgets/src/notifications/component.tsx | 29 +- .../widgets/src/notifications/display.spec.ts | 25 ++ packages/widgets/src/notifications/display.ts | 20 ++ .../widgets/src/patchmon/component.module.css | 27 ++ packages/widgets/src/patchmon/component.tsx | 76 ++-- packages/widgets/src/patchmon/index.ts | 2 +- packages/widgets/src/patchmon/layout-utils.ts | 22 ++ .../src/patchmon/test/layout-utils.spec.ts | 30 +- packages/widgets/src/releases/component.tsx | 32 +- packages/widgets/src/releases/display.spec.ts | 32 ++ packages/widgets/src/releases/display.ts | 30 ++ packages/widgets/src/releases/index.ts | 2 +- packages/widgets/src/rssFeed/component.tsx | 87 ++++- .../widgets/src/rssFeed/feed-display.spec.ts | 20 +- packages/widgets/src/rssFeed/index.ts | 2 +- .../src/smart-home/entity-state/component.tsx | 5 + .../execute-automation/component.tsx | 33 +- .../src/speedtest-tracker/component.tsx | 168 +++++---- .../src/speedtest-tracker/helpers.spec.ts | 19 +- .../widgets/src/speedtest-tracker/helpers.ts | 17 + packages/widgets/src/stocks/component.spec.ts | 14 + packages/widgets/src/stocks/component.tsx | 179 +++++++--- packages/widgets/src/stocks/index.ts | 2 +- .../widgets/src/system-disks/component.tsx | 146 +++++--- .../widgets/src/system-disks/display.spec.ts | 16 +- packages/widgets/src/system-disks/index.ts | 2 +- .../widgets/src/tracearr/component.spec.ts | 30 +- packages/widgets/src/tracearr/component.tsx | 153 +++++--- .../src/tracearr/recent-activity-section.tsx | 15 +- packages/widgets/src/tracearr/source.ts | 19 + .../widgets/src/tracearr/streams-section.tsx | 36 +- .../src/tracearr/violations-section.tsx | 15 +- .../widgets/src/traefik/component.spec.ts | 52 +++ packages/widgets/src/traefik/component.tsx | 161 +++++++-- packages/widgets/src/ups/component.spec.ts | 25 ++ packages/widgets/src/ups/component.tsx | 108 ++++-- .../widgets/src/uptime-kuma/component.tsx | 81 +++-- packages/widgets/src/wud/component.tsx | 35 +- packages/widgets/src/wud/index.ts | 3 + 119 files changed, 3624 insertions(+), 844 deletions(-) create mode 100644 packages/api/src/router/docker/docker-operation-results.ts create mode 100644 packages/api/src/router/test/docker/docker-operation-results.spec.ts create mode 100644 packages/api/src/router/widgets/__tests__/media-release-result.spec.ts create mode 100644 packages/api/src/router/widgets/media-release-result.ts create mode 100644 packages/widgets/src/beszel-alerts/display.spec.ts create mode 100644 packages/widgets/src/beszel-alerts/display.ts create mode 100644 packages/widgets/src/beszel-alerts/system-name-map.spec.ts create mode 100644 packages/widgets/src/beszel-alerts/system-name-map.ts create mode 100644 packages/widgets/src/beszel-system-grid/display.spec.ts create mode 100644 packages/widgets/src/beszel-system-grid/display.ts create mode 100644 packages/widgets/src/beszel-system-table/display.ts create mode 100644 packages/widgets/src/indexer-manager/status.spec.ts create mode 100644 packages/widgets/src/indexer-manager/status.ts create mode 100644 packages/widgets/src/network-controller/summary/display.spec.ts create mode 100644 packages/widgets/src/network-controller/summary/display.ts create mode 100644 packages/widgets/src/notebook/display.spec.ts create mode 100644 packages/widgets/src/notebook/display.ts create mode 100644 packages/widgets/src/notifications/display.spec.ts create mode 100644 packages/widgets/src/notifications/display.ts create mode 100644 packages/widgets/src/releases/display.spec.ts create mode 100644 packages/widgets/src/releases/display.ts create mode 100644 packages/widgets/src/tracearr/source.ts create mode 100644 packages/widgets/src/traefik/component.spec.ts create mode 100644 packages/widgets/src/ups/component.spec.ts diff --git a/apps/docs/docs/widgets/archive-team-warrior/index.mdx b/apps/docs/docs/widgets/archive-team-warrior/index.mdx index 5eceed2c2..c8cc97c42 100644 --- a/apps/docs/docs/widgets/archive-team-warrior/index.mdx +++ b/apps/docs/docs/widgets/archive-team-warrior/index.mdx @@ -14,7 +14,7 @@ import { archiveTeamWarriorWidget } from "."; -Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and broadcast message visible. Advanced view adds the complete active-job list and exact update time. +Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and broadcast message visible. Advanced view adds the complete active-job list, job start times, project and bandwidth-session identifiers, session transfer totals, and exact update time. ### Supported integration diff --git a/apps/docs/docs/widgets/clock/index.mdx b/apps/docs/docs/widgets/clock/index.mdx index 6ec75e9f6..6f070c679 100644 --- a/apps/docs/docs/widgets/clock/index.mdx +++ b/apps/docs/docs/widgets/clock/index.mdx @@ -15,6 +15,8 @@ import { clockWidget } from "."; The clock widget displays the current date and time in a customizable format. +Advanced view always exposes seconds, the full date, timezone and UTC offset. When weather is enabled, it also shows the configured location, conditions, today's high/low and humidity, wind, sunrise, and sunset from the same cached forecast. + ### Screenshots import localClock from "./img/local.png"; diff --git a/apps/docs/docs/widgets/health-monitoring/index.mdx b/apps/docs/docs/widgets/health-monitoring/index.mdx index bc50cf2c7..d5b685b16 100644 --- a/apps/docs/docs/widgets/health-monitoring/index.mdx +++ b/apps/docs/docs/widgets/health-monitoring/index.mdx @@ -23,6 +23,8 @@ import { glancesIntegration } from "@site/docs/integrations/glances"; This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more. +Advanced view is a complete health workspace: it shows every available CPU, temperature, memory, GPU, filesystem, update, reboot, and system-information section even when compact visibility switches hide some of them on the board. Storage-volume filters, units, and permissions remain in effect. + ### Screenshots import systemView from "./img/system-view.png"; diff --git a/apps/docs/docs/widgets/indexer-manager/index.mdx b/apps/docs/docs/widgets/indexer-manager/index.mdx index 2deeecd66..4efeb5840 100644 --- a/apps/docs/docs/widgets/indexer-manager/index.mdx +++ b/apps/docs/docs/widgets/indexer-manager/index.mdx @@ -39,7 +39,9 @@ import { prowlarrIntegration } from "@site/docs/integrations/prowlarr"; - Test all indexers by clicking on the test button. - +In advanced view, every indexer is grouped under its integration source and receives an explicit Healthy, Unhealthy, or Disabled status. Safe indexer links and the permission-checked test action behave exactly as they do on the board. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/notebook/index.mdx b/apps/docs/docs/widgets/notebook/index.mdx index 887908372..aae4ba46f 100644 --- a/apps/docs/docs/widgets/notebook/index.mdx +++ b/apps/docs/docs/widgets/notebook/index.mdx @@ -26,7 +26,7 @@ import editOn from "./img/edit.png"; notebook edit-on - + ### Adding the widget @@ -46,6 +46,10 @@ You can then decide to save your changes or cancel them to exit editing, using t ![](./img/notebook-exit-editing.webp) +### Advanced view + +Advanced view keeps the notebook read-only until an authorized user enters editing. While editing, it always shows the complete, unclipped toolbar even when **Show the toolbar** is disabled, and it always displays the document word and character counts regardless of the widget height. Permissions, saving and cancellation, and the **Allow check in read only mode** setting work exactly as they do in the compact widget. + ### Notebook toolbar functions The notebook offers a variety of functions to edit the text and more. diff --git a/apps/docs/docs/widgets/notebook/index.ts b/apps/docs/docs/widgets/notebook/index.ts index f477281e1..8e2c1c991 100644 --- a/apps/docs/docs/widgets/notebook/index.ts +++ b/apps/docs/docs/widgets/notebook/index.ts @@ -10,7 +10,8 @@ export const notebookWidget: WidgetDefinition = { items: [ { name: "Show the toolbar to help you write markdown", - description: "Upon editing, enables a toolbar at the top with document editing functions.", + description: + "Upon editing in compact mode, enables a toolbar at the top with document editing functions. Advanced view always shows the complete toolbar while editing.", values: { type: "boolean" }, defaultValue: "yes", }, diff --git a/apps/docs/docs/widgets/patchmon/index.mdx b/apps/docs/docs/widgets/patchmon/index.mdx index c9d419b50..2175a3945 100644 --- a/apps/docs/docs/widgets/patchmon/index.mdx +++ b/apps/docs/docs/widgets/patchmon/index.mdx @@ -87,7 +87,7 @@ Advanced view is a complete PatchMon posture view. It always shows the complianc ]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx index 38134344a..88f5bc9ee 100644 --- a/apps/docs/docs/widgets/releases/index.mdx +++ b/apps/docs/docs/widgets/releases/index.mdx @@ -35,7 +35,9 @@ 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. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/system-disks/index.mdx b/apps/docs/docs/widgets/system-disks/index.mdx index d533fa025..33a2e7693 100644 --- a/apps/docs/docs/widgets/system-disks/index.mdx +++ b/apps/docs/docs/widgets/system-disks/index.mdx @@ -44,7 +44,9 @@ This widget displays filesystem and SMART disk usage for supported server and NA ]} /> - +Advanced view keeps your configured volume filters while showing each integration name, usage percentage, used and total space, free-space percentage, SMART status, available temperature, and the usage background bar regardless of compact display options. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/traefik/index.mdx b/apps/docs/docs/widgets/traefik/index.mdx index dfc2f9247..edbbba8b8 100644 --- a/apps/docs/docs/widgets/traefik/index.mdx +++ b/apps/docs/docs/widgets/traefik/index.mdx @@ -17,6 +17,10 @@ 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. + +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. + ### Supported Integrations + + +In advanced view, the widget always shows its title, update ring, monitored-container summary, and the full list of available container updates. The configured horizontal or vertical layout still applies. Visibility options only affect the compact board view. + ### Adding the widget diff --git a/packages/api/src/router/docker/docker-operation-results.ts b/packages/api/src/router/docker/docker-operation-results.ts new file mode 100644 index 000000000..b27ae6917 --- /dev/null +++ b/packages/api/src/router/docker/docker-operation-results.ts @@ -0,0 +1,10 @@ +import { TRPCError } from "@trpc/server"; + +export const throwIfDockerOperationsFailed = (results: readonly PromiseSettledResult[]) => { + if (results.some((result) => result.status === "rejected")) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "One or more Docker container operations failed", + }); + } +}; diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 44b4ce381..504662cc0 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -12,6 +12,7 @@ import { import { dockerMiddleware } from "../../middlewares/docker"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; +import { throwIfDockerOperationsFailed } from "./docker-operation-results"; export const dockerRouter = createTRPCRouter({ getContainers: permissionRequiredProcedure @@ -45,12 +46,13 @@ export const dockerRouter = createTRPCRouter({ .concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { - await Promise.allSettled( + const results = await Promise.allSettled( input.ids.map(async (id) => { const container = await getContainerOrThrowAsync(id); await container.start(); }), ); + throwIfDockerOperationsFailed(results); }), stopAll: permissionRequiredProcedure .requiresPermission("admin") @@ -63,12 +65,13 @@ export const dockerRouter = createTRPCRouter({ .concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { - await Promise.allSettled( + const results = await Promise.allSettled( input.ids.map(async (id) => { const container = await getContainerOrThrowAsync(id); await container.stop(); }), ); + throwIfDockerOperationsFailed(results); }), restartAll: permissionRequiredProcedure .requiresPermission("admin") @@ -82,12 +85,13 @@ export const dockerRouter = createTRPCRouter({ .concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { - await Promise.allSettled( + const results = await Promise.allSettled( input.ids.map(async (id) => { const container = await getContainerOrThrowAsync(id); await container.restart(); }), ); + throwIfDockerOperationsFailed(results); }), removeAll: permissionRequiredProcedure .requiresPermission("admin") @@ -101,12 +105,13 @@ export const dockerRouter = createTRPCRouter({ .concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { - await Promise.allSettled( + const results = await Promise.allSettled( input.ids.map(async (id) => { const container = await getContainerOrThrowAsync(id); await container.remove(); }), ); + throwIfDockerOperationsFailed(results); }), logs: permissionRequiredProcedure .requiresPermission("admin") diff --git a/packages/api/src/router/test/docker/docker-operation-results.spec.ts b/packages/api/src/router/test/docker/docker-operation-results.spec.ts new file mode 100644 index 000000000..e9283a2fc --- /dev/null +++ b/packages/api/src/router/test/docker/docker-operation-results.spec.ts @@ -0,0 +1,26 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, test } from "vitest"; + +import { throwIfDockerOperationsFailed } from "../../docker/docker-operation-results"; + +describe("Docker operation results", () => { + test("accepts an all-success result", async () => { + const results = await Promise.allSettled([Promise.resolve(), Promise.resolve()]); + + expect(() => throwIfDockerOperationsFailed(results)).not.toThrow(); + }); + + test("throws a sanitized error after a partial failure", async () => { + const results = await Promise.allSettled([ + Promise.resolve(), + Promise.reject(new Error("sensitive Docker daemon response")), + ]); + + expect(() => throwIfDockerOperationsFailed(results)).toThrow( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "One or more Docker container operations failed", + }), + ); + }); +}); diff --git a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts index d72d125d2..858fb2ac0 100644 --- a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts +++ b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts @@ -8,17 +8,34 @@ import type { Database } from "@homarr/db"; import { integrations, integrationUserPermissions, users } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import type { IntegrationKind } from "@homarr/definitions"; -import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types"; +import type { + MediaRequest, + MediaRequestStats, + SpeedtestTrackerDashboardData, + TracearrDashboardData, + TraefikDashboardData, + UpsSummary, + UptimeKumaDashboardData, +} from "@homarr/integrations/types"; import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list"; import { calendarRouter } from "../../widgets/calendar"; +import { dnsHoleRouter } from "../../widgets/dns-hole"; +import { downloadsRouter } from "../../widgets/downloads"; import { beszelRouter } from "../../widgets/beszel"; import { firewallRouter } from "../../widgets/firewall"; +import { healthMonitoringRouter } from "../../widgets/health-monitoring"; import { indexerManagerRouter } from "../../widgets/indexer-manager"; import { mediaOrganizerRouter } from "../../widgets/media-organizer"; import { mediaRequestsRouter } from "../../widgets/media-requests"; import { mediaServerRouter } from "../../widgets/media-server"; +import { networkControllerRouter } from "../../widgets/network-controller"; import { notificationsRouter } from "../../widgets/notifications"; +import { speedtestTrackerRouter } from "../../widgets/speedtest-tracker"; +import { tracearrRouter } from "../../widgets/tracearr"; +import { traefikRouter } from "../../widgets/traefik"; +import { upsRouter } from "../../widgets/ups"; +import { uptimeKumaRouter } from "../../widgets/uptime-kuma"; import { vpnRouter } from "../../widgets/vpn"; vi.hoisted(() => { @@ -44,6 +61,65 @@ vi.mock("@homarr/request-handler/media-server", () => ({ mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, })); +vi.mock("@homarr/request-handler/dns-hole", () => ({ + dnsHoleRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + status: "enabled", + domainsBeingBlocked: 10, + adsBlockedToday: 2, + adsBlockedTodayPercentage: 20, + dnsQueriesToday: 10, + }), + }, +})); + +vi.mock("@homarr/request-handler/downloads", () => ({ + downloadClientRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + items: [], + status: { paused: false, rates: { down: 0, up: 0 }, types: ["torrent"] }, + }), + }, +})); + +vi.mock("@homarr/request-handler/health-monitoring", () => ({ + systemInfoRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + version: "1.0", + cpuModelName: "CPU", + cpuUtilization: 10, + memUsedInBytes: 1, + memAvailableInBytes: 2, + uptime: 1, + network: null, + loadAverage: null, + rebootRequired: false, + availablePkgUpdates: 0, + cpuTemp: undefined, + fileSystem: [], + smart: [], + gpu: [], + }), + }, + clusterInfoRequestHandler: { handler: () => createHandler({ name: "Online" }, {}) }, +})); + +vi.mock("@homarr/request-handler/network-controller", () => ({ + networkControllerRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + wanStatus: "enabled", + www: { status: "enabled", latency: 5, ping: 6, uptime: 99 }, + wifi: { status: "enabled", users: 2, guests: 1 }, + lan: { status: "enabled", users: 3, guests: 0 }, + vpn: { status: "enabled", users: 1 }, + }), + }, +})); + vi.mock("@homarr/request-handler/media-organizer", () => ({ mediaOrganizerRequestHandler: { handler: (integration: { name: string }) => @@ -78,6 +154,88 @@ vi.mock("@homarr/request-handler/vpn", () => ({ vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) }, })); +vi.mock("@homarr/request-handler/tracearr", () => ({ + tracearrRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + stats: { activeStreams: 0, totalUsers: 1, totalSessions: 1, recentViolations: 0, timestamp: "now" }, + streams: { + data: [], + summary: { total: 0, transcodes: 0, directStreams: 0, directPlays: 0, totalBitrate: "0", byServer: [] }, + }, + violations: null, + recentActivity: null, + } satisfies TracearrDashboardData), + }, +})); + +vi.mock("@homarr/request-handler/speedtest-tracker", () => ({ + speedtestTrackerRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + latestResult: null, + stats: null, + recentResults: [], + } satisfies SpeedtestTrackerDashboardData), + }, +})); + +vi.mock("@homarr/request-handler/ups", () => ({ + upsSummariesRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, [ + { + id: "healthy-ups", + name: "Healthy UPS", + manufacturer: null, + model: null, + serial: null, + status: "online", + batteryCharge: 100, + batteryRuntime: 3600, + batteryVoltage: null, + load: 10, + inputVoltage: null, + outputVoltage: null, + power: null, + temperature: null, + } satisfies UpsSummary, + ]), + }, +})); + +vi.mock("@homarr/request-handler/traefik", () => { + const emptySummary = { total: 0, enabled: 0, warnings: 0, errors: 0 }; + return { + traefikRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + version: "3.0.0", + entryPoints: [], + resources: [], + failedEndpoints: [], + http: { routers: emptySummary, services: emptySummary, middlewares: emptySummary }, + tcp: { routers: emptySummary, services: emptySummary, middlewares: emptySummary }, + udp: { routers: emptySummary, services: emptySummary }, + } satisfies TraefikDashboardData), + }, + }; +}); + +vi.mock("@homarr/request-handler/uptime-kuma", () => ({ + uptimeKumaRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + totalMonitors: 1, + upCount: 1, + downCount: 0, + pausedCount: 0, + averageUptimePercent: 100, + monitors: [{ id: 1, name: "Healthy monitor", status: "up", uptimePercent24h: 100 }], + } satisfies UptimeKumaDashboardData), + }, +})); + vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => { const actual = await importOriginal(); return { @@ -124,14 +282,14 @@ const createSession = (userId: string): Session => ({ expires: new Date().toISOString(), }); -const setupAsync = async (kind: IntegrationKind) => { +const setupAsync = async (kind: IntegrationKind, options: { allOffline?: boolean } = {}) => { const db = createDb(); const userId = createId(); const onlineId = createId(); const offlineId = createId(); await db.insert(users).values({ id: userId }); await db.insert(integrations).values([ - { id: onlineId, kind, name: "Online", url: "https://online.example.com" }, + { id: onlineId, kind, name: options.allOffline ? "Offline" : "Online", url: "https://online.example.com" }, { id: offlineId, kind, name: "Offline", url: "https://offline.example.com" }, ]); await db.insert(integrationUserPermissions).values([ @@ -147,6 +305,56 @@ const setupAsync = async (kind: IntegrationKind) => { }; }; +type IntegrationSetup = Awaited>; + +const dashboardPartialFailureCases = [ + { + kind: "tracearr" as const, + payloadKey: "dashboard", + fallbackValue: null, + run: async (setup: IntegrationSetup) => + await tracearrRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getDashboard({ integrationIds: setup.integrationIds }), + }, + { + kind: "speedtestTracker" as const, + payloadKey: "dashboard", + fallbackValue: null, + run: async (setup: IntegrationSetup) => + await speedtestTrackerRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getDashboard({ integrationIds: setup.integrationIds }), + }, + { + kind: "peaNut" as const, + payloadKey: "summaries", + fallbackValue: [], + run: async (setup: IntegrationSetup) => + await upsRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getSummaries({ integrationIds: setup.integrationIds }), + }, + { + kind: "traefik" as const, + payloadKey: "dashboard", + fallbackValue: null, + run: async (setup: IntegrationSetup) => + await traefikRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getDashboard({ integrationIds: setup.integrationIds }), + }, + { + kind: "uptimeKuma" as const, + payloadKey: "dashboard", + fallbackValue: null, + run: async (setup: IntegrationSetup) => + await uptimeKumaRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getDashboard({ integrationIds: setup.integrationIds }), + }, +] as const; + beforeEach(() => vi.clearAllMocks()); describe("partial integration failures", () => { @@ -187,6 +395,130 @@ describe("partial integration failures", () => { expect(JSON.stringify(streamResults)).not.toContain("secret"); }); + test("DNS widgets retain source ownership for partial failures", async () => { + const dnsHole = await setupAsync("piHole"); + const results = await dnsHoleRouter + .createCaller({ db: dnsHole.db, deviceType: undefined, session: dnsHole.session }) + .summary({ integrationIds: dnsHole.integrationIds }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + integrationId: dnsHole.onlineId, + summary: expect.objectContaining({ status: "enabled" }), + }), + expect.objectContaining({ + integrationId: dnsHole.offlineId, + integrationName: "Offline", + summary: null, + error: "INTEGRATION_REQUEST_FAILED", + }), + ]), + ); + expect(JSON.stringify(results)).not.toContain("secret"); + }); + + test("downloads retain source ownership for partial failures", async () => { + const downloads = await setupAsync("qBittorrent"); + const results = await downloadsRouter + .createCaller({ db: downloads.db, deviceType: undefined, session: downloads.session }) + .getJobsAndStatuses({ integrationIds: downloads.integrationIds, limitPerIntegration: 50 }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ integrationId: downloads.onlineId, data: expect.objectContaining({ items: [] }) }), + expect.objectContaining({ + integrationId: downloads.offlineId, + integrationName: "Offline", + data: null, + error: "INTEGRATION_REQUEST_FAILED", + }), + ]), + ); + expect(JSON.stringify(results)).not.toContain("secret"); + }); + + test("system health retains source ownership for partial failures", async () => { + const health = await setupAsync("glances"); + const results = await healthMonitoringRouter + .createCaller({ db: health.db, deviceType: undefined, session: health.session }) + .getSystemHealthStatus({ integrationIds: health.integrationIds }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + integrationId: health.onlineId, + healthInfo: expect.objectContaining({ version: "1.0" }), + }), + expect.objectContaining({ + integrationId: health.offlineId, + integrationName: "Offline", + healthInfo: null, + error: "INTEGRATION_REQUEST_FAILED", + }), + ]), + ); + expect(JSON.stringify(results)).not.toContain("secret"); + }); + + test("network controllers retain source ownership for partial failures", async () => { + const network = await setupAsync("unifiController"); + const results = await networkControllerRouter + .createCaller({ db: network.db, deviceType: undefined, session: network.session }) + .summary({ integrationIds: network.integrationIds }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + integrationId: network.onlineId, + summary: expect.objectContaining({ wanStatus: "enabled" }), + }), + expect.objectContaining({ + integrationId: network.offlineId, + integrationName: "Offline", + summary: null, + error: "INTEGRATION_REQUEST_FAILED", + }), + ]), + ); + expect(JSON.stringify(results)).not.toContain("secret"); + }); + + test("dashboard widgets preserve healthy sources and expose sanitized failed-source envelopes", async () => { + for (const testCase of dashboardPartialFailureCases) { + const setup = await setupAsync(testCase.kind); + const results = await testCase.run(setup); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + integrationId: setup.onlineId, + integrationName: "Online", + [testCase.payloadKey]: expect.anything(), + }), + expect.objectContaining({ + integrationId: setup.offlineId, + integrationName: "Offline", + [testCase.payloadKey]: testCase.fallbackValue, + error: "INTEGRATION_REQUEST_FAILED", + }), + ]), + ); + expect(JSON.stringify(results)).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i); + } + }); + + test("dashboard widgets reject total integration failure instead of returning fallback-only data", async () => { + for (const testCase of dashboardPartialFailureCases) { + const setup = await setupAsync(testCase.kind, { allOffline: true }); + + await expect(testCase.run(setup)).rejects.toMatchObject({ + code: "INTERNAL_SERVER_ERROR", + message: "All integration queries failed", + }); + } + }); + test("media request responses expose failed integrations without dropping successful data", async () => { const mediaRequests = await setupAsync("overseerr"); const caller = mediaRequestsRouter.createCaller({ diff --git a/packages/api/src/router/widgets/__tests__/media-release-result.spec.ts b/packages/api/src/router/widgets/__tests__/media-release-result.spec.ts new file mode 100644 index 000000000..2f90d5ddc --- /dev/null +++ b/packages/api/src/router/widgets/__tests__/media-release-result.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; + +import type { MediaRelease } from "@homarr/integrations/types"; + +import { PUBLIC_INTEGRATION_ERROR } from "../../../settle-integrations"; +import { getMediaReleaseResponse } from "../media-release-result"; + +const integrations = [ + { id: "plex", name: "Living room Plex", kind: "plex" as const }, + { id: "jellyfin", name: "Family Jellyfin", kind: "jellyfin" as const }, +]; + +const release: MediaRelease = { + id: "shared-id", + type: "movie", + title: "Example release", + releaseDate: new Date("2026-08-13T10:00:00Z"), + imageUrls: { poster: undefined, backdrop: undefined }, + tags: [], + href: "https://example.com/release", +}; + +describe("getMediaReleaseResponse", () => { + test("keeps healthy releases and returns a sanitized failure envelope", async () => { + const response = await getMediaReleaseResponse(integrations, async (integration) => { + if (integration.id === "plex") throw new Error("token=secret at plex.internal"); + return { releases: [release], updatedAt: new Date("2026-08-13T11:00:00Z") }; + }); + + expect(response.releases).toEqual([ + { + ...release, + integration: { + ...integrations[1], + updatedAt: new Date("2026-08-13T11:00:00Z"), + }, + }, + ]); + expect(response.failedIntegrations).toEqual([ + { + integrationId: "plex", + integrationName: "Living room Plex", + error: PUBLIC_INTEGRATION_ERROR, + }, + ]); + expect(JSON.stringify(response)).not.toMatch(/secret|plex\.internal/i); + }); + + test("keeps source identity when integrations return the same release id", async () => { + const response = await getMediaReleaseResponse(integrations, async () => ({ + releases: [release], + updatedAt: new Date("2026-08-13T11:00:00Z"), + })); + + expect(response.releases.map(({ integration }) => integration.id)).toEqual(["plex", "jellyfin"]); + }); + + test("throws when every integration fails", async () => { + await expect( + getMediaReleaseResponse(integrations, async () => { + throw new Error("Unavailable"); + }), + ).rejects.toMatchObject({ + code: "INTERNAL_SERVER_ERROR", + message: "All integration queries failed", + }); + }); +}); diff --git a/packages/api/src/router/widgets/coolify.ts b/packages/api/src/router/widgets/coolify.ts index 9476039d9..133d36f14 100644 --- a/packages/api/src/router/widgets/coolify.ts +++ b/packages/api/src/router/widgets/coolify.ts @@ -1,24 +1,48 @@ +import type { CoolifyInstanceInfo } from "@homarr/integrations/types"; import { coolifyRequestHandler } from "@homarr/request-handler/coolify"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +interface CoolifyInstanceResult { + integrationId: string; + integrationName: string; + integrationUrl: string; + instanceInfo: CoolifyInstanceInfo | null; + updatedAt: Date; + error?: string; +} + export const coolifyRouter = createTRPCRouter({ getInstancesInfo: publicProcedure .concat(createManyIntegrationMiddleware("query", "coolify")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = coolifyRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration): Promise => { + const innerHandler = coolifyRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - instanceInfo: data, - updatedAt: timestamp, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + instanceInfo: data, + updatedAt: timestamp, + }; + }, + { + fallback: (integration, error): CoolifyInstanceResult => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + instanceInfo: null, + updatedAt: new Date(0), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 32f15da98..4dffb9f2f 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -1,13 +1,28 @@ import { z } from "zod/v4"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; +import type { DnsHoleSummary } from "@homarr/integrations/types"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; +interface DnsHoleSummaryResult { + integrationId: string; + integrationName: string; + integration: { + id: string; + name: string; + kind: IntegrationKindByCategory<"dnsHole">; + updatedAt?: Date; + }; + summary: DnsHoleSummary | null; + error?: string; +} + export const dnsHoleRouter = createTRPCRouter({ summary: publicProcedure .meta({ @@ -19,13 +34,28 @@ export const dnsHoleRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync(); - return { - integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, - summary: data, - }; - }); + return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>( + ctx.integrations, + async (integration) => { + const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync(); + return { + integrationId: integration.id, + integrationName: integration.name, + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + summary: data, + }; + }, + { + fallback: (integration) => ({ + integrationId: integration.id, + integrationName: integration.name, + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + summary: null, + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); }), enable: protectedProcedure diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index bdce2473f..261d3ba46 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -1,17 +1,47 @@ import { z } from "zod/v4"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations"; import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"; import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) => createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient")); +interface DownloadClientQueryResultBase { + integrationId: string; + integrationName: string; +} + +type DownloadClientQueryResult = DownloadClientQueryResultBase & + ( + | { + integration: { + id: string; + name: string; + kind: IntegrationKindByCategory<"downloadClient">; + updatedAt: Date; + }; + data: DownloadClientJobsAndStatus; + error?: never; + } + | { + integration: { + id: string; + name: string; + kind: IntegrationKindByCategory<"downloadClient">; + }; + data: null; + error: string; + } + ); + export const downloadsRouter = createTRPCRouter({ getJobsAndStatuses: publicProcedure .meta({ @@ -24,14 +54,29 @@ export const downloadsRouter = createTRPCRouter({ .concat(createDownloadClientIntegrationMiddleware("query")) .input(z.object({ limitPerIntegration: z.number().default(50) })) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); - const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, - data, - }; - }); + return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>( + ctx.integrations, + async (integration) => { + const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); + const { data, timestamp } = await innerHandler.getDataAsync(); + return { + integrationId: integration.id, + integrationName: integration.name, + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + data, + }; + }, + { + fallback: (integration) => ({ + integrationId: integration.id, + integrationName: integration.name, + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data: null, + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); }), pause: protectedProcedure .meta({ diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 1957f459c..5c123ecb1 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,9 +1,10 @@ import { createIntegrationAsync } from "@homarr/integrations"; +import type { SystemHealthMonitoring } from "@homarr/integrations"; import { SynologyIntegration } from "@homarr/integrations"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const healthMonitoringIntegrationKinds = [ @@ -16,6 +17,14 @@ const healthMonitoringIntegrationKinds = [ "mock", ] as const; +interface SystemHealthQueryResult { + integrationId: string; + integrationName: string; + healthInfo: SystemHealthMonitoring | null; + updatedAt?: Date; + error?: string; +} + export const healthMonitoringRouter = createTRPCRouter({ getSystemHealthStatus: publicProcedure .meta({ @@ -27,15 +36,27 @@ export const healthMonitoringRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds)) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - healthInfo: data, - updatedAt: timestamp, - }; - }); + return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>( + ctx.integrations, + async (integration) => { + const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync(); + return { + integrationId: integration.id, + integrationName: integration.name, + healthInfo: data, + updatedAt: timestamp, + }; + }, + { + fallback: (integration) => ({ + integrationId: integration.id, + integrationName: integration.name, + healthInfo: null, + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); }), listStorageVolumes: publicProcedure .meta({ diff --git a/packages/api/src/router/widgets/media-release-result.ts b/packages/api/src/router/widgets/media-release-result.ts new file mode 100644 index 000000000..29694a31d --- /dev/null +++ b/packages/api/src/router/widgets/media-release-result.ts @@ -0,0 +1,61 @@ +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import type { MediaRelease } from "@homarr/integrations/types"; + +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; + +interface MediaReleaseSource { + id: string; + name: string; + kind: IntegrationKindByCategory<"mediaRelease">; +} + +interface MediaReleaseLoadResult { + releases: MediaRelease[]; + updatedAt: Date; +} + +interface MediaReleaseIntegrationResult { + integration: MediaReleaseSource & { updatedAt?: Date }; + releases: MediaRelease[]; + error?: string; +} + +export async function getMediaReleaseResponse( + integrations: TIntegration[], + load: (integration: TIntegration) => Promise, +) { + const results = await settleIntegrationQueries( + integrations, + async (integration) => { + const { releases, updatedAt } = await load(integration); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt, + }, + releases, + }; + }, + { + fallback: (integration) => ({ + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + releases: [], + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); + + return { + releases: results.flatMap(({ integration, releases }) => releases.map((release) => ({ ...release, integration }))), + failedIntegrations: results.flatMap(({ integration, error }) => + error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], + ), + }; +} diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index f05a46142..e1acb95b6 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -2,32 +2,21 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { getMediaReleaseResponse } from "./media-release-result"; export const mediaReleaseRouter = createTRPCRouter({ getMediaReleases: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) .query(async ({ ctx }) => { - const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await getMediaReleaseResponse(ctx.integrations, async (integration) => { const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, releases: data, + updatedAt: timestamp, }; }); - return results.flatMap((result) => - result.releases.map((release) => ({ - ...release, - integration: result.integration, - })), - ); }), }); diff --git a/packages/api/src/router/widgets/network-controller.ts b/packages/api/src/router/widgets/network-controller.ts index 61257d1ec..25c974da3 100644 --- a/packages/api/src/router/widgets/network-controller.ts +++ b/packages/api/src/router/widgets/network-controller.ts @@ -1,27 +1,53 @@ +import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { NetworkControllerSummary } from "@homarr/integrations/types"; import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +interface NetworkControllerQueryResult { + integrationId: string; + integrationName: string; + integration: { + id: string; + name: string; + kind: IntegrationKindByCategory<"networkController">; + }; + summary: NetworkControllerSummary | null; + updatedAt?: Date; + error?: string; +} + export const networkControllerRouter = createTRPCRouter({ summary: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = networkControllerRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>( + ctx.integrations, + async (integration) => { + const innerHandler = networkControllerRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - }, - summary: data, - updatedAt: timestamp, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + summary: data, + updatedAt: timestamp, + }; + }, + { + fallback: (integration) => ({ + integrationId: integration.id, + integrationName: integration.name, + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + summary: null, + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/speedtest-tracker.ts b/packages/api/src/router/widgets/speedtest-tracker.ts index 01e7fb58b..4730940a4 100644 --- a/packages/api/src/router/widgets/speedtest-tracker.ts +++ b/packages/api/src/router/widgets/speedtest-tracker.ts @@ -1,24 +1,39 @@ import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const speedtestTrackerRouter = createTRPCRouter({ getDashboard: publicProcedure .concat(createManyIntegrationMiddleware("query", "speedtestTracker")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = speedtestTrackerRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = speedtestTrackerRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data, - updatedAt: timestamp, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data as typeof data | null, + updatedAt: timestamp, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: null, + updatedAt: new Date(0), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts index 5075c990b..d96a02932 100644 --- a/packages/api/src/router/widgets/tracearr.ts +++ b/packages/api/src/router/widgets/tracearr.ts @@ -1,22 +1,37 @@ import { tracearrRequestHandler } from "@homarr/request-handler/tracearr"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const tracearrRouter = createTRPCRouter({ getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = tracearrRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = tracearrRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data, - updatedAt: timestamp, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data as typeof data | null, + updatedAt: timestamp, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: null, + updatedAt: new Date(0), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/traefik.ts b/packages/api/src/router/widgets/traefik.ts index 8f9c775e2..ad7e8479d 100644 --- a/packages/api/src/router/widgets/traefik.ts +++ b/packages/api/src/router/widgets/traefik.ts @@ -1,7 +1,7 @@ import { traefikRequestHandler } from "@homarr/request-handler/traefik"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const traefikRouter = createTRPCRouter({ @@ -15,17 +15,32 @@ export const traefikRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", "traefik")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = traefikRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = traefikRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data, - updatedAt: timestamp, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data as typeof data | null, + updatedAt: timestamp, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: null, + updatedAt: new Date(0), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts index 16c6312dd..bc8481690 100644 --- a/packages/api/src/router/widgets/ups.ts +++ b/packages/api/src/router/widgets/ups.ts @@ -2,24 +2,39 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { upsSummariesRequestHandler } from "@homarr/request-handler/ups"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const upsRouter = createTRPCRouter({ getSummaries: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = upsSummariesRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = upsSummariesRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - summaries: data, - updatedAt: timestamp, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + summaries: data, + updatedAt: timestamp, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + summaries: [], + updatedAt: new Date(0), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts index 6df14090c..e3082c284 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -1,24 +1,39 @@ import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const uptimeKumaRouter = createTRPCRouter({ getDashboard: publicProcedure .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data, - updatedAt: timestamp, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data as typeof data | null, + updatedAt: timestamp, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: null, + updatedAt: new Date(0), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/widgets/src/advanced-focus-support.spec.ts b/packages/widgets/src/advanced-focus-support.spec.ts index 44eb51a45..a549d7a41 100644 --- a/packages/widgets/src/advanced-focus-support.spec.ts +++ b/packages/widgets/src/advanced-focus-support.spec.ts @@ -7,22 +7,36 @@ const advancedFocusWidgetKinds = [ "anchorNote", "archiveTeamWarrior", "audioStats", + "bazarr", + "beszelAlerts", + "beszelSystemGrid", + "beszelSystemTable", "bookmarks", "calendar", "clock", + "coolify", "downloads", "firewall", "healthMonitoring", "immich-albumCarousel", "immich-serverStats", + "indexerManager", "mediaMissing", "mediaReleases", "mediaServer", "mediaTranscoding", "networkControllerStatus", + "networkControllerSummary", + "notebook", + "notifications", + "patchmon", + "releases", + "rssFeed", "smartHome-entityState", "smartHome-executeAutomation", "speedtestTracker", + "stockPrice", + "systemDisks", "systemResources", "timetable", "tracearr", @@ -31,6 +45,7 @@ const advancedFocusWidgetKinds = [ "ups", "uptimeKuma", "weather", + "wud", ] as const; describe("advanced focus support", () => { diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index acda12e12..193650b5c 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -124,7 +124,10 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget /> - {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( + {options.pingEnabled && + !settings.forceDisableStatus && + !board.disableStatus && + Boolean(app.pingUrl ?? app.href) ? ( }> diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index 35cb3049e..e708001dd 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -106,8 +106,35 @@ const ArchiveTeamWarriorWidgetContent = ({ {t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })} + + {status.project?.id && ( + + {t("projectId", { id: status.project.id })} + + )} + {status.bandwidth?.received !== undefined && ( + + {t("sessionReceived", { value: formatBandwidth(status.bandwidth.received) })} + + )} + {status.bandwidth?.sent !== undefined && ( + + {t("sessionSent", { value: formatBandwidth(status.bandwidth.sent) })} + + )} + {status.bandwidth?.session_id && ( + + {t("sessionId", { id: status.bandwidth.session_id })} + + )} + + {status.items.length === 0 && ( + + {t("emptyItems")} + + )} {status.items.map((item) => { const itemStatusKey = getStatusKey(item.status); return ( @@ -120,6 +147,11 @@ const ArchiveTeamWarriorWidgetContent = ({ {item.project ?? projectName} + {item.startTime !== undefined && ( + + {t("startedAt", { date: new Date(item.startTime * 1000).toLocaleString(locale) })} + + )} {t(`status.${itemStatusKey}`)} diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx index 3592744a4..939af20fd 100644 --- a/packages/widgets/src/audio-stats/audio-stats-content.tsx +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -36,14 +36,22 @@ interface AudioStatsContentProps { backend: AudioStatsBackend; stats: NavidromeDashboardData | AudiobookshelfDashboardData; options: AudioStatsDisplayOptions; + showAllStats?: boolean; width: number; height: number; } -export function AudioStatsContent({ backend, stats, options, width, height }: AudioStatsContentProps) { +export function AudioStatsContent({ + backend, + stats, + options, + showAllStats = false, + width, + height, +}: AudioStatsContentProps) { const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE); const compact = options.compactMode ?? false; - const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats), compact); + const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact); const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact)); const compactKey = String(compact) as keyof typeof rootClassByCompact; const gridCols = getGridCols(width, visibleStats.length, compact); diff --git a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts index c4484ac12..77534fec7 100644 --- a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts +++ b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts @@ -96,6 +96,21 @@ describe("getVisibleStats", () => { expect(result).toEqual([]); }); + test("returns every backend-relevant stat when advanced disclosure is enabled", () => { + expect(getVisibleStats("navidrome", {}, navidromeStats, true).map((stat) => stat.statKey)).toEqual([ + "artists", + "albums", + "songs", + ]); + expect(getVisibleStats("audiobookshelf", {}, audiobookshelfStats, true).map((stat) => stat.statKey)).toEqual([ + "libraryCount", + "audiobooks", + "podcasts", + "listeningTime", + "activeSessions", + ]); + }); + test("formats listening time as duration string", () => { const result = getVisibleStats("audiobookshelf", { showListeningTime: true }, audiobookshelfStats); expect(result).toHaveLength(1); diff --git a/packages/widgets/src/audio-stats/shared.ts b/packages/widgets/src/audio-stats/shared.ts index 15e6b8a12..75a875b6a 100644 --- a/packages/widgets/src/audio-stats/shared.ts +++ b/packages/widgets/src/audio-stats/shared.ts @@ -133,13 +133,14 @@ export const getVisibleStats = ( backend: AudioStatsBackend, options: AudioStatsDisplayOptions, stats: NavidromeDashboardData | AudiobookshelfDashboardData, + showAll = false, ): VisibleStat[] => { const visibilityOptions = statVisibilityOptionsByBackend[backend]; const statIcons = statIconsByBackend[backend]; const statValues = statValueGetterByBackend[backend](stats); return Object.entries(visibilityOptions) - .filter(([optionKey]) => Boolean(options[optionKey as keyof typeof visibilityOptions])) + .filter(([optionKey]) => showAll || Boolean(options[optionKey as keyof typeof visibilityOptions])) .map(([optionKey, statKey]) => ({ optionKey, statKey, diff --git a/packages/widgets/src/bazarr/component.module.css b/packages/widgets/src/bazarr/component.module.css index a41eeb5bf..f9f44256c 100644 --- a/packages/widgets/src/bazarr/component.module.css +++ b/packages/widgets/src/bazarr/component.module.css @@ -69,6 +69,8 @@ color: var(--mantine-primary-color-filled); line-height: 1.1; text-align: center; + max-width: 100%; + overflow-wrap: anywhere; } .statValueWarning { diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx index 4d4e9cedd..c423c8faf 100644 --- a/packages/widgets/src/bazarr/component.tsx +++ b/packages/widgets/src/bazarr/component.tsx @@ -2,7 +2,7 @@ import type { CSSProperties } from "react"; import { Text } from "@mantine/core"; -import { IconAlertTriangle, IconDeviceTv, IconMovie, IconPlugConnected } from "@tabler/icons-react"; +import { IconAlertTriangle, IconBell, IconDeviceTv, IconMovie, IconPlugConnected } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; @@ -11,18 +11,34 @@ import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; -const statVisibilityByOption = { - showMissingEpisodes: "episodes", - showMissingMovies: "movies", - showProviderIssues: "providers", - showHealthIssues: "status", -} as const; +const compactStatOptions = [ + { option: "showMissingEpisodes", stat: "episodes" }, + { option: "showMissingMovies", stat: "movies" }, + { option: "showProviderIssues", stat: "providers" }, + { option: "showHealthIssues", stat: "status" }, +] as const; + +const allStatKeys = [ + "episodes", + "movies", + "providers", + "status", + "sonarrSignalr", + "radarrSignalr", + "announcements", +] as const; + +type BazarrStatKey = (typeof allStatKeys)[number]; +type BazarrVisibilityOptions = Record<(typeof compactStatOptions)[number]["option"], boolean>; const statIcons = { episodes: IconDeviceTv, movies: IconMovie, providers: IconPlugConnected, status: IconAlertTriangle, + sonarrSignalr: IconPlugConnected, + radarrSignalr: IconPlugConnected, + announcements: IconBell, } as const; const gridColsByWidth = [ @@ -37,7 +53,13 @@ const iconSizeByWidth = [ { minWidth: 0, size: 16 }, ] as const; -export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) { +export default function BazarrWidget({ + integrationIds, + options, + width, + height, + displayMode, +}: WidgetComponentProps<"bazarr">) { const t = useScopedI18n("widget.bazarr"); const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery( { integrationId: integrationIds[0] ?? "" }, @@ -52,11 +74,12 @@ export default function BazarrWidget({ integrationIds, options, width, height }: movies: badges.movies, providers: badges.providers, status: badges.status, - } as const; + sonarrSignalr: badges.sonarr_signalr, + radarrSignalr: badges.radarr_signalr, + announcements: badges.announcements, + } satisfies Record; - const visibleStatKeys = Object.entries(statVisibilityByOption) - .filter(([optionKey]) => options[optionKey as keyof typeof options]) - .map(([, statKey]) => statKey); + const visibleStatKeys = getVisibleBazarrStatKeys(options, displayMode); const gridCols = getGridCols(width, height, visibleStatKeys.length); const iconSize = getIconSize(Math.min(width, height)); @@ -83,12 +106,17 @@ export default function BazarrWidget({ integrationIds, options, width, height }: {visibleStatKeys.map((statKey) => { const Icon = statIcons[statKey]; const value = statValues[statKey]; - const isWarning = (statKey === "providers" || statKey === "status") && value > 0; + const isWarning = + typeof value === "number" && + (statKey === "providers" || statKey === "status" || statKey === "announcements") && + value > 0; return (
- {value} + + {typeof value === "string" && value.trim() === "" ? "—" : value} + {t(statKey)} @@ -100,6 +128,14 @@ export default function BazarrWidget({ integrationIds, options, width, height }: ); } +export const getVisibleBazarrStatKeys = ( + options: BazarrVisibilityOptions, + displayMode?: "compact" | "advanced", +): BazarrStatKey[] => { + if (displayMode === "advanced") return [...allStatKeys]; + return compactStatOptions.filter(({ option }) => options[option]).map(({ stat }) => stat); +}; + export function getGridCols(width: number, height: number, itemCount: number): number { if (itemCount <= 1) return 1; diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index 3a63fe4bd..ff5bb0d85 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -4,7 +4,7 @@ import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconBadgeCc, supportedIntegrations: ["bazarr"], integrationsRequired: true, diff --git a/packages/widgets/src/bazarr/layout.spec.ts b/packages/widgets/src/bazarr/layout.spec.ts index f5e03fba4..67c34461f 100644 --- a/packages/widgets/src/bazarr/layout.spec.ts +++ b/packages/widgets/src/bazarr/layout.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getGridCols, getIconSize } from "./component"; +import { getGridCols, getIconSize, getVisibleBazarrStatKeys } from "./component"; describe("Bazarr responsive layout", () => { test("uses two columns for a short widget so all stats remain visible", () => { @@ -15,4 +15,18 @@ describe("Bazarr responsive layout", () => { expect(getIconSize(180)).toBe(16); expect(getIconSize(340)).toBe(22); }); + + test("advanced mode includes hidden summaries and all fetched status fields", () => { + expect( + getVisibleBazarrStatKeys( + { + showMissingEpisodes: false, + showMissingMovies: false, + showProviderIssues: false, + showHealthIssues: false, + }, + "advanced", + ), + ).toEqual(["episodes", "movies", "providers", "status", "sonarrSignalr", "radarrSignalr", "announcements"]); + }); }); diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 23243f35e..8990767f3 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -16,6 +16,7 @@ import { VisuallyHidden, } from "@mantine/core"; import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react"; +import { getQueryKey } from "@trpc/react-query"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import type { LucideIcon } from "lucide-react"; @@ -27,6 +28,10 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; +import { useWidgetRuntimeQueries } from "../runtime-hooks"; +import { getBeszelAlertsQueryInput } from "./display"; +import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map"; const alertIconMap: Record = { CPU: Cpu, @@ -50,43 +55,64 @@ export default function BeszelAlertsWidget({ integrationIds, isEditMode, height, + displayMode, + widgetRuntimeRef, }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); + const isAdvanced = displayMode === "advanced"; const alertsInput = useMemo( - () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), - [integrationIds, options.showHistory, options.maxHistoryItems], + () => + getBeszelAlertsQueryInput( + integrationIds, + { showHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }, + isAdvanced, + ), + [integrationIds, options.showHistory, options.maxHistoryItems, isAdvanced], ); const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); - const results = getUsableWidgetQueryData(alertsQuery) ?? []; + const runtimeQueries = useMemo( + () => [getQueryKey(clientApi.widget.beszel.getAlerts, alertsInput, "query")], + [alertsInput], + ); + useWidgetRuntimeQueries(widgetRuntimeRef, runtimeQueries); + const resultData = getUsableWidgetQueryData(alertsQuery); + const results = useMemo(() => resultData ?? [], [resultData]); const { isPending } = alertsQuery; - const systemNameMap = useMemo(() => { - const map: Record = {}; - for (const r of results) { - if (r.systemNameMap) { - Object.assign(map, r.systemNameMap); - } - } - return map; - }, [results]); + const systemNameMap = useMemo(() => buildBeszelSystemNameMap(results), [results]); + const showIntegrationName = isAdvanced || results.length > 1; const alerts = useMemo( - () => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))), + () => + results.flatMap((r) => + r.alerts.map((a) => ({ + ...a, + integrationId: r.integrationId, + integrationName: r.integrationName, + key: `${r.integrationId}:${a.id}`, + })), + ), [results], ); const history = useMemo(() => { - const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` }))); - return all - .toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()) - .slice(0, options.maxHistoryItems); - }, [results, options.maxHistoryItems]); + const all = results.flatMap((r) => + r.history.map((h) => ({ + ...h, + integrationId: r.integrationId, + integrationName: r.integrationName, + key: `${r.integrationId}:${h.id}`, + })), + ); + const sorted = all.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); + return isAdvanced ? sorted : sorted.slice(0, options.maxHistoryItems); + }, [results, options.maxHistoryItems, isAdvanced]); const triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); - const showOkAlerts = triggeredAlerts.length === 0 || height >= 260; - const showHistory = options.showHistory && height >= 360; - const showAlertDescriptions = height >= 190; + const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260; + const showHistory = isAdvanced || (options.showHistory && height >= 360); + const showAlertDescriptions = isAdvanced || height >= 190; if (isPending) { return ( @@ -99,7 +125,10 @@ export default function BeszelAlertsWidget({ return ( - + + + + @@ -124,11 +153,12 @@ export default function BeszelAlertsWidget({ {triggeredAlerts.map((alert) => ( @@ -148,11 +178,12 @@ export default function BeszelAlertsWidget({ {okAlerts.map((alert) => ( @@ -171,11 +202,12 @@ export default function BeszelAlertsWidget({ {history.map((entry) => { - const systemName = systemNameMap[entry.system] ?? entry.system; + const systemName = getBeszelSystemName(systemNameMap, entry.integrationId, entry.system); + const systemLabel = showIntegrationName ? `${systemName} · ${entry.integrationName}` : systemName; const isResolved = !!entry.resolved; const HistoryIcon = alertIconMap[entry.name] ?? Server; return ( - + - {systemName} + {systemLabel} @@ -218,6 +250,7 @@ interface AlertRowProps { value: number; min: number; systemName: string; + integrationName?: string; triggered: boolean; showDescription: boolean; } @@ -243,7 +276,7 @@ function formatAlertDescription(name: string, value: number, min: number): strin return `exceeds ${value}${suffix} over ${min} min`; } -function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) { +function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) { const Icon = alertIconMap[name] ?? Server; const description = formatAlertDescription(name, value, min); return ( @@ -263,7 +296,7 @@ function AlertRow({ name, value, min, systemName, triggered, showDescription }: - {systemName} + {integrationName ? `${systemName} · ${integrationName}` : systemName} · diff --git a/packages/widgets/src/beszel-alerts/display.spec.ts b/packages/widgets/src/beszel-alerts/display.spec.ts new file mode 100644 index 000000000..0b5256aa2 --- /dev/null +++ b/packages/widgets/src/beszel-alerts/display.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; + +import { getBeszelAlertsQueryInput } from "./display"; + +describe("Beszel alerts query input", () => { + test("preserves compact history settings", () => { + expect(getBeszelAlertsQueryInput(["beszel-a"], { showHistory: false, maxHistoryItems: 10 }, false)).toEqual({ + integrationIds: ["beszel-a"], + includeHistory: false, + maxHistoryItems: 10, + }); + }); + + test("requests a useful bounded history in advanced mode", () => { + expect(getBeszelAlertsQueryInput(["beszel-a"], { showHistory: false, maxHistoryItems: 10 }, true)).toEqual({ + integrationIds: ["beszel-a"], + includeHistory: true, + maxHistoryItems: 50, + }); + expect(getBeszelAlertsQueryInput(["beszel-a"], { showHistory: true, maxHistoryItems: 150 }, true)).toEqual({ + integrationIds: ["beszel-a"], + includeHistory: true, + maxHistoryItems: 100, + }); + }); +}); diff --git a/packages/widgets/src/beszel-alerts/display.ts b/packages/widgets/src/beszel-alerts/display.ts new file mode 100644 index 000000000..8a00568db --- /dev/null +++ b/packages/widgets/src/beszel-alerts/display.ts @@ -0,0 +1,22 @@ +export const BESZEL_ALERTS_ADVANCED_MIN_HISTORY_ITEMS = 50; +const BESZEL_ALERTS_MAX_HISTORY_ITEMS = 100; + +interface BeszelAlertsDisplayOptions { + showHistory: boolean; + maxHistoryItems: number; +} + +export const getBeszelAlertsQueryInput = ( + integrationIds: string[], + options: BeszelAlertsDisplayOptions, + isAdvanced: boolean, +) => ({ + integrationIds, + includeHistory: isAdvanced || options.showHistory, + maxHistoryItems: isAdvanced + ? Math.min( + BESZEL_ALERTS_MAX_HISTORY_ITEMS, + Math.max(BESZEL_ALERTS_ADVANCED_MIN_HISTORY_ITEMS, options.maxHistoryItems), + ) + : options.maxHistoryItems, +}); diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index db1e791b2..12bd3d419 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -1,19 +1,23 @@ import { IconBell, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; +import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { icon: IconBell, - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, queryKey: [["widget", "beszel", "getAlerts"]], - queryMatcher: ({ input }, scope) => - widgetQueryInputMatches(input, { + queryMatcher(query, scope) { + const hasRuntimeAlertsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getAlerts"); + if (hasRuntimeAlertsQuery) return matchesWidgetRuntimeQuery(query, scope); + + return widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds, includeHistory: scope.options.showHistory, maxHistoryItems: scope.options.maxHistoryItems, - }), + }); + }, supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, createOptions() { diff --git a/packages/widgets/src/beszel-alerts/system-name-map.spec.ts b/packages/widgets/src/beszel-alerts/system-name-map.spec.ts new file mode 100644 index 000000000..4709d5d3e --- /dev/null +++ b/packages/widgets/src/beszel-alerts/system-name-map.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; + +import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map"; + +describe("Beszel alert system names", () => { + test("keeps identical system IDs scoped to their integration", () => { + const systemNames = buildBeszelSystemNameMap([ + { integrationId: "beszel-a", systemNameMap: { shared: "Alpha server" } }, + { integrationId: "beszel-b", systemNameMap: { shared: "Beta server" } }, + ]); + + expect(getBeszelSystemName(systemNames, "beszel-a", "shared")).toBe("Alpha server"); + expect(getBeszelSystemName(systemNames, "beszel-b", "shared")).toBe("Beta server"); + }); + + test("falls back to the system ID when no name is available", () => { + expect(getBeszelSystemName(new Map(), "beszel-a", "unknown")).toBe("unknown"); + }); +}); diff --git a/packages/widgets/src/beszel-alerts/system-name-map.ts b/packages/widgets/src/beszel-alerts/system-name-map.ts new file mode 100644 index 000000000..ad127e31b --- /dev/null +++ b/packages/widgets/src/beszel-alerts/system-name-map.ts @@ -0,0 +1,24 @@ +interface BeszelSystemNamesByIntegration { + integrationId: string; + systemNameMap: Record; +} + +export const getBeszelSystemNameKey = (integrationId: string, systemId: string) => `${integrationId}:${systemId}`; + +export const buildBeszelSystemNameMap = (results: readonly BeszelSystemNamesByIntegration[]) => { + const systemNames = new Map(); + + for (const result of results) { + for (const [systemId, systemName] of Object.entries(result.systemNameMap)) { + systemNames.set(getBeszelSystemNameKey(result.integrationId, systemId), systemName); + } + } + + return systemNames; +}; + +export const getBeszelSystemName = ( + systemNames: ReadonlyMap, + integrationId: string, + systemId: string, +) => systemNames.get(getBeszelSystemNameKey(integrationId, systemId)) ?? systemId; diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 66f32dc35..84f796fe2 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { MantineSize } from "@mantine/core"; -import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core"; +import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text } from "@mantine/core"; import { Activity, Battery, @@ -19,6 +19,7 @@ import { IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; +import { formatBytes } from "@homarr/common"; import { useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; @@ -38,8 +39,10 @@ import { import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; +import { isBeszelGridMetricVisible } from "./display"; interface SizeConfig { iconSize: number; @@ -117,10 +120,12 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => { const MIN_CELL_WIDTH = 140; const MIN_CELL_HEIGHT = 80; +const ADVANCED_MIN_CELL_WIDTH = 320; +const ADVANCED_MIN_CELL_HEIGHT = 420; -const getColCount = (width: number, _height: number, itemCount: number): number => { +const getColCount = (width: number, _height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => { if (itemCount <= 1) return 1; - const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH))); + const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth))); let best = 1; for (let c = 1; c <= maxCols; c++) { @@ -168,12 +173,14 @@ const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => ( ); interface SystemCardProps { - system: BeszelSystemRow & { _key: string }; + system: BeszelSystemRow & { rowKey: string }; options: WidgetComponentProps<"beszelSystemGrid">["options"]; t: ReturnType>; size: SizeConfig; maxMetrics: number; itemRadius: MantineSize; + integrationName: string; + isAdvanced: boolean; onClick?: () => void; } @@ -190,7 +197,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showCpu, advanced), }, { key: "showMemory", @@ -204,7 +212,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showMemory, advanced), }, { key: "showDisk", @@ -217,7 +226,8 @@ const metricRenderers = [ ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showDisk, advanced), }, { key: "showGpu", @@ -231,7 +241,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showGpu, advanced, s.gpu > 0), }, { key: "showLoadAvg", @@ -244,7 +255,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showLoadAvg, advanced, s.loadAvg !== null), }, { key: "showNet", @@ -257,7 +269,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showNet, advanced), }, { key: "showTemp", @@ -270,7 +283,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showTemp, advanced, s.temp !== null), }, { key: "showBattery", @@ -287,7 +301,8 @@ const metricRenderers = [ /> ); }, - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showBattery, advanced, s.battery !== null), }, { key: "showServices", @@ -300,7 +315,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showServices, advanced), }, { key: "showUptime", @@ -313,7 +329,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showUptime, advanced), }, { key: "showAgent", @@ -326,12 +343,23 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showAgent, advanced), }, ] as const; -const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => { - const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options)); +const SystemCard = ({ + system, + options, + t, + size, + maxMetrics, + itemRadius, + integrationName, + isAdvanced, + onClick, +}: SystemCardProps) => { + const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options, isAdvanced)); const visibleMetrics = enabledMetrics.slice(0, maxMetrics); const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length; @@ -368,6 +396,20 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick + {isAdvanced && ( + + + + + + 0 ? formatBytes(system.memoryTotal) : "—"} + /> + + + )} + {visibleMetrics.map((m) => m.render(system, t, size))} @@ -380,12 +422,24 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick ); }; +const SystemMetadata = ({ label, value }: { label: string; value: string }) => ( + + + {label} + + + {value} + + +); + export default function BeszelSystemGridWidget({ options, integrationIds, isEditMode, width, height, + displayMode, }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); const board = useRequiredBoard(); @@ -394,6 +448,8 @@ export default function BeszelSystemGridWidget({ const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); const results = getUsableWidgetQueryData(systemsQuery) ?? []; const { isPending } = systemsQuery; + const isAdvanced = displayMode === "advanced"; + const integrationNames = new Map(results.map((result) => [result.integrationId, result.integrationName])); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); @@ -408,7 +464,10 @@ export default function BeszelSystemGridWidget({ if (filteredSystems.length === 0) { return ( - + + + +
@@ -421,19 +480,23 @@ export default function BeszelSystemGridWidget({ ); } - const cols = getColCount(width, height, filteredSystems.length); + const minimumCellWidth = isAdvanced ? ADVANCED_MIN_CELL_WIDTH : MIN_CELL_WIDTH; + const cols = getColCount(width, height, filteredSystems.length, minimumCellWidth); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; - const minimumCellHeight = MIN_CELL_HEIGHT; + const minimumCellHeight = isAdvanced ? ADVANCED_MIN_CELL_HEIGHT : MIN_CELL_HEIGHT; const scrollEnabled = rawCellHeight < minimumCellHeight; const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight; const cellWidth = width / cols; const size = getSizeConfig(cellWidth, effectiveCellHeight); - const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); + const maxMetrics = isAdvanced ? Number.POSITIVE_INFINITY : getMaxVisibleMetrics(effectiveCellHeight, size); return ( - + + + + {filteredSystems.map((system) => { - const integrationId = system._key.split(":")[0] ?? ""; + const integrationId = system.rowKey.split(":")[0] ?? ""; + const integrationName = integrationNames.get(integrationId) ?? "—"; const handleClick = isEditMode ? undefined : () => openModal({ integrationId, systemId: system.id }, { title: system.name }); return ( ); diff --git a/packages/widgets/src/beszel-system-grid/display.spec.ts b/packages/widgets/src/beszel-system-grid/display.spec.ts new file mode 100644 index 000000000..c655f0515 --- /dev/null +++ b/packages/widgets/src/beszel-system-grid/display.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; + +import { isBeszelGridMetricVisible } from "./display"; + +describe("Beszel grid metric visibility", () => { + test("honors compact configuration", () => { + expect(isBeszelGridMetricVisible(false, false)).toBe(false); + expect(isBeszelGridMetricVisible(true, false)).toBe(true); + }); + + test("forces configured-off metrics in advanced mode when applicable", () => { + expect(isBeszelGridMetricVisible(false, true)).toBe(true); + expect(isBeszelGridMetricVisible(false, true, false)).toBe(false); + }); +}); diff --git a/packages/widgets/src/beszel-system-grid/display.ts b/packages/widgets/src/beszel-system-grid/display.ts new file mode 100644 index 000000000..6daad5393 --- /dev/null +++ b/packages/widgets/src/beszel-system-grid/display.ts @@ -0,0 +1,2 @@ +export const isBeszelGridMetricVisible = (configured: boolean, isAdvanced: boolean, applicable = true) => + applicable && (isAdvanced || configured); diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts index b3ef14806..c4ac8ae75 100644 --- a/packages/widgets/src/beszel-system-grid/index.ts +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -6,7 +6,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { icon: IconLayoutGrid, - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, queryKey: [["widget", "beszel", "getSystems"]], supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 3c79436f0..4f2e96d56 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -18,6 +18,7 @@ import { useWidgetRuntimeQueries } from "../runtime-hooks"; import type { BeszelTimePeriod } from "../beszel/_shared/chart"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { BeszelStatsView } from "../beszel/_shared/stats-view"; import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; @@ -45,7 +46,8 @@ export default function BeszelSystemStatsWidget({ }), }); const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); - const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? []; + const systemsData = getUsableWidgetQueryData(systemsQuery); + const systemsResult = useMemo(() => systemsData ?? [], [systemsData]); const { isPending: systemsPending } = systemsQuery; const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]); @@ -138,7 +140,10 @@ export default function BeszelSystemStatsWidget({ if (systems.length === 0) { return ( - + + + +
@@ -175,7 +180,10 @@ export default function BeszelSystemStatsWidget({ return ( - + + + + { ).toEqual({ cpu: 140 }); }); }); + +describe("Beszel table metric visibility", () => { + const enabledOptions = Object.fromEntries(beszelTableMetricOptionKeys.map((key) => [key, true])) as Record< + (typeof beszelTableMetricOptionKeys)[number], + boolean + >; + + test("keeps the compact width budget", () => { + expect([...getBeszelTableVisibleMetricKeys(enabledOptions, 350, false)]).toEqual(["showCpu"]); + expect(getBeszelTableVisibleMetricKeys(enabledOptions, 600, false).size).toBe(4); + }); + + test("shows every metric in advanced mode regardless of visibility options", () => { + const disabledOptions = Object.fromEntries( + beszelTableMetricOptionKeys.map((key) => [key, false]), + ) as typeof enabledOptions; + + expect([...getBeszelTableVisibleMetricKeys(disabledOptions, 200, true)]).toEqual(beszelTableMetricOptionKeys); + }); +}); diff --git a/packages/widgets/src/beszel-system-table/display.ts b/packages/widgets/src/beszel-system-table/display.ts new file mode 100644 index 000000000..c8c622ca6 --- /dev/null +++ b/packages/widgets/src/beszel-system-table/display.ts @@ -0,0 +1,28 @@ +export const beszelTableMetricOptionKeys = [ + "showCpu", + "showMemory", + "showDisk", + "showGpu", + "showLoadAvg", + "showNet", + "showTemp", + "showBattery", + "showServices", + "showUptime", + "showAgent", +] as const; + +type BeszelTableMetricOptions = Record<(typeof beszelTableMetricOptionKeys)[number], boolean>; + +export const getBeszelTableVisibleMetricKeys = ( + options: BeszelTableMetricOptions, + width: number, + isAdvanced: boolean, +) => { + const enabled = isAdvanced + ? [...beszelTableMetricOptionKeys] + : beszelTableMetricOptionKeys.filter((key) => options[key]); + const budget = isAdvanced ? enabled.length : width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length; + + return new Set(enabled.slice(0, budget)); +}; diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index 4007bebc0..44209614a 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -25,7 +25,7 @@ const sortDirectionOptions = [ export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", { icon: IconTable, - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]], queryMatcher: (query, scope) => query.path.at(-1) === "getSystems" diff --git a/packages/widgets/src/beszel/_shared/hooks.ts b/packages/widgets/src/beszel/_shared/hooks.ts index b00e6f857..4fd63ecbd 100644 --- a/packages/widgets/src/beszel/_shared/hooks.ts +++ b/packages/widgets/src/beszel/_shared/hooks.ts @@ -4,7 +4,7 @@ import { useMemo } from "react"; import type { BeszelSystemRow } from "./types"; -type SystemRowWithKey = BeszelSystemRow & { _key: string }; +type SystemRowWithKey = BeszelSystemRow & { rowKey: string }; export const useBeszelFilteredSystems = ( results: { integrationId: string; systems: BeszelSystemRow[] }[], @@ -13,7 +13,7 @@ export const useBeszelFilteredSystems = ( const allSystems = useMemo( () => results - .flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))) + .flatMap((r) => r.systems.map((s) => ({ ...s, rowKey: `${r.integrationId}:${s.id}` }))) .toSorted((a, b) => a.name.localeCompare(b.name)), [results], ); diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index 83449f190..d9c688555 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -1,17 +1,26 @@ "use client"; -import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; -import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; -import { createWidgetKey } from "./coolify-utils"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; +import { WidgetEmptyState } from "../common/empty-state"; +import type { WidgetComponentProps } from "../definition"; +import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils"; import { InstanceCard } from "./instance-card"; import { SingleInstanceLayout } from "./single-instance-layout"; +import type { InstanceData } from "./types"; -export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) { +export default function CoolifyWidget({ + options, + integrationIds, + width, + height, + displayMode, +}: WidgetComponentProps<"coolify">) { const t = useScopedI18n("widget.coolify"); if (integrationIds.length === 0) { @@ -22,7 +31,18 @@ export default function CoolifyWidget({ options, integrationIds, width, height } ); } - return ; + const isAdvanced = displayMode === "advanced"; + const sectionVisibility = getCoolifySectionVisibility(options, displayMode); + + return ( + + ); } interface CoolifyContentProps { @@ -30,19 +50,24 @@ interface CoolifyContentProps { options: WidgetComponentProps<"coolify">["options"]; width: number; height: number; + isAdvanced: boolean; } -function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) { +function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) { const t = useScopedI18n("common"); const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds, }); const instancesData = getUsableWidgetQueryData(instancesQuery) ?? []; const { isPending } = instancesQuery; + const successfulInstances = instancesData.filter( + (instance): instance is InstanceData => instance.instanceInfo !== null, + ); + const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null); const isTiny = width < 256 || height < 144; const hideFooter = height < 112; - const [firstInstance] = instancesData; + const [firstInstance] = successfulInstances; const widgetKey = createWidgetKey(integrationIds); if (isPending) { @@ -54,33 +79,77 @@ function CoolifyContent({ integrationIds, options, width, height }: CoolifyConte ); } - - if (instancesData.length === 1 && firstInstance) { + if (successfulInstances.length === 0) { return ( - + + + + + + + ); + } + + if (successfulInstances.length === 1 && firstInstance) { + return ( + + +
+ +
+
); } return ( - - = 760 ? 2 : 1} spacing="sm" p="xs"> - {instancesData.map((instance) => ( - - ))} - - + + + + = 760 ? 2 : 1} spacing="sm" p="xs"> + {successfulInstances.map((instance) => ( + + ))} + + + ); } + +const FailedSourceBadges = ({ + sources, + queryError, +}: { + sources: { integrationId: string; integrationName: string }[]; + queryError: unknown; +}) => { + const t = useScopedI18n("widget.coolify"); + if (sources.length === 0 && !queryError) return null; + + return ( + + {sources.map((source) => ( + + {t("source.unavailable", { source: source.integrationName })} + + ))} + + + + + ); +}; diff --git a/packages/widgets/src/coolify/coolify-utils.spec.ts b/packages/widgets/src/coolify/coolify-utils.spec.ts index 0b9345861..5631a6083 100644 --- a/packages/widgets/src/coolify/coolify-utils.spec.ts +++ b/packages/widgets/src/coolify/coolify-utils.spec.ts @@ -2,7 +2,13 @@ import dayjs from "dayjs"; import frenchLocale from "dayjs/locale/fr"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { formatRelativeTime, getResourceTimestamp } from "./coolify-utils"; +import { + formatRelativeTime, + getCoolifySectionVisibility, + getCoolifyServerState, + getResourceTimestamp, + isCoolifyServerOnline, +} from "./coolify-utils"; describe("Coolify time formatting", () => { beforeEach(() => { @@ -27,3 +33,51 @@ describe("Coolify time formatting", () => { expect(getResourceTimestamp({ status: "running", updated_at: "2026-08-02T11:00:00Z" }, "service")).toBeUndefined(); }); }); + +describe("Coolify advanced display", () => { + it("forces every non-sensitive section without changing compact settings", () => { + const options = { showServers: false, showApplications: true, showServices: false }; + + expect(getCoolifySectionVisibility(options, "compact")).toEqual(options); + expect(getCoolifySectionVisibility(options, "advanced")).toEqual({ + showServers: true, + showApplications: true, + showServices: true, + }); + }); + + it("uses top-level server state before the settings fallback", () => { + const server = { + id: null, + uuid: "server-1", + name: "Build server", + ip: null, + is_reachable: false, + is_usable: null, + settings: { + server_id: null, + is_build_server: null, + is_reachable: true, + is_usable: true, + }, + }; + + expect(getCoolifyServerState(server, "is_reachable")).toBe(false); + expect(getCoolifyServerState(server, "is_usable")).toBe(true); + }); + + it("does not count unknown reachability as online", () => { + const server = { + id: null, + uuid: "server-unknown", + name: "Unknown server", + ip: null, + is_reachable: null, + is_usable: null, + settings: null, + }; + + expect(getCoolifyServerState(server, "is_reachable")).toBeUndefined(); + expect(isCoolifyServerOnline(server)).toBe(false); + }); +}); diff --git a/packages/widgets/src/coolify/coolify-utils.ts b/packages/widgets/src/coolify/coolify-utils.ts index 89343af93..2deb16755 100644 --- a/packages/widgets/src/coolify/coolify-utils.ts +++ b/packages/widgets/src/coolify/coolify-utils.ts @@ -110,3 +110,26 @@ export function createStorageKey(widgetKey: string, integrationId: string, type: const cardKey = widgetKey.includes("-") ? `${widgetKey}-${integrationId}` : widgetKey; return `coolify-${type}-${cardKey}`; } + +interface CoolifySectionOptions { + showServers: boolean; + showApplications: boolean; + showServices: boolean; +} + +export function getCoolifySectionVisibility( + options: CoolifySectionOptions, + displayMode?: "compact" | "advanced", +): CoolifySectionOptions { + if (displayMode === "advanced") { + return { showServers: true, showApplications: true, showServices: true }; + } + return options; +} + +export function getCoolifyServerState(server: CoolifyServer, field: "is_reachable" | "is_usable"): boolean | undefined { + return server[field] ?? server.settings?.[field] ?? undefined; +} + +export const isCoolifyServerOnline = (server: CoolifyServer): boolean => + getCoolifyServerState(server, "is_reachable") === true; diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts index 5aad13a33..cfc96bc9a 100644 --- a/packages/widgets/src/coolify/index.ts +++ b/packages/widgets/src/coolify/index.ts @@ -4,7 +4,7 @@ import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("coolify", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconCloud, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index 2e79bfdf0..ebd1a2b58 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -1,20 +1,28 @@ "use client"; import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core"; -import { IconCircleCheckFilled, IconCircleXFilled, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react"; +import { + IconCircleCheckFilled, + IconCircleFilled, + IconCircleXFilled, + IconEye, + IconEyeOff, + IconServer, +} from "@tabler/icons-react"; import type { CoolifyServer } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; import actionTargetClasses from "../common/action-target.module.css"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; -import { getBadgeColor } from "./coolify-utils"; +import { getBadgeColor, getCoolifyServerState, isCoolifyServerOnline } from "./coolify-utils"; interface ServersSectionProps { servers: CoolifyServer[]; serverResourceCounts: Map; baseUrl: string; isTiny: boolean; + isAdvanced: boolean; showIp: boolean; onToggleIp: () => void; } @@ -24,12 +32,13 @@ export function ServersSection({ serverResourceCounts, baseUrl, isTiny, + isAdvanced, showIp, onToggleIp, }: ServersSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); - const onlineServers = servers.filter((server) => server.is_reachable !== false).length; + const onlineServers = servers.filter(isCoolifyServerOnline).length; return ( @@ -67,6 +76,7 @@ export function ServersSection({ } baseUrl={baseUrl} isTiny={isTiny} + isAdvanced={isAdvanced} showIp={showIp} /> ))} @@ -86,23 +96,28 @@ interface ServerRowProps { counts: { apps: number; services: number }; baseUrl: string; isTiny: boolean; + isAdvanced: boolean; showIp: boolean; } -function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) { +function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: ServerRowProps) { const t = useScopedI18n("widget.coolify"); const isBuildServer = server.settings?.is_build_server === true; - const isOnline = server.is_reachable !== false; + const reachability = getCoolifyServerState(server, "is_reachable"); + const usability = getCoolifyServerState(server, "is_usable"); const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`); - const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled; + const status = reachability === true ? "online" : reachability === false ? "offline" : "unknown"; + const StatusIcon = + reachability === true ? IconCircleCheckFilled : reachability === false ? IconCircleXFilled : IconCircleFilled; + const statusColor = reachability === true ? "green" : reachability === false ? "red" : "gray"; return ( - {showIp ? server.ip : "***.***.***.***"} + {showIp ? (server.ip ?? "—") : "***.***.***.***"} + {isAdvanced && ( + + + + + )} ); } + +const ServerStateBadge = ({ field, value }: { field: "reachability" | "usability"; value: boolean | undefined }) => { + const t = useScopedI18n("widget.coolify"); + const status = + value === undefined + ? t("status.unknown") + : field === "reachability" + ? t(value ? "status.reachable" : "status.unreachable") + : t(value ? "status.usable" : "status.unusable"); + return ( + + {t(`server.${field}`)}: {status} + + ); +}; diff --git a/packages/widgets/src/coolify/services-section.tsx b/packages/widgets/src/coolify/services-section.tsx index c225db27d..1961077cc 100644 --- a/packages/widgets/src/coolify/services-section.tsx +++ b/packages/widgets/src/coolify/services-section.tsx @@ -1,21 +1,24 @@ "use client"; -import { Accordion, Badge, Group, Stack, Text } from "@mantine/core"; +import { Accordion, Anchor, Badge, Group, Stack, Text } from "@mantine/core"; import { IconStack2 } from "@tabler/icons-react"; import type { CoolifyServiceWithContext } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; -import { getBadgeColor, parseStatus } from "./coolify-utils"; +import actionTargetClasses from "../common/action-target.module.css"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { cleanFqdn, getBadgeColor, getStatusColor, parseStatus } from "./coolify-utils"; import { ResourceRow } from "./resource-row"; interface ServicesSectionProps { services: CoolifyServiceWithContext[]; baseUrl: string; isTiny: boolean; + isAdvanced: boolean; } -export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) { +export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: ServicesSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length; @@ -34,7 +37,19 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr {services.length > 0 ? ( {services.map((service) => ( - + + + {isAdvanced && service.applications && service.applications.length > 0 && ( + + + {tCommon("applications")} + + {service.applications.map((application) => ( + + ))} + + )} + ))} ) : ( @@ -46,3 +61,45 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr ); } + +type ServiceApplication = NonNullable[number]; + +const ServiceApplicationRow = ({ application }: { application: ServiceApplication }) => { + const t = useScopedI18n("widget.coolify"); + const status = parseStatus(application.status ?? ""); + const publicUrl = getSafeApplicationUrl(cleanFqdn(application.fqdn)); + const statusLabel = + status === "running" || + status === "stopped" || + status === "exited" || + status === "starting" || + status === "restarting" + ? t(`status.${status}`) + : t("status.unknown"); + + return ( + + + {statusLabel} + + {publicUrl ? ( + + {application.name} + + ) : ( + + {application.name} + + )} + + ); +}; diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 13977a5a0..d9e4b578e 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { ActionIcon, Badge, + Box, Button, Card, Flex, @@ -33,7 +34,9 @@ import { MaskedOrNormalImage } from "@homarr/ui"; import type { widgetKind } from "."; import type { WidgetComponentProps } from "../../definition"; +import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../../common/query-state"; +import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import classes from "./component.module.css"; import TimerModal from "./TimerModal"; @@ -41,6 +44,15 @@ import TimerModal from "./TimerModal"; const dnsLightStatus = (enabled: boolean | undefined) => `var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`; +type DnsSummaryResult = RouterOutputs["widget"]["dnsHole"]["summary"][number]; +type AvailableDnsSummaryResult = DnsSummaryResult & { + summary: NonNullable; + integration: DnsSummaryResult["integration"] & { updatedAt: Date }; +}; + +const isAvailableDnsSummaryResult = (result: DnsSummaryResult): result is AvailableDnsSummaryResult => + result.summary !== null && result.integration.updatedAt !== undefined; + export default function DnsHoleControlsWidget({ options, integrationIds, @@ -55,7 +67,8 @@ export default function DnsHoleControlsWidget({ .filter((id) => integrationIds.includes(id)); const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds }); - const summaries = getUsableWidgetQueryData(summaryQuery) ?? []; + const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? []; + const summaries = summaryResults.filter(isAvailableDnsSummaryResult); const { isPending: isSummaryPending } = summaryQuery; const utils = clientApi.useUtils(); @@ -84,7 +97,7 @@ export default function DnsHoleControlsWidget({ if (!prevData) return []; return prevData.map((item) => - item.integration.id === integrationId + item.integration.id === integrationId && item.summary ? { ...item, summary: { @@ -147,7 +160,14 @@ export default function DnsHoleControlsWidget({ p="sm" gap="sm" style={{ pointerEvents: isEditMode ? "none" : undefined }} + pos="relative" > + + + + + + {controlAllButtonsVisible && ( @@ -260,7 +280,7 @@ export default function DnsHoleControlsWidget({ interface ControlsCardProps { integrationsWithInteractions: string[]; toggleDns: (integrationId: string) => Promise; - data: RouterOutputs["widget"]["dnsHole"]["summary"][number]; + data: AvailableDnsSummaryResult; setSelectedIntegrationIds: (integrationId: string[]) => void; open: () => void; t: TranslationFunction; diff --git a/packages/widgets/src/dns-hole/summary/component.tsx b/packages/widgets/src/dns-hole/summary/component.tsx index f61991d27..19f8af177 100644 --- a/packages/widgets/src/dns-hole/summary/component.tsx +++ b/packages/widgets/src/dns-hole/summary/component.tsx @@ -5,6 +5,7 @@ import { Avatar, AvatarGroup, Badge, + Box, Card, Flex, Group, @@ -29,7 +30,9 @@ import type { TablerIcon } from "@homarr/ui"; import type { widgetKind } from "."; import type { WidgetComponentProps, WidgetProps } from "../../definition"; +import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../../common/query-state"; +import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator"; export default function DnsHoleSummaryWidget({ options, @@ -45,9 +48,10 @@ export default function DnsHoleSummaryWidget({ const t = useI18n(); - const data = summaries.flatMap(({ summary }) => summary); + const successfulSummaries = summaries.filter(({ summary }) => summary !== null); + const data = successfulSummaries.flatMap(({ summary }) => (summary ? [summary] : [])); const layoutProps = boxPropsByLayout(options.layout); - const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220; + const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220; if (isPending) { return ( @@ -60,7 +64,13 @@ export default function DnsHoleSummaryWidget({ } return ( - + + + + + + + {data.length > 0 ? ( stats.map((item) => ( @@ -78,7 +88,7 @@ export default function DnsHoleSummaryWidget({ style={{ gridColumn: "1 / -1" }} > - {summaries.map(({ integration }) => ( + {successfulSummaries.map(({ integration }) => ( @@ -93,14 +103,14 @@ export default function DnsHoleSummaryWidget({ {showSourceStatuses && ( - {summaries.map(({ integration, summary }) => ( + {successfulSummaries.map(({ integration, summary }) => ( - {integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}` as never)} + {integration.name}: {t(`widget.dnsHoleSummary.status.${summary?.status ?? "unknown"}` as never)} ))} diff --git a/packages/widgets/src/downloads/helpers.spec.ts b/packages/widgets/src/downloads/helpers.spec.ts index 09a4aa042..8782bf4bb 100644 --- a/packages/widgets/src/downloads/helpers.spec.ts +++ b/packages/widgets/src/downloads/helpers.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "vitest"; -import { columnWidthsToRecord, filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers"; +import { + DOWNLOAD_COLUMN_ACCESSORS, + filterDownloadItemsByStatus, + getAvailableDownloadStates, + getDownloadColumnAccessors, +} from "./helpers"; describe("downloads filters", () => { const eligibleItems = [{ state: "downloading" as const }, { state: "seeding" as const }]; @@ -18,11 +23,9 @@ describe("downloads filters", () => { }); }); -describe("downloads column widths", () => { - test("serializes numeric and pixel widths while ignoring automatic widths", () => { - expect(columnWidthsToRecord([{ name: 240 }, { progress: "120px" }, { state: "auto" }])).toEqual({ - name: 240, - progress: 120, - }); +describe("downloads advanced columns", () => { + test("uses every column in advanced mode without changing the compact selection", () => { + expect(getDownloadColumnAccessors(["name"], true)).toEqual(DOWNLOAD_COLUMN_ACCESSORS); + expect(getDownloadColumnAccessors(["name"], false)).toEqual(["name"]); }); }); diff --git a/packages/widgets/src/downloads/helpers.ts b/packages/widgets/src/downloads/helpers.ts index 2763d57ee..968c31f4b 100644 --- a/packages/widgets/src/downloads/helpers.ts +++ b/packages/widgets/src/downloads/helpers.ts @@ -2,6 +2,33 @@ import type { ExtendedDownloadClientItem } from "@homarr/integrations"; type DownloadState = ExtendedDownloadClientItem["state"]; +export const DOWNLOAD_COLUMN_ACCESSORS = [ + "name", + "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "state", + "added", + "ratio", + "received", + "sent", + "category", + "integration", + "index", + "type", +] as const satisfies readonly (keyof ExtendedDownloadClientItem)[]; + +type DownloadColumnAccessor = (typeof DOWNLOAD_COLUMN_ACCESSORS)[number]; + +export function getDownloadColumnAccessors( + configuredColumns: readonly DownloadColumnAccessor[], + isAdvanced: boolean, +): readonly DownloadColumnAccessor[] { + return isAdvanced ? DOWNLOAD_COLUMN_ACCESSORS : configuredColumns; +} + export function filterDownloadItemsByStatus( items: readonly T[], statusFilter: readonly string[], diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index 7ac9d1182..b98cba5c6 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -2,28 +2,10 @@ import { IconDownload } from "@tabler/icons-react"; import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { ExtendedDownloadClientItem } from "@homarr/integrations"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; - -const columnsList = [ - "name", - "progress", - "size", - "downSpeed", - "upSpeed", - "time", - "state", - "added", - "ratio", - "received", - "sent", - "category", - "integration", - "index", - "type", -] as const satisfies (keyof ExtendedDownloadClientItem)[]; +import { DOWNLOAD_COLUMN_ACCESSORS } from "./helpers"; const sortColumns = [ "name", @@ -38,7 +20,7 @@ const sortColumns = [ "sent", "index", "type", -] as const satisfies readonly (typeof columnsList)[number][]; +] as const satisfies readonly (typeof DOWNLOAD_COLUMN_ACCESSORS)[number][]; export const { definition, componentLoader } = createWidgetDefinition("downloads", { icon: IconDownload, @@ -55,7 +37,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads (factory) => ({ columns: factory.multiSelect({ defaultValue: ["name", "progress", "downSpeed", "time", "state"], - options: columnsList.map((value) => ({ + options: DOWNLOAD_COLUMN_ACCESSORS.map((value) => ({ value, label: (t) => t(`widget.downloads.items.${value}.columnTitle`), })), diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index f8cfafda0..79886384e 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -24,6 +24,8 @@ import { IconCpu2, IconFileReport, IconInfoCircle, + IconPackages, + IconRefreshAlert, IconServer, IconTemperature, IconVersions, @@ -41,7 +43,9 @@ import { useI18n } from "@homarr/translation/client"; import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes"; import { WidgetEmptyState } from "../common/empty-state"; -import { getUsableWidgetQueryData } from "../common/query-state"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import actionTargetClasses from "../common/action-target.module.css"; import { CpuRing } from "./rings/cpu-ring"; @@ -60,21 +64,46 @@ export const SystemHealthMonitoring = ({ withScrollArea = true, }: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => { const t = useI18n(); - const healthData = - getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds })) ?? - []; + const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds }); + const healthResults = getUsableWidgetQueryData(healthQuery) ?? []; + const healthData = healthResults.filter( + (entry): entry is typeof entry & { healthInfo: NonNullable } => entry.healthInfo !== null, + ); const [openedIntegrationId, setOpenedIntegrationId] = useState(null); const board = useRequiredBoard(); const isAdvanced = displayMode === "advanced"; const isTiny = !isAdvanced && width < 256; + const showCpu = isAdvanced || options.cpu; + const showMemory = isAdvanced || options.memory; + const showGpu = isAdvanced || options.gpu; + const showFileSystem = isAdvanced || options.fileSystem; + const queryIndicators = ( + + + + + ); - if (healthData.length === 0) return ; + if (isInitialWidgetQueryPending(healthQuery)) return ; + if (healthData.length === 0) { + return ( + + + {queryIndicators} + + + + ); + } const Container = withScrollArea ? ScrollArea : Box; return ( - + + + {queryIndicators} + {healthData.map(({ integrationId, integrationName, healthInfo }) => { const filteredFileSystem = filterStorageVolumes( @@ -145,18 +174,18 @@ export const SystemHealthMonitoring = ({ - {options.cpu && } - {options.cpu && ( + {showCpu && } + {showCpu && ( )} - {options.memory && ( + {showMemory && ( )} - {options.gpu && + {showGpu && healthInfo.gpu.map((gpu) => ( ))} @@ -166,7 +195,7 @@ export const SystemHealthMonitoring = ({ )} - {options.fileSystem && + {showFileSystem && disksData.map((disk) => { return ( ; const SystemInformationList = ({ healthInfo, @@ -285,6 +316,14 @@ const SystemInformationList = ({ {healthInfo.loadAverage["5min"]}% / {healthInfo.loadAverage["15min"]}% )} + }> + {t("widget.healthMonitoring.popover.updatesAvailable", { count: healthInfo.availablePkgUpdates })} + + }> + {healthInfo.rebootRequired + ? t("widget.healthMonitoring.popover.rebootRequired") + : t("widget.healthMonitoring.popover.rebootNotRequired")} + ); diff --git a/packages/widgets/src/immich/server-stats/layout.spec.ts b/packages/widgets/src/immich/server-stats/layout.spec.ts index 7640bda4b..d8e7baf75 100644 --- a/packages/widgets/src/immich/server-stats/layout.spec.ts +++ b/packages/widgets/src/immich/server-stats/layout.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getImmichStatsLayout } from "./component"; +import { getImmichStatsLayout, getImmichStatVisibility } from "./component"; describe("Immich server statistics compact layout", () => { test("keeps four enabled stats legible in a short widget", () => { @@ -12,4 +12,16 @@ describe("Immich server statistics compact layout", () => { expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 2, dense: false }); expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 2, dense: true }); }); + + test("advanced mode shows all four headline stats", () => { + const options = { showUsers: false, showPhotos: false, showVideos: false, showStorage: false }; + + expect(getImmichStatVisibility(options, true)).toEqual({ + showUsers: true, + showPhotos: true, + showVideos: true, + showStorage: true, + }); + expect(getImmichStatVisibility(options, false)).toEqual(options); + }); }); diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx index 54758dfe3..377597341 100644 --- a/packages/widgets/src/indexer-manager/component.tsx +++ b/packages/widgets/src/indexer-manager/component.tsx @@ -1,20 +1,44 @@ "use client"; -import { useMemo } from "react"; -import { ActionIcon, Anchor, Badge, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; +import { useCallback, useMemo } from "react"; +import { + ActionIcon, + Anchor, + Badge, + Card, + Center, + Flex, + Group, + Loader, + ScrollArea, + Stack, + Text, + Tooltip, + VisuallyHidden, +} from "@mantine/core"; import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react"; import combineClasses from "clsx"; import { clientApi } from "@homarr/api/client"; +import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { useWidgetRuntimeActions } from "../runtime-hooks"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; -import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetEmptyState } from "../common/empty-state"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import actionTargetClasses from "../common/action-target.module.css"; import classes from "./component.module.css"; +import { getIndexerDisplayStatus } from "./status"; + +const statusPresentation = { + healthy: { color: "green" }, + unhealthy: { color: "red" }, + disabled: { color: "gray" }, +} as const; export default function IndexerManagerWidget({ options, @@ -23,25 +47,41 @@ export default function IndexerManagerWidget({ height, isEditMode, widgetRuntimeRef, + displayMode, }: WidgetComponentProps<"indexerManager">) { const t = useI18n(); - const indexersData = - getUsableWidgetQueryData(clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds })) ?? []; + const indexersQuery = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds }); + const isInitialPending = isInitialWidgetQueryPending(indexersQuery); + const indexersData = getUsableWidgetQueryData(indexersQuery) ?? []; const utils = clientApi.useUtils(); - const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation({ + const { + mutate: testAll, + isPending, + error: testAllError, + } = clientApi.widget.indexerManager.testAllIndexers.useMutation({ onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(), }); + const interactIntegrationIds = new Set(useIntegrationsWithInteractAccess().map(({ id }) => id)); + const canInteract = integrationIds.length > 0 && integrationIds.every((id) => interactIntegrationIds.has(id)); const board = useRequiredBoard(); - const hasSmallWidth = width < 256; - const isDense = width < 280 || height < 180; - const showHealthCounts = width >= 200 && height >= 100; - const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]); + const isAdvanced = displayMode === "advanced"; + const hasSmallWidth = !isAdvanced && width < 256; + const isDense = !isAdvanced && (width < 280 || height < 180); + const showHealthCounts = isAdvanced || (width >= 200 && height >= 100); + const allIndexers = indexersData.flatMap((entry) => entry.indexers); + const hasSourceError = indexersData.some((entry) => Boolean(entry.error)); const unavailableCount = allIndexers.filter( (indexer) => indexer.status === false || indexer.enabled === false, ).length; - useWidgetRuntimeActions(widgetRuntimeRef, { testAllIndexers: () => testAll({ integrationIds }) }); + const testAllIndexers = useCallback(() => { + if (!canInteract || isPending) return; + testAll({ integrationIds }); + }, [canInteract, integrationIds, isPending, testAll]); + + const runtimeActions = useMemo(() => ({ testAllIndexers }), [testAllIndexers]); + useWidgetRuntimeActions(widgetRuntimeRef, runtimeActions); return ( + {testAllError && {t("common.error")}} )} - + + { - testAll({ integrationIds }); + testAllIndexers(); }} aria-label={t("widget.indexerManager.testAll")} > @@ -101,67 +144,81 @@ export default function IndexerManagerWidget({ radius={board.itemRadius} flex={1} > - - {indexersData.map(({ integrationId, integrationName, indexers, error }) => ( - - {indexersData.length > 1 && ( - - - {integrationName} - - {error && ( - - {t("common.error")} - - )} - - )} - {indexers.map((indexer) => { - const href = getSafeApplicationUrl(indexer.url); - return ( - - - - {indexer.name} - - - {indexer.status === false || indexer.enabled === false ? ( - - ) : ( - + {isInitialPending ? ( +
+ +
+ ) : allIndexers.length === 0 && !hasSourceError ? ( + + ) : ( + + {indexersData.map(({ integrationId, integrationName, indexers, error }) => ( + + {(isAdvanced || indexersData.length > 1 || error) && ( + + + {integrationName} + + {error && ( + + {t("common.error")} + )} - ); - })} - - ))} - + )} + {indexers.map((indexer) => { + const href = getSafeApplicationUrl(indexer.url); + const displayStatus = getIndexerDisplayStatus(indexer); + const presentation = statusPresentation[displayStatus]; + return ( + + + + {indexer.name} + + + {isAdvanced ? ( + + {t(`widget.indexerManager.status.${displayStatus}`)} + + ) : displayStatus === "healthy" ? ( + + ) : ( + + )} + + ); + })} +
+ ))} +
+ )}
); diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts index c61bd84c1..aa8bc73f8 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -13,7 +13,7 @@ const createOptions = () => })); export const { definition, componentLoader } = createWidgetDefinition("indexerManager", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconReportSearch, contextActions: ({ widgetRuntimeRef, context }) => [ { diff --git a/packages/widgets/src/indexer-manager/status.spec.ts b/packages/widgets/src/indexer-manager/status.spec.ts new file mode 100644 index 000000000..a91f0cbbb --- /dev/null +++ b/packages/widgets/src/indexer-manager/status.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "vitest"; + +import { getIndexerDisplayStatus } from "./status"; + +describe("Indexer display status", () => { + test("distinguishes disabled indexers from unhealthy enabled indexers", () => { + expect(getIndexerDisplayStatus({ enabled: false, status: false })).toBe("disabled"); + expect(getIndexerDisplayStatus({ enabled: false, status: true })).toBe("disabled"); + expect(getIndexerDisplayStatus({ enabled: true, status: false })).toBe("unhealthy"); + expect(getIndexerDisplayStatus({ enabled: true, status: true })).toBe("healthy"); + }); +}); diff --git a/packages/widgets/src/indexer-manager/status.ts b/packages/widgets/src/indexer-manager/status.ts new file mode 100644 index 000000000..b73a3582c --- /dev/null +++ b/packages/widgets/src/indexer-manager/status.ts @@ -0,0 +1,12 @@ +export type IndexerDisplayStatus = "healthy" | "unhealthy" | "disabled"; + +export const getIndexerDisplayStatus = ({ + enabled, + status, +}: { + enabled: boolean; + status: boolean; +}): IndexerDisplayStatus => { + if (!enabled) return "disabled"; + return status ? "healthy" : "unhealthy"; +}; diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 9893a91fb..1bb724044 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -31,6 +31,7 @@ import { OverflowBadge } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import classes from "./component.module.css"; @@ -44,36 +45,43 @@ export default function MediaReleasesWidget({ }: WidgetComponentProps<"mediaReleases">) { const t = useI18n(); const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds }); - const releases = getUsableWidgetQueryData(releasesQuery); + const response = getUsableWidgetQueryData(releasesQuery); if (isInitialWidgetQueryPending(releasesQuery)) return ; - if (!releases || releases.length === 0) return ; + if (!response) return ; + + const { releases, failedIntegrations } = response; const isAdvanced = displayMode === "advanced"; return ( - - - {releases.map((item, index) => ( - - {!isAdvanced && index !== 0 && options.layout === "poster" && } - - - ))} - - - {releasesQuery.error && ( - + {releases.length === 0 ? ( + + ) : ( + + + {releases.map((item, index) => ( + + {!isAdvanced && index !== 0 && options.layout === "poster" && } + + + ))} + + + )} + {(failedIntegrations.length > 0 || releasesQuery.error) && ( + + - + )} ); } interface ItemProps { - item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number]; + item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number]; options: WidgetComponentProps<"mediaReleases">["options"]; isAdvanced: boolean; width: number; @@ -211,7 +219,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { /> )} {isAdvanced && item.description && ( - + {item.description} )} @@ -230,9 +238,17 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { )} - {(isAdvanced || options.showSource) && ( - - )} + {(isAdvanced || options.showSource) && + (isAdvanced ? ( + + + + {item.integration.name} + + + ) : ( + + ))}
)} diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx index bfd25dec5..4c612b07a 100644 --- a/packages/widgets/src/network-controller/network-status/component.tsx +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -11,6 +11,7 @@ import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; +import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { getNetworkControllerStatusLayout } from "./layout"; import { WifiVariant } from "./variants/wifi-variant"; import { WiredVariant } from "./variants/wired-variant"; @@ -36,7 +37,7 @@ export default function NetworkControllerNetworkStatusWidget({ const t = useScopedI18n("widget.networkControllerStatus"); const tCommon = useScopedI18n("common"); - const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => summary), [summaries]); + const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => (summary ? [summary] : [])), [summaries]); const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0); const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0); const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0); @@ -56,7 +57,10 @@ export default function NetworkControllerNetworkStatusWidget({ } return ( - + + + + {layout.showWifi && ( diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx index 1584e70a7..29cd04118 100644 --- a/packages/widgets/src/network-controller/summary/component.tsx +++ b/packages/widgets/src/network-controller/summary/component.tsx @@ -8,11 +8,16 @@ import objectSupport from "dayjs/plugin/objectSupport"; import relativeTime from "dayjs/plugin/relativeTime"; import { clientApi } from "@homarr/api/client"; -import { useI18n } from "@homarr/translation/client"; +import type { TranslationFunction } from "@homarr/translation"; +import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; +import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { getBinaryStatusKey } from "../../common/locale"; import { getUsableWidgetQueryData } from "../../common/query-state"; +import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator"; +import type { NetworkControllerMatrixMetricKey, NetworkControllerMatrixSectionKey } from "./display"; +import { getNetworkControllerMatrix } from "./display"; dayjs.extend(objectSupport); dayjs.extend(relativeTime); @@ -22,14 +27,20 @@ export default function NetworkControllerSummaryWidget({ integrationIds, width, height, + displayMode, }: WidgetComponentProps<"networkControllerSummary">) { const summaryQuery = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); - const summaries = getUsableWidgetQueryData(summaryQuery) ?? []; + const results = getUsableWidgetQueryData(summaryQuery) ?? []; + const summaries = results.filter( + (result): result is typeof result & { summary: NonNullable; updatedAt: Date } => + result.summary !== null && result.updatedAt !== undefined, + ); const { isPending } = summaryQuery; const t = useI18n(); + const locale = useCurrentIntlLocale(); const statusLabels = { enabled: t("widget.dnsHoleSummary.status.enabled"), disabled: t("widget.dnsHoleSummary.status.disabled"), @@ -39,19 +50,106 @@ export default function NetworkControllerSummaryWidget({ const isDense = height < 160; const showDetails = height >= 180 && width >= 240; const columns = width >= 720 ? 2 : 1; + const isAdvanced = displayMode === "advanced"; + const queryIndicators = ( + + + + + ); if (isPending || summaries.length === 0) { return ( -
- - {isPending ? t("common.action.loading") : t("widget.networkControllerSummary.error.integrationsDisconnected")} - -
+ + {queryIndicators} +
+ + {isPending + ? t("common.action.loading") + : t("widget.networkControllerSummary.error.integrationsDisconnected")} + +
+
+ ); + } + + if (isAdvanced) { + const controllerColumns = width >= 960 ? 2 : 1; + const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1; + + return ( + + {queryIndicators} + + {summaries.map(({ integration, summary, updatedAt }) => ( + + + + + + {integration.name} + + + {integration.kind} + + + + + {t("widget.networkControllerSummary.advanced.updated")} + + + {new Date(updatedAt).toLocaleString(locale)} + + + + + {getNetworkControllerMatrix(summary).map((section) => ( + + + + + + + {getMatrixSectionLabel(section.key, t)} + + + + {statusLabels[getBinaryStatusKey(section.status)]} + + + {section.metrics.length > 0 && ( + + {section.metrics.map((metric) => ( + + + {getMatrixMetricLabel(metric.key, t)} + + + {formatMatrixMetric(metric.key, metric.value)} + + + ))} + + )} + + + ))} + + + + ))} + + ); } return ( + {queryIndicators} {summaries.map(({ integration, summary }) => ( 1}> @@ -144,6 +242,20 @@ export default function NetworkControllerSummaryWidget({ ); } +const getMatrixMetricLabel = (key: NetworkControllerMatrixMetricKey, t: TranslationFunction) => + t(`widget.networkControllerSummary.advanced.metric.${key}`); + +const formatMatrixMetric = (key: NetworkControllerMatrixMetricKey, value: number) => { + if (key === "latency" || key === "ping") return `${value}ms`; + if (key === "uptime") return dayjs.duration(value, "seconds").humanize(); + return String(value); +}; + +const getMatrixSectionLabel = (key: NetworkControllerMatrixSectionKey, t: TranslationFunction) => { + if (key === "vpn") return t("widget.networkControllerSummary.card.vpn.label"); + return t(`widget.networkControllerSummary.card.${key}`); +}; + const StatusIcon = ({ status, label, size }: { status?: "enabled" | "disabled"; label: string; size: number }) => { const mantineTheme = useMantineTheme(); if (status === "enabled") { diff --git a/packages/widgets/src/network-controller/summary/display.spec.ts b/packages/widgets/src/network-controller/summary/display.spec.ts new file mode 100644 index 000000000..3caad91d3 --- /dev/null +++ b/packages/widgets/src/network-controller/summary/display.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; + +import { getNetworkControllerMatrix } from "./display"; + +describe("Network controller advanced matrix", () => { + test("retains every summary field without aggregating users and guests", () => { + expect( + getNetworkControllerMatrix({ + wanStatus: "enabled", + www: { status: "disabled", latency: 22, ping: 14, uptime: 86400 }, + wifi: { status: "enabled", users: 12, guests: 2 }, + lan: { status: "enabled", users: 18, guests: 5 }, + vpn: { status: "disabled", users: 3 }, + }), + ).toEqual([ + { key: "wan", status: "enabled", metrics: [] }, + { + key: "web", + status: "disabled", + metrics: [ + { key: "latency", value: 22 }, + { key: "ping", value: 14 }, + { key: "uptime", value: 86400 }, + ], + }, + { + key: "wifi", + status: "enabled", + metrics: [ + { key: "users", value: 12 }, + { key: "guests", value: 2 }, + ], + }, + { + key: "lan", + status: "enabled", + metrics: [ + { key: "users", value: 18 }, + { key: "guests", value: 5 }, + ], + }, + { key: "vpn", status: "disabled", metrics: [{ key: "users", value: 3 }] }, + ]); + }); +}); diff --git a/packages/widgets/src/network-controller/summary/display.ts b/packages/widgets/src/network-controller/summary/display.ts new file mode 100644 index 000000000..11cb28c29 --- /dev/null +++ b/packages/widgets/src/network-controller/summary/display.ts @@ -0,0 +1,40 @@ +import type { NetworkControllerSummary } from "@homarr/integrations/types"; + +export type NetworkControllerMatrixSectionKey = "wan" | "web" | "wifi" | "lan" | "vpn"; +export type NetworkControllerMatrixMetricKey = "latency" | "ping" | "uptime" | "users" | "guests"; + +export interface NetworkControllerMatrixSection { + key: NetworkControllerMatrixSectionKey; + status: "enabled" | "disabled"; + metrics: { key: NetworkControllerMatrixMetricKey; value: number }[]; +} + +export const getNetworkControllerMatrix = (summary: NetworkControllerSummary): NetworkControllerMatrixSection[] => [ + { key: "wan", status: summary.wanStatus, metrics: [] }, + { + key: "web", + status: summary.www.status, + metrics: [ + { key: "latency", value: summary.www.latency }, + { key: "ping", value: summary.www.ping }, + { key: "uptime", value: summary.www.uptime }, + ], + }, + { + key: "wifi", + status: summary.wifi.status, + metrics: [ + { key: "users", value: summary.wifi.users }, + { key: "guests", value: summary.wifi.guests }, + ], + }, + { + key: "lan", + status: summary.lan.status, + metrics: [ + { key: "users", value: summary.lan.users }, + { key: "guests", value: summary.lan.guests }, + ], + }, + { key: "vpn", status: summary.vpn.status, metrics: [{ key: "users", value: summary.vpn.users }] }, +]; diff --git a/packages/widgets/src/network-controller/summary/index.ts b/packages/widgets/src/network-controller/summary/index.ts index 1a4544b30..7cdc77a35 100644 --- a/packages/widgets/src/network-controller/summary/index.ts +++ b/packages/widgets/src/network-controller/summary/index.ts @@ -6,7 +6,7 @@ import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconTopologyFull, queryKey: [["widget", "networkController"]], refetchInterval: null, diff --git a/packages/widgets/src/notebook/display.spec.ts b/packages/widgets/src/notebook/display.spec.ts new file mode 100644 index 000000000..a1dcdc5c7 --- /dev/null +++ b/packages/widgets/src/notebook/display.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; + +import { getNotebookDisplay } from "./display"; + +describe("Notebook advanced disclosure", () => { + test("shows the complete toolbar and document stats regardless of compact preferences", () => { + expect( + getNotebookDisplay({ height: 100, isAdvanced: true, isEditing: true, isSaving: false, showToolbar: false }), + ).toEqual({ showToolbar: true, toolbarMaxHeight: undefined, showDocumentStats: true }); + }); + + test("does not expose editing controls outside an active editable session", () => { + expect( + getNotebookDisplay({ height: 300, isAdvanced: true, isEditing: false, isSaving: false, showToolbar: true }) + .showToolbar, + ).toBe(false); + }); +}); diff --git a/packages/widgets/src/notebook/display.ts b/packages/widgets/src/notebook/display.ts new file mode 100644 index 000000000..c5a1811a8 --- /dev/null +++ b/packages/widgets/src/notebook/display.ts @@ -0,0 +1,13 @@ +interface NotebookDisplayInput { + height: number; + isAdvanced: boolean; + isEditing: boolean; + isSaving: boolean; + showToolbar: boolean; +} + +export const getNotebookDisplay = ({ height, isAdvanced, isEditing, isSaving, showToolbar }: NotebookDisplayInput) => ({ + showToolbar: isEditing && !isSaving && (isAdvanced || showToolbar), + toolbarMaxHeight: isAdvanced ? undefined : height < 180 ? "4.5rem" : "10rem", + showDocumentStats: isAdvanced || height >= 180, +}); diff --git a/packages/widgets/src/notebook/index.ts b/packages/widgets/src/notebook/index.ts index 7b01c83a7..b4221e55c 100644 --- a/packages/widgets/src/notebook/index.ts +++ b/packages/widgets/src/notebook/index.ts @@ -5,7 +5,7 @@ import { optionsBuilder } from "../options"; import { defaultContent } from "./default-content"; export const { definition, componentLoader } = createWidgetDefinition("notebook", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconNotes, createOptions() { return optionsBuilder.from( diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index 6ecaae51b..b190d2a82 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -70,6 +70,7 @@ import type { TablerIcon } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import actionTargetClasses from "../common/action-target.module.css"; import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item"; +import { getNotebookDisplay } from "./display"; import { NotebookTextDirection, setTextDirection } from "./text-direction"; import "@mantine/tiptap/styles.css"; @@ -98,6 +99,7 @@ export function Notebook({ boardId, itemId, height, + displayMode = "compact", }: WidgetComponentProps<"notebook">) { const [content, setContent] = useState(options.content); const previousContentRef = useRef(options.content); @@ -339,6 +341,13 @@ export function Notebook({ characters: documentText.length, words: documentText.length === 0 ? 0 : documentText.split(/\s+/).length, }; + const display = getNotebookDisplay({ + height, + isAdvanced: displayMode === "advanced", + isEditing, + isSaving, + showToolbar: options.showToolbar, + }); return ( @@ -375,9 +384,9 @@ export function Notebook({ > @@ -505,7 +514,7 @@ export function Notebook({ - {(saveError || height >= 180) && ( + {(saveError || display.showDocumentStats) && ( {saveError && ( @@ -521,7 +530,7 @@ export function Notebook({ )} - {height >= 180 && ( + {display.showDocumentStats && ( {t("widget.notebook.documentStats", documentStats)} @@ -873,9 +882,18 @@ function ListIndentIncrease() { editor?.chain().focus().sinkListItem(itemType).run(); }, [editor, itemType]); - editor?.on("selectionUpdate", ({ editor }) => { - setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem"); - }); + useEffect(() => { + if (!editor) return; + + const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => { + setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem"); + }; + + editor.on("selectionUpdate", handleSelectionUpdate); + return () => { + editor.off("selectionUpdate", handleSelectionUpdate); + }; + }, [editor]); return ( { - setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem"); - }); + useEffect(() => { + if (!editor) return; + + const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => { + setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem"); + }; + + editor.on("selectionUpdate", handleSelectionUpdate); + return () => { + editor.off("selectionUpdate", handleSelectionUpdate); + }; + }, [editor]); return ( ) { const notificationsQuery = clientApi.widget.notifications.getNotifications.useQuery({ ...options, integrationIds, }); - const notificationIntegrations = getUsableWidgetQueryData(notificationsQuery) ?? []; + const notificationData = getUsableWidgetQueryData(notificationsQuery); + const notificationIntegrations = useMemo(() => notificationData ?? [], [notificationData]); const { isPending } = notificationsQuery; const t = useI18n(); @@ -49,12 +53,23 @@ export default function NotificationsWidget({ 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, + }); const columns = width >= 720 ? 2 : 1; const spacing = isRoomy ? "sm" : "xs"; return ( + {notificationsQuery.error && ( + + + + )} {failedIntegrations.length > 0 && ( {failedIntegrations.map((integration) => ( @@ -95,7 +110,7 @@ export default function NotificationsWidget({ }} > - {!options.hideLogos && notification.source?.iconUrl && ( + {notificationDisplay.showLogos && notification.source?.iconUrl && ( {notification.body} @@ -121,7 +136,11 @@ export default function NotificationsWidget({ diff --git a/packages/widgets/src/notifications/display.spec.ts b/packages/widgets/src/notifications/display.spec.ts new file mode 100644 index 000000000..edf4a95dc --- /dev/null +++ b/packages/widgets/src/notifications/display.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; + +import { getNotificationDisplay } from "./display"; + +describe("notification display", () => { + test("advanced mode exposes logos, sources, and full bodies", () => { + expect( + getNotificationDisplay({ displayMode: "advanced", hideLogos: true, isRoomy: false, bodyLineClamp: 1 }), + ).toEqual({ + showLogos: true, + showSource: true, + bodyLineClamp: undefined, + }); + }); + + test("compact mode respects presentation constraints", () => { + expect( + getNotificationDisplay({ displayMode: "compact", hideLogos: true, isRoomy: false, bodyLineClamp: 2 }), + ).toEqual({ + showLogos: false, + showSource: false, + bodyLineClamp: 2, + }); + }); +}); diff --git a/packages/widgets/src/notifications/display.ts b/packages/widgets/src/notifications/display.ts new file mode 100644 index 000000000..16ec47f03 --- /dev/null +++ b/packages/widgets/src/notifications/display.ts @@ -0,0 +1,20 @@ +interface NotificationDisplayInput { + displayMode?: "compact" | "advanced"; + hideLogos: boolean; + isRoomy: boolean; + bodyLineClamp: number; +} + +export const getNotificationDisplay = ({ + displayMode, + hideLogos, + isRoomy, + bodyLineClamp, +}: NotificationDisplayInput) => { + const isAdvanced = displayMode === "advanced"; + return { + showLogos: isAdvanced || !hideLogos, + showSource: isAdvanced || isRoomy, + bodyLineClamp: isAdvanced ? undefined : bodyLineClamp, + }; +}; diff --git a/packages/widgets/src/patchmon/component.module.css b/packages/widgets/src/patchmon/component.module.css index bb7dc2784..078ccbdc5 100644 --- a/packages/widgets/src/patchmon/component.module.css +++ b/packages/widgets/src/patchmon/component.module.css @@ -1,4 +1,5 @@ .root { + position: relative; container-type: size; display: flex; flex-direction: column; @@ -9,6 +10,32 @@ overflow: hidden; } +.advancedContent { + height: auto; + min-height: 100%; +} + +.advancedContent .statsSection { + flex: none; +} + +.advancedContent .grid { + height: auto; + grid-auto-rows: minmax(92px, auto); +} + +.advancedContent .osSectionWrapper { + flex: none; + min-height: 220px; +} + +.queryIndicator { + position: absolute; + top: 4px; + right: 4px; + z-index: 2; +} + .compactRoot { padding: clamp(4px, 3cqi, 8px); gap: 2px; diff --git a/packages/widgets/src/patchmon/component.tsx b/packages/widgets/src/patchmon/component.tsx index 9889b0797..401bd1803 100644 --- a/packages/widgets/src/patchmon/component.tsx +++ b/packages/widgets/src/patchmon/component.tsx @@ -20,26 +20,17 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; -import { getGridCols, shouldShowComplianceHeroText } from "./layout-utils"; +import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "./layout-utils"; import { OsDistributionSection } from "./os-distribution-section"; import type { PatchMonStatKey } from "./stat-colors"; import { resolveStatColor, severityToIconColor, severityToMantineBgColor, severityToMantineColor } from "./stat-colors"; dayjs.extend(relativeTime); -const statVisibilityByOption = { - showTotalHosts: "totalHosts", - showHostsNeedingUpdates: "hostsNeedingUpdates", - showSecurityUpdates: "securityUpdates", - showUpToDateHosts: "upToDateHosts", - showHostsWithSecurityUpdates: "hostsWithSecurityUpdates", - showRecentUpdates24h: "recentUpdates24h", - showTotalOutdatedPackages: "totalOutdatedPackages", - showTotalRepos: "totalRepos", -} as const; - const statIcons: Record = { totalHosts: IconServer, hostsNeedingUpdates: IconRefreshAlert, @@ -75,15 +66,19 @@ const getLayoutMode = (width: number, height: number): LayoutMode => { return "comfortable"; }; -export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) { +export default function PatchMonWidget({ + integrationIds, + options, + width, + height, + displayMode, +}: WidgetComponentProps<"patchmon">) { const t = useScopedI18n("widget.patchmon"); const integrationId = integrationIds[0] ?? ""; - const { data: stats, error } = clientApi.widget.patchmon.getStats.useQuery( - { integrationId }, - { staleTime: 60 * 1000 }, - ); + const statsQuery = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 }); + const stats = getUsableWidgetQueryData(statsQuery); - if (error && !stats) throw error; + if (isInitialWidgetQueryPending(statsQuery)) return ; if (!stats) return ; const statValues: Record = { @@ -97,24 +92,24 @@ export default function PatchMonWidget({ integrationIds, options, width, height totalRepos: stats.totalRepos, }; - const visibleStatKeys = Object.entries(statVisibilityByOption) - .filter(([optionKey]) => options[optionKey as keyof typeof options]) - .map(([, statKey]) => statKey); + const isAdvanced = displayMode === "advanced"; + const visibleStatKeys = getVisiblePatchMonStatKeys(options, isAdvanced); const colorContext = { totalHosts: stats.totalHosts }; - const layout = getLayoutMode(width, height); + const layout = isAdvanced ? "spacious" : getLayoutMode(width, height); const isLandscape = width / height > 2.5; const isTinySquare = width < 160 && height < 160; const isShortLandscape = width >= 160 && height < 160; - const isCompactSurface = isTinySquare || isShortLandscape; + const isCompactSurface = !isAdvanced && (isTinySquare || isShortLandscape); const isNarrow = width < 180; const osDisplayMode = options.osDisplayMode as "bars" | "donut"; - const showOsData = options.showOsDistribution && stats.osDistribution.length > 0; + const showOsData = (isAdvanced || options.showOsDistribution) && stats.osDistribution.length > 0; - const showIcons = layout !== "mini"; - const showLabels = layout !== "mini"; - const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90; - const showHeroText = showHero && shouldShowComplianceHeroText(width); + const showIcons = isAdvanced || layout !== "mini"; + const showLabels = isAdvanced || layout !== "mini"; + const showHero = + isAdvanced || (options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90); + const showHeroText = showHero && (isAdvanced || shouldShowComplianceHeroText(width)); const showHeroRingOnly = showHero && !showHeroText; const compactPrimaryContent = isCompactSurface ? getCompactPrimaryContent({ @@ -124,16 +119,18 @@ export default function PatchMonWidget({ integrationIds, options, width, height }) : null; const showStats = visibleStatKeys.length > 0 && !isCompactSurface; - const showOsSection = showOsData && !isCompactSurface && height > 200; + const showOsSection = showOsData && !isCompactSurface && (isAdvanced || height > 200); const showCompactFooter = isCompactSurface && height >= 110; - const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220; - const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300; + const showFooter = isAdvanced || (isCompactSurface ? showCompactFooter : !isLandscape && height > 220); + const showOsLegend = osDisplayMode === "donut" && (isAdvanced || (width >= 380 && height >= 300)); const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero; - const gridCols = isLandscape - ? Math.min(visibleStatKeys.length, 4) - : Math.min(getGridCols(width), visibleStatKeys.length || 1); + const gridCols = isAdvanced + ? Math.min(visibleStatKeys.length, width >= 500 ? 4 : width >= 220 ? 2 : 1) + : isLandscape + ? Math.min(visibleStatKeys.length, 4) + : Math.min(getGridCols(width), visibleStatKeys.length || 1); const iconSize = getIconSize(width); const ringSize = getRingSize(width, height, showHeroRingOnly); @@ -266,8 +263,8 @@ export default function PatchMonWidget({ integrationIds, options, width, height
0 ? classes.osSectionWithStats : ""}`}> ); - const needsScroll = layout === "mini" || layout === "compact"; + const needsScroll = isAdvanced || layout === "mini" || layout === "compact"; return (
+
+ +
{showHero && ( -
+
{statGridContent} {osContent}
diff --git a/packages/widgets/src/patchmon/index.ts b/packages/widgets/src/patchmon/index.ts index 868a804e5..d715e90ac 100644 --- a/packages/widgets/src/patchmon/index.ts +++ b/packages/widgets/src/patchmon/index.ts @@ -7,7 +7,7 @@ import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors"; import { patchmonOptionsSuperRefine } from "./threshold-validation"; export const { definition, componentLoader } = createWidgetDefinition("patchmon", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconShieldCheck, supportedIntegrations: ["patchmon"], integrationsRequired: true, diff --git a/packages/widgets/src/patchmon/layout-utils.ts b/packages/widgets/src/patchmon/layout-utils.ts index dd8ca1282..72d451413 100644 --- a/packages/widgets/src/patchmon/layout-utils.ts +++ b/packages/widgets/src/patchmon/layout-utils.ts @@ -1,3 +1,5 @@ +import type { PatchMonStatKey } from "./stat-colors"; + const gridColsByWidth = [ { minWidth: 500, cols: 4 }, { minWidth: 380, cols: 3 }, @@ -5,6 +7,26 @@ const gridColsByWidth = [ { minWidth: 0, cols: 1 }, ] as const; +const statVisibilityByOption = [ + ["showTotalHosts", "totalHosts"], + ["showHostsNeedingUpdates", "hostsNeedingUpdates"], + ["showSecurityUpdates", "securityUpdates"], + ["showUpToDateHosts", "upToDateHosts"], + ["showHostsWithSecurityUpdates", "hostsWithSecurityUpdates"], + ["showRecentUpdates24h", "recentUpdates24h"], + ["showTotalOutdatedPackages", "totalOutdatedPackages"], + ["showTotalRepos", "totalRepos"], +] as const satisfies readonly (readonly [string, PatchMonStatKey])[]; + +type PatchMonStatVisibilityOptions = Partial>; + +export function getVisiblePatchMonStatKeys( + options: PatchMonStatVisibilityOptions, + isAdvanced: boolean, +): PatchMonStatKey[] { + return statVisibilityByOption.filter(([optionKey]) => isAdvanced || options[optionKey]).map(([, statKey]) => statKey); +} + export function getGridCols(width: number): number { const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth); return match?.cols ?? 1; diff --git a/packages/widgets/src/patchmon/test/layout-utils.spec.ts b/packages/widgets/src/patchmon/test/layout-utils.spec.ts index b2a486c67..b63b1a480 100644 --- a/packages/widgets/src/patchmon/test/layout-utils.spec.ts +++ b/packages/widgets/src/patchmon/test/layout-utils.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getGridCols, shouldShowComplianceHeroText } from "../layout-utils"; +import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "../layout-utils"; describe("getGridCols", () => { test("returns 1 column for narrow widths", () => { @@ -34,3 +34,31 @@ describe("shouldShowComplianceHeroText", () => { expect(shouldShowComplianceHeroText(384)).toBe(true); }); }); + +describe("getVisiblePatchMonStatKeys", () => { + test("keeps compact visibility choices", () => { + expect( + getVisiblePatchMonStatKeys( + { + showTotalHosts: true, + showSecurityUpdates: true, + showTotalRepos: false, + }, + false, + ), + ).toEqual(["totalHosts", "securityUpdates"]); + }); + + test("returns every owned statistic in advanced mode", () => { + expect(getVisiblePatchMonStatKeys({}, true)).toEqual([ + "totalHosts", + "hostsNeedingUpdates", + "securityUpdates", + "upToDateHosts", + "hostsWithSecurityUpdates", + "recentUpdates24h", + "totalOutdatedPackages", + "totalRepos", + ]); + }); +}); diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index c7295ec59..d4276f669 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -37,9 +37,13 @@ import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/defin import { useScopedI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; -import type { WidgetComponentProps } from "../definition"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryLoadingState } from "../common/query-state-indicator"; +import { WidgetEmptyState } from "../common/empty-state"; +import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.scss"; +import { selectReleaseRepositoriesForDisplay } from "./display"; import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository"; import { getReleasesQueryStaleTimeMs } from "./query-options"; @@ -49,7 +53,7 @@ const formatRelativeDate = (value: string): string => { return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value; }; -export default function ReleasesWidget({ options, itemId, width }: WidgetComponentProps<"releases">) { +export default function ReleasesWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"releases">) { const t = useScopedI18n("widget.releases"); const now = useNow(); const formatter = useFormatter(); @@ -60,6 +64,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone key: "releases-viewed-versions", defaultValue: {}, }); + const isAdvanced = displayMode === "advanced"; const columns = Math.max(1, Math.floor(width / 420)); const relativeDateOptions = useMemo( @@ -123,6 +128,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone () => queryResults.map((q) => q.data).filter((d): d is NonNullable => d != null), [queryResults], ); + const isInitialLoading = queryResults.some(isInitialWidgetQueryPending); const repositories = useMemo(() => { const formattedResults = normalizedRepositories @@ -147,22 +153,19 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone viewed: releasesViewedList[repository.id] === release.latestRelease, }; }) - .filter( - (repository) => - "error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease, - ) .toSorted((repoA, repoB) => { if ("error" in repoA) return -1; if ("error" in repoB) return 1; return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1; }) as ReleasesRepositoryResponse[]; - if (typeof options.topReleases !== "string" && options.topReleases > 0) { - return formattedResults.slice(0, options.topReleases); - } - - return formattedResults; + return selectReleaseRepositoriesForDisplay(formattedResults, { + displayMode, + showOnlyHighlighted: options.showOnlyHighlighted, + topReleases: options.topReleases, + }); }, [ + displayMode, results, normalizedRepositories, options.showOnlyHighlighted, @@ -186,6 +189,9 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone [setReleasesViewedList], ); + if (isInitialLoading) return ; + if (repositories.length === 0) return ; + return ( 1 ? "sm" : 0} p={columns > 1 ? "xs" : 0}> @@ -241,7 +247,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone miw={0} style={{ flex: 1 }} > - {!options.showOnlyIcon && ( + {(isAdvanced || !options.showOnlyIcon) && ( {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} {repository.name || repository.identifier} @@ -321,7 +327,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone - {options.showDetails && ( + {(isAdvanced || options.showDetails) && ( { + test("applies highlight and top limits in compact mode", () => { + const result = selectReleaseRepositoriesForDisplay(repositories, { + displayMode: "compact", + showOnlyHighlighted: true, + topReleases: 2, + }); + + expect(result.map(({ id }) => id)).toEqual(["new", "stale"]); + }); + + test("keeps every configured repository in advanced mode", () => { + const result = selectReleaseRepositoriesForDisplay(repositories, { + displayMode: "advanced", + showOnlyHighlighted: true, + topReleases: 1, + }); + + expect(result.map(({ id }) => id)).toEqual(["ordinary", "new", "stale", "error"]); + }); +}); diff --git a/packages/widgets/src/releases/display.ts b/packages/widgets/src/releases/display.ts new file mode 100644 index 000000000..7342b524f --- /dev/null +++ b/packages/widgets/src/releases/display.ts @@ -0,0 +1,30 @@ +interface ReleasePresentationItem { + error?: unknown; + isNewRelease?: boolean; + isStaleRelease?: boolean; +} + +interface ReleasePresentationOptions { + displayMode?: "compact" | "advanced"; + showOnlyHighlighted: boolean; + topReleases: number | string; +} + +export const selectReleaseRepositoriesForDisplay = ( + repositories: readonly T[], + options: ReleasePresentationOptions, +): T[] => { + const visibleRepositories = + options.displayMode === "advanced" || !options.showOnlyHighlighted + ? [...repositories] + : repositories.filter( + (repository) => + repository.error !== undefined || repository.isNewRelease === true || repository.isStaleRelease === true, + ); + + if (options.displayMode === "advanced" || typeof options.topReleases === "string" || options.topReleases <= 0) { + return visibleRepositories; + } + + return visibleRepositories.slice(0, options.topReleases); +}; diff --git a/packages/widgets/src/releases/index.ts b/packages/widgets/src/releases/index.ts index 0507e8f42..75a42f203 100644 --- a/packages/widgets/src/releases/index.ts +++ b/packages/widgets/src/releases/index.ts @@ -13,7 +13,7 @@ const relativeDateSchema = z .or(z.literal("")); export const { definition, componentLoader } = createWidgetDefinition("releases", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconRocket, queryKey: [["widget", "releases", "getLatest"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }), diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index 5bcd3bfd3..db8a31641 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -9,8 +9,10 @@ import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useScopedI18n } from "@homarr/translation/client"; -import type { WidgetComponentProps } from "../definition"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { WidgetEmptyState } from "../common/empty-state"; +import { WidgetQueryLoadingState } from "../common/query-state-indicator"; +import type { WidgetComponentProps } from "../definition"; import { BaseWidgetError } from "../errors/base-component"; import classes from "./component.module.scss"; @@ -27,6 +29,7 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { hasError: false as const, entries: normalizedData, failedFeedCount: 0, + isPending: query.isPending, isStale: Boolean(query.error), }; } @@ -35,11 +38,12 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { hasError: false as const, entries: normalizedData?.entries ?? [], failedFeedCount: normalizedData?.failedFeedCount ?? 0, + isPending: query.isPending, isStale: Boolean(query.error), }; }; -export default function RssFeed({ options, width, height }: WidgetComponentProps<"rssFeed">) { +export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) { const feed = useLiveFeed({ urls: options.feedUrls, maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100, @@ -57,6 +61,7 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps /> ); } + if (feed.isPending) return ; const warning = feed.isStale ? t("warning.stale") @@ -64,13 +69,35 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps ? t("warning.partial", { count: feed.failedFeedCount }) : undefined; + if (feed.entries.length === 0) { + return ( + + {warning && ( + } p="xs"> + {warning} + + )} + + + ); + } + const languageDir = options.enableRtl ? "RTL" : "LTR"; const isDense = width < 420 || height < 180; const isTiny = width < 260 || height < 110; const isRoomy = width >= 420 && height >= 240; + const isAdvanced = displayMode === "advanced"; const columns = width >= 720 && height >= 260 ? 2 : 1; const descriptionLines = isRoomy ? Math.max(options.textLinesClamp, 6) : isDense ? 1 : options.textLinesClamp; + const entryDisplay = getRssEntryDisplay({ + isAdvanced, + isDense, + isTiny, + hideDescription: options.hideDescription, + showPosterImage: options.showPosterImage, + descriptionLines, + }); const spacing = isRoomy ? "sm" : "xs"; return ( @@ -97,7 +124,7 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps title={feedEntry.title} > - {feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && ( + {feedEntry.enclosure !== undefined && entryDisplay.showImage && ( - + {feedEntry.title} - {!options.hideDescription && feedEntry.description && !isTiny && ( - + {entryDisplay.showDescription && feedEntry.description && ( + {feedDescriptionToText(feedEntry.description)} )} @@ -151,15 +189,44 @@ export const getHostname = (url: string): string => { } }; -const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => ( - +interface RssEntryDisplayInput { + isAdvanced: boolean; + isDense: boolean; + isTiny: boolean; + hideDescription: boolean; + showPosterImage: boolean; + descriptionLines: number; +} + +export const getRssEntryDisplay = ({ + isAdvanced, + isDense, + isTiny, + hideDescription, + showPosterImage, + descriptionLines, +}: RssEntryDisplayInput) => ({ + showDescription: isAdvanced || (!hideDescription && !isTiny), + showImage: isAdvanced || (showPosterImage && !isTiny), + showSource: isAdvanced || !isDense, + descriptionLineClamp: isAdvanced ? undefined : descriptionLines, +}); + +const InfoDisplay = ({ date, timestamp, source }: { date?: string; timestamp?: string; source?: string }) => ( + {date && } {date && ( {date} )} - {date && source && •} + {timestamp && •} + {timestamp && ( + + {timestamp} + + )} + {(date || timestamp) && source && •} {source && ( {source} diff --git a/packages/widgets/src/rssFeed/feed-display.spec.ts b/packages/widgets/src/rssFeed/feed-display.spec.ts index d18a7c9e0..5b954e8ae 100644 --- a/packages/widgets/src/rssFeed/feed-display.spec.ts +++ b/packages/widgets/src/rssFeed/feed-display.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { feedDescriptionToText, getHostname } from "./component"; +import { feedDescriptionToText, getHostname, getRssEntryDisplay } from "./component"; describe("RSS feed display helpers", () => { test("renders feed markup as inert text", () => { @@ -13,4 +13,22 @@ describe("RSS feed display helpers", () => { expect(getHostname("not a URL")).toBe("not a URL"); expect(getHostname("https://example.com/feed.xml")).toBe("example.com"); }); + + test("advanced mode exposes all fetched entry details", () => { + expect( + getRssEntryDisplay({ + isAdvanced: true, + isDense: true, + isTiny: true, + hideDescription: true, + showPosterImage: false, + descriptionLines: 1, + }), + ).toEqual({ + showDescription: true, + showImage: true, + showSource: true, + descriptionLineClamp: undefined, + }); + }); }); diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts index 683dd07ef..abf728aa0 100644 --- a/packages/widgets/src/rssFeed/index.ts +++ b/packages/widgets/src/rssFeed/index.ts @@ -11,7 +11,7 @@ import { optionsBuilder } from "../options"; * - https://www.jsonfeed.org/version/1.1/ */ export const { definition, componentLoader } = createWidgetDefinition("rssFeed", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconRss, queryKey: [["widget", "rssFeed", "getFeeds"]], queryMatcher: ({ input }, scope) => diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index f5d6a2ed9..b2992e8f4 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -157,6 +157,11 @@ export default function SmartHomeEntityStateWidget({ {t("advanced.entityId", { id: entity.entity_id })} + + {t("advanced.lastChanged", { + date: new Date(entity.last_changed).toLocaleString(locale), + })} + {t("advanced.lastUpdated", { date: new Date(entity.last_updated).toLocaleString(locale), diff --git a/packages/widgets/src/smart-home/execute-automation/component.tsx b/packages/widgets/src/smart-home/execute-automation/component.tsx index 40e5a3487..1d14f1616 100644 --- a/packages/widgets/src/smart-home/execute-automation/component.tsx +++ b/packages/widgets/src/smart-home/execute-automation/component.tsx @@ -138,23 +138,30 @@ export default function SmartHomeTriggerAutomationWidget({ {options.displayName} {displayMode === "advanced" && ( - <> - {error ? ( + + + {t("widget.smartHome-executeAutomation.advanced.automationId", { id: options.automationId })} + + + {t( + canInteract + ? "widget.smartHome-executeAutomation.advanced.ready" + : "widget.smartHome-executeAutomation.advanced.noPermission", + )} + + {lastExecutedAt && ( + + {t("widget.smartHome-executeAutomation.advanced.lastExecuted", { + time: lastExecutedAt.toLocaleTimeString(locale), + })} + + )} + {error && ( {t("widget.smartHome-executeAutomation.error.executeFailed")} - ) : ( - - {lastExecutedAt - ? t("widget.smartHome-executeAutomation.advanced.lastExecuted", { - time: lastExecutedAt.toLocaleTimeString(locale), - }) - : t("widget.smartHome-executeAutomation.advanced.automationId", { - id: options.automationId, - })} - )} - + )}
diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx index f1b096e4a..0830eab27 100644 --- a/packages/widgets/src/speedtest-tracker/component.tsx +++ b/packages/widgets/src/speedtest-tracker/component.tsx @@ -1,19 +1,23 @@ "use client"; import { useMemo, useState } from "react"; -import { SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { WidgetEmptyState } from "../common/empty-state"; import { AveragesSection } from "./averages"; -import { combineSpeedtestDashboards, getCompactSections } from "./helpers"; +import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers"; import { LatestResultSection } from "./latest-result"; import { RecentResultsSection } from "./recent-results"; +const emptyDashboardData = [] as const; + export default function SpeedtestTrackerWidget({ options, integrationIds, @@ -22,33 +26,36 @@ export default function SpeedtestTrackerWidget({ displayMode = "compact", }: WidgetComponentProps<"speedtestTracker">) { const t = useScopedI18n("widget.speedtestTracker"); - const dashboardData = - getUsableWidgetQueryData(clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds })) ?? []; + const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds }); + const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData; const [advancedRange, setAdvancedRange] = useState("24"); + const isAdvanced = displayMode === "advanced"; + const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]); const combined = useMemo( - () => combineSpeedtestDashboards(dashboardData.map((item) => item.dashboard)), - [dashboardData], + () => combineSpeedtestDashboards(successfulDashboards.map(({ dashboard }) => dashboard)), + [successfulDashboards], ); - const rangeHours = displayMode === "advanced" ? Number(advancedRange) : 12; + const rangeHours = isAdvanced ? Number(advancedRange) : 12; const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0; const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff); - const hasStatSection = - (options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null); - const hasChart = options.showRecentResults && recentFiltered.length > 0; - const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults; + const showLatestResult = isAdvanced || options.showLatestResult; + const showStats = isAdvanced || options.showStats; + const showRecentResults = isAdvanced || options.showRecentResults; + const showPingGraph = isAdvanced || options.showPingGraph; + const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null); + const hasChart = showRecentResults && recentFiltered.length > 0; + const noSectionsEnabled = + !isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults; const advancedSectionWidth = width >= 720 ? width / 2 : width; - const latest = options.showLatestResult && combined.latestResult && ( - + const latest = showLatestResult && combined.latestResult && ( + ); - const averages = options.showStats && combined.stats && ( - + const averages = showStats && combined.stats && ( + ); const compactSections = getCompactSections(height, { latest: Boolean(latest), @@ -57,27 +64,90 @@ export default function SpeedtestTrackerWidget({ }); if (displayMode === "advanced") { + const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1; + const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32); + const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth; + return ( - - - {hasStatSection && ( - = 720 ? 2 : 1} spacing="md"> - {latest} - {averages} - + + + + + + + + + {successfulDashboards.length === 0 ? ( + + ) : ( + + {successfulDashboards.map(({ integrationId, integrationName, dashboard }) => { + const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff); + const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null; + const sourceHasChart = sourceRecent.length > 0; + + return ( + + + + {integrationName} + + {sourceHasStats && ( + = 720 ? 2 : 1} spacing="md"> + {dashboard.latestResult && ( + + )} + {dashboard.stats && } + + )} + {sourceHasChart && ( + + + + )} + {!sourceHasStats && !sourceHasChart && } + + + ); + })} + + )} + + + + ); + } + + return ( + + + + + + + {dashboardData.length > 1 && ( + + {successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")} + )} - {hasChart && ( -
- + {(compactSections.latest || compactSections.averages) && ( + + {compactSections.latest &&
{latest}
} + {compactSections.averages &&
{averages}
} +
+ )} + {compactSections.chart && ( +
+
)} {noSectionsEnabled && ( @@ -87,28 +157,6 @@ export default function SpeedtestTrackerWidget({ )} {!noSectionsEnabled && !hasStatSection && !hasChart && } - ); - } - - return ( - - {(compactSections.latest || compactSections.averages) && ( - - {compactSections.latest &&
{latest}
} - {compactSections.averages &&
{averages}
} -
- )} - {compactSections.chart && ( -
- -
- )} - {noSectionsEnabled && ( - - {t("noSectionsEnabled")} - - )} - {!noSectionsEnabled && !hasStatSection && !hasChart && } -
+ ); } diff --git a/packages/widgets/src/speedtest-tracker/helpers.spec.ts b/packages/widgets/src/speedtest-tracker/helpers.spec.ts index 1e02bf183..7d7d702ad 100644 --- a/packages/widgets/src/speedtest-tracker/helpers.spec.ts +++ b/packages/widgets/src/speedtest-tracker/helpers.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types"; -import { combineSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers"; +import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers"; const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({ ping: { avg: average, min: average, max: average }, @@ -27,6 +27,23 @@ describe("speedtest dashboard aggregation", () => { }); }); +describe("speedtest source ownership", () => { + it("keeps integration identity when upstream result ids overlap", () => { + const result = { id: 1, ping: 1, download_bits: 1, upload_bits: 1, healthy: true, created_at: new Date(1) }; + const dashboard = { latestResult: result, stats: null, recentResults: [result] }; + + expect( + getAvailableSpeedtestDashboards([ + { integrationId: "speed-a", integrationName: "Speed A", dashboard }, + { integrationId: "speed-b", integrationName: "Speed B", dashboard }, + ]).map(({ integrationId, integrationName }) => ({ integrationId, integrationName })), + ).toEqual([ + { integrationId: "speed-a", integrationName: "Speed A" }, + { integrationId: "speed-b", integrationName: "Speed B" }, + ]); + }); +}); + describe("getCompactSections", () => { const available = { latest: true, chart: true, averages: true }; diff --git a/packages/widgets/src/speedtest-tracker/helpers.ts b/packages/widgets/src/speedtest-tracker/helpers.ts index b590d0214..eb58e91f4 100644 --- a/packages/widgets/src/speedtest-tracker/helpers.ts +++ b/packages/widgets/src/speedtest-tracker/helpers.ts @@ -5,6 +5,23 @@ import type { SpeedtestTrackerStats, } from "@homarr/integrations/types"; +export interface SpeedtestDashboardSource { + integrationId: string; + integrationName: string; + dashboard: SpeedtestTrackerDashboardData; +} + +export const getAvailableSpeedtestDashboards = ( + results: readonly { + integrationId: string; + integrationName: string; + dashboard: SpeedtestTrackerDashboardData | null; + }[], +): SpeedtestDashboardSource[] => + results.flatMap(({ integrationId, integrationName, dashboard }) => + dashboard === null ? [] : [{ integrationId, integrationName, dashboard }], + ); + export const mergeStats = ( statsA: SpeedtestTrackerDashboardData["stats"], statsB: SpeedtestTrackerDashboardData["stats"], diff --git a/packages/widgets/src/stocks/component.spec.ts b/packages/widgets/src/stocks/component.spec.ts index f7a39e0b1..f9fa1c464 100644 --- a/packages/widgets/src/stocks/component.spec.ts +++ b/packages/widgets/src/stocks/component.spec.ts @@ -10,11 +10,25 @@ describe("stock summary", () => { it("does not divide by a zero previous close", () => { expect(getStockSummary([0, 5], 0)).toMatchObject({ currentPrice: 5, + previousClose: 0, change: 5, changePercentage: null, + minimum: 0, + maximum: 5, graphValues: [50, 55], }); }); + + it("derives every advanced metric from the owned price series", () => { + expect(getStockSummary([90, 110, 105], 100)).toMatchObject({ + currentPrice: 105, + previousClose: 100, + change: 5, + changePercentage: 5, + minimum: 90, + maximum: 110, + }); + }); }); describe("stock layout", () => { diff --git a/packages/widgets/src/stocks/component.tsx b/packages/widgets/src/stocks/component.tsx index a66f216e9..0ed620cb2 100644 --- a/packages/widgets/src/stocks/component.tsx +++ b/packages/widgets/src/stocks/component.tsx @@ -1,13 +1,15 @@ "use client"; import { Sparkline } from "@mantine/charts"; -import { Flex, Stack, Text, Title, useMantineTheme } from "@mantine/core"; +import { Badge, Box, Flex, Group, Paper, SimpleGrid, Stack, Text, Title, useMantineTheme } from "@mantine/core"; import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; function round(value: number) { @@ -25,8 +27,11 @@ function calculateChangePercentage(currentPrice: number, previousClose: number) export interface StockSummary { currentPrice: number; + previousClose: number; change: number; changePercentage: number | null; + minimum: number; + maximum: number; graphValues: number[]; } @@ -55,20 +60,29 @@ export function getStockSummary(priceHistory: number[], previousClose: number): const minimum = Math.min(...priceHistory); return { currentPrice, + previousClose, change: round(calculateChange(currentPrice, previousClose)), changePercentage: calculateChangePercentage(currentPrice, previousClose), + minimum, + maximum: Math.max(...priceHistory), graphValues: priceHistory.map((value) => value - minimum + 50), }; } -export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) { +export default function StockPriceWidget({ + options, + width, + height, + displayMode = "compact", +}: WidgetComponentProps<"stockPrice">) { const t = useScopedI18n("widget.stockPrice"); const locale = useCurrentIntlLocale(); const numberFormatter = new Intl.NumberFormat(locale); const theme = useMantineTheme(); - const { data: result, error } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options); + const stockQuery = clientApi.widget.stockPrice.getPriceHistory.useQuery(options); + const result = getUsableWidgetQueryData(stockQuery); - if (error && !result) throw error; + if (isInitialWidgetQueryPending(stockQuery)) return ; if (!result) return ; const { data } = result; @@ -79,57 +93,126 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo const stockGraphValues = summary.graphValues; const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6"; const layout = getStockLayout(width, height); + const formatValue = (value: number) => numberFormatter.format(round(value)); + const formatSignedValue = (value: number) => `${value > 0 ? "+" : ""}${formatValue(value)}`; - return ( - - + const content = + displayMode === "advanced" ? ( + + + + {data.symbol} + {data.shortName} + + + + {t("option.timeRange.label")}: {t(`option.timeRange.option.${options.timeRange}.label`)} + + + {t("option.timeInterval.label")}: {t(`option.timeInterval.option.${options.timeInterval}.label`)} + + + - - - {stockValuesChange > 0 ? ( - - ) : stockValuesChange < 0 ? ( - - ) : ( - + = 840 ? 6 : width >= 480 ? 3 : 2} spacing="xs"> + + + + + + + + + + + + + ) : ( + + + + + + {stockValuesChange > 0 ? ( + + ) : stockValuesChange < 0 ? ( + + ) : ( + + )} + {data.symbol} + + {layout.showName && ( + + {data.shortName} + )} - {data.symbol} - - {layout.showName && ( - - {data.shortName} + + + + {formatValue(summary.currentPrice)} + + + {layout.showChange && ( + + {numberFormatter.format(stockValuesChange)} + {stockValuesChangePercentage === null + ? null + : ` (${stockValuesChange > 0 ? "+" : ""}${numberFormatter.format(stockValuesChangePercentage)}%)`} )} - - - {numberFormatter.format(round(summary.currentPrice))} - + {layout.showRange && ( + + {t(`option.timeRange.option.${options.timeRange}.label`)} + + )} + + ); - {layout.showChange && ( - - {numberFormatter.format(stockValuesChange)} - {stockValuesChangePercentage === null - ? null - : ` (${stockValuesChange > 0 ? "+" : ""}${numberFormatter.format(stockValuesChangePercentage)}%)`} - + return ( + + {content} + {stockQuery.error && ( + + + )} - - {layout.showRange && ( - - {t(`option.timeRange.option.${options.timeRange}.label`)} - - )} - + + ); +} + +function StockMetric({ label, value, color }: { label: string; value: string; color?: string }) { + return ( + + + {label} + + + {value} + + ); } diff --git a/packages/widgets/src/stocks/index.ts b/packages/widgets/src/stocks/index.ts index 2b6e637e6..ea6398180 100644 --- a/packages/widgets/src/stocks/index.ts +++ b/packages/widgets/src/stocks/index.ts @@ -12,7 +12,7 @@ const timeRangeOptions = stockPriceTimeFrames.range; const timeIntervalOptions = stockPriceTimeFrames.interval; export const { definition, componentLoader } = createWidgetDefinition("stockPrice", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconBuildingBank, queryKey: [["widget", "stockPrice", "getPriceHistory"]], queryMatcher: ({ input }, scope) => diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx index 80e0dd1e8..f9b8cac37 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -9,16 +9,21 @@ import { formatBytesPair } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; -import { getUsableWidgetQueryData } from "../common/query-state"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes"; import { NoIntegrationDataError } from "../errors/no-data-integration"; -type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; +type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; + +export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage)); export const getDisplayText = ( item: { used: string; available: string; percentage: number }, - displayMode: DisplayMode, + displayMode: DiskDisplayMode, + translatedFreeText?: string, ) => { switch (displayMode) { case "percentage": @@ -27,27 +32,26 @@ export const getDisplayText = ( const usedInBytes = Number(item.used); const availableInBytes = Number(item.available); if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) { - const { used, available } = formatBytesPair(usedInBytes, availableInBytes); - return `${used} / ${available}`; + const { used, available: total } = formatBytesPair(usedInBytes, usedInBytes + availableInBytes); + return `${used} / ${total}`; } return `${item.used} / ${item.available}`; } case "free": - return `${Math.round(100 - item.percentage)}% free`; + return translatedFreeText ?? `${Math.round(100 - clampPercentage(item.percentage))}% free`; default: return `${Math.round(item.percentage)}%`; } }; -export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage)); - -const getAbsoluteText = (item: { used: string; available: string }) => { - const usedInBytes = Number(item.used); - const availableInBytes = Number(item.available); - if (!Number.isFinite(usedInBytes) || !Number.isFinite(availableInBytes)) return `${item.used} / ${item.available}`; - const { used, available } = formatBytesPair(usedInBytes, availableInBytes); - return `${used} / ${available}`; -}; +export const getAdvancedDisplayTexts = ( + item: { used: string; available: string; percentage: number }, + translatedFreeText?: string, +) => ({ + percentage: getDisplayText(item, "percentage"), + absolute: getDisplayText(item, "absolute"), + free: getDisplayText(item, "free", translatedFreeText), +}); interface SystemDiskCardProps { deviceName: string; @@ -58,6 +62,9 @@ interface SystemDiskCardProps { showBackgroundBar: boolean; integrationName?: string; secondaryText?: string; + freeText?: string; + smartStatus?: string; + isAdvanced: boolean; showSecondaryText: boolean; showTemperature: boolean; } @@ -71,6 +78,9 @@ const SystemDiskCard = ({ showBackgroundBar, integrationName, secondaryText, + freeText, + smartStatus, + isAdvanced, showSecondaryText, showTemperature, }: SystemDiskCardProps) => { @@ -98,7 +108,9 @@ const SystemDiskCard = ({ }, [displayText, healthy]); const unhealthyLabel = t("widget.systemDisks.status.unhealthy"); - const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature; + const hasTemperature = temperature !== null && temperature !== undefined; + const temperatureText = hasTemperature ? `${temperature}°C` : "—°C"; + const hasHiddenTemperature = hasTemperature && !showTemperature; const hasHiddenSecondaryText = Boolean(secondaryText && secondaryText !== displayText && !showSecondaryText); const tooltipLabel = [ healthy ? displayText : `${displayText} (${unhealthyLabel})`, @@ -140,11 +152,19 @@ const SystemDiskCard = ({ {secondaryText} )} + {isAdvanced && freeText && freeText !== displayText && ( + + {freeText} + + )} + {isAdvanced && ( + + {t("widget.systemDisks.status.smart")}: {smartStatus?.trim() || "—"} + + )}
- {showTemperature && temperature !== null && temperature !== undefined ? ( - {temperature}°C - ) : null} + {showTemperature && (hasTemperature || isAdvanced) ? {temperatureText} : null}
) { + const t = useI18n(); const queryInput = { integrationIds }; - const data = - getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput)) ?? []; + const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput); + const results = getUsableWidgetQueryData(healthQuery) ?? []; + const data = results.filter( + (entry): entry is typeof entry & { healthInfo: NonNullable } => entry.healthInfo !== null, + ); + const queryIndicators = ( + + + + + ); + const emptyState = ( + + + {queryIndicators} + + + + ); - if (data.length === 0) return ; + if (isInitialWidgetQueryPending(healthQuery)) return ; + if (data.length === 0) return emptyState; const disks = data.flatMap((entry) => { const fileSystem = filterStorageVolumes( @@ -193,32 +233,52 @@ export default function SystemResources({ })); }); - if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError(); - if (disks.length === 0) return ; + const hasNoFileSystems = data.every((entry) => entry.healthInfo.fileSystem.length === 0); + const hasFailedSource = results.some((entry) => Boolean(entry.error)); + if (hasNoFileSystems && !hasFailedSource && !healthQuery.error) throw new NoIntegrationDataError(); + if (hasNoFileSystems || disks.length === 0) return emptyState; + const isAdvanced = displayMode === "advanced"; const minimumCardWidth = 260; const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth))); const cellWidth = width / columns; return ( - - - {disks.map(({ integrationId, integrationName, item, smartItem }) => ( - 1 ? integrationName : undefined} - secondaryText={getAbsoluteText(item)} - showSecondaryText={height >= 160 && cellWidth >= 260} - showTemperature={height >= 100 && cellWidth >= 220} - /> - ))} - - + + + {queryIndicators} + + + + {disks.map(({ integrationId, integrationName, item, smartItem }) => { + const freeText = t("widget.systemDisks.status.free", { + percentage: String(Math.round(100 - clampPercentage(item.percentage))), + }); + const advancedDisplayTexts = getAdvancedDisplayTexts(item, freeText); + + return ( + 1 ? integrationName : undefined} + secondaryText={advancedDisplayTexts.absolute} + freeText={advancedDisplayTexts.free} + smartStatus={smartItem?.overallStatus} + isAdvanced={isAdvanced} + showSecondaryText={isAdvanced || (height >= 160 && cellWidth >= 260)} + showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)} + /> + ); + })} + + + ); } diff --git a/packages/widgets/src/system-disks/display.spec.ts b/packages/widgets/src/system-disks/display.spec.ts index 1ed6e3eac..061db3a9e 100644 --- a/packages/widgets/src/system-disks/display.spec.ts +++ b/packages/widgets/src/system-disks/display.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { clampPercentage, getDisplayText } from "./component"; +import { clampPercentage, getAdvancedDisplayTexts, getDisplayText } from "./component"; describe("system disk display", () => { test("clamps malformed utilization before sizing the background", () => { @@ -11,4 +11,18 @@ describe("system disk display", () => { test("falls back to provider values when sizes are already formatted", () => { expect(getDisplayText({ used: "5 GiB", available: "10 GiB", percentage: 50 }, "absolute")).toBe("5 GiB / 10 GiB"); }); + + test("provides every value used by the advanced view", () => { + expect( + getAdvancedDisplayTexts({ used: `${5 * 1024 ** 3}`, available: `${5 * 1024 ** 3}`, percentage: 50 }), + ).toEqual({ + percentage: "50%", + absolute: "5.0 GiB / 10.0 GiB", + free: "50% free", + }); + }); + + test("clamps free space derived from malformed utilization", () => { + expect(getDisplayText({ used: "11", available: "10", percentage: 108 }, "free")).toBe("0% free"); + }); }); diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts index 375d51232..d25f23b58 100644 --- a/packages/widgets/src/system-disks/index.ts +++ b/packages/widgets/src/system-disks/index.ts @@ -5,7 +5,7 @@ import { optionsBuilder } from "../options"; import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options"; export const { definition, componentLoader } = createWidgetDefinition("systemDisks", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconServer2, queryKey: [["widget", "healthMonitoring"]], refetchInterval: 5, diff --git a/packages/widgets/src/tracearr/component.spec.ts b/packages/widgets/src/tracearr/component.spec.ts index e74b35a54..3a678fc2f 100644 --- a/packages/widgets/src/tracearr/component.spec.ts +++ b/packages/widgets/src/tracearr/component.spec.ts @@ -1,6 +1,23 @@ import { describe, expect, it } from "vitest"; -import { getAdvancedColumnWidth, getCompactSectionVisibility } from "./component"; +import { getAdvancedColumnWidth, getCompactSectionVisibility, getTracearrSectionVisibility } from "./component"; +import { attachTracearrSource } from "./source"; + +describe("Tracearr source ownership", () => { + it("uses integration-scoped keys for overlapping upstream ids", () => { + const first = attachTracearrSource([{ id: "same" }], { + integrationId: "tracearr-a", + integrationName: "Tracearr A", + }); + const second = attachTracearrSource([{ id: "same" }], { + integrationId: "tracearr-b", + integrationName: "Tracearr B", + }); + + expect([first[0]?.key, second[0]?.key]).toEqual(["tracearr-a:same", "tracearr-b:same"]); + expect(second[0]?.integrationName).toBe("Tracearr B"); + }); +}); describe("Tracearr advanced layout", () => { it("subtracts grid padding and the inter-column gap", () => { @@ -38,3 +55,14 @@ describe("Tracearr compact disclosure", () => { ).toEqual({ violations: true, recentActivity: true }); }); }); + +describe("Tracearr advanced disclosure", () => { + it("shows all sections even when compact presentation toggles are disabled", () => { + expect( + getTracearrSectionVisibility( + { showStats: false, showStreams: false, showViolations: false, showRecentActivity: false }, + true, + ), + ).toEqual({ stats: true, streams: true, violations: true, recentActivity: true }); + }); +}); diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 04687c823..287b5d7da 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -1,16 +1,19 @@ "use client"; -import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import type { TracearrDashboardData } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import { formatTotalTracearrBitrate } from "./bitrate"; import { RecentActivityList } from "./recent-activity-section"; +import { attachTracearrSource } from "./source"; import { StatsBar } from "./stats-section"; import { StreamsList } from "./streams-section"; import { ViolationsList } from "./violations-section"; @@ -19,6 +22,18 @@ const ADVANCED_GRID_BREAKPOINT = 800; const ADVANCED_GRID_GAP_PX = 16; const ADVANCED_GRID_PADDING_PX = 16; +type TracearrSectionOptions = Pick< + WidgetComponentProps<"tracearr">["options"], + "showStats" | "showStreams" | "showViolations" | "showRecentActivity" +>; + +export const getTracearrSectionVisibility = (options: TracearrSectionOptions, isAdvanced: boolean) => ({ + stats: isAdvanced || options.showStats, + streams: isAdvanced || options.showStreams, + violations: isAdvanced || options.showViolations, + recentActivity: isAdvanced || options.showRecentActivity, +}); + export const getCompactSectionVisibility = ({ height, showStreams, @@ -68,13 +83,16 @@ interface TracearrContentProps { function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) { const t = useScopedI18n("widget.tracearr"); - const dashboardData = - getUsableWidgetQueryData(clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds })) ?? []; + const dashboardQuery = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds }); + const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? []; + const successfulDashboards = dashboardData.flatMap((item) => + item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }], + ); // Merge data from all integrations - const combinedData = dashboardData.reduce( - (acc, item) => { - const { stats, streams, violations, recentActivity } = item.dashboard; + const combinedData = successfulDashboards.reduce( + (acc, { dashboard }) => { + const { stats, streams, violations, recentActivity } = dashboard; const vData = violations ?? { data: [], meta: { total: 0, page: 1, pageSize: 5 } }; const aData = recentActivity ?? { data: [], meta: { total: 0, page: 1, pageSize: 5 } }; @@ -136,60 +154,101 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } }, }; - const noSectionsEnabled = - !options.showStats && !options.showStreams && !options.showRecentActivity && !options.showViolations; + const isAdvanced = displayMode === "advanced"; + const sectionVisibility = getTracearrSectionVisibility(options, isAdvanced); + const noSectionsEnabled = Object.values(sectionVisibility).every((isVisible) => !isVisible); const compactLimit = height < 220 ? 2 : height < 360 ? 4 : 8; - const streams = displayMode === "advanced" ? combined.streams.data : combined.streams.data.slice(0, compactLimit); - const violations = combined.violations?.data ?? []; - const recentActivity = combined.recentActivity?.data ?? []; + const sourcedStreams = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) => + attachTracearrSource(dashboard.streams.data, { integrationId, integrationName }), + ); + const sourcedViolations = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) => + attachTracearrSource(dashboard.violations?.data ?? [], { integrationId, integrationName }), + ); + 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: options.showStreams, - showViolations: options.showViolations, - hasViolations: violations.length > 0, - showRecentActivity: options.showRecentActivity, + showStreams: sectionVisibility.streams, + showViolations: sectionVisibility.violations, + hasViolations: sourcedViolations.length > 0, + showRecentActivity: sectionVisibility.recentActivity, }); - if (displayMode === "advanced") { + if (isAdvanced) { const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT; const columnWidth = getAdvancedColumnWidth(width, isTwoColumn); return ( - - - - {options.showStats && ( - - )} - {options.showStreams && } - - - {options.showViolations && } - {options.showRecentActivity && } - - - + + + + + + + + + + {successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")} + + {sectionVisibility.stats && ( + + )} + {sectionVisibility.streams && ( + + )} + + + {sectionVisibility.violations && ( + + )} + {sectionVisibility.recentActivity && ( + + )} + + + + ); } return ( - - - {options.showStats && } - {compactSections.violations && } - {options.showStreams && } - {compactSections.recentActivity && } - {noSectionsEnabled && ( - - {t("noSectionsEnabled")} - - )} - - + + + + + + + + {showSource && ( + + {successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")} + + )} + {sectionVisibility.stats && ( + + )} + {compactSections.violations && ( + + )} + {sectionVisibility.streams && } + {compactSections.recentActivity && ( + + )} + {noSectionsEnabled && ( + + {t("noSectionsEnabled")} + + )} + + + ); } diff --git a/packages/widgets/src/tracearr/recent-activity-section.tsx b/packages/widgets/src/tracearr/recent-activity-section.tsx index 994ff89a2..3730cdf76 100644 --- a/packages/widgets/src/tracearr/recent-activity-section.tsx +++ b/packages/widgets/src/tracearr/recent-activity-section.tsx @@ -3,7 +3,17 @@ import { Avatar, Badge, Group, Paper, Stack, Text } from "@mantine/core"; import type { TracearrHistorySession } from "@homarr/integrations/types"; import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; -export function RecentActivityList({ sessions }: { sessions: TracearrHistorySession[] }) { +import type { SourcedTracearrItem } from "./source"; + +type SourcedTracearrHistorySession = SourcedTracearrItem; + +export function RecentActivityList({ + sessions, + showSource, +}: { + sessions: SourcedTracearrHistorySession[]; + showSource: boolean; +}) { const t = useScopedI18n("widget.tracearr"); const locale = useCurrentIntlLocale(); @@ -25,7 +35,7 @@ export function RecentActivityList({ sessions }: { sessions: TracearrHistorySess : session.mediaTitle; return ( - + @@ -35,6 +45,7 @@ export function RecentActivityList({ sessions }: { sessions: TracearrHistorySess {session.user.username} • {session.serverName} + {showSource ? ` • ${session.integrationName}` : ""}
diff --git a/packages/widgets/src/tracearr/source.ts b/packages/widgets/src/tracearr/source.ts new file mode 100644 index 000000000..4e8bfe963 --- /dev/null +++ b/packages/widgets/src/tracearr/source.ts @@ -0,0 +1,19 @@ +export interface TracearrSource { + integrationId: string; + integrationName: string; +} + +export type SourcedTracearrItem = T & + TracearrSource & { + key: string; + }; + +export const attachTracearrSource = ( + items: readonly T[], + source: TracearrSource, +): SourcedTracearrItem[] => + items.map((item) => ({ + ...item, + ...source, + key: `${source.integrationId}:${item.id}`, + })); diff --git a/packages/widgets/src/tracearr/streams-section.tsx b/packages/widgets/src/tracearr/streams-section.tsx index 0b9d0a3bc..119584832 100644 --- a/packages/widgets/src/tracearr/streams-section.tsx +++ b/packages/widgets/src/tracearr/streams-section.tsx @@ -5,7 +5,19 @@ import { formatDuration } from "@homarr/common"; import type { TracearrStream } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; -export function StreamsList({ streams, width }: { streams: TracearrStream[]; width: number }) { +import type { SourcedTracearrItem } from "./source"; + +type SourcedTracearrStream = SourcedTracearrItem; + +export function StreamsList({ + streams, + width, + showSource, +}: { + streams: SourcedTracearrStream[]; + width: number; + showSource: boolean; +}) { const t = useScopedI18n("widget.tracearr"); if (streams.length === 0) { @@ -19,13 +31,21 @@ export function StreamsList({ streams, width }: { streams: TracearrStream[]; wid return ( {streams.map((stream) => ( - + ))} ); } -function StreamCard({ stream, compact }: { stream: TracearrStream; compact: boolean }) { +function StreamCard({ + stream, + compact, + showSource, +}: { + stream: SourcedTracearrStream; + compact: boolean; + showSource: boolean; +}) { const t = useScopedI18n("widget.tracearr"); const progressPercent = stream.durationMs && stream.durationMs > 0 ? (stream.progressMs / stream.durationMs) * 100 : 0; @@ -115,13 +135,13 @@ function StreamCard({ stream, compact }: { stream: TracearrStream; compact: bool )} - - {stream.device && ( + {(stream.device || showSource) && ( + - {stream.player ?? stream.device} + {[stream.player ?? stream.device, showSource ? stream.integrationName : null].filter(Boolean).join(" · ")} - )} - + + )}
); diff --git a/packages/widgets/src/tracearr/violations-section.tsx b/packages/widgets/src/tracearr/violations-section.tsx index 5c96a5c83..9d10240ac 100644 --- a/packages/widgets/src/tracearr/violations-section.tsx +++ b/packages/widgets/src/tracearr/violations-section.tsx @@ -4,7 +4,17 @@ import { IconAlertTriangle } from "@tabler/icons-react"; import type { TracearrViolation } from "@homarr/integrations/types"; import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; -export function ViolationsList({ violations }: { violations: TracearrViolation[] }) { +import type { SourcedTracearrItem } from "./source"; + +type SourcedTracearrViolation = SourcedTracearrItem; + +export function ViolationsList({ + violations, + showSource, +}: { + violations: SourcedTracearrViolation[]; + showSource: boolean; +}) { const t = useScopedI18n("widget.tracearr"); const locale = useCurrentIntlLocale(); @@ -20,7 +30,7 @@ export function ViolationsList({ violations }: { violations: TracearrViolation[] ) : ( {violations.map((violation) => ( - + {t("violations.rule")}: {violation.rule.name} + {showSource ? ` · ${violation.integrationName}` : ""} diff --git a/packages/widgets/src/traefik/component.spec.ts b/packages/widgets/src/traefik/component.spec.ts new file mode 100644 index 000000000..7167ab319 --- /dev/null +++ b/packages/widgets/src/traefik/component.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vitest"; + +import { getTraefikProtocolKeys, getTraefikSourceDetails, getVisibleTraefikEntryPoints } from "./component"; + +const emptyProtocol = { + routers: { total: 0, enabled: 0, warnings: 0, errors: 0 }, + services: { total: 0, enabled: 0, warnings: 0, errors: 0 }, + middlewares: { total: 0, enabled: 0, warnings: 0, errors: 0 }, +}; + +describe("Traefik source ownership", () => { + test("scopes identical resource and endpoint keys to their integrations", () => { + const dashboard = { + version: "3.0", + entryPoints: [], + resources: [ + { protocol: "http" as const, type: "router" as const, name: "api", provider: null, status: "enabled" as const }, + ], + failedEndpoints: ["/api/http/services"], + http: emptyProtocol, + tcp: emptyProtocol, + udp: { routers: emptyProtocol.routers, services: emptyProtocol.services }, + }; + + const details = getTraefikSourceDetails([ + { integrationId: "traefik-a", integrationName: "Traefik A", dashboard }, + { integrationId: "traefik-b", integrationName: "Traefik B", dashboard }, + ]); + + expect(details.resources.map(({ key }) => key)).toEqual([ + "traefik-a:http:router:api:0", + "traefik-b:http:router:api:0", + ]); + expect(details.failedEndpoints.map(({ key }) => key)).toEqual([ + "traefik-a:/api/http/services:0", + "traefik-b:/api/http/services:0", + ]); + }); +}); + +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 unique entry point regardless of compact width limits", () => { + const entryPoints = ["web", "websecure", "metrics", "admin", "postgres", "dns", "ssh", "smtp", "web"]; + + expect(getVisibleTraefikEntryPoints(entryPoints, 100, true)).toEqual(entryPoints.slice(0, -1)); + expect(getVisibleTraefikEntryPoints(entryPoints, 100, false)).toEqual(["web", "websecure"]); + }); +}); diff --git a/packages/widgets/src/traefik/component.tsx b/packages/widgets/src/traefik/component.tsx index 5af263385..c3bba0ae2 100644 --- a/packages/widgets/src/traefik/component.tsx +++ b/packages/widgets/src/traefik/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconAlertTriangle, IconCircleCheck, @@ -16,8 +16,10 @@ import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSumma import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; @@ -50,6 +52,47 @@ const emptyDashboard: TraefikDashboardData = { type ProtocolKey = "http" | "tcp" | "udp"; type ResourceKey = "routers" | "services" | "middlewares"; +interface TraefikDashboardSource { + integrationId: string; + integrationName: string; + dashboard: TraefikDashboardData; +} + +export const getTraefikSourceDetails = (sources: readonly TraefikDashboardSource[]) => ({ + resources: sources.flatMap(({ integrationId, integrationName, dashboard }) => + dashboard.resources.map((resource, index) => ({ + ...resource, + integrationId, + integrationName, + key: `${integrationId}:${resource.protocol}:${resource.type}:${resource.name}:${index}`, + })), + ), + failedEndpoints: sources.flatMap(({ integrationId, integrationName, dashboard }) => + dashboard.failedEndpoints.map((endpoint, index) => ({ + endpoint, + integrationId, + integrationName, + key: `${integrationId}:${endpoint}:${index}`, + })), + ), +}); + +export function getTraefikProtocolKeys( + options: Pick["options"], "showTcp" | "showUdp">, + height: number, + isAdvanced: boolean, +): ProtocolKey[] { + const protocols: ProtocolKey[] = ["http"]; + if (isAdvanced || (options.showTcp && height >= 220)) protocols.push("tcp"); + if (isAdvanced || (options.showUdp && height >= 300)) protocols.push("udp"); + return protocols; +} + +export function getVisibleTraefikEntryPoints(values: string[], width: number, isAdvanced: boolean): string[] { + const entryPoints = dedupe(values); + return isAdvanced ? entryPoints : entryPoints.slice(0, getEntryPointLimit(width)); +} + export default function TraefikWidget(props: WidgetComponentProps<"traefik">) { if (props.integrationIds.length === 0) { throw new NoIntegrationDataError(); @@ -66,11 +109,15 @@ function TraefikWidgetContent({ displayMode = "compact", }: WidgetComponentProps<"traefik">) { const t = useScopedI18n("widget.traefik"); - const data = getUsableWidgetQueryData(clientApi.widget.traefik.getDashboard.useQuery({ integrationIds })); + const dashboardQuery = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds }); + const data = getUsableWidgetQueryData(dashboardQuery); if (!data) return ; + const successfulData = data.flatMap((item) => + item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }], + ); - const combined = data.reduce( + const combined = successfulData.reduce( (acc, item) => ({ version: acc.version ?? item.dashboard.version, entryPoints: [...acc.entryPoints, ...item.dashboard.entryPoints], @@ -86,6 +133,7 @@ function TraefikWidgetContent({ emptyDashboard, ); + const isAdvanced = displayMode === "advanced"; const totalRouters = combined.http.routers.total + combined.tcp.routers.total + combined.udp.routers.total; const totalErrors = getProtocolErrors(combined.http) + @@ -94,20 +142,38 @@ function TraefikWidgetContent({ combined.failedEndpoints.length; const totalWarnings = getProtocolWarnings(combined.http) + getProtocolWarnings(combined.tcp) + getUdpWarnings(combined.udp); - const protocolKeys: ProtocolKey[] = ["http"]; - if (options.showTcp && (displayMode === "advanced" || height >= 220)) protocolKeys.push("tcp"); - if (options.showUdp && (displayMode === "advanced" || height >= 300)) protocolKeys.push("udp"); + const protocolKeys = getTraefikProtocolKeys(options, height, isAdvanced); + const entryPoints = getVisibleTraefikEntryPoints(combined.entryPoints, width, isAdvanced); + const displayedVersion = successfulData.length === 1 ? successfulData[0]?.dashboard.version : null; const summary = (
- {data.length === 1 ? data[0]?.integrationName : t("instances")} - {combined.version ? `v${combined.version}` : t("versionUnknown")} + + {successfulData.length === 1 ? successfulData[0]?.integrationName : t("instances")} + + + {displayedVersion + ? `v${displayedVersion}` + : successfulData.length > 1 + ? successfulData.map(({ integrationName }) => integrationName).join(" · ") + : t("versionUnknown")} +
+ {isAdvanced && ( + + {successfulData.map(({ integrationId, integrationName, dashboard }) => ( + + {integrationName} · {dashboard.version ? `v${dashboard.version}` : t("versionUnknown")} + + ))} + + )} +
} @@ -127,46 +193,51 @@ function TraefikWidgetContent({ ))}
- {options.showEntryPoints && combined.entryPoints.length > 0 && (displayMode === "advanced" || height >= 340) && ( + {(isAdvanced || options.showEntryPoints) && entryPoints.length > 0 && (isAdvanced || height >= 340) && (
- {dedupe(combined.entryPoints) - .slice(0, getEntryPointLimit(width)) - .map((entryPoint) => ( - - {entryPoint} - - ))} + {entryPoints.map((entryPoint) => ( + + {entryPoint} + + ))}
)}
); - if (displayMode === "compact") return {summary}; + if (!isAdvanced) { + return ( + + + + + + {summary} + + ); + } - const resources = [...combined.resources].toSorted( + const sourceDetails = getTraefikSourceDetails(successfulData); + const resources = sourceDetails.resources.toSorted( (left, right) => resourceStatusOrder[left.status] - resourceStatusOrder[right.status] || left.name.localeCompare(right.name), ); const resourceList = ( - {combined.failedEndpoints.map((endpoint) => ( - - {t("failedEndpoint", { endpoint })} + {sourceDetails.failedEndpoints.map(({ endpoint, integrationName, key }) => ( + + {integrationName} · {t("failedEndpoint", { endpoint })} ))} - {resources.map((resource, index) => ( - + {resources.map((resource) => ( + {resource.name} - {resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"} + {resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"} ·{" "} + {resource.integrationName} - - {summary} - {resourceList} - - + + + + + + + + {summary} + {resourceList} + + + ); } return ( - - {summary} - {resourceList} - + + + + + + + {summary} + {resourceList} + + ); } diff --git a/packages/widgets/src/ups/component.spec.ts b/packages/widgets/src/ups/component.spec.ts new file mode 100644 index 000000000..ab48147b5 --- /dev/null +++ b/packages/widgets/src/ups/component.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { getUpsDevices } from "./component"; + +describe("UPS source ownership", () => { + it("uses integration-scoped keys for overlapping device ids", () => { + const devices = getUpsDevices([ + { + integrationId: "ups-a", + integrationName: "UPS A", + summaries: [{ id: "same", status: "online" as const }], + }, + { + integrationId: "ups-b", + integrationName: "UPS B", + summaries: [{ id: "same", status: "online" as const }], + }, + ]); + + expect(devices.map(({ key, integrationName }) => ({ key, integrationName }))).toEqual([ + { key: "ups-a:same", integrationName: "UPS A" }, + { key: "ups-b:same", integrationName: "UPS B" }, + ]); + }); +}); diff --git a/packages/widgets/src/ups/component.tsx b/packages/widgets/src/ups/component.tsx index b291c5937..32310a966 100644 --- a/packages/widgets/src/ups/component.tsx +++ b/packages/widgets/src/ups/component.tsx @@ -21,8 +21,10 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { NoIntegrationSelectedError } from "../errors/no-integration-selected"; const statusColors: Record = { @@ -67,18 +69,24 @@ interface UpsContentProps { function UpsContent({ integrationIds, options, width, height, displayMode }: UpsContentProps) { const t = useScopedI18n("widget.ups"); - const data = getUsableWidgetQueryData(clientApi.widget.ups.getSummaries.useQuery({ integrationIds })); + const summariesQuery = clientApi.widget.ups.getSummaries.useQuery({ integrationIds }); + const data = getUsableWidgetQueryData(summariesQuery); if (!data) return ; - const devices = data - .flatMap((instance) => - instance.summaries.map((summary) => ({ key: `${instance.integrationId}:${summary.id}`, summary })), - ) - .toSorted((left, right) => statusPriority[left.summary.status] - statusPriority[right.summary.status]); + const devices = getUpsDevices(data); + const showSource = displayMode === "advanced" || data.length > 1; if (devices.length === 0) { - return ; + return ( + + + + + + + + ); } // Pick a layout from the available width so the widget stays useful at any size. The surrounding @@ -89,10 +97,11 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups const layout: UpsLayout = displayMode === "advanced" ? "full" : width < 150 || height < 110 ? "mini" : width < 256 ? "compact" : "full"; - const cards = devices.map(({ key, summary }) => ( + const cards = devices.map(({ key, integrationName, summary }) => ( - {displayMode === "advanced" ? ( - = 760 ? 2 : 1} spacing="md" p="md"> - {cards} - - ) : ( - - {cards} - - )} - + + + + + + + {displayMode === "advanced" ? ( + = 760 ? 2 : 1} spacing="md" p="md"> + {cards} + + ) : ( + + {cards} + + )} + + ); } @@ -123,16 +138,38 @@ const statusPriority: Record = { online: 4, }; +export const getUpsDevices = >( + instances: readonly { + integrationId: string; + integrationName: string; + summaries: readonly T[]; + }[], +) => + instances + .flatMap(({ integrationId, integrationName, summaries }) => + summaries.map((summary) => ({ + key: `${integrationId}:${summary.id}`, + integrationId, + integrationName, + summary, + })), + ) + .toSorted((left, right) => statusPriority[left.summary.status] - statusPriority[right.summary.status]); + interface UpsDeviceCardProps { summary: UpsSummary; + sourceName?: string; options: WidgetComponentProps<"ups">["options"]; layout: UpsLayout; advanced: boolean; t: ScopedTranslationFunction<"widget.ups">; } -function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardProps) { - const showRing = options.showBattery && summary.batteryCharge !== null; +function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: UpsDeviceCardProps) { + const showBattery = advanced || options.showBattery; + const showLoad = advanced || options.showLoad; + const showVoltage = advanced || options.showVoltage; + const showRing = showBattery && summary.batteryCharge !== null; const statusBadge = ( + {sourceName && ( + + {sourceName} + + )} {showRing && summary.batteryCharge !== null && ( {summary.name} + {sourceName && ( + + {sourceName} + + )} {statusBadge} @@ -213,9 +260,16 @@ function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardP return ( - - {summary.name} - + + + {summary.name} + + {sourceName && ( + + {sourceName} + + )} + {statusBadge} @@ -235,11 +289,11 @@ function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardP )} - {options.showBattery && summary.batteryRuntime !== null && ( + {showBattery && summary.batteryRuntime !== null && ( )} - {options.showLoad && summary.load !== null && ( + {showLoad && summary.load !== null && ( @@ -251,7 +305,7 @@ function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardP )} - {options.showVoltage && (summary.inputVoltage !== null || summary.outputVoltage !== null) && ( + {showVoltage && (summary.inputVoltage !== null || summary.outputVoltage !== null) && ( {summary.inputVoltage !== null && ( diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index 6dccbbb11..c7c803898 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -9,8 +9,10 @@ import { formatNumber } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; import { aggregateUptimeKumaDashboards } from "./aggregate"; @@ -128,12 +130,16 @@ interface UptimeKumaContentProps { function UptimeKumaContent({ integrationIds, options, width, height, displayMode }: UptimeKumaContentProps) { const t = useScopedI18n("widget.uptimeKuma"); - const dashboardData = getUsableWidgetQueryData(clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds })); + const dashboardQuery = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds }); + const dashboardData = getUsableWidgetQueryData(dashboardQuery); if (!dashboardData) return ; + const successfulDashboardData = dashboardData.flatMap((item) => + item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }], + ); - const combined = aggregateUptimeKumaDashboards(dashboardData.map((item) => item.dashboard)); - const monitors = dashboardData.flatMap((result) => + const combined = aggregateUptimeKumaDashboards(successfulDashboardData.map((item) => item.dashboard)); + const monitors = successfulDashboardData.flatMap((result) => result.dashboard.monitors.map((monitor) => ({ ...monitor, key: `${result.integrationId}:${monitor.id}`, @@ -145,25 +151,22 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode const ringSize = getRingSize(width); const iconSize = getIconSize(width); const gridCols = getGridCols(width); + const isAdvanced = displayMode === "advanced"; const enabledStatKeys = Object.entries(statVisibilityByOption) - .filter(([optionKey]) => options[optionKey as keyof typeof options]) + .filter(([optionKey]) => isAdvanced || options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); - const showHero = options.showAverageUptime; - const showRing = options.showUptimeRing && (displayMode === "advanced" || (width >= 170 && height >= 110)); - const prioritizedStatKeys = - displayMode === "advanced" - ? enabledStatKeys - : enabledStatKeys.toSorted( - (left, right) => - getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]), - ); + const showHero = isAdvanced || options.showAverageUptime; + const showRing = isAdvanced || (options.showUptimeRing && width >= 170 && height >= 110); + const prioritizedStatKeys = isAdvanced + ? enabledStatKeys + : enabledStatKeys.toSorted( + (left, right) => getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]), + ); const visibleStatKeys = prioritizedStatKeys.slice( 0, - displayMode === "advanced" - ? prioritizedStatKeys.length - : getCompactStatLimit(height, showHero, prioritizedStatKeys.length), + isAdvanced ? prioritizedStatKeys.length : getCompactStatLimit(height, showHero, prioritizedStatKeys.length), ); const hasContent = showHero || visibleStatKeys.length > 0; @@ -245,7 +248,17 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
); - if (displayMode === "compact") return summaryContent; + if (displayMode === "compact") { + return ( + + + + + + {summaryContent} + + ); + } const monitorList = ( @@ -257,7 +270,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode {monitor.name} - {dashboardData.length > 1 && ( + {(isAdvanced || successfulDashboardData.length > 1) && ( {monitor.integrationName} @@ -274,20 +287,32 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode if (width < 760) { return ( - - - {summaryContent} - {monitorList} - - + + + + + + + + {summaryContent} + {monitorList} + + + ); } return ( - - {summaryContent} - {monitorList} - + + + + + + + {summaryContent} + {monitorList} + + ); } diff --git a/packages/widgets/src/wud/component.tsx b/packages/widgets/src/wud/component.tsx index 0d2f6744a..a2db93cb3 100644 --- a/packages/widgets/src/wud/component.tsx +++ b/packages/widgets/src/wud/component.tsx @@ -11,36 +11,48 @@ import type { WudContainerUpdate } from "@homarr/integrations"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; -export default function WudWidget({ integrationIds, options, width }: WidgetComponentProps<"wud">) { +export default function WudWidget({ integrationIds, options, width, displayMode }: WidgetComponentProps<"wud">) { const integrationId = integrationIds[0]; if (!integrationId) return null; - return ; + return ( + + ); } const WudWidgetContent = ({ integrationId, options, width, + isAdvanced, }: { integrationId: string; options: WidgetComponentProps<"wud">["options"]; width: number; + isAdvanced: boolean; }) => { const t = useScopedI18n("widget.wud"); const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId }); const board = useRequiredBoard(); - const isTiny = width < 256; + const isTiny = !isAdvanced && width < 256; const stats = data.stats; if (stats.totalContainers === 0) return ; const updatePercentage = Math.round((stats.updatesAvailable / stats.totalContainers) * 100); const badgeColor = stats.updatesAvailable === 0 ? "green" : progressColor(updatePercentage); - const showUpdateList = options.showUpdateList && stats.updates.length > 0; + const showTitle = isAdvanced || options.showTitle; + const showRing = isAdvanced || options.showRing; + const showUpdateList = (isAdvanced || options.showUpdateList) && stats.updates.length > 0; const ring = ( ); - const tinyContent = options.showRing ? ( + const tinyContent = showRing ? ( @@ -102,7 +114,7 @@ const WudWidgetContent = ({ return ( - {options.showTitle && !isTiny && ( + {showTitle && !isTiny && ( @@ -122,12 +134,12 @@ const WudWidgetContent = ({ tinyContent ) : options.layout === "horizontal" ? ( - {options.showRing && ring} + {showRing && ring} {summary} ) : ( - {options.showRing && ring} + {showRing && ring} {summary} )} @@ -181,6 +193,7 @@ const UpdateCard = ({ className: string | undefined; }) => { const t = useScopedI18n("widget.wud"); + const href = getSafeApplicationUrl(update.link); const isDigestUpdate = isDigestVersion(update.newVersion); const fullVersionText = buildVersionText(update.currentVersion, update.newVersion); const versionText = isDigestUpdate @@ -201,12 +214,12 @@ const UpdateCard = ({
)} - {update.link && ( + {href && ( ({ showTitle: factory.switch({ defaultValue: true }),