feat: expand advanced widget experiences

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:55 +02:00
1 parent 55226842f9
commit 65a7e84bea
137 files changed
+3684 -850

No files matched your search

@@ -14,7 +14,7 @@ import { archiveTeamWarriorWidget } from ".";
<WidgetHeader widget={archiveTeamWarriorWidget} categories={["Archiving", "Monitoring"]} />
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
+3 -1
View File
@@ -36,7 +36,9 @@ import widgetOverview from "./img/widget-overview.png";
]}
/>
<WidgetInteractionGuide advancedView={false} />
Advanced view shows every missing-subtitle and health summary regardless of compact visibility options, together with the already-fetched Sonarr and Radarr SignalR states and announcement count.
<WidgetInteractionGuide advancedView />
### Adding the widget
@@ -34,6 +34,8 @@ This widget displays your Beszel alert configurations separated by status (trigg
]}
/>
In advanced view, the widget always shows triggered and OK alerts, their descriptions, integration source names, and the complete fetched history. Advanced view requests history even when compact history is disabled and raises the bounded per-integration history request to at least 50 entries, without exceeding 100.
<WidgetInteractionGuide />
### Adding the widget
@@ -4,6 +4,8 @@ description: "Card grid view of all Beszel-monitored systems with real-time metr
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -34,6 +36,10 @@ Select a system card to open the complete statistics view, including system, fil
]}
/>
In advanced view, every applicable metric is shown even when its compact visibility option is disabled. Each card also shows its integration source, hostname, operating system, CPU model, core count, and total memory. Cards remain selectable to open the complete statistics view.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+2
View File
@@ -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";
+5 -1
View File
@@ -27,7 +27,11 @@ The Coolify widget displays a compact overview of your Coolify instance. It can
]}
/>
<WidgetInteractionGuide advancedView={false} />
Advanced view opens the Servers, Applications, and Services sections regardless of their compact visibility switches. It identifies each Coolify integration, shows server `is_reachable` and `is_usable` values, and lists applications nested in services when the API provides them. Healthy integrations remain visible if another source fails, with the failed source identified by a warning.
IP addresses remain masked in advanced view until you explicitly use the eye control. Public resource, Coolify dashboard, and log links retain the widget's safe new-tab handling.
<WidgetInteractionGuide advancedView />
### Adding the widget
@@ -20,6 +20,8 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
<WidgetHeader widget={dnsHoleControlsWidget} categories={["DNS Hole"]} />
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget&apos;s width and height.
### Screenshots
@@ -17,6 +17,8 @@ import { dnsHoleSummaryWidget } from ".";
<WidgetHeader widget={dnsHoleSummaryWidget} categories={["DNS Hole"]} />
When several DNS integrations are selected, Homarr keeps healthy sources visible if one source fails and shows a warning identifying the unavailable integration.
This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. When the widget has enough room, it adds a scrollable status row naming each selected source.
### Screenshots
@@ -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";
@@ -39,7 +39,9 @@ import { prowlarrIntegration } from "@site/docs/integrations/prowlarr";
- Test all indexers by clicking on the test button.
<WidgetInteractionGuide advancedView={false} />
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.
<WidgetInteractionGuide />
### Adding the widget
@@ -18,6 +18,8 @@ import { sonarrIntegration } from "@site/docs/integrations/sonarr";
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
Advanced view always shows both Missing and Queued panels side by side when space allows, requests at least 50 items per source, exposes queue status and time remaining, and keeps partial-integration warnings visible. Compact tab visibility and page-size choices continue to shape the board tile.
### Screenshots
import widgetMissing from "./img/missing.png";
@@ -19,6 +19,10 @@ import { plexIntegration } from "@site/docs/integrations/plex";
Provides a list of newly added media or upcoming releases from your media server.
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
Advanced view displays every release in a roomy poster layout with its complete available metadata, description, tags, and visible source name, regardless of the compact layout and visibility options.
### Screenshots
import backdrop from "./img/backdrop.png";
@@ -36,7 +36,9 @@ import summaryCard from "./img/summary.png";
]}
/>
<WidgetInteractionGuide advancedView={false} />
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
<WidgetInteractionGuide />
### Adding the widget
+5 -1
View File
@@ -26,7 +26,7 @@ import editOn from "./img/edit.png";
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### 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.
+2 -1
View File
@@ -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",
},
@@ -34,7 +34,9 @@ import notificationsScreenshot from "./img/notifications.png";
items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]}
/>
<WidgetInteractionGuide advancedView={false} />
Advanced view always shows available notification logos and sources and removes the compact body line limit. External notification links retain the same safe new-tab behavior.
<WidgetInteractionGuide advancedView />
### Adding the widget
+5 -1
View File
@@ -72,6 +72,10 @@ The widget adapts its layout based on the available space:
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
### Advanced view
Advanced view is a complete PatchMon posture view. It always shows the compliance hero, all eight host and package statistics, the full OS distribution with versions, and the last-updated time. Compact visibility switches and OS limits still shape the board tile, but do not hide already-fetched security information in advanced view. Threshold and chart-style choices remain in effect.
### Supported Integrations
<WidgetIntegrations
@@ -83,7 +87,7 @@ When the widget has enough space, a "last updated" timestamp appears at the bott
]}
/>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
+3 -1
View File
@@ -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.
<WidgetInteractionGuide advancedView={false} />
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.
<WidgetInteractionGuide />
### Adding the widget
+3 -1
View File
@@ -21,7 +21,9 @@ import screenshot from "./img/screenshot.png";
<img src={screenshot} alt="rss-feed noted-lol" width={128 * 4} />
<WidgetInteractionGuide advancedView={false} />
Advanced view keeps the configured feeds and post limit unchanged while showing each fetched post's full description, image, source, relative time, and exact publication timestamp. Right-to-left text and safe external links continue to follow the widget configuration.
<WidgetInteractionGuide advancedView />
### Adding the widget
@@ -22,6 +22,8 @@ The entity state enables you to display the current state of an entity.
This works with most entities and enables you display almost any data.
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
Advanced view shows the safe Home Assistant metadata owned by Homarr: the friendly name, entity ID, unit, device class, icon, last-changed time, and last-updated time. Arbitrary attributes remain intentionally excluded.
### Screenshots
import sun from "./img/sun.png";
@@ -20,6 +20,8 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
The execute automation widget allows you to trigger an automation with one click.
Advanced view shows the automation ID, interaction permission, last successful run in the current browser session, and the latest action error together. Session-only history is not presented as Home Assistant history.
### Screenshots
import empty from "./img/empty.png";
@@ -18,6 +18,10 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Screenshots
import screenshot from "./img/screenshot.png";
+3 -1
View File
@@ -16,6 +16,8 @@ import { stockPriceWidget } from ".";
Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard.
The widget prioritizes the symbol, current price, and trend. Company name, change, time range, and a taller chart appear progressively when both widget dimensions can support them.
In advanced view, the widget always shows the returned symbol and company name, configured range and interval, current and previous close, absolute and percentage change, range minimum and maximum, and the full price-history chart. These values use the same configured Yahoo Finance query as the compact widget; advanced view does not change the symbol, range, or interval.
### Screenshots
import apple from "./img/apple.png";
@@ -34,7 +36,7 @@ import nvidia from "./img/nvidia.png";
<img src={nvidia} alt="stock-price nvidia" width={128 * 2 + 2} height={128 * 2 + 4} />
</div>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
@@ -44,7 +44,9 @@ This widget displays filesystem and SMART disk usage for supported server and NA
]}
/>
<WidgetInteractionGuide advancedView={false} />
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.
<WidgetInteractionGuide />
### Adding the widget
@@ -17,6 +17,8 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Sections
| Section | Description |
+4
View File
@@ -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
<WidgetIntegrations
+4
View File
@@ -19,6 +19,10 @@ This widget displays the status of the UPS devices reported by a Network UPS Too
status (online, on battery, charging or low battery), battery charge and estimated runtime, output load, and input and
output voltage.
Every device keeps an integration-scoped identity. Advanced view always labels each device with its owning integration, while compact layouts add the source label only when multiple healthy integrations are present.
When multiple UPS integrations are selected, devices from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Layouts
import screenshotFull from "./img/screenshot.png";
@@ -17,6 +17,10 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
When multiple Uptime Kuma integrations are selected, monitors from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Supported Integrations
<WidgetIntegrations
@@ -8,6 +8,7 @@ import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { wudWidget } from ".";
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
@@ -26,6 +27,10 @@ This widget shows two stats from your WUD instance: the total number of monitore
]}
/>
<WidgetInteractionGuide />
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
<AddingWidget />
@@ -0,0 +1,10 @@
import { TRPCError } from "@trpc/server";
export const throwIfDockerOperationsFailed = (results: readonly PromiseSettledResult<unknown>[]) => {
if (results.some((result) => result.status === "rejected")) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "One or more Docker container operations failed",
});
}
};
@@ -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")
@@ -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",
}),
);
});
});
@@ -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<typeof MediaRequestListHandlerModule>();
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<ReturnType<typeof setupAsync>>;
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({
@@ -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",
});
});
});
+36 -12
View File
@@ -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<CoolifyInstanceResult> => {
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,
},
);
}),
});
+38 -8
View File
@@ -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
+54 -9
View File
@@ -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({
@@ -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({
@@ -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<TIntegration extends MediaReleaseSource>(
integrations: TIntegration[],
load: (integration: TIntegration) => Promise<MediaReleaseLoadResult>,
) {
const results = await settleIntegrationQueries<TIntegration, MediaReleaseIntegrationResult>(
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 }] : [],
),
};
}
@@ -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,
})),
);
}),
});
@@ -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,
},
);
}),
});
@@ -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,
},
);
}),
});
+27 -12
View File
@@ -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,
},
);
}),
});
+27 -12
View File
@@ -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,
},
);
}),
});
+27 -12
View File
@@ -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,
},
);
}),
});
+27 -12
View File
@@ -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,
},
);
}),
});
@@ -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", () => {
+4 -1
View File
@@ -124,7 +124,10 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
/>
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
{options.pingEnabled &&
!settings.forceDisableStatus &&
!board.disableStatus &&
Boolean(app.pingUrl ?? app.href) ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
@@ -106,8 +106,35 @@ const ArchiveTeamWarriorWidgetContent = ({
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
</Text>
</Group>
<Group gap="xs" wrap="wrap">
{status.project?.id && (
<Badge size="xs" variant="light">
{t("projectId", { id: status.project.id })}
</Badge>
)}
{status.bandwidth?.received !== undefined && (
<Text size="xs" c="dimmed">
{t("sessionReceived", { value: formatBandwidth(status.bandwidth.received) })}
</Text>
)}
{status.bandwidth?.sent !== undefined && (
<Text size="xs" c="dimmed">
{t("sessionSent", { value: formatBandwidth(status.bandwidth.sent) })}
</Text>
)}
{status.bandwidth?.session_id && (
<Text size="xs" c="dimmed">
{t("sessionId", { id: status.bandwidth.session_id })}
</Text>
)}
</Group>
<ScrollArea style={{ flex: 1 }}>
<Stack gap={4}>
{status.items.length === 0 && (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("emptyItems")}
</Text>
)}
{status.items.map((item) => {
const itemStatusKey = getStatusKey(item.status);
return (
@@ -120,6 +147,11 @@ const ArchiveTeamWarriorWidgetContent = ({
<Text size="xs" c="dimmed" lineClamp={1}>
{item.project ?? projectName}
</Text>
{item.startTime !== undefined && (
<Text size="xs" c="dimmed" lineClamp={1}>
{t("startedAt", { date: new Date(item.startTime * 1000).toLocaleString(locale) })}
</Text>
)}
</Stack>
<Badge size="xs" color={getStatusColor(itemStatusKey)}>
{t(`status.${itemStatusKey}`)}
@@ -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);
@@ -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);
+2 -1
View File
@@ -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,
@@ -69,6 +69,8 @@
color: var(--mantine-primary-color-filled);
line-height: 1.1;
text-align: center;
max-width: 100%;
overflow-wrap: anywhere;
}
.statValueWarning {
+50 -14
View File
@@ -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<BazarrStatKey, number | string>;
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 (
<div key={statKey} className={`${classes.statTile} ${isWarning ? classes.statTileWarning : ""}`}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>{value}</span>
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>
{typeof value === "string" && value.trim() === "" ? "—" : value}
</span>
<span className={classes.statLabel} title={t(statKey)}>
{t(statKey)}
</span>
@@ -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;
+1 -1
View File
@@ -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,
+15 -1
View File
@@ -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"]);
});
});
@@ -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<string, LucideIcon> = {
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<string, string> = {};
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 (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<IntegrationErrorIndicator results={results} />
<Group gap={0}>
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
</Box>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
@@ -124,11 +153,12 @@ export default function BeszelAlertsWidget({
</Group>
{triggeredAlerts.map((alert) => (
<AlertRow
key={alert._key}
key={alert.key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered
showDescription={showAlertDescriptions}
/>
@@ -148,11 +178,12 @@ export default function BeszelAlertsWidget({
</Group>
{okAlerts.map((alert) => (
<AlertRow
key={alert._key}
key={alert.key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered={false}
showDescription={showAlertDescriptions}
/>
@@ -171,11 +202,12 @@ export default function BeszelAlertsWidget({
</Text>
</Group>
{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 (
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group key={entry.key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={3}
@@ -189,7 +221,7 @@ export default function BeszelAlertsWidget({
{entry.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{systemName}
{systemLabel}
</Text>
</Stack>
</Group>
@@ -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 }:
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{systemName}
{integrationName ? `${systemName} · ${integrationName}` : systemName}
</Text>
<Text size="xs" c="dimmed">
·
@@ -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,
});
});
});
@@ -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,
});
+9 -5
View File
@@ -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() {
@@ -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");
});
});
@@ -0,0 +1,24 @@
interface BeszelSystemNamesByIntegration {
integrationId: string;
systemNameMap: Record<string, string>;
}
export const getBeszelSystemNameKey = (integrationId: string, systemId: string) => `${integrationId}:${systemId}`;
export const buildBeszelSystemNameMap = (results: readonly BeszelSystemNamesByIntegration[]) => {
const systemNames = new Map<string, string>();
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<string, string>,
integrationId: string,
systemId: string,
) => systemNames.get(getBeszelSystemNameKey(integrationId, systemId)) ?? systemId;
@@ -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<typeof useScopedI18n<"widget.beszelSystemGrid">>;
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 = [
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
</Group>
),
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
</Badge>
</Group>
{isAdvanced && (
<SimpleGrid cols={2} spacing={4} verticalSpacing={2} mb={4}>
<SystemMetadata label={t("metadata.hostname")} value={system.hostname || "—"} />
<SystemMetadata label={t("metadata.os")} value={system.osName || "—"} />
<SystemMetadata label={t("metadata.cpuModel")} value={system.cpuModel || "—"} />
<SystemMetadata label={t("metadata.cores")} value={String(system.cores)} />
<SystemMetadata
label={t("metadata.memoryTotal")}
value={system.memoryTotal > 0 ? formatBytes(system.memoryTotal) : "—"}
/>
<SystemMetadata label={t("metadata.source")} value={integrationName} />
</SimpleGrid>
)}
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
</Stack>
@@ -380,12 +422,24 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
);
};
const SystemMetadata = ({ label, value }: { label: string; value: string }) => (
<Box style={{ minWidth: 0 }}>
<Text size="10px" c="dimmed">
{label}
</Text>
<Text size="xs" fw={500} truncate title={value}>
{value}
</Text>
</Box>
);
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 (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<IntegrationErrorIndicator results={results} />
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -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 (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<IntegrationErrorIndicator results={results} />
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<Box
h="100%"
style={{
@@ -445,19 +508,22 @@ export default function BeszelSystemGridWidget({
}}
>
{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 (
<SystemCard
key={system._key}
key={system.rowKey}
system={system}
options={options}
t={t}
size={size}
maxMetrics={maxMetrics}
itemRadius={board.itemRadius}
integrationName={integrationName}
isAdvanced={isAdvanced}
onClick={handleClick}
/>
);
@@ -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);
});
});
@@ -0,0 +1,2 @@
export const isBeszelGridMetricVisible = (configured: boolean, isAdvanced: boolean, applicable = true) =>
applicable && (isAdvanced || configured);
@@ -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,
@@ -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 (
<Box h="100%" pos="relative">
<IntegrationErrorIndicator results={systemsResult} />
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -175,7 +180,10 @@ export default function BeszelSystemStatsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<IntegrationErrorIndicator results={systemsResult} />
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
</Box>
<ScrollArea
h="100%"
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { parseColumnOrder, parseColumnWidths } from "../common/use-persisted-table-layout";
import { beszelTableMetricOptionKeys, getBeszelTableVisibleMetricKeys } from "./display";
const columnAccessors = ["name", "cpu", "memory", "disk", "temp"];
@@ -32,3 +33,23 @@ describe("Beszel table column layout options", () => {
).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);
});
});
@@ -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));
};
@@ -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"
+2 -2
View File
@@ -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],
);
+99 -30
View File
@@ -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 <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
const isAdvanced = displayMode === "advanced";
const sectionVisibility = getCoolifySectionVisibility(options, displayMode);
return (
<CoolifyContent
integrationIds={integrationIds}
options={{ ...options, ...sectionVisibility }}
width={width}
height={height}
isAdvanced={isAdvanced}
/>
);
}
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
</Stack>
);
}
if (instancesData.length === 1 && firstInstance) {
if (successfulInstances.length === 0) {
return (
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
hideFooter={hideFooter}
/>
<Stack h="100%" gap={0}>
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
<Box style={{ minHeight: 0, flex: 1 }}>
<WidgetEmptyState />
</Box>
</Stack>
);
}
if (successfulInstances.length === 1 && firstInstance) {
return (
<Stack h="100%" gap={0}>
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
<div style={{ minHeight: 0, flex: 1 }}>
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/>
</div>
</Stack>
);
}
return (
<ScrollArea h="100%">
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
{instancesData.map((instance) => (
<InstanceCard
key={instance.integrationId}
instance={instance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
hideFooter={hideFooter}
/>
))}
</SimpleGrid>
</ScrollArea>
<Stack h="100%" gap={0}>
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
<ScrollArea style={{ minHeight: 0, flex: 1 }}>
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
{successfulInstances.map((instance) => (
<InstanceCard
key={instance.integrationId}
instance={instance}
options={options}
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/>
))}
</SimpleGrid>
</ScrollArea>
</Stack>
);
}
const FailedSourceBadges = ({
sources,
queryError,
}: {
sources: { integrationId: string; integrationName: string }[];
queryError: unknown;
}) => {
const t = useScopedI18n("widget.coolify");
if (sources.length === 0 && !queryError) return null;
return (
<Group gap={4} p="xs" pb={0} wrap="wrap">
{sources.map((source) => (
<Badge key={source.integrationId} color="red" variant="light" size="xs">
{t("source.unavailable", { source: source.integrationName })}
</Badge>
))}
<Box ml="auto">
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Box>
</Group>
);
};
@@ -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);
});
});
@@ -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;
+1 -1
View File
@@ -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) => ({
@@ -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<number, { apps: number; services: number }>;
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 (
<Accordion.Item value="servers">
@@ -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 (
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<StatusIcon
aria-label={t(isOnline ? "status.online" : "status.offline")}
aria-label={t(`status.${status}`)}
size={isTiny ? 12 : 16}
color={`var(--mantine-color-${isOnline ? "green" : "red"}-6)`}
color={`var(--mantine-color-${statusColor}-6)`}
/>
<Anchor
className={actionTargetClasses.root}
@@ -129,9 +144,30 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
<Text fz="10px" c="dimmed">
{showIp ? server.ip : "***.***.***.***"}
{showIp ? (server.ip ?? "—") : "***.***.***.***"}
</Text>
</Group>
{isAdvanced && (
<Group gap={4} ml={16} wrap="wrap">
<ServerStateBadge field="reachability" value={reachability} />
<ServerStateBadge field="usability" value={usability} />
</Group>
)}
</Stack>
);
}
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 (
<Badge size="xs" variant="light" color={value === true ? "green" : value === false ? "red" : "gray"}>
{t(`server.${field}`)}: {status}
</Badge>
);
};
@@ -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 ? (
<Stack gap={4}>
{services.map((service) => (
<ResourceRow key={service.uuid} item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
<Stack key={service.uuid} gap={2}>
<ResourceRow item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
{isAdvanced && service.applications && service.applications.length > 0 && (
<Stack gap={2} ml="lg">
<Text fz="10px" c="dimmed">
{tCommon("applications")}
</Text>
{service.applications.map((application) => (
<ServiceApplicationRow key={application.uuid} application={application} />
))}
</Stack>
)}
</Stack>
))}
</Stack>
) : (
@@ -46,3 +61,45 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr
</Accordion.Item>
);
}
type ServiceApplication = NonNullable<CoolifyServiceWithContext["applications"]>[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 (
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="dot" color={getStatusColor(status)}>
{statusLabel}
</Badge>
{publicUrl ? (
<Anchor
className={actionTargetClasses.root}
href={publicUrl}
target="_blank"
rel={SAFE_NEW_TAB_REL}
fz="xs"
c="inherit"
truncate="end"
style={{ textDecoration: "none" }}
>
{application.name}
</Anchor>
) : (
<Text fz="xs" truncate="end">
{application.name}
</Text>
)}
</Group>
);
};
@@ -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<DnsSummaryResult["summary"]>;
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"
>
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
<Group gap={0}>
<IntegrationErrorIndicator results={summaryResults} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.dnsHoleControls.name")} />
</Group>
</Box>
{controlAllButtonsVisible && (
<Flex className="dns-hole-controls-buttons" gap="sm">
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
@@ -260,7 +280,7 @@ export default function DnsHoleControlsWidget({
interface ControlsCardProps {
integrationsWithInteractions: string[];
toggleDns: (integrationId: string) => Promise<void>;
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
data: AvailableDnsSummaryResult;
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: TranslationFunction;
@@ -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 (
<Stack h="100%" gap={0}>
<Stack h="100%" gap={0} pos="relative">
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
<Group gap={0}>
<IntegrationErrorIndicator results={summaries} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.dnsHoleSummary.name")} />
</Group>
</Box>
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
{data.length > 0 ? (
stats.map((item) => (
@@ -78,7 +88,7 @@ export default function DnsHoleSummaryWidget({
style={{ gridColumn: "1 / -1" }}
>
<AvatarGroup spacing="md">
{summaries.map(({ integration }) => (
{successfulSummaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
</Tooltip>
@@ -93,14 +103,14 @@ export default function DnsHoleSummaryWidget({
{showSourceStatuses && (
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
<Group gap="xs" wrap="nowrap">
{summaries.map(({ integration, summary }) => (
{successfulSummaries.map(({ integration, summary }) => (
<Badge
key={integration.id}
variant="light"
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
color={summary?.status === "enabled" ? "green" : summary?.status === "disabled" ? "red" : "gray"}
style={{ flexShrink: 0 }}
>
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}` as never)}
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary?.status ?? "unknown"}` as never)}
</Badge>
))}
</Group>
+10 -7
View File
@@ -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"]);
});
});
+27
View File
@@ -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<T extends { state: DownloadState }>(
items: readonly T[],
statusFilter: readonly string[],
+3 -21
View File
@@ -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`),
})),
@@ -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<typeof entry.healthInfo> } => entry.healthInfo !== null,
);
const [openedIntegrationId, setOpenedIntegrationId] = useState<string | null>(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 = (
<Group gap={0}>
<IntegrationErrorIndicator results={healthResults} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("widget.healthMonitoring.name")} />
</Group>
);
if (healthData.length === 0) return <WidgetEmptyState />;
if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />;
if (healthData.length === 0) {
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<WidgetEmptyState />
</Box>
);
}
const Container = withScrollArea ? ScrollArea : Box;
return (
<Container h={withScrollArea ? "100%" : undefined}>
<Container h={withScrollArea ? "100%" : undefined} pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Stack mih="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const filteredFileSystem = filterStorageVolumes(
@@ -145,18 +174,18 @@ export const SystemHealthMonitoring = ({
</Modal>
</Box>
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{options.cpu && (
{showCpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{showCpu && (
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
)}
{options.memory && (
{showMemory && (
<MemoryRing
available={healthInfo.memAvailableInBytes}
used={healthInfo.memUsedInBytes}
isTiny={isTiny}
/>
)}
{options.gpu &&
{showGpu &&
healthInfo.gpu.map((gpu) => (
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
))}
@@ -166,7 +195,7 @@ export const SystemHealthMonitoring = ({
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
</Card>
)}
{options.fileSystem &&
{showFileSystem &&
disksData.map((disk) => {
return (
<Card
@@ -242,7 +271,9 @@ export const SystemHealthMonitoring = ({
);
};
type HealthInfo = RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"];
type HealthInfo = NonNullable<
RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"]
>;
const SystemInformationList = ({
healthInfo,
@@ -285,6 +316,14 @@ const SystemInformationList = ({
{healthInfo.loadAverage["5min"]}% / {healthInfo.loadAverage["15min"]}%
</List.Item>
)}
<List.Item className="health-monitoring-information-updates" icon={<IconPackages size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.updatesAvailable", { count: healthInfo.availablePkgUpdates })}
</List.Item>
<List.Item className="health-monitoring-information-reboot" icon={<IconRefreshAlert size={compact ? 18 : 30} />}>
{healthInfo.rebootRequired
? t("widget.healthMonitoring.popover.rebootRequired")
: t("widget.healthMonitoring.popover.rebootNotRequired")}
</List.Item>
</List>
);
@@ -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);
});
});
@@ -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 (
<Flex
@@ -52,6 +92,7 @@ export default function IndexerManagerWidget({
p={isDense ? "xs" : "sm"}
>
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap" w="100%">
{testAllError && <VisuallyHidden role="alert">{t("common.error")}</VisuallyHidden>}
<Tooltip label={t("widget.indexerManager.title")} disabled={!hasSmallWidth}>
<IconReportSearch
className="indexer-manager-title-icon"
@@ -76,17 +117,19 @@ export default function IndexerManagerWidget({
)}
</Group>
)}
<Tooltip label={t("widget.indexerManager.testAll")}>
<WidgetQueryErrorIndicator error={indexersQuery.error} label={t("widget.indexerManager.title")} />
<Tooltip label={testAllError ? t("common.error") : t("widget.indexerManager.testAll")}>
<ActionIcon
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
size="sm"
radius={board.itemRadius}
variant="light"
color={testAllError ? "red" : undefined}
loading={isPending}
disabled={isEditMode}
disabled={isEditMode || !canInteract}
loaderProps={{ type: "dots" }}
onClick={() => {
testAll({ integrationIds });
testAllIndexers();
}}
aria-label={t("widget.indexerManager.testAll")}
>
@@ -101,67 +144,81 @@ export default function IndexerManagerWidget({
radius={board.itemRadius}
flex={1}
>
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
{indexersData.length > 1 && (
<Group justify="space-between" wrap="nowrap" py={4}>
<Text size="xs" fw={600} truncate="end">
{integrationName}
</Text>
{error && (
<Badge size="xs" color="red" variant="light">
{t("common.error")}
</Badge>
)}
</Group>
)}
{indexers.map((indexer) => {
const href = getSafeApplicationUrl(indexer.url);
return (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Anchor
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
component={href ? "a" : "span"}
href={href}
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
rel={href && options.openIndexerSiteInNewTab ? SAFE_NEW_TAB_REL : undefined}
title={href}
>
<Text
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
>
{indexer.name}
</Text>
</Anchor>
{indexer.status === false || indexer.enabled === false ? (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="var(--mantine-color-red-6)"
size={hasSmallWidth ? 12 : 16}
/>
) : (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="var(--mantine-color-green-6)"
size={hasSmallWidth ? 12 : 16}
/>
{isInitialPending ? (
<Center h="100%">
<Loader size="sm" />
</Center>
) : allIndexers.length === 0 && !hasSourceError ? (
<WidgetEmptyState />
) : (
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
{(isAdvanced || indexersData.length > 1 || error) && (
<Group justify="space-between" wrap="nowrap" py={4}>
<Text size="xs" fw={600} truncate="end">
{integrationName}
</Text>
{error && (
<Badge size="xs" color="red" variant="light">
{t("common.error")}
</Badge>
)}
</Group>
);
})}
</Stack>
))}
</ScrollArea>
)}
{indexers.map((indexer) => {
const href = getSafeApplicationUrl(indexer.url);
const displayStatus = getIndexerDisplayStatus(indexer);
const presentation = statusPresentation[displayStatus];
return (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Anchor
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
component={href ? "a" : "span"}
href={href}
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
rel={href && options.openIndexerSiteInNewTab ? SAFE_NEW_TAB_REL : undefined}
title={href}
>
<Text
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
>
{indexer.name}
</Text>
</Anchor>
{isAdvanced ? (
<Badge size="xs" color={presentation.color} variant="light">
{t(`widget.indexerManager.status.${displayStatus}`)}
</Badge>
) : displayStatus === "healthy" ? (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="var(--mantine-color-green-6)"
size={hasSmallWidth ? 12 : 16}
/>
) : (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="var(--mantine-color-red-6)"
size={hasSmallWidth ? 12 : 16}
/>
)}
</Group>
);
})}
</Stack>
))}
</ScrollArea>
)}
</Card>
</Flex>
);
@@ -13,7 +13,7 @@ const createOptions = () =>
}));
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
supportsAdvancedFocus: false,
supportsAdvancedFocus: true,
icon: IconReportSearch,
contextActions: ({ widgetRuntimeRef, context }) => [
{
@@ -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");
});
});
@@ -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";
};
@@ -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 <WidgetQueryLoadingState />;
if (!releases || releases.length === 0) return <WidgetEmptyState />;
if (!response) return <WidgetEmptyState />;
const { releases, failedIntegrations } = response;
const isAdvanced = displayMode === "advanced";
return (
<Box h="100%" pos="relative">
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
</Fragment>
))}
</SimpleGrid>
</ScrollArea>
{releasesQuery.error && (
<Box pos="absolute" top={4} right={4}>
{releases.length === 0 ? (
<WidgetEmptyState />
) : (
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
</Fragment>
))}
</SimpleGrid>
</ScrollArea>
)}
{(failedIntegrations.length > 0 || releasesQuery.error) && (
<Group pos="absolute" top={4} right={4} gap={0}>
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("widget.mediaReleases.name")} />
</Box>
</Group>
)}
</Box>
);
}
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 && (
<Text size="xs" c="dimmed" lineClamp={3}>
<Text size="xs" c="dimmed">
{item.description}
</Text>
)}
@@ -230,9 +238,17 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
</Badge>
)}
{(isAdvanced || options.showSource) && (
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
)}
{(isAdvanced || options.showSource) &&
(isAdvanced ? (
<Group gap={4} wrap="nowrap">
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt="" />
<Text size="xs" fw={500} lineClamp={1}>
{item.integration.name}
</Text>
</Group>
) : (
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
))}
</Stack>
)}
</Group>
@@ -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 (
<Box p={layout.padding} h="100%">
<Box p={layout.padding} h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={summaries ?? []} />
</Box>
<SimpleGrid cols={layout.columns} h="100%" spacing="sm">
{layout.showWifi && (
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
@@ -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<typeof result.summary>; 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 = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.networkControllerSummary.name")} />
</Group>
);
if (isPending || summaries.length === 0) {
return (
<Center h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{isPending ? t("common.action.loading") : t("widget.networkControllerSummary.error.integrationsDisconnected")}
</Text>
</Center>
<Stack h="100%" gap={0}>
{queryIndicators}
<Center p="sm" style={{ flex: 1 }}>
<Text c="dimmed" size="sm" ta="center">
{isPending
? t("common.action.loading")
: t("widget.networkControllerSummary.error.integrationsDisconnected")}
</Text>
</Center>
</Stack>
);
}
if (isAdvanced) {
const controllerColumns = width >= 960 ? 2 : 1;
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
return (
<ScrollArea h="100%" p="sm" pos="relative">
{queryIndicators}
<SimpleGrid cols={controllerColumns} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} p="sm" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{integration.kind}
</Text>
</Stack>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Text size="10px" c="dimmed">
{t("widget.networkControllerSummary.advanced.updated")}
</Text>
<Text size="xs" c="dimmed" ta="right">
{new Date(updatedAt).toLocaleString(locale)}
</Text>
</Stack>
</Group>
<SimpleGrid cols={matrixColumns} spacing="xs">
{getNetworkControllerMatrix(summary).map((section) => (
<Card key={section.key} p="xs" withBorder>
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<StatusIcon
status={section.status}
label={statusLabels[getBinaryStatusKey(section.status)]}
size={16}
/>
<Text size="sm" fw={600}>
{getMatrixSectionLabel(section.key, t)}
</Text>
</Group>
<Text size="xs" c="dimmed">
{statusLabels[getBinaryStatusKey(section.status)]}
</Text>
</Group>
{section.metrics.length > 0 && (
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
{section.metrics.map((metric) => (
<Stack key={metric.key} gap={0}>
<Text size="10px" c="dimmed">
{getMatrixMetricLabel(metric.key, t)}
</Text>
<Text size="xs" fw={500}>
{formatMatrixMetric(metric.key, metric.value)}
</Text>
</Stack>
))}
</SimpleGrid>
)}
</Stack>
</Card>
))}
</SimpleGrid>
</Stack>
</Card>
))}
</SimpleGrid>
</ScrollArea>
);
}
return (
<ScrollArea h="100%" p={isDense ? "xs" : "sm"}>
{queryIndicators}
<SimpleGrid cols={columns} spacing="sm">
{summaries.map(({ integration, summary }) => (
<Card key={integration.id} p={isDense ? "xs" : "sm"} withBorder={summaries.length > 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") {
@@ -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 }] },
]);
});
});
@@ -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 }] },
];
@@ -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,
@@ -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);
});
});
+13
View File
@@ -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,
});
+1 -1
View File
@@ -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(
+38 -11
View File
@@ -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 (
<Box className="homarr-notebook" h="100%" onDoubleClick={handleDoubleClick}>
@@ -375,9 +384,9 @@ export function Notebook({
>
<RichTextEditor.Toolbar
style={{
display: isEditing && !isSaving && options.showToolbar === true ? "flex" : "none",
maxHeight: height < 180 ? "4.5rem" : "10rem",
overflowY: "auto",
display: display.showToolbar ? "flex" : "none",
maxHeight: display.toolbarMaxHeight,
overflowY: display.toolbarMaxHeight === undefined ? undefined : "auto",
}}
>
<RichTextEditor.ControlsGroup>
@@ -505,7 +514,7 @@ export function Notebook({
<RichTextEditor.Content />
</ScrollArea>
</RichTextEditor>
{(saveError || height >= 180) && (
{(saveError || display.showDocumentStats) && (
<Group pos="absolute" bottom={4} right={8} gap="xs" style={{ pointerEvents: saveError ? undefined : "none" }}>
{saveError && (
<Tooltip label={saveError} multiline>
@@ -521,7 +530,7 @@ export function Notebook({
</Text>
</Tooltip>
)}
{height >= 180 && (
{display.showDocumentStats && (
<Text size="xs" c="dimmed">
{t("widget.notebook.documentStats", documentStats)}
</Text>
@@ -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 (
<RichTextEditor.Control
@@ -897,9 +915,18 @@ function ListIndentDecrease() {
editor?.chain().focus().liftListItem(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 (
<RichTextEditor.Control
@@ -9,21 +9,25 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useTimeAgo } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { getNotificationDisplay } from "./display";
export default function NotificationsWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"notifications">) {
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 (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
<Stack w="100%" gap="xs">
{notificationsQuery.error && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={notificationsQuery.error} label={t("widget.notifications.name")} />
</Group>
)}
{failedIntegrations.length > 0 && (
<Group gap={4} wrap="wrap">
{failedIntegrations.map((integration) => (
@@ -95,7 +110,7 @@ export default function NotificationsWidget({
}}
>
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
{!options.hideLogos && notification.source?.iconUrl && (
{notificationDisplay.showLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
@@ -113,7 +128,7 @@ export default function NotificationsWidget({
<Text
c="dimmed"
size={isDense ? "xs" : "sm"}
lineClamp={bodyLineClamp}
lineClamp={notificationDisplay.bodyLineClamp}
style={{ whiteSpace: "pre-line" }}
>
{notification.body}
@@ -121,7 +136,11 @@ export default function NotificationsWidget({
<InfoDisplay
date={notification.time}
source={isRoomy ? (notification.source?.name ?? notification.integrationName) : undefined}
source={
notificationDisplay.showSource
? (notification.source?.name ?? notification.integrationName)
: undefined
}
dense={isDense}
/>
</Flex>
@@ -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,
});
});
});
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more