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