feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:07 +02:00
1 parent f3048f4804
commit a2763d1f6f
261 files changed
+9392 -2697

No files matched your search

@@ -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">;