feat: expand advanced widget experiences

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:09 +02:00
1 parent 9437d211c8
commit c099a8eef6
119 files changed
+3624 -844

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
+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";
@@ -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
+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",
},
+1 -1
View File
@@ -87,7 +87,7 @@ Advanced view is a complete PatchMon posture view. It always shows the complianc
]}
/>
<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
@@ -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
+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
@@ -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,
});
});
});
@@ -0,0 +1,20 @@
interface NotificationDisplayInput {
displayMode?: "compact" | "advanced";
hideLogos: boolean;
isRoomy: boolean;
bodyLineClamp: number;
}
export const getNotificationDisplay = ({
displayMode,
hideLogos,
isRoomy,
bodyLineClamp,
}: NotificationDisplayInput) => {
const isAdvanced = displayMode === "advanced";
return {
showLogos: isAdvanced || !hideLogos,
showSource: isAdvanced || isRoomy,
bodyLineClamp: isAdvanced ? undefined : bodyLineClamp,
};
};
@@ -1,4 +1,5 @@
.root {
position: relative;
container-type: size;
display: flex;
flex-direction: column;
@@ -9,6 +10,32 @@
overflow: hidden;
}
.advancedContent {
height: auto;
min-height: 100%;
}
.advancedContent .statsSection {
flex: none;
}
.advancedContent .grid {
height: auto;
grid-auto-rows: minmax(92px, auto);
}
.advancedContent .osSectionWrapper {
flex: none;
min-height: 220px;
}
.queryIndicator {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
}
.compactRoot {
padding: clamp(4px, 3cqi, 8px);
gap: 2px;
+38 -38
View File
@@ -20,26 +20,17 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
import { getGridCols, shouldShowComplianceHeroText } from "./layout-utils";
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "./layout-utils";
import { OsDistributionSection } from "./os-distribution-section";
import type { PatchMonStatKey } from "./stat-colors";
import { resolveStatColor, severityToIconColor, severityToMantineBgColor, severityToMantineColor } from "./stat-colors";
dayjs.extend(relativeTime);
const statVisibilityByOption = {
showTotalHosts: "totalHosts",
showHostsNeedingUpdates: "hostsNeedingUpdates",
showSecurityUpdates: "securityUpdates",
showUpToDateHosts: "upToDateHosts",
showHostsWithSecurityUpdates: "hostsWithSecurityUpdates",
showRecentUpdates24h: "recentUpdates24h",
showTotalOutdatedPackages: "totalOutdatedPackages",
showTotalRepos: "totalRepos",
} as const;
const statIcons: Record<PatchMonStatKey, TablerIcon> = {
totalHosts: IconServer,
hostsNeedingUpdates: IconRefreshAlert,
@@ -75,15 +66,19 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
return "comfortable";
};
export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) {
export default function PatchMonWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"patchmon">) {
const t = useScopedI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? "";
const { data: stats, error } = clientApi.widget.patchmon.getStats.useQuery(
{ integrationId },
{ staleTime: 60 * 1000 },
);
const statsQuery = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
const stats = getUsableWidgetQueryData(statsQuery);
if (error && !stats) throw error;
if (isInitialWidgetQueryPending(statsQuery)) return <WidgetQueryLoadingState />;
if (!stats) return <WidgetEmptyState />;
const statValues: Record<PatchMonStatKey, number> = {
@@ -97,24 +92,24 @@ export default function PatchMonWidget({ integrationIds, options, width, height
totalRepos: stats.totalRepos,
};
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const isAdvanced = displayMode === "advanced";
const visibleStatKeys = getVisiblePatchMonStatKeys(options, isAdvanced);
const colorContext = { totalHosts: stats.totalHosts };
const layout = getLayoutMode(width, height);
const layout = isAdvanced ? "spacious" : getLayoutMode(width, height);
const isLandscape = width / height > 2.5;
const isTinySquare = width < 160 && height < 160;
const isShortLandscape = width >= 160 && height < 160;
const isCompactSurface = isTinySquare || isShortLandscape;
const isCompactSurface = !isAdvanced && (isTinySquare || isShortLandscape);
const isNarrow = width < 180;
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
const showOsData = (isAdvanced || options.showOsDistribution) && stats.osDistribution.length > 0;
const showIcons = layout !== "mini";
const showLabels = layout !== "mini";
const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
const showHeroText = showHero && shouldShowComplianceHeroText(width);
const showIcons = isAdvanced || layout !== "mini";
const showLabels = isAdvanced || layout !== "mini";
const showHero =
isAdvanced || (options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90);
const showHeroText = showHero && (isAdvanced || shouldShowComplianceHeroText(width));
const showHeroRingOnly = showHero && !showHeroText;
const compactPrimaryContent = isCompactSurface
? getCompactPrimaryContent({
@@ -124,16 +119,18 @@ export default function PatchMonWidget({ integrationIds, options, width, height
})
: null;
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
const showOsSection = showOsData && !isCompactSurface && height > 200;
const showOsSection = showOsData && !isCompactSurface && (isAdvanced || height > 200);
const showCompactFooter = isCompactSurface && height >= 110;
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
const showFooter = isAdvanced || (isCompactSurface ? showCompactFooter : !isLandscape && height > 220);
const showOsLegend = osDisplayMode === "donut" && (isAdvanced || (width >= 380 && height >= 300));
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
const gridCols = isLandscape
? Math.min(visibleStatKeys.length, 4)
: Math.min(getGridCols(width), visibleStatKeys.length || 1);
const gridCols = isAdvanced
? Math.min(visibleStatKeys.length, width >= 500 ? 4 : width >= 220 ? 2 : 1)
: isLandscape
? Math.min(visibleStatKeys.length, 4)
: Math.min(getGridCols(width), visibleStatKeys.length || 1);
const iconSize = getIconSize(width);
const ringSize = getRingSize(width, height, showHeroRingOnly);
@@ -266,8 +263,8 @@ export default function PatchMonWidget({ integrationIds, options, width, height
<div className={`${classes.osSectionWrapper} ${visibleStatKeys.length > 0 ? classes.osSectionWithStats : ""}`}>
<OsDistributionSection
entries={stats.osDistribution}
limit={Number(options.osDistributionLimit)}
showOsVersion={options.showOsVersion}
limit={isAdvanced ? 0 : Number(options.osDistributionLimit)}
showOsVersion={isAdvanced || options.showOsVersion}
displayMode={osDisplayMode}
width={width}
height={height}
@@ -276,10 +273,13 @@ export default function PatchMonWidget({ integrationIds, options, width, height
</div>
);
const needsScroll = layout === "mini" || layout === "compact";
const needsScroll = isAdvanced || layout === "mini" || layout === "compact";
return (
<div className={classes.root}>
<div className={classes.queryIndicator}>
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
</div>
{showHero && (
<ComplianceHero
compliancePercent={compliancePercent}
@@ -296,7 +296,7 @@ export default function PatchMonWidget({ integrationIds, options, width, height
{(statGridContent || osContent) &&
(needsScroll ? (
<ScrollArea style={{ flex: 1, minHeight: 0 }} scrollbars="y">
<div className={classes.content}>
<div className={`${classes.content} ${isAdvanced ? classes.advancedContent : ""}`}>
{statGridContent}
{osContent}
</div>
+1 -1
View File
@@ -7,7 +7,7 @@ import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors";
import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
supportsAdvancedFocus: false,
supportsAdvancedFocus: true,
icon: IconShieldCheck,
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
@@ -1,3 +1,5 @@
import type { PatchMonStatKey } from "./stat-colors";
const gridColsByWidth = [
{ minWidth: 500, cols: 4 },
{ minWidth: 380, cols: 3 },
@@ -5,6 +7,26 @@ const gridColsByWidth = [
{ minWidth: 0, cols: 1 },
] as const;
const statVisibilityByOption = [
["showTotalHosts", "totalHosts"],
["showHostsNeedingUpdates", "hostsNeedingUpdates"],
["showSecurityUpdates", "securityUpdates"],
["showUpToDateHosts", "upToDateHosts"],
["showHostsWithSecurityUpdates", "hostsWithSecurityUpdates"],
["showRecentUpdates24h", "recentUpdates24h"],
["showTotalOutdatedPackages", "totalOutdatedPackages"],
["showTotalRepos", "totalRepos"],
] as const satisfies readonly (readonly [string, PatchMonStatKey])[];
type PatchMonStatVisibilityOptions = Partial<Record<(typeof statVisibilityByOption)[number][0], boolean>>;
export function getVisiblePatchMonStatKeys(
options: PatchMonStatVisibilityOptions,
isAdvanced: boolean,
): PatchMonStatKey[] {
return statVisibilityByOption.filter(([optionKey]) => isAdvanced || options[optionKey]).map(([, statKey]) => statKey);
}
export function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getGridCols, shouldShowComplianceHeroText } from "../layout-utils";
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "../layout-utils";
describe("getGridCols", () => {
test("returns 1 column for narrow widths", () => {
@@ -34,3 +34,31 @@ describe("shouldShowComplianceHeroText", () => {
expect(shouldShowComplianceHeroText(384)).toBe(true);
});
});
describe("getVisiblePatchMonStatKeys", () => {
test("keeps compact visibility choices", () => {
expect(
getVisiblePatchMonStatKeys(
{
showTotalHosts: true,
showSecurityUpdates: true,
showTotalRepos: false,
},
false,
),
).toEqual(["totalHosts", "securityUpdates"]);
});
test("returns every owned statistic in advanced mode", () => {
expect(getVisiblePatchMonStatKeys({}, true)).toEqual([
"totalHosts",
"hostsNeedingUpdates",
"securityUpdates",
"upToDateHosts",
"hostsWithSecurityUpdates",
"recentUpdates24h",
"totalOutdatedPackages",
"totalRepos",
]);
});
});
+19 -13
View File
@@ -37,9 +37,13 @@ import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/defin
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
import { selectReleaseRepositoriesForDisplay } from "./display";
import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository";
import { getReleasesQueryStaleTimeMs } from "./query-options";
@@ -49,7 +53,7 @@ const formatRelativeDate = (value: string): string => {
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
};
export default function ReleasesWidget({ options, itemId, width }: WidgetComponentProps<"releases">) {
export default function ReleasesWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"releases">) {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
@@ -60,6 +64,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
key: "releases-viewed-versions",
defaultValue: {},
});
const isAdvanced = displayMode === "advanced";
const columns = Math.max(1, Math.floor(width / 420));
const relativeDateOptions = useMemo(
@@ -123,6 +128,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
() => queryResults.map((q) => q.data).filter((d): d is NonNullable<typeof d> => d != null),
[queryResults],
);
const isInitialLoading = queryResults.some(isInitialWidgetQueryPending);
const repositories = useMemo(() => {
const formattedResults = normalizedRepositories
@@ -147,22 +153,19 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
viewed: releasesViewedList[repository.id] === release.latestRelease,
};
})
.filter(
(repository) =>
"error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease,
)
.toSorted((repoA, repoB) => {
if ("error" in repoA) return -1;
if ("error" in repoB) return 1;
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
}) as ReleasesRepositoryResponse[];
if (typeof options.topReleases !== "string" && options.topReleases > 0) {
return formattedResults.slice(0, options.topReleases);
}
return formattedResults;
return selectReleaseRepositoriesForDisplay(formattedResults, {
displayMode,
showOnlyHighlighted: options.showOnlyHighlighted,
topReleases: options.topReleases,
});
}, [
displayMode,
results,
normalizedRepositories,
options.showOnlyHighlighted,
@@ -186,6 +189,9 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
[setReleasesViewedList],
);
if (isInitialLoading) return <WidgetQueryLoadingState />;
if (repositories.length === 0) return <WidgetEmptyState />;
return (
<ScrollArea h="100%" className="releases">
<SimpleGrid cols={columns} spacing={columns > 1 ? "sm" : 0} p={columns > 1 ? "xs" : 0}>
@@ -241,7 +247,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
miw={0}
style={{ flex: 1 }}
>
{!options.showOnlyIcon && (
{(isAdvanced || !options.showOnlyIcon) && (
<Text className="releases-repository-header-name" size="xs">
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{repository.name || repository.identifier}
@@ -321,7 +327,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
</Group>
</Group>
</UnstyledButton>
{options.showDetails && (
{(isAdvanced || options.showDetails) && (
<DetailsDisplay
repository={repository}
isExpanded={isActive}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { selectReleaseRepositoriesForDisplay } from "./display";
const repositories = [
{ id: "ordinary", isNewRelease: false, isStaleRelease: false },
{ id: "new", isNewRelease: true, isStaleRelease: false },
{ id: "stale", isNewRelease: false, isStaleRelease: true },
{ id: "error", error: { code: "provider" } },
];
describe("selectReleaseRepositoriesForDisplay", () => {
test("applies highlight and top limits in compact mode", () => {
const result = selectReleaseRepositoriesForDisplay(repositories, {
displayMode: "compact",
showOnlyHighlighted: true,
topReleases: 2,
});
expect(result.map(({ id }) => id)).toEqual(["new", "stale"]);
});
test("keeps every configured repository in advanced mode", () => {
const result = selectReleaseRepositoriesForDisplay(repositories, {
displayMode: "advanced",
showOnlyHighlighted: true,
topReleases: 1,
});
expect(result.map(({ id }) => id)).toEqual(["ordinary", "new", "stale", "error"]);
});
});
+30
View File
@@ -0,0 +1,30 @@
interface ReleasePresentationItem {
error?: unknown;
isNewRelease?: boolean;
isStaleRelease?: boolean;
}
interface ReleasePresentationOptions {
displayMode?: "compact" | "advanced";
showOnlyHighlighted: boolean;
topReleases: number | string;
}
export const selectReleaseRepositoriesForDisplay = <T extends ReleasePresentationItem>(
repositories: readonly T[],
options: ReleasePresentationOptions,
): T[] => {
const visibleRepositories =
options.displayMode === "advanced" || !options.showOnlyHighlighted
? [...repositories]
: repositories.filter(
(repository) =>
repository.error !== undefined || repository.isNewRelease === true || repository.isStaleRelease === true,
);
if (options.displayMode === "advanced" || typeof options.topReleases === "string" || options.topReleases <= 0) {
return visibleRepositories;
}
return visibleRepositories.slice(0, options.topReleases);
};
+1 -1
View File
@@ -13,7 +13,7 @@ const relativeDateSchema = z
.or(z.literal(""));
export const { definition, componentLoader } = createWidgetDefinition("releases", {
supportsAdvancedFocus: false,
supportsAdvancedFocus: true,
icon: IconRocket,
queryKey: [["widget", "releases", "getLatest"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),
+77 -10
View File
@@ -9,8 +9,10 @@ import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { BaseWidgetError } from "../errors/base-component";
import classes from "./component.module.scss";
@@ -27,6 +29,7 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
hasError: false as const,
entries: normalizedData,
failedFeedCount: 0,
isPending: query.isPending,
isStale: Boolean(query.error),
};
}
@@ -35,11 +38,12 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
hasError: false as const,
entries: normalizedData?.entries ?? [],
failedFeedCount: normalizedData?.failedFeedCount ?? 0,
isPending: query.isPending,
isStale: Boolean(query.error),
};
};
export default function RssFeed({ options, width, height }: WidgetComponentProps<"rssFeed">) {
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) {
const feed = useLiveFeed({
urls: options.feedUrls,
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
@@ -57,6 +61,7 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps
/>
);
}
if (feed.isPending) return <WidgetQueryLoadingState />;
const warning = feed.isStale
? t("warning.stale")
@@ -64,13 +69,35 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps
? t("warning.partial", { count: feed.failedFeedCount })
: undefined;
if (feed.entries.length === 0) {
return (
<Flex direction="column" h="100%" p="xs">
{warning && (
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs">
<output>{warning}</output>
</Alert>
)}
<WidgetEmptyState />
</Flex>
);
}
const languageDir = options.enableRtl ? "RTL" : "LTR";
const isDense = width < 420 || height < 180;
const isTiny = width < 260 || height < 110;
const isRoomy = width >= 420 && height >= 240;
const isAdvanced = displayMode === "advanced";
const columns = width >= 720 && height >= 260 ? 2 : 1;
const descriptionLines = isRoomy ? Math.max(options.textLinesClamp, 6) : isDense ? 1 : options.textLinesClamp;
const entryDisplay = getRssEntryDisplay({
isAdvanced,
isDense,
isTiny,
hideDescription: options.hideDescription,
showPosterImage: options.showPosterImage,
descriptionLines,
});
const spacing = isRoomy ? "sm" : "xs";
return (
@@ -97,7 +124,7 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps
title={feedEntry.title}
>
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
<Image
className={classes.poster}
src={feedEntry.enclosure}
@@ -110,18 +137,29 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps
)}
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
<Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
<Text
dir={languageDir}
fz={isRoomy ? "md" : "sm"}
fw={600}
lh={1.25}
lineClamp={isAdvanced ? undefined : 2}
>
{feedEntry.title}
</Text>
{!options.hideDescription && feedEntry.description && !isTiny && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
{entryDisplay.showDescription && feedEntry.description && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={entryDisplay.descriptionLineClamp}>
{feedDescriptionToText(feedEntry.description)}
</Text>
)}
<InfoDisplay
source={!isDense ? getHostname(feedEntry.feedUrl) : undefined}
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
timestamp={
isAdvanced && feedEntry.published
? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z")
: undefined
}
/>
</Flex>
</Group>
@@ -151,15 +189,44 @@ export const getHostname = (url: string): string => {
}
};
const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => (
<Group gap={5} align="center" wrap="nowrap">
interface RssEntryDisplayInput {
isAdvanced: boolean;
isDense: boolean;
isTiny: boolean;
hideDescription: boolean;
showPosterImage: boolean;
descriptionLines: number;
}
export const getRssEntryDisplay = ({
isAdvanced,
isDense,
isTiny,
hideDescription,
showPosterImage,
descriptionLines,
}: RssEntryDisplayInput) => ({
showDescription: isAdvanced || (!hideDescription && !isTiny),
showImage: isAdvanced || (showPosterImage && !isTiny),
showSource: isAdvanced || !isDense,
descriptionLineClamp: isAdvanced ? undefined : descriptionLines,
});
const InfoDisplay = ({ date, timestamp, source }: { date?: string; timestamp?: string; source?: string }) => (
<Group gap={5} align="center" wrap={timestamp ? "wrap" : "nowrap"}>
{date && <IconClock size="1rem" color="var(--mantine-color-dimmed)" />}
{date && (
<Text size="xs" c="dimmed">
{date}
</Text>
)}
{date && source && <Text c="dimmed">•</Text>}
{timestamp && <Text c="dimmed">•</Text>}
{timestamp && (
<Text size="xs" c="dimmed" ff="monospace">
{timestamp}
</Text>
)}
{(date || timestamp) && source && <Text c="dimmed">•</Text>}
{source && (
<Text size="xs" c="dimmed" truncate="end">
{source}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { feedDescriptionToText, getHostname } from "./component";
import { feedDescriptionToText, getHostname, getRssEntryDisplay } from "./component";
describe("RSS feed display helpers", () => {
test("renders feed markup as inert text", () => {
@@ -13,4 +13,22 @@ describe("RSS feed display helpers", () => {
expect(getHostname("not a URL")).toBe("not a URL");
expect(getHostname("https://example.com/feed.xml")).toBe("example.com");
});
test("advanced mode exposes all fetched entry details", () => {
expect(
getRssEntryDisplay({
isAdvanced: true,
isDense: true,
isTiny: true,
hideDescription: true,
showPosterImage: false,
descriptionLines: 1,
}),
).toEqual({
showDescription: true,
showImage: true,
showSource: true,
descriptionLineClamp: undefined,
});
});
});
+1 -1
View File
@@ -11,7 +11,7 @@ import { optionsBuilder } from "../options";
* - https://www.jsonfeed.org/version/1.1/
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
supportsAdvancedFocus: false,
supportsAdvancedFocus: true,
icon: IconRss,
queryKey: [["widget", "rssFeed", "getFeeds"]],
queryMatcher: ({ input }, scope) =>
@@ -157,6 +157,11 @@ export default function SmartHomeEntityStateWidget({
<Text size="xs" c="dimmed">
{t("advanced.entityId", { id: entity.entity_id })}
</Text>
<Text size="xs" c="dimmed">
{t("advanced.lastChanged", {
date: new Date(entity.last_changed).toLocaleString(locale),
})}
</Text>
<Text size="xs" c="dimmed">
{t("advanced.lastUpdated", {
date: new Date(entity.last_updated).toLocaleString(locale),
@@ -138,23 +138,30 @@ export default function SmartHomeTriggerAutomationWidget({
{options.displayName}
</Text>
{displayMode === "advanced" && (
<>
{error ? (
<Stack gap={2} align="center">
<Text ta="center" size="xs" c="dimmed">
{t("widget.smartHome-executeAutomation.advanced.automationId", { id: options.automationId })}
</Text>
<Text ta="center" size="xs" c={canInteract ? "dimmed" : "orange"}>
{t(
canInteract
? "widget.smartHome-executeAutomation.advanced.ready"
: "widget.smartHome-executeAutomation.advanced.noPermission",
)}
</Text>
{lastExecutedAt && (
<Text ta="center" size="xs" c="dimmed">
{t("widget.smartHome-executeAutomation.advanced.lastExecuted", {
time: lastExecutedAt.toLocaleTimeString(locale),
})}
</Text>
)}
{error && (
<Text ta="center" size="xs" c="red">
{t("widget.smartHome-executeAutomation.error.executeFailed")}
</Text>
) : (
<Text ta="center" size="xs" c="dimmed">
{lastExecutedAt
? t("widget.smartHome-executeAutomation.advanced.lastExecuted", {
time: lastExecutedAt.toLocaleTimeString(locale),
})
: t("widget.smartHome-executeAutomation.advanced.automationId", {
id: options.automationId,
})}
</Text>
)}
</>
</Stack>
)}
</Stack>
</Center>
@@ -1,19 +1,23 @@
"use client";
import { useMemo, useState } from "react";
import { SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { combineSpeedtestDashboards, getCompactSections } from "./helpers";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results";
const emptyDashboardData = [] as const;
export default function SpeedtestTrackerWidget({
options,
integrationIds,
@@ -22,33 +26,36 @@ export default function SpeedtestTrackerWidget({
displayMode = "compact",
}: WidgetComponentProps<"speedtestTracker">) {
const t = useScopedI18n("widget.speedtestTracker");
const dashboardData =
getUsableWidgetQueryData(clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds })) ?? [];
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
const [advancedRange, setAdvancedRange] = useState("24");
const isAdvanced = displayMode === "advanced";
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
const combined = useMemo(
() => combineSpeedtestDashboards(dashboardData.map((item) => item.dashboard)),
[dashboardData],
() => combineSpeedtestDashboards(successfulDashboards.map(({ dashboard }) => dashboard)),
[successfulDashboards],
);
const rangeHours = displayMode === "advanced" ? Number(advancedRange) : 12;
const rangeHours = isAdvanced ? Number(advancedRange) : 12;
const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const hasStatSection =
(options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null);
const hasChart = options.showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const showLatestResult = isAdvanced || options.showLatestResult;
const showStats = isAdvanced || options.showStats;
const showRecentResults = isAdvanced || options.showRecentResults;
const showPingGraph = isAdvanced || options.showPingGraph;
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
const hasChart = showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled =
!isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults;
const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = options.showLatestResult && combined.latestResult && (
<LatestResultSection
result={combined.latestResult}
width={displayMode === "advanced" ? advancedSectionWidth : width}
/>
const latest = showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={isAdvanced ? advancedSectionWidth : width} />
);
const averages = options.showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? advancedSectionWidth : width} />
const averages = showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={isAdvanced ? advancedSectionWidth : width} />
);
const compactSections = getCompactSections(height, {
latest: Boolean(latest),
@@ -57,27 +64,90 @@ export default function SpeedtestTrackerWidget({
});
if (displayMode === "advanced") {
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32);
const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth;
return (
<Stack h="100%" gap="md" p="md" style={{ overflow: "hidden" }}>
<SegmentedControl
size="xs"
value={advancedRange}
onChange={setAdvancedRange}
data={[
{ value: "12", label: t("range.twelveHours") },
{ value: "24", label: t("range.oneDay") },
{ value: "0", label: t("range.all") },
]}
/>
{hasStatSection && (
<SimpleGrid cols={width >= 720 ? 2 : 1} spacing="md">
{latest}
{averages}
</SimpleGrid>
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
</Group>
<ScrollArea h="100%">
<Stack gap="md" p="md">
<SegmentedControl
size="xs"
value={advancedRange}
onChange={setAdvancedRange}
data={[
{ value: "12", label: t("range.twelveHours") },
{ value: "24", label: t("range.oneDay") },
{ value: "0", label: t("range.lastThirty") },
]}
/>
{successfulDashboards.length === 0 ? (
<WidgetEmptyState />
) : (
<SimpleGrid cols={sourceColumns} spacing="md">
{successfulDashboards.map(({ integrationId, integrationName, dashboard }) => {
const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null;
const sourceHasChart = sourceRecent.length > 0;
return (
<Card key={integrationId} data-integration-id={integrationId} p="sm" withBorder>
<Stack gap="md">
<Text size="sm" fw={600} truncate>
{integrationName}
</Text>
{sourceHasStats && (
<SimpleGrid cols={sourcePanelWidth >= 720 ? 2 : 1} spacing="md">
{dashboard.latestResult && (
<LatestResultSection result={dashboard.latestResult} width={sourceSectionWidth} />
)}
{dashboard.stats && <AveragesSection stats={dashboard.stats} width={sourceSectionWidth} />}
</SimpleGrid>
)}
{sourceHasChart && (
<Box h={Math.max(260, Math.min(420, height / 2))}>
<RecentResultsSection results={sourceRecent} showPingGraph />
</Box>
)}
{!sourceHasStats && !sourceHasChart && <WidgetEmptyState />}
</Stack>
</Card>
);
})}
</SimpleGrid>
)}
</Stack>
</ScrollArea>
</Box>
);
}
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
</Group>
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
{dashboardData.length > 1 && (
<Text size="xs" c="dimmed" truncate>
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
</Text>
)}
{hasChart && (
<div style={{ flex: 1, minHeight: Math.max(220, height / 2) }}>
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
{(compactSections.latest || compactSections.averages) && (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
</Stack>
)}
{compactSections.chart && (
<div style={{ flex: 2, minHeight: 0 }}>
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} />
</div>
)}
{noSectionsEnabled && (
@@ -87,28 +157,6 @@ export default function SpeedtestTrackerWidget({
)}
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
</Stack>
);
}
return (
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
{(compactSections.latest || compactSections.averages) && (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
</Stack>
)}
{compactSections.chart && (
<div style={{ flex: 2, minHeight: 0 }}>
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
</div>
)}
{noSectionsEnabled && (
<Text c="dimmed" ta="center" size="sm">
{t("noSectionsEnabled")}
</Text>
)}
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
</Stack>
</Box>
);
}
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
import { combineSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({
ping: { avg: average, min: average, max: average },
@@ -27,6 +27,23 @@ describe("speedtest dashboard aggregation", () => {
});
});
describe("speedtest source ownership", () => {
it("keeps integration identity when upstream result ids overlap", () => {
const result = { id: 1, ping: 1, download_bits: 1, upload_bits: 1, healthy: true, created_at: new Date(1) };
const dashboard = { latestResult: result, stats: null, recentResults: [result] };
expect(
getAvailableSpeedtestDashboards([
{ integrationId: "speed-a", integrationName: "Speed A", dashboard },
{ integrationId: "speed-b", integrationName: "Speed B", dashboard },
]).map(({ integrationId, integrationName }) => ({ integrationId, integrationName })),
).toEqual([
{ integrationId: "speed-a", integrationName: "Speed A" },
{ integrationId: "speed-b", integrationName: "Speed B" },
]);
});
});
describe("getCompactSections", () => {
const available = { latest: true, chart: true, averages: true };
@@ -5,6 +5,23 @@ import type {
SpeedtestTrackerStats,
} from "@homarr/integrations/types";
export interface SpeedtestDashboardSource {
integrationId: string;
integrationName: string;
dashboard: SpeedtestTrackerDashboardData;
}
export const getAvailableSpeedtestDashboards = (
results: readonly {
integrationId: string;
integrationName: string;
dashboard: SpeedtestTrackerDashboardData | null;
}[],
): SpeedtestDashboardSource[] =>
results.flatMap(({ integrationId, integrationName, dashboard }) =>
dashboard === null ? [] : [{ integrationId, integrationName, dashboard }],
);
export const mergeStats = (
statsA: SpeedtestTrackerDashboardData["stats"],
statsB: SpeedtestTrackerDashboardData["stats"],
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more