feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

@@ -54,6 +54,7 @@ export class AudiobookshelfIntegration extends Integration {
const statsByLibrary = await Promise.all(
libraries.map(async (library) => ({
library,
mediaType: library.mediaType,
stats: await this.getLibraryStatsAsync(library.id),
})),
@@ -75,6 +76,14 @@ export class AudiobookshelfIntegration extends Integration {
totalPodcasts: counts.totalPodcasts,
totalListeningTimeSeconds: listeningStats.totalTime,
activeSessions,
libraries: statsByLibrary.map(({ library, stats }) => ({
id: library.id,
name: library.name,
mediaType: library.mediaType,
totalItems: stats.totalItems,
totalDurationSeconds: stats.totalDuration ?? null,
totalSizeBytes: stats.totalSize ?? null,
})),
};
}
@@ -30,6 +30,14 @@ export interface AudiobookshelfDashboardData {
totalPodcasts: number;
totalListeningTimeSeconds: number;
activeSessions: number;
libraries?: {
id: string;
name: string;
mediaType: "book" | "podcast";
totalItems: number;
totalDurationSeconds: number | null;
totalSizeBytes: number | null;
}[];
}
export type AudiobookshelfLibrary = z.infer<typeof audiobookshelfLibrarySchema>;
@@ -36,7 +36,6 @@ export interface ImmichAlbum {
export interface ImmichAsset {
id: string;
originalPath: string;
thumbhash: string | null;
fileModifiedAt: string;
fileCreatedAt: string;
@@ -156,7 +155,6 @@ export class ImmichIntegration extends Integration {
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
+1
View File
@@ -62,6 +62,7 @@ export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/heal
export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
export type { StreamSession } from "./interfaces/media-server/media-server-types";
export type { EntityState } from "./interfaces/smart-home/smart-home-types";
export type {
TdarrQueue,
TdarrPieSegment,
@@ -1,4 +1,4 @@
interface EntityState {
export interface EntityState {
attributes: Record<string, string | number | boolean | null | (string | number)[]>;
entity_id: string;
last_changed: Date;
@@ -0,0 +1,30 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { OverseerrIntegration } from "./overseerr-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new OverseerrIntegration({
id: "overseerr-1",
name: "Overseerr",
url: "https://overseerr.example.com",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "test-key" }],
});
describe("Overseerr media request actions", () => {
beforeEach(() => vi.clearAllMocks());
it.each([
["approve", () => integration.approveRequestAsync(42)],
["decline", () => integration.declineRequestAsync(42)],
])("rejects %s when Overseerr returns a non-success response", async (_action, run) => {
mockFetch.mockResolvedValue(new Response(null, { status: 500 }));
await expect(run()).rejects.toThrow("Response from integration did not indicate success");
});
});
@@ -1,8 +1,8 @@
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -259,49 +259,27 @@ export class OverseerrIntegration
public async approveRequestAsync(requestId: number): Promise<void> {
logger.info("Approving media request", { requestId, integration: this.integration.name });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to approve media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
});
if (!response.ok) throw new ResponseError(response);
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
}
public async declineRequestAsync(requestId: number): Promise<void> {
logger.info("Declining media request", { requestId, integration: this.integration.name });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to decline media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
});
if (!response.ok) throw new ResponseError(response);
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
}
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
@@ -0,0 +1,12 @@
import { Response } from "undici";
import { describe, expect, it } from "vitest";
import { readBoundedTraefikJsonAsync } from "./traefik-bounds";
describe("readBoundedTraefikJsonAsync", () => {
it("rejects a response that exceeds its byte budget", async () => {
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
await expect(readBoundedTraefikJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
});
});
@@ -0,0 +1,12 @@
import type { Response } from "undici";
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const MAX_TRAEFIK_RESPONSE_BYTES = 2 * 1024 * 1024;
export async function readBoundedTraefikJsonAsync(
response: Response,
maxBytes = MAX_TRAEFIK_RESPONSE_BYTES,
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Traefik response");
}
@@ -0,0 +1,43 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { TraefikIntegration } from "./traefik-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new TraefikIntegration({
id: "traefik-1",
name: "Traefik",
url: "https://traefik.example.com",
externalUrl: null,
decryptedSecrets: [],
});
describe("TraefikIntegration response bounds", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockImplementation((url) => {
const path = new URL(url.toString()).pathname;
if (path === "/api/version") return Promise.resolve(jsonResponse({ Version: "3.0.0" }));
if (path === "/api/http/routers") {
return Promise.resolve(
jsonResponse(Array.from({ length: 250 }, (_, index) => ({ name: `router-${index}`, status: "enabled" }))),
);
}
return Promise.resolve(jsonResponse([]));
});
});
it("caps detailed resource allocation while retaining bounded summary counts", async () => {
const result = await integration.getDashboardDataAsync();
expect(result.resources).toHaveLength(200);
expect(result.resources.at(-1)?.name).toBe("router-199");
expect(result.http.routers.total).toBe(250);
});
});
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
@@ -4,10 +4,12 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import { readBoundedTraefikJsonAsync } from "./traefik-bounds";
import type {
TraefikDashboardData,
TraefikProtocolSummary,
TraefikResource,
TraefikResourceDetail,
TraefikResourceStatus,
TraefikResourceSummary,
} from "./traefik-types";
@@ -24,6 +26,8 @@ type ResourcePath =
| "/api/udp/services"
| "/api/entrypoints";
const MAX_RESOURCE_DETAILS = 200;
export class TraefikIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() });
@@ -32,7 +36,7 @@ export class TraefikIntegration extends Integration {
throw new ResponseError(response);
}
traefikVersionSchema.parse(await response.json());
traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response));
return { success: true };
}
@@ -60,6 +64,38 @@ export class TraefikIntegration extends Integration {
this.getResourcesAsync("/api/udp/routers"),
this.getResourcesAsync("/api/udp/services"),
]);
const allResults = [
versionResult,
entryPointsResult,
httpRoutersResult,
httpServicesResult,
httpMiddlewaresResult,
tcpRoutersResult,
tcpServicesResult,
tcpMiddlewaresResult,
udpRoutersResult,
udpServicesResult,
];
if (allResults.every((result) => result.status === "rejected")) {
throw versionResult.status === "rejected" ? versionResult.reason : new Error("Every Traefik endpoint failed");
}
const endpointResults = [
["version", versionResult],
["entrypoints", entryPointsResult],
["http/routers", httpRoutersResult],
["http/services", httpServicesResult],
["http/middlewares", httpMiddlewaresResult],
["tcp/routers", tcpRoutersResult],
["tcp/services", tcpServicesResult],
["tcp/middlewares", tcpMiddlewaresResult],
["udp/routers", udpRoutersResult],
["udp/services", udpServicesResult],
] as const;
const failedEndpoints = endpointResults
.filter(([, result]) => result.status === "rejected")
.map(([endpoint]) => endpoint);
const version = versionResult.status === "fulfilled" ? versionResult.value : null;
const entryPoints = entryPointsResult.status === "fulfilled" ? entryPointsResult.value : [];
const httpRouters = httpRoutersResult.status === "fulfilled" ? httpRoutersResult.value : [];
@@ -71,9 +107,30 @@ export class TraefikIntegration extends Integration {
const udpRouters = udpRoutersResult.status === "fulfilled" ? udpRoutersResult.value : [];
const udpServices = udpServicesResult.status === "fulfilled" ? udpServicesResult.value : [];
const resources: TraefikResourceDetail[] = [];
const resourceGroups = [
["http", "router", httpRouters],
["http", "service", httpServices],
["http", "middleware", httpMiddlewares],
["tcp", "router", tcpRouters],
["tcp", "service", tcpServices],
["tcp", "middleware", tcpMiddlewares],
["udp", "router", udpRouters],
["udp", "service", udpServices],
] as const;
for (const [protocol, type, entries] of resourceGroups) {
const remaining = MAX_RESOURCE_DETAILS - resources.length;
if (remaining === 0) break;
resources.push(...this.createResourceDetails(protocol, type, entries.slice(0, remaining)));
}
return {
version,
entryPoints: entryPoints.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`),
entryPoints: entryPoints
.slice(0, MAX_RESOURCE_DETAILS)
.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`),
resources,
failedEndpoints,
http: this.createProtocolSummary(httpRouters, httpServices, httpMiddlewares),
tcp: this.createProtocolSummary(tcpRouters, tcpServices, tcpMiddlewares),
udp: {
@@ -83,15 +140,29 @@ export class TraefikIntegration extends Integration {
};
}
private createResourceDetails(
protocol: TraefikResourceDetail["protocol"],
type: TraefikResourceDetail["type"],
resources: TraefikResource[],
): TraefikResourceDetail[] {
return resources.map((resource, index) => ({
protocol,
type,
name: resource.name ?? `${type}-${index + 1}`,
provider: resource.provider ?? null,
status: this.normalizeStatus(resource.status),
}));
}
private async getVersionAsync() {
const response = await this.fetchOkAsync("/api/version");
const version = traefikVersionSchema.parse(await response.json());
const version = traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response));
return version.Version ?? null;
}
private async getResourcesAsync(path: ResourcePath) {
const response = await this.fetchOkAsync(path);
return traefikResourcesSchema.parse(await response.json());
return traefikResourcesSchema.parse(await readBoundedTraefikJsonAsync(response));
}
private createProtocolSummary(
@@ -137,6 +208,7 @@ export class TraefikIntegration extends Integration {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
timeout: 10_000,
bodyTimeout: 10_000,
});
if (!response.ok) {
@@ -31,9 +31,19 @@ export interface TraefikProtocolSummary {
middlewares: TraefikResourceSummary;
}
export interface TraefikResourceDetail {
protocol: "http" | "tcp" | "udp";
type: "router" | "service" | "middleware";
name: string;
provider: string | null;
status: TraefikResourceStatus;
}
export interface TraefikDashboardData {
version: string | null;
entryPoints: string[];
resources: TraefikResourceDetail[];
failedEndpoints: string[];
http: TraefikProtocolSummary;
tcp: TraefikProtocolSummary;
udp: Omit<TraefikProtocolSummary, "middlewares">;