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