feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -1,252 +0,0 @@
import { describe, expect, it } from "vitest";
import { JSONPath } from "jsonpath-plus";
import {
sonarrResponse,
sonarrStatGridConfig,
proxmoxResponse,
proxmoxProgressBarsConfig,
diskUsageResponse,
diskTableConfig,
piholeResponse,
piholeStatusIndicatorConfig,
piholeCountGridConfig,
radarrResponse,
radarrStatGridConfig,
jellyfinItemCountsResponse,
jellyfinCountGridConfig,
} from "./custom-api-fixtures";
const extractors: Record<string, (json: unknown, config: Record<string, unknown>) => unknown> = {
singleValue: (json, c) => ({
type: "singleValue",
label: (c.label as string) ?? "",
unit: (c.unit as string) ?? "",
value: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (json, c) => ({
type: "keyValue",
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
label: m.label,
unit: m.unit,
value: JSONPath({ path: m.jsonPath, json: json as object, wrap: false }),
})),
layout: c.layout ?? "list",
columns: c.columns ?? 2,
}),
table: (json, c) => {
const tablePath = (c.tablePath as string) ?? "$";
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
const rows = JSONPath({ path: tablePath, json: json as object, wrap: true }) as unknown[];
const flatRows = Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
return {
type: "table",
columns: columns.map((col) => col.header),
rows: flatRows.map((row) =>
columns.map((col) => JSONPath({ path: col.jsonPath, json: row as object, wrap: false })),
),
striped: c.striped ?? true,
compact: c.compact ?? false,
};
},
statGrid: (json, c) => ({
type: "statGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
(item) => ({
label: item.label,
unit: item.unit,
color: item.color ?? "blue",
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
}),
),
columns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (json, c) => ({
type: "progressBars",
bars: (
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
).map((bar) => {
const value = JSONPath({ path: bar.valuePath, json: json as object, wrap: false });
const max = bar.maxPath ? JSONPath({ path: bar.maxPath, json: json as object, wrap: false }) : undefined;
return {
label: bar.label,
unit: bar.unit,
color: bar.color ?? "blue",
value: Number(value) || 0,
max: max !== undefined ? Number(max) || 100 : undefined,
};
}),
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (json, c) => ({
type: "statusIndicator",
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
const value = JSONPath({ path: item.jsonPath, json: json as object, wrap: false });
const isGood = item.goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
return { label: item.label, value: String(value ?? "unknown"), isGood };
}),
layout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (json, c) => ({
type: "countGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
label: item.label,
unit: item.unit,
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
})),
columns: c.columns ?? 2,
valueSize: c.valueSize ?? "md",
}),
raw: (json, c) => ({
type: "raw",
data: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
maxHeight: c.maxHeight ?? 300,
}),
customJsx: (json, config) => ({
type: "customJsx" as const,
template: config.template as string,
data: json,
}),
};
function extract(type: string, json: unknown, config: Record<string, unknown>) {
const fn = extractors[type];
if (!fn) throw new Error(`No extractor for type: ${type}`);
return fn(json, config);
}
describe("custom-api extractors", () => {
it("sonarr statGrid extracts missing and queue counts", () => {
const result = extract("statGrid", sonarrResponse, sonarrStatGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; color: string }>;
};
expect(result.type).toBe("statGrid");
expect(result.items).toHaveLength(2);
expect(result.items[0]?.value).toBe(42);
expect(result.items[0]?.label).toBe("Missing");
expect(result.items[0]?.color).toBe("red");
expect(result.items[1]?.value).toBe(5);
});
it("proxmox progressBars extracts memory and disk usage", () => {
const result = extract("progressBars", proxmoxResponse, proxmoxProgressBarsConfig) as {
type: string;
bars: Array<{ label: string; value: number; max: number }>;
};
expect(result.type).toBe("progressBars");
expect(result.bars).toHaveLength(2);
expect(result.bars[0]?.value).toBe(12_884_901_888);
expect(result.bars[0]?.max).toBe(34_359_738_368);
expect(result.bars[1]?.label).toBe("Disk");
});
it("disk table extracts filesystem rows", () => {
const result = extract("table", diskUsageResponse, diskTableConfig) as {
type: string;
columns: string[];
rows: unknown[][];
};
expect(result.type).toBe("table");
expect(result.columns).toEqual(["Mount", "Size", "Used"]);
expect(result.rows).toHaveLength(3);
expect(result.rows[0]?.[0]).toBe("/");
expect(result.rows[2]?.[2]).toBe("95%");
});
it("pihole statusIndicator detects enabled state", () => {
const result = extract("statusIndicator", piholeResponse, piholeStatusIndicatorConfig) as {
type: string;
items: Array<{ label: string; value: string; isGood: boolean }>;
};
expect(result.type).toBe("statusIndicator");
expect(result.items).toHaveLength(1);
expect(result.items[0]?.isGood).toBe(true);
expect(result.items[0]?.value).toBe("enabled");
});
it("pihole countGrid extracts all 4 stats", () => {
const result = extract("countGrid", piholeResponse, piholeCountGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; unit: string }>;
};
expect(result.type).toBe("countGrid");
expect(result.items).toHaveLength(4);
expect(result.items[0]?.value).toBe(12_847);
expect(result.items[1]?.value).toBe(48_291);
expect(result.items[2]?.value).toBe(26.6);
expect(result.items[2]?.unit).toBe("%");
expect(result.items[3]?.value).toBe(14);
});
it("radarr statGrid with outline card style", () => {
const result = extract("statGrid", radarrResponse, radarrStatGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown }>;
cardStyle: string;
};
expect(result.type).toBe("statGrid");
expect(result.cardStyle).toBe("outline");
expect(result.items[0]?.value).toBe(15);
expect(result.items[1]?.value).toBe(3);
});
it("raw extractor returns raw data at path", () => {
const result = extract("raw", piholeResponse, { type: "raw", jsonPath: "$.status", maxHeight: 200 }) as {
type: string;
data: unknown;
maxHeight: number;
};
expect(result.type).toBe("raw");
expect(result.data).toBe("enabled");
expect(result.maxHeight).toBe(200);
});
it("customJsx passes full JSON and template through", () => {
const json = { title: "Status", items: [{ name: "CPU", value: 42 }] };
const config = {
type: "customJsx" as const,
template: "<Stack><Title>{data.title}</Title></Stack>",
};
const result = extract("customJsx", json, config) as {
type: string;
template: string;
data: typeof json;
};
expect(result.type).toBe("customJsx");
expect(result.template).toBe(config.template);
expect(result.data).toEqual(json);
});
it("jellyfin countGrid extracts movies/series/episodes/songs in 4-column grid", () => {
const result = extract("countGrid", jellyfinItemCountsResponse, jellyfinCountGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; unit: string }>;
columns: number;
valueSize: string;
};
expect(result.type).toBe("countGrid");
expect(result.columns).toBe(4);
expect(result.valueSize).toBe("lg");
expect(result.items).toHaveLength(4);
expect(result.items[0]).toEqual({ label: "Movies", value: 185, unit: "" });
expect(result.items[1]).toEqual({ label: "Series", value: 38, unit: "" });
expect(result.items[2]).toEqual({ label: "Episodes", value: 770, unit: "" });
expect(result.items[3]).toEqual({ label: "Songs", value: 0, unit: "" });
});
});
@@ -1,142 +0,0 @@
export const sonarrResponse = {
wanted: { totalRecords: 42 },
queue: { totalRecords: 5 },
series: [
{ title: "Breaking Bad", seasons: 5, episodeCount: 62, episodeFileCount: 60 },
{ title: "The Wire", seasons: 5, episodeCount: 60, episodeFileCount: 55 },
],
diskspace: [
{ path: "/tv", label: "TV Shows", freeSpace: 150_000_000_000, totalSpace: 500_000_000_000 },
{ path: "/movies", label: "Movies", freeSpace: 80_000_000_000, totalSpace: 250_000_000_000 },
],
};
export const sonarrStatGridConfig = {
type: "statGrid" as const,
items: [
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "blue" },
],
columns: 2,
cardStyle: "filled" as const,
};
export const proxmoxResponse = {
data: {
cpu: 0.23,
memory: { used: 12_884_901_888, total: 34_359_738_368 },
rootfs: { used: 42_949_672_960, total: 107_374_182_400 },
uptime: 864_000,
status: "running",
maxcpu: 8,
maxmem: 34_359_738_368,
name: "proxmox-node1",
},
};
export const proxmoxProgressBarsConfig = {
type: "progressBars" as const,
bars: [
{ label: "Memory", valuePath: "$.data.memory.used", maxPath: "$.data.memory.total", unit: "B", color: "blue" },
{ label: "Disk", valuePath: "$.data.rootfs.used", maxPath: "$.data.rootfs.total", unit: "B", color: "orange" },
],
showPercentage: true,
barSize: "md" as const,
};
export const diskUsageResponse = {
filesystems: [
{ filesystem: "/dev/sda1", size: "500G", used: "350G", avail: "150G", usePct: "70%", mountedOn: "/" },
{ filesystem: "/dev/sdb1", size: "2T", used: "1.2T", avail: "800G", usePct: "60%", mountedOn: "/data" },
{ filesystem: "/dev/sdc1", size: "4T", used: "3.8T", avail: "200G", usePct: "95%", mountedOn: "/media" },
],
};
export const diskTableConfig = {
type: "table" as const,
tablePath: "$.filesystems",
columns: [
{ header: "Mount", jsonPath: "$.mountedOn" },
{ header: "Size", jsonPath: "$.size" },
{ header: "Used", jsonPath: "$.usePct" },
],
striped: true,
compact: false,
};
export const piholeResponse = {
domains_being_blocked: 125_432,
dns_queries_today: 48_291,
ads_blocked_today: 12_847,
ads_percentage_today: 26.6,
unique_clients: 14,
status: "enabled",
gravity_last_updated: { relative: { days: 2, hours: 3 } },
};
export const piholeStatusIndicatorConfig = {
type: "statusIndicator" as const,
items: [{ label: "Pi-Hole", jsonPath: "$.status", goodValues: ["enabled"] }],
layout: "list" as const,
dotSize: "md" as const,
};
export const piholeCountGridConfig = {
type: "countGrid" as const,
items: [
{ label: "Blocked Today", jsonPath: "$.ads_blocked_today", unit: "" },
{ label: "DNS Queries", jsonPath: "$.dns_queries_today", unit: "" },
{ label: "Block Rate", jsonPath: "$.ads_percentage_today", unit: "%" },
{ label: "Clients", jsonPath: "$.unique_clients", unit: "" },
],
columns: 2,
valueSize: "md" as const,
};
export const radarrResponse = {
wanted: { totalRecords: 15 },
queue: { totalRecords: 3 },
movie: [
{ title: "Inception", hasFile: true, monitored: true, runtime: 148, year: 2010 },
{ title: "Interstellar", hasFile: false, monitored: true, runtime: 169, year: 2014 },
{ title: "The Dark Knight", hasFile: true, monitored: false, runtime: 152, year: 2008 },
],
rootfolder: [{ path: "/movies", freeSpace: 200_000_000_000, totalSpace: 1_000_000_000_000 }],
};
export const radarrStatGridConfig = {
type: "statGrid" as const,
items: [
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "teal" },
],
columns: 2,
cardStyle: "outline" as const,
};
export const jellyfinItemCountsResponse = {
MovieCount: 185,
SeriesCount: 38,
EpisodeCount: 770,
ArtistCount: 0,
ProgramCount: 0,
TrailerCount: 0,
SongCount: 0,
AlbumCount: 0,
MusicVideoCount: 0,
BoxSetCount: 12,
BookCount: 0,
ItemCount: 0,
};
export const jellyfinCountGridConfig = {
type: "countGrid" as const,
items: [
{ label: "Movies", jsonPath: "$.MovieCount", unit: "" },
{ label: "Series", jsonPath: "$.SeriesCount", unit: "" },
{ label: "Episodes", jsonPath: "$.EpisodeCount", unit: "" },
{ label: "Songs", jsonPath: "$.SongCount", unit: "" },
],
columns: 4,
valueSize: "lg" as const,
};
@@ -0,0 +1,20 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security";
describe("custom API request hardening", () => {
it("accepts HTTP endpoints without embedded credentials", () => {
expect(validateCustomApiUrl("https://api.example.com/v1/data").hostname).toBe("api.example.com");
});
it.each(["file:///etc/passwd", "https://user:secret@example.com/data"])("rejects unsafe URL %s", (url) => {
expect(() => validateCustomApiUrl(url)).toThrow();
});
it("rejects a response once its byte budget is exceeded", async () => {
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
await expect(readBoundedJsonResponseAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
});
});
@@ -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",
});
});
});
@@ -0,0 +1,29 @@
import { z } from "zod/v4";
import { env } from "@homarr/common/env";
import { airQualityRequestHandler } from "@homarr/request-handler/air-quality";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const atLocationInput = z.object({
longitude: z.number().min(-180).max(180),
latitude: z.number().min(-90).max(90),
});
export const airQualityRouter = createTRPCRouter({
atLocation: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get current and forecast air quality, UV, pollutant, and pollen data for geographic coordinates. REQUIRED: latitude (-90 to 90) and longitude (-180 to 180).",
},
})
.input(atLocationInput)
.query(async ({ input }) => {
if (env.NO_EXTERNAL_CONNECTION) return null;
const handler = airQualityRequestHandler.handler(input);
return await handler.getDataAsync().then((result) => result.data);
}),
});
@@ -1,12 +1,15 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations";
import { mockWidgetData } from "@homarr/integrations";
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const noteIdInput = z.object({
@@ -15,10 +18,16 @@ const noteIdInput = z.object({
const logger = createLogger({ module: "anchorNotesRouter" });
const assertMockNoteExists = (noteId: string) => {
if (noteId === mockWidgetData.anchorNote.id) return;
throw new TRPCError({ code: "NOT_FOUND", message: "Note not found" });
};
const isJsonDeltaString = (value: string) => {
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === "object" && parsed !== null && Array.isArray((parsed as { ops?: unknown }).ops);
return isRecord(parsed) && Array.isArray(parsed.ops);
} catch {
return false;
}
@@ -40,40 +49,72 @@ const normalizeAnchorContent = (content: string | undefined) => {
export const anchorNotesRouter = createTRPCRouter({
listNotes: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor"))
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => {
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
if (ctx.integration.kind === "mock") {
const search = input.search?.toLowerCase();
const notes = mockWidgetData.anchorNotes.filter((note) => !search || note.title.toLowerCase().includes(search));
return notes.slice(0, input.limit ?? 50);
}
const handler = anchorNotesListRequestHandler.handler(
{ ...ctx.integration, kind: "anchor" },
{
...input,
limit: input.limit ?? 50,
},
);
const { data } = await handler.getDataAsync();
return data;
}),
getNote: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor"))
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(noteIdInput)
.query(async ({ ctx, input }) => {
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
if (ctx.integration.kind === "mock") {
assertMockNoteExists(input.noteId);
return mockWidgetData.anchorNote;
}
const handler = anchorNoteRequestHandler.handler(
{ ...ctx.integration, kind: "anchor" },
{ noteId: input.noteId },
);
const { data } = await handler.getDataAsync();
return data;
}),
updateNote: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "anchor"))
.concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
.input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
const normalizedContent = normalizeAnchorContent(input.content);
const isContentNormalized =
input.content !== undefined && normalizedContent !== undefined && input.content !== normalizedContent;
if (ctx.integration.kind === "mock") {
assertMockNoteExists(input.noteId);
return {
...mockWidgetData.anchorNote,
...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
};
}
const integrationInstance = await createIntegrationAsync({ ...ctx.integration, kind: "anchor" });
try {
const updatedNote = await integrationInstance.updateNoteAsync({
noteId: input.noteId,
...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
});
anchorNotesListRequestHandler.invalidateCache();
await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]);
return updatedNote;
} catch (error) {
@@ -1,13 +1,18 @@
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
.concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
.query(async ({ ctx }) => {
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") {
return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) };
}
const handler = archiveTeamWarriorRequestHandler.handler({ ...ctx.integration, kind: "archiveTeamWarrior" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
@@ -1,14 +1,16 @@
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const audioStatsRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf"))
.concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
.query(async ({ ctx }) => {
const { kind } = ctx.integration;
if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
const fetchByKind = {
navidrome: async () => {
+6 -3
View File
@@ -1,6 +1,7 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const bazarrRouter = createTRPCRouter({
@@ -12,9 +13,11 @@ export const bazarrRouter = createTRPCRouter({
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
},
})
.concat(createOneIntegrationMiddleware("query", "bazarr"))
.concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
.query(async ({ ctx }) => {
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
const innerHandler = bazarrBadgesRequestHandler.handler({ ...ctx.integration, kind: "bazarr" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
+18 -21
View File
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { LiveStatsEvent } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
@@ -10,16 +10,14 @@ import {
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -29,7 +27,7 @@ export const beszelRouter = createTRPCRouter({
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid"))
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
@@ -41,7 +39,6 @@ export const beszelRouter = createTRPCRouter({
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: data,
updatedAt: timestamp,
};
@@ -50,11 +47,11 @@ export const beszelRouter = createTRPCRouter({
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: [],
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
logger.debug("getSystems completed", {
@@ -74,7 +71,7 @@ export const beszelRouter = createTRPCRouter({
"Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "beszelAlerts"))
.input(
z.object({
includeHistory: z.boolean().default(true),
@@ -126,8 +123,9 @@ export const beszelRouter = createTRPCRouter({
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
logger.debug("getAlerts completed", {
@@ -147,7 +145,7 @@ export const beszelRouter = createTRPCRouter({
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input(
z.object({
systemId: z.string(),
@@ -192,13 +190,13 @@ export const beszelRouter = createTRPCRouter({
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
};
}
}),
subscribeSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input(
z.object({
systemId: z.string(),
@@ -269,15 +267,14 @@ export const beszelRouter = createTRPCRouter({
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
error,
});
queue.fail(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
new TRPCError({
code: "BAD_GATEWAY",
message: "Live integration request failed",
cause: error,
}),
);
}
})();
+27 -32
View File
@@ -1,68 +1,63 @@
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get calendar events for upcoming and recent media releases. REQUIRED: integrationIds (array of integration IDs from integration_all, filter by kind sonarr/radarr/lidarr/readarr), year (number), month (number), releaseType (array of 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'",
"Get calendar events from calendar-capable integrations. REQUIRED: integrationIds (IDs from integration_all whose kinds include Sonarr, Radarr, Lidarr, Readarr, Home Assistant, Nextcloud, or iCal), year (four-digit number), month (human month number from 1=January through 12=December), releaseType (Radarr filters: 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'",
},
})
.input(
z.object({
year: z.number(),
month: z.number(),
year: z.number().int().min(1970).max(9999),
month: z.number().int().min(1).max(12),
releaseType: z.array(z.enum(radarrReleaseTypes)),
showUnmonitored: z.boolean(),
}),
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.concat(createManyWidgetIntegrationMiddleware("query", "calendar"))
.query(async ({ ctx, input }) => {
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
const events = data.filter(
(event) => event.metadata?.type !== "radarr" || input.releaseType.includes(event.metadata.releaseType),
);
return {
events: data,
events,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
error: undefined,
};
}),
);
return settled.flatMap((result, index) => {
if (result.status === "fulfilled") {
return [result.value];
}
const integration = ctx.integrations[index];
logger.warn(
new ErrorWithMetadata(
"Calendar integration request failed; skipping events for this integration",
{
integrationId: integration?.id,
integrationKind: integration?.kind,
},
{
fallback: (integration) => ({
events: [],
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
{ cause: result.reason },
),
);
return [];
});
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
}),
});
+49 -14
View File
@@ -1,24 +1,59 @@
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { mockWidgetData } from "@homarr/integrations";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
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"))
.concat(createManyWidgetIntegrationMiddleware("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> => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: mockWidgetData.coolify,
updatedAt: new Date(mockWidgetData.timestamp),
};
}
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: data,
updatedAt: timestamp,
};
});
const innerHandler = coolifyRequestHandler.handler({ ...integration, kind: "coolify" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
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,
},
);
}),
});
@@ -0,0 +1,67 @@
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024;
export const CUSTOM_API_FETCH_TIMEOUT_MS = 10_000;
export function validateCustomApiUrl(urlString: string): URL {
if (urlString.length > 2_048) throw new Error("Custom API URL is too long");
const url = new URL(urlString);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Custom API URL must use HTTP or HTTPS");
}
if (url.username || url.password) throw new Error("Custom API URL must not contain credentials");
return url;
}
export async function readBoundedJsonResponseAsync(
response: Response,
maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES,
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Custom API response");
}
export async function consumeBoundedResponseAsync(
response: Response,
maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES,
): Promise<void> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel();
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
}
if (!response.body) return;
const reader = response.body.getReader();
let byteLength = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) return;
byteLength += value?.byteLength ?? 0;
if (byteLength > maxBytes) {
await reader.cancel();
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
}
}
}
export async function withCustomApiResponseAsync<TResult>(
url: URL,
init: Omit<RequestInit, "redirect" | "signal">,
consumeResponseAsync: (response: Response) => Promise<TResult>,
timeoutMs = CUSTOM_API_FETCH_TIMEOUT_MS,
): Promise<TResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url.toString(), {
...init,
redirect: "error",
signal: controller.signal,
});
return await consumeResponseAsync(response);
} finally {
clearTimeout(timeout);
}
}
+316 -79
View File
@@ -1,91 +1,328 @@
import { getCustomWidgetIntegrationCacheVersion, resolveCustomWidgetSource } from "../custom-widget/source-resolver";
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { decryptSecret } from "@homarr/common/server";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { applyAuth } from "../custom-widget/auth";
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import {
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
normalizeCustomWidgetOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import type { RequestLimitInput } from "@homarr/custom-widgets/server";
const logger = createLogger({ module: "widget:customApi" });
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "../custom-widget/request-executor";
import {
hashRuntimeParams,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "../custom-widget/request-manifest";
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
import { getCustomWidgetCacheVersion } from "../custom-widget/cache-version";
import { parseStoredCustomWidgetDefinition } from "../custom-widget/stored-definition";
const FETCH_TIMEOUT_MS = 10_000;
const runtimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
const itemInputSchema = z.object({ itemId: z.string().min(1) });
const namedRequestInputSchema = itemInputSchema.extend({
requestId: z.string().min(1).max(64),
params: runtimeParamsSchema.default({}),
});
interface CustomWidgetItemOptions {
definitionId: string;
configuration: Record<string, unknown>;
configurationVersion: number;
refreshInterval?: number;
}
const validateUrl = (urlString: string): URL => new URL(urlString);
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
type ResolvedDefinition = Awaited<ReturnType<typeof resolvePlacedDefinitionAsync>>;
const parseItemOptions = (raw: string): CustomWidgetItemOptions => {
try {
const options = parseSuperJson(raw) as Record<string, unknown>;
if (typeof options.definitionId !== "string" || options.definitionId.length === 0) throw new Error();
return {
definitionId: options.definitionId,
configuration: isRecord(options.configuration) ? options.configuration : {},
configurationVersion:
typeof options.configurationVersion === "number" && Number.isInteger(options.configurationVersion)
? options.configurationVersion
: 1,
refreshInterval: typeof options.refreshInterval === "number" ? options.refreshInterval : undefined,
};
} catch {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
}
};
async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) {
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, itemId),
columns: { id: true, boardId: true, kind: true, options: true },
});
if (!item || item.kind !== "customApi") {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const itemOptions = parseItemOptions(item.options);
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, itemOptions.definitionId),
with: { secrets: true },
});
if (!stored) {
const legacy = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, itemOptions.definitionId),
columns: { id: true },
});
if (legacy) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
});
}
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget unavailable" });
}
if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
const definition = parseStoredCustomWidgetDefinition(stored);
const configuration =
itemOptions.configurationVersion === stored.updatedAt.getTime()
? { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration }
: normalizeCustomWidgetOptions(definition.options, itemOptions.configuration);
const issues = validateCustomWidgetOptions(definition.options, configuration);
if (issues.length > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Custom widget configuration needs repair: ${issues[0]?.path} ${issues[0]?.message}`,
});
}
return { item, stored, definition, itemOptions, configuration };
}
const findRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
const request = resolved.definition.requests[requestId];
if (request?.kind !== kind) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
return { id: requestId, ...request };
};
const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
const source = resolved.definition.sources[sourceId];
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget API source not found" });
return { id: sourceId, ...source };
};
type IdentifiedRequest = CustomJsxRequest & { id: string };
const withRequestLimit = async <T>(
ctx: RouterContext,
resolved: ResolvedDefinition,
category: RequestLimitInput["category"],
callback: () => Promise<T>,
) => {
const release = await acquireCustomWidgetRequestLimit({
category,
userId: ctx.session?.user.id,
itemId: resolved.item.id,
definitionId: resolved.stored.id,
});
try {
return await callback();
} finally {
await release();
}
};
const getCacheKey = (resolved: ResolvedDefinition, request: IdentifiedRequest, params: Record<string, unknown>) =>
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${request.id}:${hashRuntimeParams(params)}`;
const executeRequest = async (
ctx: RouterContext,
resolved: ResolvedDefinition,
request: IdentifiedRequest,
params: Record<string, string | number | boolean>,
) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
const source = findSource(resolved, request.source);
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
let category: RequestLimitInput["category"] = "query";
if (request.kind === "action") category = request.method === "DELETE" ? "delete" : "action";
return withRequestLimit(ctx, resolved, category, async () => {
const connection = await resolveCustomWidgetSource(ctx, source, request, () =>
resolved.stored.secrets
.filter((secret) => secret.sourceId === source.id)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
);
const targetUrl = renderRequestTarget(connection.baseUrl, request, values);
const response = await executeCustomWidgetRequest({
...connection,
targetUrl,
method: request.method,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
kind: request.kind,
cacheKey:
request.kind === "query" ? `${getCacheKey(resolved, request, values)}:${connection.cacheVersion}` : undefined,
cacheTtlSeconds: request.cacheSeconds,
});
return { ...response, sourceCacheVersion: connection.cacheVersion };
});
};
export const customApiRouter = createTRPCRouter({
getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
if (!definition.enabled) {
return { type: "disabled" };
}
let displayConfig: Record<string, unknown>;
try {
displayConfig = superjson.parse(definition.displayConfig) as Record<string, unknown>;
} catch {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
}
if (definition.displayType === "actionButton") {
return extractActionButtonDisplay(displayConfig);
}
const decryptedSecrets = definition.secrets.map((s) => ({
kind: s.kind,
value: decryptSecret(s.value),
}));
const url = validateUrl(definition.url);
const headers = new Headers({ Accept: "application/json" });
if (definition.method !== "GET" && definition.requestBody) {
headers.set("Content-Type", "application/json");
}
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), {
method: definition.method,
headers,
body: definition.method !== "GET" ? definition.requestBody : undefined,
redirect: "follow",
signal: controller.signal,
});
if (!response.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `API returned ${response.status}: ${response.statusText}`,
});
}
const json: unknown = await response.json();
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Failed to fetch custom API data", { definitionId: input.definitionId, error });
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : "Failed to fetch data from external API",
});
} finally {
clearTimeout(timeout);
}
refresh: publicProcedure.input(itemInputSchema).mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
invalidateCustomWidgetResponseCache([
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:`,
]);
}),
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
const entries = await Promise.all(
loadRequests.map(async ([requestId, request]) => {
try {
const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {});
return [
requestId,
{
sourceId: request.source,
sourceCacheVersion: response.sourceCacheVersion,
data: response.data,
status: {
loading: false,
ok: response.ok,
status: response.status,
statusText: response.statusText,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
},
},
] as const;
} catch (error) {
return [
requestId,
{
sourceId: request.source,
sourceCacheVersion:
resolved.definition.sources[request.source]?.type === "integration" ? "unavailable" : "",
data: null,
status: {
loading: false,
ok: false,
status: 0,
error: error instanceof Error ? error.message : "Request failed",
},
},
] as const;
}
}),
);
const integrationVersions = new Map<string, string>();
for (const [, result] of entries) {
if (!result.sourceCacheVersion) continue;
if (result.sourceCacheVersion === "unavailable" && integrationVersions.has(result.sourceId)) continue;
integrationVersions.set(result.sourceId, result.sourceCacheVersion);
}
const loadSourceIds = new Set(loadRequests.map(([, request]) => request.source));
const manualSourceIds = new Set<string>();
for (const request of Object.values(resolved.definition.requests)) {
if (request.kind !== "query" || request.trigger !== "manual" || loadSourceIds.has(request.source)) continue;
if (resolved.definition.sources[request.source]?.type !== "integration") continue;
manualSourceIds.add(request.source);
}
const manualIntegrationVersions = await Promise.all(
[...manualSourceIds].map(async (sourceId) => {
const source = resolved.definition.sources[sourceId];
if (source?.type !== "integration") return [sourceId, "unavailable"] as const;
try {
const version = await withRequestLimit(ctx, resolved, "metadata", () =>
getCustomWidgetIntegrationCacheVersion(ctx, source),
);
return [sourceId, version] as const;
} catch {
return [sourceId, "unavailable"] as const;
}
}),
);
for (const [sourceId, version] of manualIntegrationVersions) integrationVersions.set(sourceId, version);
const integrationCacheKey = [...integrationVersions]
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([sourceId, version]) => `${sourceId}:${version}`)
.join(":");
return {
type: "customJsx" as const,
template: resolved.definition.template,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${integrationCacheKey}`,
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,
requestCapabilities: Object.entries(resolved.definition.requests).map(([id, request]) => ({
id,
kind: request.kind,
method: request.method,
trigger: request.trigger,
minimumBoardPermission: request.permission,
confirmation: getCustomWidgetConfirmation(request),
invalidates: request.invalidates ?? [],
})),
};
}),
queryRequest: publicProcedure.input(namedRequestInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findRequest(resolved, input.requestId, "query");
if (request.trigger !== "manual") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Load queries cannot be invoked manually" });
}
const response = await executeRequest(ctx, resolved, request, input.params);
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
};
}),
executeAction: protectedProcedure
.input(namedRequestInputSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findRequest(resolved, input.requestId, "action");
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
if (needsConfirmation && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
}
const response = await executeRequest(ctx, resolved, request, input.params);
if (response.ok && request.invalidates?.length) {
invalidateCustomWidgetResponseCache(
request.invalidates.flatMap((requestId) => [
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
`custom-widget:options:${resolved.stored.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
]),
);
}
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
invalidates: request.invalidates ?? [],
};
}),
});
+48 -14
View File
@@ -1,13 +1,30 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations/factory";
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 {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
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({
@@ -17,15 +34,30 @@ export const dnsHoleRouter = createTRPCRouter({
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary"))
.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
@@ -36,10 +68,11 @@ export const dnsHoleRouter = createTRPCRouter({
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
disable: protectedProcedure
@@ -55,9 +88,10 @@ export const dnsHoleRouter = createTRPCRouter({
duration: z.number().optional(),
}),
)
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
});
+80 -31
View File
@@ -1,16 +1,50 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { downloadClientItemSchema } from "@homarr/integrations/downloads";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
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;
}
);
const runDownloadMutationsAsync = async (mutations: Promise<void>[], integrationIds: string[]) => {
const results = await Promise.allSettled(mutations);
await downloadClientRequestHandler.invalidateCacheAsync(integrationIds);
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
};
export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
@@ -21,17 +55,32 @@ export const downloadsRouter = createTRPCRouter({
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
},
})
.concat(createDownloadClientIntegrationMiddleware("query"))
.concat(createManyWidgetIntegrationMiddleware("query", "downloads"))
.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({
@@ -41,27 +90,27 @@ export const downloadsRouter = createTRPCRouter({
"Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseItemAsync(input.item);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
resume: protectedProcedure
.meta({
@@ -71,38 +120,38 @@ export const downloadsRouter = createTRPCRouter({
"Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeItemAsync(input.item);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
deleteItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
});
+76 -22
View File
@@ -1,4 +1,4 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
@@ -6,32 +6,86 @@ import {
firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall");
const queryFirewall = <
THandler extends {
handler: (integration: any, input: any) => { getDataAsync: () => Promise<{ data: any; timestamp: Date }> };
},
>(
handler: THandler,
) =>
type FirewallIntegration = Parameters<typeof firewallCpuRequestHandler.handler>[0];
interface FirewallHandler<TData> {
handler: (
integration: FirewallIntegration,
input: Record<string, never>,
) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> };
}
interface FirewallQueryResult<TData> {
integration: {
id: string;
name: string;
kind: FirewallIntegration["kind"] | "mock";
updatedAt: Date;
};
summary: TData;
error: string | undefined;
}
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData, getMockData: () => TData) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
};
}),
settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<FirewallQueryResult<TData>> => {
if (integration.kind === "mock") {
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(mockWidgetData.timestamp),
},
summary: getMockData(),
error: undefined,
};
}
const { data, timestamp } = await handler.handler({ ...integration, kind: "opnsense" }, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data,
error: undefined,
};
},
{
fallback: (integration, error): FirewallQueryResult<TData> => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
),
);
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
getFirewallCpuStatus: queryFirewall(
firewallCpuRequestHandler,
() => ({ total: 0 }),
() => mockWidgetData.firewallCpu,
),
getFirewallInterfacesStatus: queryFirewall(
firewallInterfacesRequestHandler,
() => [],
() => mockWidgetData.firewallInterfaces,
),
getFirewallVersionStatus: queryFirewall(
firewallVersionRequestHandler,
() => ({ version: "Unknown" }),
() => mockWidgetData.firewallVersion,
),
getFirewallMemoryStatus: queryFirewall(
firewallMemoryRequestHandler,
() => ({ used: 0, total: 0, percent: 0 }),
() => mockWidgetData.firewallMemory,
),
});
@@ -1,20 +1,19 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import { healthMonitoringClusterIntegrationKinds, healthMonitoringSystemIntegrationKinds } from "@homarr/definitions";
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 = [
"openmediavault",
"dashDot",
"truenas",
"unraid",
"glances",
"synology",
"mock",
] as const;
interface SystemHealthQueryResult {
integrationId: string;
integrationName: string;
healthInfo: SystemHealthMonitoring | null;
updatedAt?: Date;
error?: string;
}
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
@@ -25,33 +24,41 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringSystemIntegrationKinds))
.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({
mcp: {
enabled: true,
description:
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
"List storage volumes from a Synology DiskStation or OpenMediaVault integration for widget configuration. REQUIRED: integrationId from integration_all (Synology or OpenMediaVault integration only). The caller needs query permission for that integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "synology"))
.concat(createOneIntegrationMiddleware("query", "synology", "openmediavault"))
.query(async ({ ctx }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
if (!(integrationInstance instanceof SynologyIntegration)) {
throw new Error("Expected Synology integration");
}
return await integrationInstance.listStorageVolumesAsync();
}),
getClusterHealthStatus: publicProcedure
@@ -62,7 +69,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.concat(createOneIntegrationMiddleware("query", ...healthMonitoringClusterIntegrationKinds))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
+55 -18
View File
@@ -1,42 +1,79 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import {
immichAlbumRequestHandler,
immichAlbumPreviewRequestHandler,
immichAlbumsRequestHandler,
immichStatsRequestHandler,
} from "@homarr/request-handler/immich";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"));
export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getServerStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbum: publicProcedure
.concat(createImmichIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.input(
z.object({
albumId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbum;
const innerHandler = immichAlbumRequestHandler.handler(
{ ...ctx.integration, kind: "immich" },
{
albumId: input.albumId,
},
);
const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbumPreview: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.input(z.object({ albumId: z.string().min(1), randomizePhotos: z.boolean() }))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
return { ...mockWidgetData.immichAlbum, assets: mockWidgetData.immichAlbum.assets.slice(0, 1) };
}
const innerHandler = immichAlbumPreviewRequestHandler.handler(
{ ...ctx.integration, kind: "immich" },
{ albumId: input.albumId, randomizePhotos: input.randomizePhotos },
);
const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbums: publicProcedure
.input(
z.object({
limit: z.number().int().min(1).max(500).optional(),
}),
)
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
return mockWidgetData.immichAlbums.slice(0, input.limit);
}
const innerHandler = immichAlbumsRequestHandler.handler(
{ ...ctx.integration, kind: "immich" },
{
limit: input.limit,
},
);
const data = await innerHandler.getDataAsync();
return data.data;
}),
});
+4
View File
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
export const widgetRouter = createTRPCRouter({
stats: lazy(() => import("./stats").then((mod) => mod.statsRouter)),
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
@@ -13,6 +14,7 @@ export const widgetRouter = createTRPCRouter({
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
@@ -45,4 +47,6 @@ export const widgetRouter = createTRPCRouter({
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
llamacpp: lazy(() => import("./llamacpp").then((mod) => mod.llamacppRouter)),
wazuh: lazy(() => import("./wazuh").then((mod) => mod.wazuhRouter)),
});
@@ -1,35 +1,44 @@
import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.concat(createManyWidgetIntegrationMiddleware("query", "indexerManager"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
indexers,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
indexers,
error: undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
indexers: [],
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager"))
.mutation(async ({ ctx }) => {
await Promise.all(
const results = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const client = await createIntegrationAsync(integration);
await client.testAllAsync().catch((err) => {
@@ -41,5 +50,9 @@ export const indexerManagerRouter = createTRPCRouter({
});
}),
);
await indexerManagerRequestHandler.invalidateCacheAsync(ctx.integrations.map(({ id }) => id));
const firstFailure = results.find((result) => result.status === "rejected");
if (firstFailure?.status === "rejected") throw firstFailure.reason;
}),
});
@@ -0,0 +1,29 @@
import { llamacppStatsRequestHandler } from "@homarr/request-handler/llamacpp";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const llamacppRouter = createTRPCRouter({
getStats: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Returns health, loaded model and Prometheus metrics (generation speed, token counts, request queue) from a llama.cpp (llama-server) integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneWidgetIntegrationMiddleware("query", "llamacpp"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") {
return { stats: mockWidgetData.llamacpp, updatedAt: new Date(mockWidgetData.timestamp) };
}
const handler = llamacppStatsRequestHandler.handler({ ...ctx.integration, kind: "llamacpp" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
stats: data,
updatedAt: timestamp,
};
}),
});
@@ -1,9 +1,9 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({
@@ -15,19 +15,33 @@ export const mediaOrganizerRouter = createTRPCRouter({
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaMissing"))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
const { data } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
...data,
error: undefined,
};
}),
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
missing: [],
missingCount: 0,
queued: [],
queuedCount: 0,
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
return results;
}),
});
@@ -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 }] : [],
),
};
}
@@ -1,33 +1,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 { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { getMediaReleaseResponse } from "./media-release-result";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaReleases"))
.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,7 +1,8 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import {
mediaRequestListInputSchema,
@@ -9,10 +10,19 @@ import {
} from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
interface IntegrationQueryResult<TData> {
integration: { id: string; name: string; kind: IntegrationKindByCategory<"mediaRequest"> };
data: TData;
error?: string;
}
export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure
.meta({
@@ -22,18 +32,34 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestList"))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
return results
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data,
error: undefined,
};
},
{
fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: [],
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
const requests = results
.flatMap(({ data, integration }) =>
data.map((request) => ({
...request,
integrationId: integration.id,
integration,
})),
)
.toSorted((dataA, dataB) => {
@@ -46,6 +72,12 @@ export const mediaRequestsRouter = createTRPCRouter({
mediaRequestStatusConfiguration[dataB.status].position
);
});
return {
requests,
failedIntegrations: results.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
),
};
}),
getStats: publicProcedure
.meta({
@@ -55,14 +87,32 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestStats"))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
const results: IntegrationQueryResult<{
stats: MediaRequestStats["stats"] | null;
users: MediaRequestStats["users"];
}>[] = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data,
error: undefined,
};
},
{
fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { stats: null, users: [] },
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
return {
stats: results.flatMap((result) => result.data.stats),
stats: results.flatMap((result) => (result.data.stats ? [result.data.stats] : [])),
users: results
.map((result) =>
result.data.users.map((user) => ({
@@ -73,6 +123,9 @@ export const mediaRequestsRouter = createTRPCRouter({
.flat()
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
failedIntegrations: results.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
),
};
}),
answerRequest: protectedProcedure
@@ -83,7 +136,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.concat(createOneWidgetIntegrationMiddleware("interact", "mediaRequests-requestList"))
.input(
z.object({
requestId: z.number(),
@@ -99,5 +152,8 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
mediaRequestListRequestHandler.invalidateCache();
await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]);
return { success: true };
}),
});
+28 -14
View File
@@ -1,16 +1,11 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService"));
export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure
.meta({
@@ -20,14 +15,33 @@ export const mediaServerRouter = createTRPCRouter({
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
},
})
.concat(createMediaServerIntegrationMiddleware("query"))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaServer"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
error: undefined,
};
},
{
fallback: (integration) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: [],
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -1,17 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { paginatedSchema } from "@homarr/validation/common";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
export const mediaTranscodingRouter = createTRPCRouter({
getDataAsync: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "mediaTranscoding"))
.input(paginatedSchema.pick({ page: true, pageSize: true }))
.query(async ({ ctx, input }) => {
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
@@ -1,27 +1,52 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKindByCategory } 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 { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
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")))
.concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary"))
.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,31 +1,61 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { z } from "zod/v4";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
import { createManyWidgetIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const notificationsRouter = createTRPCRouter({
deleteNotification: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "gotify"))
.input(
z.object({
notificationId: z
.string()
.regex(/^[1-9]\d*$/)
.max(20),
}),
)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
await integration.deleteNotificationAsync(input.notificationId);
await notificationsRequestHandler.invalidateCacheAsync([ctx.integration.id]);
}),
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "notifications"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
error: undefined,
};
},
{
fallback: (integration, error) => ({
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(0),
},
data: [],
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+13 -3
View File
@@ -9,6 +9,8 @@ import { boards, items } from "@homarr/db/schema";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { validateTimetableOptionsChangeAsync } from "./timetable";
import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access";
export const optionsRouter = createTRPCRouter({
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
@@ -45,11 +47,19 @@ export const optionsRouter = createTRPCRouter({
});
}
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
Object.assign(options, input.newOptions);
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
const updatedOptions = { ...previousOptions, ...input.newOptions };
if (item.kind === "timetable") {
await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions);
}
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: item.id, kind: item.kind, options: updatedOptions }],
storedItems: [{ id: item.id, kind: item.kind, options: previousOptions }],
});
await ctx.db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.set({ options: SuperJSON.stringify(updatedOptions) })
.where(eq(items.id, input.itemId));
}),
});
@@ -1,12 +1,16 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
});
+5 -3
View File
@@ -1,6 +1,7 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const patchmonRouter = createTRPCRouter({
@@ -12,9 +13,10 @@ export const patchmonRouter = createTRPCRouter({
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
},
})
.concat(createOneIntegrationMiddleware("query", "patchmon"))
.concat(createOneWidgetIntegrationMiddleware("query", "patchmon"))
.query(async ({ ctx }) => {
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") return mockWidgetData.patchmon;
const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {});
const { data } = await innerHandler.getDataAsync();
return data;
}),
+23 -3
View File
@@ -1,5 +1,4 @@
import SuperJSON from "superjson";
import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
@@ -13,6 +12,8 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const PUBLIC_RELEASE_ERROR_MESSAGE: string = "Release provider request failed";
const escapeForRegEx = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -100,19 +101,38 @@ export const releasesRouter = createTRPCRouter({
})
.getDataAsync();
const data = response.data.success
? response.data
: {
success: false as const,
error: { code: response.data.error.code, message: PUBLIC_RELEASE_ERROR_MESSAGE },
};
if (!response.data.success) {
logger.warn("Release provider request failed", {
repositoryId,
provider: repository.provider,
error: response.data.error,
});
}
return {
id: repositoryId,
provider: repository.provider,
timestamp: response.timestamp,
...response.data,
...data,
};
} catch (error) {
logger.warn("Release provider request failed", {
repositoryId,
provider: repository.provider,
error,
});
return {
id: repositoryId,
provider: repository.provider,
timestamp: new Date(),
success: false as const,
error: { code: "unexpected" as const, message: String(error) },
error: { code: "unexpected" as const, message: PUBLIC_RELEASE_ERROR_MESSAGE },
};
}
}),
+43 -13
View File
@@ -1,3 +1,4 @@
import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import { z } from "zod/v4";
@@ -14,6 +15,16 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "rssFeed" });
const safeFeedLogContext = (url: string, feedIndex: number) => {
try {
return { feedIndex, origin: new URL(url).origin };
} catch {
return { feedIndex };
}
};
const redactFeedFailureMessage = (message: string) => message.replace(/https?:\/\/\S+/gi, "[redacted URL]");
const feedsInput = z.object({
urls: z.array(z.string()).max(100),
maximumAmountPosts: z.number(),
@@ -21,7 +32,7 @@ const feedsInput = z.object({
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
if (env.NO_EXTERNAL_CONNECTION) return [];
if (env.NO_EXTERNAL_CONNECTION) return { entries: [], failedFeedCount: 0 };
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
? input.urls
@@ -37,18 +48,37 @@ export const rssFeedRouter = createTRPCRouter({
}),
);
return settled
.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value.data.entries;
logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason });
return [];
})
.toSorted((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
})
.slice(0, input.maximumAmountPosts);
const failures = settled.filter((result) => result.status === "rejected");
settled.forEach((result, index) => {
if (result.status === "rejected") {
logger.warn("RSS feed fetch failed", {
...safeFeedLogContext(urls[index] ?? "", index),
errorType: result.reason instanceof Error ? result.reason.name : typeof result.reason,
errorMessage: result.reason instanceof Error ? redactFeedFailureMessage(result.reason.message) : undefined,
});
}
});
if (settled.length > 0 && failures.length === settled.length) {
throw new TRPCError({
code: "BAD_GATEWAY",
message: "All RSS feed requests failed",
});
}
return {
entries: settled
.flatMap((result) => {
if (result.status === "fulfilled") return result.value.data.entries;
return [];
})
.toSorted((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
})
.slice(0, input.maximumAmountPosts),
failedFeedCount: failures.length,
};
}),
});
+32 -25
View File
@@ -1,17 +1,15 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import {
smartHomeEntityStateRequestHandler,
toSafeEntityDetails,
} from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
export const smartHomeRouter = createTRPCRouter({
entityState: publicProcedure
.meta({
@@ -22,11 +20,26 @@ export const smartHomeRouter = createTRPCRouter({
},
})
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
return data;
return data.state;
}),
entityDetails: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get the state, attributes, and update timestamps for a Home Assistant entity. REQUIRED: integrationId (Home Assistant integration ID), entityId (for example 'sensor.temperature')",
},
})
.input(z.object({ entityId: z.string() }))
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
return toSafeEntityDetails(data);
}),
switchEntity: protectedProcedure
.meta({
@@ -36,20 +49,16 @@ export const smartHomeRouter = createTRPCRouter({
"Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-entityState"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
if (!success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Home Assistant failed to toggle the entity",
});
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
}
return success;
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true;
}),
executeAutomation: protectedProcedure
.meta({
@@ -59,17 +68,15 @@ export const smartHomeRouter = createTRPCRouter({
"Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-executeAutomation"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const success = await client.triggerAutomationAsync(input.automationId);
if (!success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Home Assistant failed to trigger the automation",
});
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
}
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true;
}),
});
@@ -1,24 +1,50 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.concat(createManyWidgetIntegrationMiddleware("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) => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.speedtestTracker,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = speedtestTrackerRequestHandler.handler({ ...integration, kind: "speedtestTracker" }, {});
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,
updatedAt: timestamp,
error: undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+51
View File
@@ -0,0 +1,51 @@
import { z } from "zod/v4";
import { integrationKinds, integrationDefs } from "@homarr/definitions";
import { getStatsMetrics } from "@homarr/integrations/stats";
import { getStatsSnapshotAsync, refreshStatsAsync } from "@homarr/request-handler/stats";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const access = createOneIntegrationMiddleware("query", ...integrationKinds);
export const statsRouter = createTRPCRouter({
catalog: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"List available statistics fields for an integration. Obtain integrationId from integration_all. Requires query access to that integration.",
},
})
.concat(access)
.query(({ ctx }) => ({
name: ctx.integration.name,
iconUrl: integrationDefs[ctx.integration.kind].iconUrl,
metrics: getStatsMetrics(ctx.integration.kind),
})),
snapshot: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Read cached integration statistics and their age without contacting the service. Requires integrationId from integration_all and query access.",
},
})
.concat(access)
.query(async ({ ctx }) => await getStatsSnapshotAsync(ctx.integration)),
refresh: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Refresh read-only integration statistics through a bounded queue. Requires integrationId from integration_all and query access. force bypasses the one-hour freshness window.",
},
})
.concat(access)
.input(z.object({ force: z.boolean().default(false) }))
.mutation(async ({ ctx, input }) => {
await refreshStatsAsync(ctx.integration, input.force);
return await getStatsSnapshotAsync(ctx.integration);
}),
});
+256 -11
View File
@@ -1,36 +1,281 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import z from "zod";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable";
import {
timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler,
} from "@homarr/request-handler/timetable";
import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const baseUrlSchema = z.string().url();
const baseUrlSchema = z.string().url().max(2_048);
const timetableSourceSchema = z.object({
baseUrl: baseUrlSchema,
itemId: z.string().max(100).optional(),
boardId: z.string().max(100).optional(),
});
const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
const blockedTimetableIpv4Addresses = new BlockList();
const blockedTimetableIpv6Addresses = new BlockList();
const blockedTimetableSubnets = [
["0.0.0.0", 8, "ipv4"],
["10.0.0.0", 8, "ipv4"],
["100.64.0.0", 10, "ipv4"],
["127.0.0.0", 8, "ipv4"],
["169.254.0.0", 16, "ipv4"],
["172.16.0.0", 12, "ipv4"],
["192.0.0.0", 24, "ipv4"],
["192.0.2.0", 24, "ipv4"],
["192.88.99.0", 24, "ipv4"],
["192.168.0.0", 16, "ipv4"],
["198.18.0.0", 15, "ipv4"],
["198.51.100.0", 24, "ipv4"],
["203.0.113.0", 24, "ipv4"],
["224.0.0.0", 4, "ipv4"],
["240.0.0.0", 4, "ipv4"],
["::", 96, "ipv6"],
["::ffff:0:0", 96, "ipv6"],
["::ffff:0:0:0", 96, "ipv6"],
["64:ff9b::", 96, "ipv6"],
["64:ff9b:1::", 48, "ipv6"],
["100::", 64, "ipv6"],
["2001::", 32, "ipv6"],
["2001:2::", 48, "ipv6"],
["2001:10::", 28, "ipv6"],
["2001:db8::", 32, "ipv6"],
["2002::", 16, "ipv6"],
["fc00::", 7, "ipv6"],
["fe80::", 10, "ipv6"],
["fec0::", 10, "ipv6"],
["ff00::", 8, "ipv6"],
] as const;
for (const [network, prefix, family] of blockedTimetableSubnets) {
const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
blockList.addSubnet(network, prefix, family);
}
const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: cause instanceof Error ? cause.message : "Invalid timetable base URL",
cause,
});
}
};
const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Timetable widget configuration is invalid",
cause,
});
};
const parseSavedTimetableOptions = (serializedOptions: string) => {
try {
return timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
const normalizeHostname = (hostname: string) => {
const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
return withoutBrackets.toLowerCase().replace(/\.$/, "");
};
const isBlockedTimetableAddress = (address: string, family: number) => {
return family === 6
? blockedTimetableIpv6Addresses.check(address, "ipv6")
: blockedTimetableIpv4Addresses.check(address, "ipv4");
};
const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<TimetableResolvedAddress[]> => {
const hostname = normalizeHostname(new URL(baseUrl).hostname);
const hostnameFamily = isIP(hostname);
const isInternalHostname =
hostnameFamily === 0 &&
(!hostname.includes(".") ||
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname.endsWith(".home.arpa"));
if (isInternalHostname) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" });
}
let addresses: TimetableResolvedAddress[];
try {
const resolvedAddresses =
hostnameFamily === 0
? await lookup(hostname, { all: true, verbatim: true })
: [{ address: hostname, family: hostnameFamily }];
addresses = resolvedAddresses.filter(
(entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL host could not be resolved",
cause,
});
}
if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL must not target a private or internal address",
});
}
return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address));
};
const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record<string, unknown>) => {
const parsedOptions = timetableOptionsSchema.safeParse(options);
if (!parsedOptions.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" });
}
return normalizeBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
};
export const validateTimetableOptionsChangeAsync = async (
nextOptions: Record<string, unknown>,
previousOptions?: Record<string, unknown>,
) => {
const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions);
if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return;
let previousBaseUrl: string | undefined;
if (previousOptions) {
try {
previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions);
} catch {
// Invalid legacy options must not prevent replacing them with a valid configuration.
}
}
if (nextBaseUrl === previousBaseUrl) return;
await resolvePublicTimetableAddressesAsync(nextBaseUrl);
};
const getSavedTimetableBaseUrl = (serializedOptions: string) => {
const options = parseSavedTimetableOptions(serializedOptions);
return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
};
interface ResolvedTimetableSource {
baseUrl: string;
pinnedAddresses?: TimetableResolvedAddress[];
}
const resolveTimetableBaseUrlAsync = async (
ctx: { db: Database; session: Session | null },
input: z.infer<typeof timetableSourceSchema>,
allowBoardConfiguration = false,
): Promise<ResolvedTimetableSource> => {
const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl);
if (allowBoardConfiguration) {
if (input.boardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
if (input.itemId) {
const item = await ctx.db.query.items.findFirst({
columns: { id: true },
where: and(eq(items.id, input.itemId), eq(items.kind, "timetable"), eq(items.boardId, input.boardId)),
});
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
}
if (requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) {
return { baseUrl: requestedBaseUrl };
}
// Resolve immediately before every custom request. A URL that was public when saved can later resolve to a
// private address, so matching the persisted value is not sufficient protection against DNS rebinding.
const pinnedAddresses = await resolvePublicTimetableAddressesAsync(requestedBaseUrl);
return { baseUrl: requestedBaseUrl, pinnedAddresses };
}
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" });
}
return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
}
if (!input.itemId) {
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" });
}
return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
}
const item = await ctx.db.query.items.findFirst({
where: and(eq(items.id, input.itemId), eq(items.kind, "timetable")),
});
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const savedBaseUrl = getSavedTimetableBaseUrl(item.options);
if (requestedBaseUrl !== savedBaseUrl) {
throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" });
}
if (savedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return { baseUrl: savedBaseUrl };
const pinnedAddresses = await resolvePublicTimetableAddressesAsync(savedBaseUrl);
return { baseUrl: savedBaseUrl, pinnedAddresses };
};
export const timetableRouter = createTRPCRouter({
getTimetable: publicProcedure
.input(
z.object({
baseUrl: baseUrlSchema,
stationId: z.string(),
timetableSourceSchema.extend({
stationId: z.string().min(1).max(200),
limit: z.number().int().min(1).max(100),
}),
)
.query(async ({ input }) => {
const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync();
.query(async ({ ctx, input }) => {
const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableGetTimetableRequestHandler
.handler({ ...handlerInput, baseUrl, pinnedAddresses })
.getDataAsync();
return data;
}),
searchStations: publicProcedure
.input(
z.object({
baseUrl: baseUrlSchema,
query: z.string(),
timetableSourceSchema.extend({
query: z.string().max(200),
}),
)
.query(async ({ input }) => {
const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
.query(async ({ ctx, input }) => {
const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input, true);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableSearchStationsRequestHandler
.handler({ ...handlerInput, baseUrl, pinnedAddresses })
.getDataAsync();
return data;
}),
});
+43 -15
View File
@@ -1,22 +1,50 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
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();
getDashboard: publicProcedure
.concat(createManyWidgetIntegrationMiddleware("query", "tracearr"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.tracearr,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = tracearrRequestHandler.handler({ ...integration, kind: "tracearr" }, {});
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,
},
);
}),
});
+40 -14
View File
@@ -1,7 +1,8 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const traefikRouter = createTRPCRouter({
@@ -13,19 +14,44 @@ export const traefikRouter = createTRPCRouter({
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
},
})
.concat(createManyIntegrationMiddleware("query", "traefik"))
.concat(createManyWidgetIntegrationMiddleware("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) => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.traefik,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = traefikRequestHandler.handler({ ...integration, kind: "traefik" }, {});
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,
updatedAt: timestamp,
error: undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+75 -34
View File
@@ -2,7 +2,8 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createIntegrationAsync } from "@homarr/integrations";
import { mockWidgetData } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import {
umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler,
@@ -12,16 +13,20 @@ import {
umamiTopReferrersRequestHandler,
} from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
getWebsites: publicProcedure.concat(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
try {
const instance = await createIntegrationAsync(ctx.integration);
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
@@ -37,14 +42,26 @@ export const umamiRouter = createTRPCRouter({
eventName: z.string().optional(),
}),
)
.concat(createManyIntegrationMiddleware("query", "umami"))
.concat(createManyWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = umamiRequestHandler.handler(integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
});
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
visitorStats: { ...mockWidgetData.umamiVisitorStats, timeFrame: input.timeFrame },
updatedAt: new Date(mockWidgetData.timestamp),
};
}
const innerHandler = umamiRequestHandler.handler(
{ ...integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
},
);
const { data, timestamp } = await innerHandler.getDataAsync();
return {
@@ -59,10 +76,16 @@ export const umamiRouter = createTRPCRouter({
getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames;
try {
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
const innerHandler = umamiEventNamesRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -81,14 +104,18 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit);
try {
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const innerHandler = umamiTopPagesRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -111,14 +138,18 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit);
try {
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const innerHandler = umamiTopReferrersRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -141,15 +172,21 @@ export const umamiRouter = createTRPCRouter({
eventNames: z.array(z.string()),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName));
}
try {
const sortedNames = [...input.eventNames].toSorted();
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
});
const innerHandler = umamiMultiEventRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -166,12 +203,16 @@ export const umamiRouter = createTRPCRouter({
getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return 7;
try {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const innerHandler = umamiActiveVisitorsRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
+21 -9
View File
@@ -1,15 +1,14 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
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) => {
getSummaries: publicProcedure.concat(createManyWidgetIntegrationMiddleware("query", "ups")).query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
@@ -19,7 +18,20 @@ export const upsRouter = createTRPCRouter({
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
error: undefined,
};
});
}),
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: [],
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+40 -14
View File
@@ -1,24 +1,50 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.concat(createManyWidgetIntegrationMiddleware("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) => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.uptimeKuma,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = uptimeKumaRequestHandler.handler({ ...integration, kind: "uptimeKuma" }, {});
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,
updatedAt: timestamp,
error: undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+55 -16
View File
@@ -1,25 +1,64 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
interface VpnResult {
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
summary: VpnSummary | null;
error?: string;
}
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<VpnResult> => {
if (integration.kind === "mock") {
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(mockWidgetData.timestamp),
},
summary: mockWidgetData.vpn,
error: undefined,
};
}
const { data, timestamp } = await vpnSummaryHandler
.handler({ ...integration, kind: "gluetun" }, {})
.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
error: undefined,
};
},
{
fallback: (integration, error): VpnResult => ({
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(0),
},
summary: null,
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+230
View File
@@ -0,0 +1,230 @@
import { z } from "zod/v4";
import { removeTrailingSlash } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { WidgetKind } from "@homarr/definitions";
import { mockWidgetData, toWazuhPublicError } from "@homarr/integrations";
import type { WazuhPublicError } from "@homarr/integrations";
import {
wazuhCveOrders,
wazuhFimEvents,
wazuhTimelineIntervals,
wazuhTimeRanges,
wazuhTopListKinds,
} from "@homarr/integrations/types";
import {
getWazuhTimelineRequestHandler,
wazuhAgentsRequestHandler,
wazuhAlertsRequestHandler,
wazuhAuthFailuresRequestHandler,
wazuhFimRequestHandler,
wazuhSummaryRequestHandler,
wazuhTopListRequestHandler,
wazuhVulnerabilitiesRequestHandler,
} from "@homarr/request-handler/wazuh";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "wazuhRouter" });
type WazuhResult<TData> =
| { data: TData; updatedAt: Date; error: null; dashboardUrl: string | null }
| { data: null; updatedAt: null; error: WazuhPublicError; dashboardUrl: string | null };
interface WazuhContextIntegration {
id: string;
kind: string;
url: string;
externalUrl: string | null;
decryptedSecrets: { kind: string; value: string }[];
}
/**
* Base URL for deep links into the Wazuh dashboard: the optional "Dashboard URL" field, otherwise the linked app
* (unless the app just points at the API or indexer URL that the integration talks to).
*/
const getDashboardUrl = (integration: WazuhContextIntegration): string | null => {
const configured = integration.decryptedSecrets.find((secret) => secret.kind === "wazuhDashboardUrl")?.value;
const candidate = configured?.trim() ? configured : integration.externalUrl;
if (!candidate) return null;
if (!configured && removeTrailingSlash(candidate) === removeTrailingSlash(integration.url)) return null;
try {
const url = new URL(candidate);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return removeTrailingSlash(url.toString());
} catch {
return null;
}
};
/**
* Wazuh widgets render friendly messages for auth, TLS and reachability problems, so upstream failures are
* returned as a safe reason instead of a generic tRPC error.
*/
const settleAsync = async <TData>(
operation: string,
integration: WazuhContextIntegration,
getDataAsync: () => Promise<{ data: TData; timestamp: Date }>,
): Promise<WazuhResult<TData>> => {
const dashboardUrl = getDashboardUrl(integration);
try {
const { data, timestamp } = await getDataAsync();
return { data, updatedAt: timestamp, error: null, dashboardUrl };
} catch (error) {
const publicError = toWazuhPublicError(error);
logger.warn("Wazuh widget request failed", { operation, integrationId: integration.id, ...publicError });
return { data: null, updatedAt: null, error: publicError, dashboardUrl };
}
};
const mockResult = <TData>(data: TData): WazuhResult<TData> => ({
data,
updatedAt: new Date(mockWidgetData.timestamp),
error: null,
dashboardUrl: null,
});
const integrationIdHint = "REQUIRED: integrationId (single Wazuh integration ID from integration_all).";
const indexerHint = "Requires indexer credentials on the integration.";
const wazuhProcedure = (widgetKind: WidgetKind & `wazuh${string}`, description: string) =>
publicProcedure
.meta({ mcp: { enabled: true, description: `${description} ${integrationIdHint}` } })
.concat(createOneWidgetIntegrationMiddleware("query", widgetKind));
const timeRange = z.enum(wazuhTimeRanges);
export const wazuhRouter = createTRPCRouter({
getAgents: wazuhProcedure(
"wazuhAgents",
"Get Wazuh agent counts and the full agent list (status, IP, OS, version, last keepalive, groups).",
).query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockResult(mockWidgetData.wazuhAgents);
const integration = { ...ctx.integration, kind: "wazuh" as const };
return await settleAsync("getAgents", integration, () =>
wazuhAgentsRequestHandler.handler(integration, {}).getDataAsync(),
);
}),
getAlerts: wazuhProcedure("wazuhAlerts", `Get recent Wazuh alerts at or above a rule level. ${indexerHint}`)
.input(
z.object({
minLevel: z.number().int().min(0).max(16).default(7),
limit: z.number().int().min(1).max(100).default(20),
hours: z.number().int().min(1).max(720).default(24),
}),
)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockResult(mockWidgetData.wazuhAlerts);
const integration = { ...ctx.integration, kind: "wazuh" as const };
return await settleAsync("getAlerts", integration, () =>
wazuhAlertsRequestHandler
.handler(integration, { minLevel: input.minLevel, limit: input.limit, hours: input.hours })
.getDataAsync(),
);
}),
getSummary: wazuhProcedure(
"wazuhSummary",
"Get Wazuh alert counts by severity for a time range compared with the previous period, plus manager version and indexer health.",
)
.input(z.object({ range: timeRange.default("24h") }))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockResult(mockWidgetData.wazuhSummary);
const integration = { ...ctx.integration, kind: "wazuh" as const };
return await settleAsync("getSummary", integration, () =>
wazuhSummaryRequestHandler.handler(integration, { range: input.range }).getDataAsync(),
);
}),
getTimeline: wazuhProcedure(
"wazuhTimeline",
`Get a histogram of Wazuh alerts over time split by severity (low, medium, high, critical). ${indexerHint}`,
)
.input(
z.object({
range: timeRange.default("24h"),
interval: z.enum(wazuhTimelineIntervals).default("auto"),
minLevel: z.number().int().min(0).max(16).default(0),
}),
)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockResult(mockWidgetData.wazuhTimeline);
const integration = { ...ctx.integration, kind: "wazuh" as const };
return await settleAsync("getTimeline", integration, () =>
getWazuhTimelineRequestHandler(input.range).handler(integration, input).getDataAsync(),
);
}),
getTopList: wazuhProcedure(
"wazuhTopList",
`Get the top Wazuh rules, agents, source IPs, MITRE ATT&CK tactics or techniques by alert count. ${indexerHint}`,
)
.input(
z.object({
kind: z.enum(wazuhTopListKinds).default("rules"),
range: timeRange.default("24h"),
limit: z.number().int().min(1).max(50).default(8),
minLevel: z.number().int().min(0).max(16).default(0),
}),
)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockResult({ ...mockWidgetData.wazuhTopList, kind: input.kind });
const integration = { ...ctx.integration, kind: "wazuh" as const };
return await settleAsync("getTopList", integration, () =>
wazuhTopListRequestHandler.handler(integration, input).getDataAsync(),
);
}),
getVulnerabilities: wazuhProcedure(
"wazuhVulnerabilities",
`Get Wazuh vulnerability counts by severity, the top CVEs and the most vulnerable agents. ${indexerHint}`,
)
.input(
z.object({
cveLimit: z.number().int().min(1).max(50).default(8),
agentLimit: z.number().int().min(1).max(50).default(5),
cveOrder: z.enum(wazuhCveOrders).default("score"),
minSeverity: z.enum(["all", "medium", "high", "critical"]).default("all"),
}),
)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockResult(mockWidgetData.wazuhVulnerabilities);
const integration = { ...ctx.integration, kind: "wazuh" as const };
return await settleAsync("getVulnerabilities", integration, () =>
wazuhVulnerabilitiesRequestHandler.handler(integration, input).getDataAsync(),
);
}),
getFim: wazuhProcedure(
"wazuhFim",
`Get recent Wazuh file integrity monitoring (syscheck) events with counts per event type. ${indexerHint}`,
)
.input(
z.object({
range: timeRange.default("24h"),
event: z.enum(wazuhFimEvents).default("all"),
limit: z.number().int().min(1).max(100).default(20),
includeRegistry: z.boolean().default(true),
}),
)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockResult(mockWidgetData.wazuhFim);
const integration = { ...ctx.integration, kind: "wazuh" as const };
return await settleAsync("getFim", integration, () =>
wazuhFimRequestHandler.handler(integration, input).getDataAsync(),
);
}),
getAuthFailures: wazuhProcedure(
"wazuhAuthFailures",
`Get failed-login and brute-force alert counts per agent with hourly series and top source IPs. ${indexerHint}`,
)
.input(
z.object({
hours: z.number().int().min(1).max(168).default(24),
limit: z.number().int().min(1).max(25).default(6),
}),
)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockResult(mockWidgetData.wazuhAuthFailures);
const integration = { ...ctx.integration, kind: "wazuh" as const };
return await settleAsync("getAuthFailures", integration, () =>
wazuhAuthFailuresRequestHandler.handler(integration, input).getDataAsync(),
);
}),
});
+2 -2
View File
@@ -6,8 +6,8 @@ import { weatherRequestHandler } from "@homarr/request-handler/weather";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const atLocationInput = z.object({
longitude: z.number(),
latitude: z.number(),
longitude: z.number().min(-180).max(180),
latitude: z.number().min(-90).max(90),
});
export const weatherRouter = createTRPCRouter({
+8 -4
View File
@@ -1,6 +1,7 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const wudRouter = createTRPCRouter({
@@ -9,12 +10,15 @@ export const wudRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
"Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "wud"))
.concat(createOneWidgetIntegrationMiddleware("query", "wud"))
.query(async ({ ctx }) => {
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") {
return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) };
}
const handler = wudStatsRequestHandler.handler({ ...ctx.integration, kind: "wud" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {