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

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { toSafeEntityDetails } from "./smart-home-entity-state";
describe("toSafeEntityDetails", () => {
it("keeps display attributes and removes sensitive Home Assistant metadata", () => {
const details = toSafeEntityDetails({
entity_id: "camera.front_door",
state: "streaming",
last_changed: new Date("2026-01-01T00:00:00Z"),
last_updated: new Date("2026-01-01T00:00:01Z"),
attributes: {
friendly_name: "Front door",
icon: "mdi:camera",
access_token: "secret-camera-token",
latitude: 48.85,
},
});
expect(details.attributes).toEqual({ friendly_name: "Front door", icon: "mdi:camera" });
expect(details.attributes).not.toHaveProperty("access_token");
expect(details.attributes).not.toHaveProperty("latitude");
});
});
@@ -1,10 +1,29 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { EntityState } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const safeAttributeKeys = ["friendly_name", "unit_of_measurement", "device_class", "icon"] as const;
export const toSafeEntityDetails = (entity: EntityState) => {
const attributes: Partial<Record<(typeof safeAttributeKeys)[number], string>> = {};
for (const key of safeAttributeKeys) {
const value = entity.attributes[key];
if (typeof value === "string") attributes[key] = value;
}
return {
attributes,
entity_id: entity.entity_id,
last_changed: entity.last_changed,
last_updated: entity.last_updated,
state: entity.state,
};
};
export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler<
string,
EntityState,
IntegrationKindByCategory<"smartHomeServer">,
{ entityId: string }
>({
@@ -16,6 +35,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
throw new Error(`Unable to fetch data from Home Assistant error='${result.error as string}'`);
}
return result.data.state;
return result.data;
},
});
@@ -0,0 +1,26 @@
import type { Response } from "undici";
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const DEFAULT_TIMETABLE_BASE_URL = "https://search.ch";
export const MAX_TIMETABLE_RESPONSE_BYTES = 512 * 1024;
export const normalizeTimetableBaseUrl = (baseUrl: string) => {
if (baseUrl.length > 2_048) throw new Error("Timetable base URL is too long");
const url = new URL(baseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Timetable base URL must use HTTP or HTTPS");
}
if (url.username || url.password) throw new Error("Timetable base URL must not contain credentials");
if (url.search || url.hash) throw new Error("Timetable base URL must not contain a query or fragment");
return url.toString().replace(/\/$/, "");
};
export async function readBoundedTimetableJsonAsync(
response: Response,
maxBytes = MAX_TIMETABLE_RESPONSE_BYTES,
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Timetable response");
}
@@ -0,0 +1,79 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
beforeEach(() => vi.clearAllMocks());
describe("normalizeTimetableBaseUrl", () => {
it("normalizes an HTTP endpoint", () => {
expect(normalizeTimetableBaseUrl("https://search.ch/")).toBe("https://search.ch");
});
it.each(["file:///etc/passwd", "https://user:secret@example.com", "https://example.com?redirect=/admin"])(
"rejects unsafe base URL %s",
(url) => expect(() => normalizeTimetableBaseUrl(url)).toThrow(),
);
});
describe("timetable response bounds", () => {
it("rejects a response once its byte budget is exceeded", async () => {
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
await expect(readBoundedTimetableJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
});
it("caps and caches station results and rejects redirects", async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify(
Array.from({ length: 125 }, (_, index) => ({
id: String(index),
label: `Station ${index}`,
iconclass: "train",
})),
),
),
);
const input = { baseUrl: "http://timetable.internal", query: "bounded-stations" };
const first = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
const second = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
expect(first.data).toHaveLength(100);
expect(second.data).toHaveLength(100);
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ redirect: "error", bodyTimeout: 10_000 }),
);
});
it("never caches more departures than requested", async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify({
connections: Array.from({ length: 5 }, (_, index) => ({
time: `2026-01-01T00:0${index}:00Z`,
line: "1",
color: "ffffff",
terminal: { name: `Stop ${index}` },
dep_delay: "0",
})),
}),
),
);
const result = await timetableGetTimetableRequestHandler
.handler({ baseUrl: "http://timetable.internal", stationId: "bounded-departures", limit: 2 })
.getDataAsync();
expect(result.data.entries).toHaveLength(2);
});
});
+16 -3
View File
@@ -4,6 +4,7 @@ import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createRequestHandler } from "./lib/request-handler";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
export interface Station {
id: string;
@@ -31,6 +32,9 @@ export interface Timetable {
}
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
const MAX_STATION_RESULTS = 100;
const MAX_TIMETABLE_ENTRIES = 100;
const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const;
export const timetableSearchStationsRequestHandler = createRequestHandler<
Station[],
@@ -53,7 +57,7 @@ export const timetableGetTimetableRequestHandler = createRequestHandler<
});
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<string, string | number>) => {
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
const url = new URL(`${normalizeTimetableBaseUrl(baseUrl)}${path}`);
for (const [key, value] of Object.entries(queryParams)) {
url.searchParams.set(key, value.toString());
}
@@ -63,10 +67,12 @@ const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<strin
const searchStationsAsync = async (baseUrl: string, query: string): Promise<Station[]> => {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
timetableFetchOptions,
);
if (!response.ok) throw new ResponseError(response);
const data = await searchSchema.parseAsync(await response.json());
const body = await readBoundedTimetableJsonAsync(response);
const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
return data
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
@@ -99,10 +105,15 @@ const getTimetableAsync = async (
date,
time,
}),
timetableFetchOptions,
);
if (!response.ok) throw new ResponseError(response);
const data = await timetableSchema.parseAsync(await response.json());
const body = await readBoundedTimetableJsonAsync(response);
const envelope = timetableEnvelopeSchema.parse(body);
const data = await timetableSchema.parseAsync({
connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)),
});
return {
stationId: options.stationId,
timestamp: now,
@@ -145,6 +156,8 @@ const timetableSchema = z.object({
),
});
const timetableEnvelopeSchema = z.object({ connections: z.array(z.unknown()) });
const searchSchema = z.array(
z.object({
id: z.string().optional(),
+3 -15
View File
@@ -1,28 +1,16 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const logger = createLogger({ module: "vpnRequestHandler" });
export const vpnSummaryHandler = createIntegrationRequestHandler<
VpnSummary | null,
VpnSummary,
IntegrationKindByCategory<"vpn">,
Record<string, never>
>({
async requestAsync(integration, _input) {
try {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
} catch (error) {
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
integrationId: integration.id,
integrationKind: integration.kind,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
},
});