merge: stack widget modernization on dnd kit
This commit is contained in:
940 files changed
+104475
-33649
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,
|
||||
};
|
||||
@@ -1,88 +1,293 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { parse } from "superjson";
|
||||
import { parse as parseSuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } 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 { assertCustomApiItemBindingAsync, customApiItemInputSchema } from "../custom-widget/custom-widget-access";
|
||||
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
|
||||
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import {
|
||||
consumeBoundedResponseAsync,
|
||||
readBoundedJsonResponseAsync,
|
||||
validateCustomApiUrl,
|
||||
withCustomApiResponseAsync,
|
||||
} from "./custom-api-security";
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
|
||||
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";
|
||||
|
||||
export const customApiRouter = createTRPCRouter({
|
||||
getData: protectedProcedure.input(customApiItemInputSchema).query(async ({ ctx, input }) => {
|
||||
await assertCustomApiItemBindingAsync(ctx, input, "view");
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
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;
|
||||
}
|
||||
|
||||
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:
|
||||
options.configuration !== null &&
|
||||
typeof options.configuration === "object" &&
|
||||
!Array.isArray(options.configuration)
|
||||
? (options.configuration as Record<string, unknown>)
|
||||
: {},
|
||||
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 (!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 = 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") {
|
||||
const actionDisplay = extractActionButtonDisplay(displayConfig);
|
||||
return {
|
||||
...actionDisplay,
|
||||
canExecute: ctx.session.user.permissions.includes("admin"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const decryptedSecrets = definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: decryptSecret(s.value),
|
||||
}));
|
||||
const url = validateCustomApiUrl(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);
|
||||
|
||||
return await withCustomApiResponseAsync(
|
||||
url,
|
||||
{
|
||||
method: definition.method,
|
||||
headers,
|
||||
body: definition.method !== "GET" ? definition.requestBody : undefined,
|
||||
},
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
await consumeBoundedResponseAsync(response);
|
||||
throw new Error(`Custom API returned HTTP ${response.status}`);
|
||||
}
|
||||
const json = await readBoundedJsonResponseAsync(response);
|
||||
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const errorId = createId();
|
||||
logger.error("Failed to fetch custom API data", { errorId, definitionId: input.definitionId, error });
|
||||
if (legacy) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Custom API request failed",
|
||||
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 = { ...getCustomWidgetDefaultOptions(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 };
|
||||
type IdentifiedSource = CustomWidgetSource & { id: string };
|
||||
|
||||
const getAuth = (resolved: ResolvedDefinition, source: IdentifiedSource, mode: "inherit" | "none") => {
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
if (mode === "none" || authType === "none") return undefined;
|
||||
const secrets = resolved.stored.secrets
|
||||
.filter((secret) => secret.sourceId === source.id)
|
||||
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) }));
|
||||
return {
|
||||
type: authType,
|
||||
secrets,
|
||||
headerName:
|
||||
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
|
||||
? source.auth.name
|
||||
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
|
||||
? source.auth.name
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const withRequestLimit = async <T>(
|
||||
ctx: RouterContext,
|
||||
resolved: ResolvedDefinition,
|
||||
request: IdentifiedRequest,
|
||||
callback: () => Promise<T>,
|
||||
) => {
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category: request.kind === "query" ? "query" : request.method === "DELETE" ? "delete" : "action",
|
||||
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);
|
||||
const targetUrl = renderRequestTarget(source.baseUrl, request, values);
|
||||
return withRequestLimit(ctx, resolved, request, () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: source.baseUrl,
|
||||
targetUrl,
|
||||
method: request.method,
|
||||
body: renderRequestBody(request, values),
|
||||
staticHeaders: request.headers,
|
||||
auth: getAuth(resolved, source, request.auth),
|
||||
networkScope: source.networkScope,
|
||||
kind: request.kind,
|
||||
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined,
|
||||
cacheTtlSeconds: request.cacheSeconds,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const customApiRouter = createTRPCRouter({
|
||||
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,
|
||||
{
|
||||
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,
|
||||
{
|
||||
data: null,
|
||||
status: {
|
||||
loading: false,
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
type: "customJsx" as const,
|
||||
template: resolved.definition.template,
|
||||
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 ?? [],
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -10,6 +10,7 @@ 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> => {
|
||||
@@ -47,13 +48,18 @@ export const optionsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
|
||||
const options = { ...previousOptions, ...input.newOptions };
|
||||
const updatedOptions = { ...previousOptions, ...input.newOptions };
|
||||
if (item.kind === "timetable") {
|
||||
await validateTimetableOptionsChangeAsync(options, previousOptions);
|
||||
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));
|
||||
}),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import SuperJSON from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { env } from "@homarr/common/env";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { items, users } from "@homarr/db/schema";
|
||||
@@ -31,6 +32,8 @@ const feedsInput = z.object({
|
||||
|
||||
export const rssFeedRouter = createTRPCRouter({
|
||||
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return { entries: [], failedFeedCount: 0 };
|
||||
|
||||
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
|
||||
? input.urls
|
||||
: await restrictUrlsAsync(ctx.db, input.urls);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -11,6 +12,8 @@ const atLocationInput = z.object({
|
||||
|
||||
export const weatherRouter = createTRPCRouter({
|
||||
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
|
||||
const handler = weatherRequestHandler.handler(input);
|
||||
return await handler.getDataAsync().then((result) => result.data);
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user