chore: reconcile performance layer after stack rebase
This commit is contained in:
1 parent
1088f058e3
commit
c92ca2af5f
235 files changed
+18492
-15756
No files matched your search
@@ -16,7 +16,6 @@ import { bazarrRouter } from "./router/widgets/bazarr";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
import { calendarRouter } from "./router/widgets/calendar";
|
||||
import { customWidgetRouter } from "./router/custom-widget/custom-widget-router";
|
||||
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
||||
import { downloadsRouter } from "./router/widgets/downloads";
|
||||
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
||||
@@ -40,7 +39,6 @@ export const mcpRouter = createTRPCRouter({
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
downloads: downloadsRouter,
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
|
||||
@@ -1853,47 +1853,6 @@ describe("saveLayouts should save layout changes", () => {
|
||||
expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
});
|
||||
test("should project a new custom layout from the resized Base layout", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const secondItemId = createId();
|
||||
|
||||
await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId));
|
||||
await db.insert(items).values({
|
||||
id: secondItemId,
|
||||
kind: "clock",
|
||||
boardId,
|
||||
options: SuperJSON.stringify({ is24HourFormat: true }),
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId: secondItemId,
|
||||
layoutId,
|
||||
sectionId,
|
||||
width: 4,
|
||||
height: 1,
|
||||
xOffset: 6,
|
||||
yOffset: 0,
|
||||
});
|
||||
|
||||
const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 };
|
||||
const savedLayouts = await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase],
|
||||
});
|
||||
const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id;
|
||||
|
||||
const basePosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
});
|
||||
const customPosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)),
|
||||
});
|
||||
|
||||
expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
});
|
||||
test("should remove layout when not present in input", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { stringify } from "superjson";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, boardUserPermissions, customWidgetDefinitions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import { customWidgetRouter } from "../custom-widget/custom-widget-router";
|
||||
import { customApiRouter } from "../widgets/custom-api";
|
||||
|
||||
const createSession = (userId: string, permissions: GroupPermissionKey[] = []): Session => ({
|
||||
user: { id: userId, permissions, colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCustomWidgetCaller = (db: Database, session: Session | null) =>
|
||||
customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
const createCustomApiCaller = (db: Database, session: Session | null) =>
|
||||
customApiRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
async function createFixtureAsync(db: Database) {
|
||||
const ownerId = createId();
|
||||
const viewerId = createId();
|
||||
const modifierId = createId();
|
||||
const unrelatedId = createId();
|
||||
const adminId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
const definitionId = createId();
|
||||
|
||||
await db.insert(users).values(
|
||||
[ownerId, viewerId, modifierId, unrelatedId, adminId].map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
})),
|
||||
);
|
||||
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic: false });
|
||||
await db.insert(boardUserPermissions).values([
|
||||
{ boardId, userId: viewerId, permission: "view" },
|
||||
{ boardId, userId: modifierId, permission: "modify" },
|
||||
]);
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: definitionId,
|
||||
name: "Restart service",
|
||||
description: "Restarts a service",
|
||||
url: "https://api.example.com/restart",
|
||||
method: "POST",
|
||||
displayType: "actionButton",
|
||||
displayConfig: stringify({
|
||||
type: "actionButton",
|
||||
buttonLabel: "Restart",
|
||||
confirmText: "Continue?",
|
||||
}),
|
||||
creatorId: adminId,
|
||||
});
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: stringify({ definitionId, refreshInterval: 30 }),
|
||||
});
|
||||
|
||||
return { ownerId, viewerId, modifierId, unrelatedId, adminId, boardId, itemId, definitionId };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("custom widget definition authorization", () => {
|
||||
it("restricts the full definition list to administrators", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
|
||||
await expect(createCustomWidgetCaller(db, null).all()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
|
||||
await expect(createCustomWidgetCaller(db, createSession(userId)).all()).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
await expect(createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).all()).resolves.toEqual([
|
||||
expect.objectContaining({ id: fixture.definitionId, url: "https://api.example.com/restart" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only safe definition summaries to board modifiers", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
|
||||
const result = await createCustomWidgetCaller(db, createSession(fixture.modifierId)).available({
|
||||
boardId: fixture.boardId,
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: fixture.definitionId,
|
||||
name: "Restart service",
|
||||
description: "Restarts a service",
|
||||
iconUrl: null,
|
||||
displayType: "actionButton",
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain("api.example.com");
|
||||
|
||||
for (const userId of [fixture.viewerId, fixture.unrelatedId]) {
|
||||
await expect(
|
||||
createCustomWidgetCaller(db, createSession(userId)).available({ boardId: fixture.boardId }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom widget action authorization", () => {
|
||||
it("requires an administrator even for users who can view or modify the board", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const input = {
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
};
|
||||
|
||||
await expect(createCustomWidgetCaller(db, null).execute(input)).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
|
||||
await expect(createCustomWidgetCaller(db, createSession(userId)).execute(input)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("executes for an administrator only when board, item, and definition are bound", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
|
||||
|
||||
await expect(
|
||||
caller.execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
}),
|
||||
).resolves.toEqual({ success: true, responseInfo: { status: 204 } });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/restart",
|
||||
expect.objectContaining({ method: "POST", redirect: "error", signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["board", "item", "definition"] as const)("rejects a mismatched %s binding", async (mismatch) => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const input = {
|
||||
boardId: mismatch === "board" ? createId() : fixture.boardId,
|
||||
itemId: mismatch === "item" ? createId() : fixture.itemId,
|
||||
definitionId: mismatch === "definition" ? createId() : fixture.definitionId,
|
||||
};
|
||||
|
||||
await expect(
|
||||
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute(input),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects disabled definitions before sending a request", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: false })
|
||||
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom API outbound request hardening", () => {
|
||||
it("rejects credential-bearing action URLs without exposing the credential", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ url: "https://operator:super-secret@private.example.com/restart" })
|
||||
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
});
|
||||
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
|
||||
expect(JSON.stringify(result)).not.toMatch(/super-secret|private\.example\.com/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects redirects and redacts fetch errors", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new TypeError("Redirect to http://token:secret@internal.example.local/admin?apiKey=top-secret was rejected"),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/restart",
|
||||
expect.objectContaining({ redirect: "error" }),
|
||||
);
|
||||
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
|
||||
expect(JSON.stringify(result)).not.toMatch(/top-secret|internal\.example\.local|apiKey/);
|
||||
});
|
||||
|
||||
it("aborts timed-out action requests and returns a stable failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const started = Promise.withResolvers<void>();
|
||||
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
|
||||
started.resolve();
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
reject(new Error("Timed out calling http://internal.example.local?secret=timeout-secret"));
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
});
|
||||
await started.promise;
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
const result = await request;
|
||||
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
|
||||
expect(JSON.stringify(result)).not.toMatch(/timeout-secret|internal\.example\.local/);
|
||||
});
|
||||
|
||||
it("bounds action responses before reporting success", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1))));
|
||||
|
||||
await expect(
|
||||
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
}),
|
||||
).resolves.toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
|
||||
});
|
||||
|
||||
it("binds data requests to a viewable item and never exposes backend error text", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({
|
||||
displayType: "singleValue",
|
||||
method: "GET",
|
||||
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
|
||||
})
|
||||
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(new Error("Backend http://private.example.local/path?token=super-secret\nstack trace")),
|
||||
);
|
||||
|
||||
const input = {
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
};
|
||||
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
|
||||
.getData(input)
|
||||
.catch((caught: unknown) => caught);
|
||||
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
|
||||
expect(JSON.stringify(error)).not.toMatch(/super-secret|private\.example\.local|stack trace/);
|
||||
|
||||
await expect(createCustomApiCaller(db, createSession(fixture.unrelatedId)).getData(input)).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await expect(
|
||||
createCustomApiCaller(db, createSession(fixture.viewerId)).getData({ ...input, definitionId: createId() }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("bounds and disposes non-success data responses", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({
|
||||
displayType: "singleValue",
|
||||
method: "GET",
|
||||
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
|
||||
})
|
||||
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1), { status: 502 })));
|
||||
|
||||
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
|
||||
.getData({
|
||||
boardId: fixture.boardId,
|
||||
itemId: fixture.itemId,
|
||||
definitionId: fixture.definitionId,
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
|
||||
expect(JSON.stringify(error)).not.toContain("x".repeat(100));
|
||||
});
|
||||
|
||||
it("redacts unsafe preview failures and rejects redirects", async () => {
|
||||
const db = createDb();
|
||||
const fixture = await createFixtureAsync(db);
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error("redirected to http://private.local?secret=value"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
|
||||
|
||||
const result = await caller.preview({
|
||||
url: "https://api.example.com/data",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
displayType: "singleValue",
|
||||
displayConfig: { type: "singleValue", jsonPath: "$.value", label: "Value", unit: "" },
|
||||
secrets: [],
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/data",
|
||||
expect.objectContaining({ redirect: "error" }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Custom API preview failed",
|
||||
responseInfo: null,
|
||||
rawResponse: null,
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toMatch(/private\.local|secret=value/);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,47 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
|
||||
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
|
||||
|
||||
interface DownloadClientQueryResultBase {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
}
|
||||
|
||||
type DownloadClientQueryResult = DownloadClientQueryResultBase &
|
||||
(
|
||||
| {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: IntegrationKindByCategory<"downloadClient">;
|
||||
updatedAt: Date;
|
||||
};
|
||||
data: DownloadClientJobsAndStatus;
|
||||
error?: never;
|
||||
}
|
||||
| {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: IntegrationKindByCategory<"downloadClient">;
|
||||
};
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
);
|
||||
|
||||
export const downloadsRouter = createTRPCRouter({
|
||||
getJobsAndStatuses: publicProcedure
|
||||
.meta({
|
||||
@@ -24,14 +54,29 @@ export const downloadsRouter = createTRPCRouter({
|
||||
.concat(createDownloadClientIntegrationMiddleware("query"))
|
||||
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
data,
|
||||
};
|
||||
});
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
data,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data: null,
|
||||
error: PUBLIC_INTEGRATION_ERROR,
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
pause: protectedProcedure
|
||||
.meta({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { SystemHealthMonitoring } from "@homarr/integrations";
|
||||
import { SynologyIntegration } from "@homarr/integrations";
|
||||
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const healthMonitoringIntegrationKinds = [
|
||||
@@ -16,6 +17,14 @@ const healthMonitoringIntegrationKinds = [
|
||||
"mock",
|
||||
] as const;
|
||||
|
||||
interface SystemHealthQueryResult {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
healthInfo: SystemHealthMonitoring | null;
|
||||
updatedAt?: Date;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const healthMonitoringRouter = createTRPCRouter({
|
||||
getSystemHealthStatus: publicProcedure
|
||||
.meta({
|
||||
@@ -27,15 +36,27 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
healthInfo: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
healthInfo: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
healthInfo: null,
|
||||
error: PUBLIC_INTEGRATION_ERROR,
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
listStorageVolumes: publicProcedure
|
||||
.meta({
|
||||
|
||||
@@ -2,32 +2,21 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { getMediaReleaseResponse } from "./media-release-result";
|
||||
|
||||
export const mediaReleaseRouter = createTRPCRouter({
|
||||
getMediaReleases: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
return await getMediaReleaseResponse(ctx.integrations, async (integration) => {
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
releases: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
return results.flatMap((result) =>
|
||||
result.releases.map((release) => ({
|
||||
...release,
|
||||
integration: result.integration,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -32,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
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const uptimeKumaRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data as typeof data | null,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: null,
|
||||
updatedAt: new Date(0),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user