feat: expand advanced widget experiences

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:09 +02:00
1 parent 9437d211c8
commit c099a8eef6
119 files changed
+3624 -844

No files matched your search

@@ -0,0 +1,10 @@
import { TRPCError } from "@trpc/server";
export const throwIfDockerOperationsFailed = (results: readonly PromiseSettledResult<unknown>[]) => {
if (results.some((result) => result.status === "rejected")) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "One or more Docker container operations failed",
});
}
};
@@ -12,6 +12,7 @@ import {
import { dockerMiddleware } from "../../middlewares/docker";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { throwIfDockerOperationsFailed } from "./docker-operation-results";
export const dockerRouter = createTRPCRouter({
getContainers: permissionRequiredProcedure
@@ -45,12 +46,13 @@ export const dockerRouter = createTRPCRouter({
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
await Promise.allSettled(
const results = await Promise.allSettled(
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.start();
}),
);
throwIfDockerOperationsFailed(results);
}),
stopAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -63,12 +65,13 @@ export const dockerRouter = createTRPCRouter({
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
await Promise.allSettled(
const results = await Promise.allSettled(
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.stop();
}),
);
throwIfDockerOperationsFailed(results);
}),
restartAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -82,12 +85,13 @@ export const dockerRouter = createTRPCRouter({
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
await Promise.allSettled(
const results = await Promise.allSettled(
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.restart();
}),
);
throwIfDockerOperationsFailed(results);
}),
removeAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -101,12 +105,13 @@ export const dockerRouter = createTRPCRouter({
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
await Promise.allSettled(
const results = await Promise.allSettled(
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.remove();
}),
);
throwIfDockerOperationsFailed(results);
}),
logs: permissionRequiredProcedure
.requiresPermission("admin")
@@ -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({
@@ -0,0 +1,68 @@
import { describe, expect, test } from "vitest";
import type { MediaRelease } from "@homarr/integrations/types";
import { PUBLIC_INTEGRATION_ERROR } from "../../../settle-integrations";
import { getMediaReleaseResponse } from "../media-release-result";
const integrations = [
{ id: "plex", name: "Living room Plex", kind: "plex" as const },
{ id: "jellyfin", name: "Family Jellyfin", kind: "jellyfin" as const },
];
const release: MediaRelease = {
id: "shared-id",
type: "movie",
title: "Example release",
releaseDate: new Date("2026-08-13T10:00:00Z"),
imageUrls: { poster: undefined, backdrop: undefined },
tags: [],
href: "https://example.com/release",
};
describe("getMediaReleaseResponse", () => {
test("keeps healthy releases and returns a sanitized failure envelope", async () => {
const response = await getMediaReleaseResponse(integrations, async (integration) => {
if (integration.id === "plex") throw new Error("token=secret at plex.internal");
return { releases: [release], updatedAt: new Date("2026-08-13T11:00:00Z") };
});
expect(response.releases).toEqual([
{
...release,
integration: {
...integrations[1],
updatedAt: new Date("2026-08-13T11:00:00Z"),
},
},
]);
expect(response.failedIntegrations).toEqual([
{
integrationId: "plex",
integrationName: "Living room Plex",
error: PUBLIC_INTEGRATION_ERROR,
},
]);
expect(JSON.stringify(response)).not.toMatch(/secret|plex\.internal/i);
});
test("keeps source identity when integrations return the same release id", async () => {
const response = await getMediaReleaseResponse(integrations, async () => ({
releases: [release],
updatedAt: new Date("2026-08-13T11:00:00Z"),
}));
expect(response.releases.map(({ integration }) => integration.id)).toEqual(["plex", "jellyfin"]);
});
test("throws when every integration fails", async () => {
await expect(
getMediaReleaseResponse(integrations, async () => {
throw new Error("Unavailable");
}),
).rejects.toMatchObject({
code: "INTERNAL_SERVER_ERROR",
message: "All integration queries failed",
});
});
});
+36 -12
View File
@@ -1,24 +1,48 @@
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
interface CoolifyInstanceResult {
integrationId: string;
integrationName: string;
integrationUrl: string;
instanceInfo: CoolifyInstanceInfo | null;
updatedAt: Date;
error?: string;
}
export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure
.concat(createManyIntegrationMiddleware("query", "coolify"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = coolifyRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<CoolifyInstanceResult> => {
const innerHandler = coolifyRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: data,
updatedAt: timestamp,
};
},
{
fallback: (integration, error): CoolifyInstanceResult => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+38 -8
View File
@@ -1,13 +1,28 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
interface DnsHoleSummaryResult {
integrationId: string;
integrationName: string;
integration: {
id: string;
name: string;
kind: IntegrationKindByCategory<"dnsHole">;
updatedAt?: Date;
};
summary: DnsHoleSummary | null;
error?: string;
}
export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure
.meta({
@@ -19,13 +34,28 @@ export const dnsHoleRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data,
};
});
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data,
};
},
{
fallback: (integration) => ({
integrationId: integration.id,
integrationName: integration.name,
integration: { id: integration.id, name: integration.name, kind: integration.kind },
summary: null,
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
}),
enable: protectedProcedure
+54 -9
View File
@@ -1,17 +1,47 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
interface DownloadClientQueryResultBase {
integrationId: string;
integrationName: string;
}
type DownloadClientQueryResult = DownloadClientQueryResultBase &
(
| {
integration: {
id: string;
name: string;
kind: IntegrationKindByCategory<"downloadClient">;
updatedAt: Date;
};
data: DownloadClientJobsAndStatus;
error?: never;
}
| {
integration: {
id: string;
name: string;
kind: IntegrationKindByCategory<"downloadClient">;
};
data: null;
error: string;
}
);
export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
.meta({
@@ -24,14 +54,29 @@ export const downloadsRouter = createTRPCRouter({
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
data,
};
});
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
ctx.integrations,
async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
data,
};
},
{
fallback: (integration) => ({
integrationId: integration.id,
integrationName: integration.name,
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: null,
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
}),
pause: protectedProcedure
.meta({
@@ -1,9 +1,10 @@
import { createIntegrationAsync } from "@homarr/integrations";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const healthMonitoringIntegrationKinds = [
@@ -16,6 +17,14 @@ const healthMonitoringIntegrationKinds = [
"mock",
] as const;
interface SystemHealthQueryResult {
integrationId: string;
integrationName: string;
healthInfo: SystemHealthMonitoring | null;
updatedAt?: Date;
error?: string;
}
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.meta({
@@ -27,15 +36,27 @@ export const healthMonitoringRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
};
});
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
};
},
{
fallback: (integration) => ({
integrationId: integration.id,
integrationName: integration.name,
healthInfo: null,
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
}),
listStorageVolumes: publicProcedure
.meta({
@@ -0,0 +1,61 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
interface MediaReleaseSource {
id: string;
name: string;
kind: IntegrationKindByCategory<"mediaRelease">;
}
interface MediaReleaseLoadResult {
releases: MediaRelease[];
updatedAt: Date;
}
interface MediaReleaseIntegrationResult {
integration: MediaReleaseSource & { updatedAt?: Date };
releases: MediaRelease[];
error?: string;
}
export async function getMediaReleaseResponse<TIntegration extends MediaReleaseSource>(
integrations: TIntegration[],
load: (integration: TIntegration) => Promise<MediaReleaseLoadResult>,
) {
const results = await settleIntegrationQueries<TIntegration, MediaReleaseIntegrationResult>(
integrations,
async (integration) => {
const { releases, updatedAt } = await load(integration);
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt,
},
releases,
};
},
{
fallback: (integration) => ({
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
releases: [],
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
return {
releases: results.flatMap(({ integration, releases }) => releases.map((release) => ({ ...release, integration }))),
failedIntegrations: results.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
),
};
}
@@ -2,32 +2,21 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { getMediaReleaseResponse } from "./media-release-result";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
return await getMediaReleaseResponse(ctx.integrations, async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
updatedAt: timestamp,
};
});
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
integration: result.integration,
})),
);
}),
});
@@ -1,27 +1,53 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
interface NetworkControllerQueryResult {
integrationId: string;
integrationName: string;
integration: {
id: string;
name: string;
kind: IntegrationKindByCategory<"networkController">;
};
summary: NetworkControllerSummary | null;
updatedAt?: Date;
error?: string;
}
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = networkControllerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
ctx.integrations,
async (integration) => {
const innerHandler = networkControllerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
summary: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integration: { id: integration.id, name: integration.name, kind: integration.kind },
summary: data,
updatedAt: timestamp,
};
},
{
fallback: (integration) => ({
integrationId: integration.id,
integrationName: integration.name,
integration: { id: integration.id, name: integration.name, kind: integration.kind },
summary: null,
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -1,24 +1,39 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+27 -12
View File
@@ -1,22 +1,37 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = tracearrRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = tracearrRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+27 -12
View File
@@ -1,7 +1,7 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const traefikRouter = createTRPCRouter({
@@ -15,17 +15,32 @@ export const traefikRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", "traefik"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = traefikRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = traefikRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+27 -12
View File
@@ -2,24 +2,39 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: [],
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+27 -12
View File
@@ -1,24 +1,39 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});