feat: expand advanced widget experiences
This commit is contained in:
1 parent
55226842f9
commit
65a7e84bea
137 files changed
+3684
-850
No files matched your search
@@ -0,0 +1,26 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { throwIfDockerOperationsFailed } from "../../docker/docker-operation-results";
|
||||
|
||||
describe("Docker operation results", () => {
|
||||
test("accepts an all-success result", async () => {
|
||||
const results = await Promise.allSettled([Promise.resolve(), Promise.resolve()]);
|
||||
|
||||
expect(() => throwIfDockerOperationsFailed(results)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws a sanitized error after a partial failure", async () => {
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve(),
|
||||
Promise.reject(new Error("sensitive Docker daemon response")),
|
||||
]);
|
||||
|
||||
expect(() => throwIfDockerOperationsFailed(results)).toThrow(
|
||||
new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "One or more Docker container operations failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,17 +8,34 @@ import type { Database } from "@homarr/db";
|
||||
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
|
||||
import type {
|
||||
MediaRequest,
|
||||
MediaRequestStats,
|
||||
SpeedtestTrackerDashboardData,
|
||||
TracearrDashboardData,
|
||||
TraefikDashboardData,
|
||||
UpsSummary,
|
||||
UptimeKumaDashboardData,
|
||||
} from "@homarr/integrations/types";
|
||||
import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list";
|
||||
|
||||
import { calendarRouter } from "../../widgets/calendar";
|
||||
import { dnsHoleRouter } from "../../widgets/dns-hole";
|
||||
import { downloadsRouter } from "../../widgets/downloads";
|
||||
import { beszelRouter } from "../../widgets/beszel";
|
||||
import { firewallRouter } from "../../widgets/firewall";
|
||||
import { healthMonitoringRouter } from "../../widgets/health-monitoring";
|
||||
import { indexerManagerRouter } from "../../widgets/indexer-manager";
|
||||
import { mediaOrganizerRouter } from "../../widgets/media-organizer";
|
||||
import { mediaRequestsRouter } from "../../widgets/media-requests";
|
||||
import { mediaServerRouter } from "../../widgets/media-server";
|
||||
import { networkControllerRouter } from "../../widgets/network-controller";
|
||||
import { notificationsRouter } from "../../widgets/notifications";
|
||||
import { speedtestTrackerRouter } from "../../widgets/speedtest-tracker";
|
||||
import { tracearrRouter } from "../../widgets/tracearr";
|
||||
import { traefikRouter } from "../../widgets/traefik";
|
||||
import { upsRouter } from "../../widgets/ups";
|
||||
import { uptimeKumaRouter } from "../../widgets/uptime-kuma";
|
||||
import { vpnRouter } from "../../widgets/vpn";
|
||||
|
||||
vi.hoisted(() => {
|
||||
@@ -44,6 +61,65 @@ vi.mock("@homarr/request-handler/media-server", () => ({
|
||||
mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/dns-hole", () => ({
|
||||
dnsHoleRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
status: "enabled",
|
||||
domainsBeingBlocked: 10,
|
||||
adsBlockedToday: 2,
|
||||
adsBlockedTodayPercentage: 20,
|
||||
dnsQueriesToday: 10,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/downloads", () => ({
|
||||
downloadClientRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
items: [],
|
||||
status: { paused: false, rates: { down: 0, up: 0 }, types: ["torrent"] },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/health-monitoring", () => ({
|
||||
systemInfoRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
version: "1.0",
|
||||
cpuModelName: "CPU",
|
||||
cpuUtilization: 10,
|
||||
memUsedInBytes: 1,
|
||||
memAvailableInBytes: 2,
|
||||
uptime: 1,
|
||||
network: null,
|
||||
loadAverage: null,
|
||||
rebootRequired: false,
|
||||
availablePkgUpdates: 0,
|
||||
cpuTemp: undefined,
|
||||
fileSystem: [],
|
||||
smart: [],
|
||||
gpu: [],
|
||||
}),
|
||||
},
|
||||
clusterInfoRequestHandler: { handler: () => createHandler({ name: "Online" }, {}) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/network-controller", () => ({
|
||||
networkControllerRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
wanStatus: "enabled",
|
||||
www: { status: "enabled", latency: 5, ping: 6, uptime: 99 },
|
||||
wifi: { status: "enabled", users: 2, guests: 1 },
|
||||
lan: { status: "enabled", users: 3, guests: 0 },
|
||||
vpn: { status: "enabled", users: 1 },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-organizer", () => ({
|
||||
mediaOrganizerRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
@@ -78,6 +154,88 @@ vi.mock("@homarr/request-handler/vpn", () => ({
|
||||
vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/tracearr", () => ({
|
||||
tracearrRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
stats: { activeStreams: 0, totalUsers: 1, totalSessions: 1, recentViolations: 0, timestamp: "now" },
|
||||
streams: {
|
||||
data: [],
|
||||
summary: { total: 0, transcodes: 0, directStreams: 0, directPlays: 0, totalBitrate: "0", byServer: [] },
|
||||
},
|
||||
violations: null,
|
||||
recentActivity: null,
|
||||
} satisfies TracearrDashboardData),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/speedtest-tracker", () => ({
|
||||
speedtestTrackerRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
latestResult: null,
|
||||
stats: null,
|
||||
recentResults: [],
|
||||
} satisfies SpeedtestTrackerDashboardData),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/ups", () => ({
|
||||
upsSummariesRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, [
|
||||
{
|
||||
id: "healthy-ups",
|
||||
name: "Healthy UPS",
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
serial: null,
|
||||
status: "online",
|
||||
batteryCharge: 100,
|
||||
batteryRuntime: 3600,
|
||||
batteryVoltage: null,
|
||||
load: 10,
|
||||
inputVoltage: null,
|
||||
outputVoltage: null,
|
||||
power: null,
|
||||
temperature: null,
|
||||
} satisfies UpsSummary,
|
||||
]),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/traefik", () => {
|
||||
const emptySummary = { total: 0, enabled: 0, warnings: 0, errors: 0 };
|
||||
return {
|
||||
traefikRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
version: "3.0.0",
|
||||
entryPoints: [],
|
||||
resources: [],
|
||||
failedEndpoints: [],
|
||||
http: { routers: emptySummary, services: emptySummary, middlewares: emptySummary },
|
||||
tcp: { routers: emptySummary, services: emptySummary, middlewares: emptySummary },
|
||||
udp: { routers: emptySummary, services: emptySummary },
|
||||
} satisfies TraefikDashboardData),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/request-handler/uptime-kuma", () => ({
|
||||
uptimeKumaRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
totalMonitors: 1,
|
||||
upCount: 1,
|
||||
downCount: 0,
|
||||
pausedCount: 0,
|
||||
averageUptimePercent: 100,
|
||||
monitors: [{ id: 1, name: "Healthy monitor", status: "up", uptimePercent24h: 100 }],
|
||||
} satisfies UptimeKumaDashboardData),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof MediaRequestListHandlerModule>();
|
||||
return {
|
||||
@@ -124,14 +282,14 @@ const createSession = (userId: string): Session => ({
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const setupAsync = async (kind: IntegrationKind) => {
|
||||
const setupAsync = async (kind: IntegrationKind, options: { allOffline?: boolean } = {}) => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const onlineId = createId();
|
||||
const offlineId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(integrations).values([
|
||||
{ id: onlineId, kind, name: "Online", url: "https://online.example.com" },
|
||||
{ id: onlineId, kind, name: options.allOffline ? "Offline" : "Online", url: "https://online.example.com" },
|
||||
{ id: offlineId, kind, name: "Offline", url: "https://offline.example.com" },
|
||||
]);
|
||||
await db.insert(integrationUserPermissions).values([
|
||||
@@ -147,6 +305,56 @@ const setupAsync = async (kind: IntegrationKind) => {
|
||||
};
|
||||
};
|
||||
|
||||
type IntegrationSetup = Awaited<ReturnType<typeof setupAsync>>;
|
||||
|
||||
const dashboardPartialFailureCases = [
|
||||
{
|
||||
kind: "tracearr" as const,
|
||||
payloadKey: "dashboard",
|
||||
fallbackValue: null,
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await tracearrRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getDashboard({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "speedtestTracker" as const,
|
||||
payloadKey: "dashboard",
|
||||
fallbackValue: null,
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await speedtestTrackerRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getDashboard({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "peaNut" as const,
|
||||
payloadKey: "summaries",
|
||||
fallbackValue: [],
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await upsRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getSummaries({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "traefik" as const,
|
||||
payloadKey: "dashboard",
|
||||
fallbackValue: null,
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await traefikRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getDashboard({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "uptimeKuma" as const,
|
||||
payloadKey: "dashboard",
|
||||
fallbackValue: null,
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await uptimeKumaRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getDashboard({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
] as const;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("partial integration failures", () => {
|
||||
@@ -187,6 +395,130 @@ describe("partial integration failures", () => {
|
||||
expect(JSON.stringify(streamResults)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("DNS widgets retain source ownership for partial failures", async () => {
|
||||
const dnsHole = await setupAsync("piHole");
|
||||
const results = await dnsHoleRouter
|
||||
.createCaller({ db: dnsHole.db, deviceType: undefined, session: dnsHole.session })
|
||||
.summary({ integrationIds: dnsHole.integrationIds });
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: dnsHole.onlineId,
|
||||
summary: expect.objectContaining({ status: "enabled" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
integrationId: dnsHole.offlineId,
|
||||
integrationName: "Offline",
|
||||
summary: null,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("downloads retain source ownership for partial failures", async () => {
|
||||
const downloads = await setupAsync("qBittorrent");
|
||||
const results = await downloadsRouter
|
||||
.createCaller({ db: downloads.db, deviceType: undefined, session: downloads.session })
|
||||
.getJobsAndStatuses({ integrationIds: downloads.integrationIds, limitPerIntegration: 50 });
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ integrationId: downloads.onlineId, data: expect.objectContaining({ items: [] }) }),
|
||||
expect.objectContaining({
|
||||
integrationId: downloads.offlineId,
|
||||
integrationName: "Offline",
|
||||
data: null,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("system health retains source ownership for partial failures", async () => {
|
||||
const health = await setupAsync("glances");
|
||||
const results = await healthMonitoringRouter
|
||||
.createCaller({ db: health.db, deviceType: undefined, session: health.session })
|
||||
.getSystemHealthStatus({ integrationIds: health.integrationIds });
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: health.onlineId,
|
||||
healthInfo: expect.objectContaining({ version: "1.0" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
integrationId: health.offlineId,
|
||||
integrationName: "Offline",
|
||||
healthInfo: null,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("network controllers retain source ownership for partial failures", async () => {
|
||||
const network = await setupAsync("unifiController");
|
||||
const results = await networkControllerRouter
|
||||
.createCaller({ db: network.db, deviceType: undefined, session: network.session })
|
||||
.summary({ integrationIds: network.integrationIds });
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: network.onlineId,
|
||||
summary: expect.objectContaining({ wanStatus: "enabled" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
integrationId: network.offlineId,
|
||||
integrationName: "Offline",
|
||||
summary: null,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("dashboard widgets preserve healthy sources and expose sanitized failed-source envelopes", async () => {
|
||||
for (const testCase of dashboardPartialFailureCases) {
|
||||
const setup = await setupAsync(testCase.kind);
|
||||
const results = await testCase.run(setup);
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: setup.onlineId,
|
||||
integrationName: "Online",
|
||||
[testCase.payloadKey]: expect.anything(),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
integrationId: setup.offlineId,
|
||||
integrationName: "Offline",
|
||||
[testCase.payloadKey]: testCase.fallbackValue,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboard widgets reject total integration failure instead of returning fallback-only data", async () => {
|
||||
for (const testCase of dashboardPartialFailureCases) {
|
||||
const setup = await setupAsync(testCase.kind, { allOffline: true });
|
||||
|
||||
await expect(testCase.run(setup)).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "All integration queries failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("media request responses expose failed integrations without dropping successful data", async () => {
|
||||
const mediaRequests = await setupAsync("overseerr");
|
||||
const caller = mediaRequestsRouter.createCaller({
|
||||
|
||||
Reference in new issue
Block a user