feat: modernize dashboard widgets
This commit is contained in:
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">;
|
||||
|
||||
Reference in new issue
Block a user