feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

@@ -101,9 +101,7 @@ describe("patchmonRouter.getStats access control", () => {
});
const caller = createCaller(db, null);
await expect(caller.getStats({ integrationId })).rejects.toThrow(
new TRPCError({ code: "UNAUTHORIZED" }),
);
await expect(caller.getStats({ integrationId })).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
});
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
@@ -0,0 +1,114 @@
import { stringify } from "superjson";
import { describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { timetableRouter } from "../../widgets/timetable";
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn() }));
vi.mock("@homarr/request-handler/timetable", () => ({
timetableGetTimetableRequestHandler: {
handler: (input: unknown) => ({
getDataAsync: async () => {
mocks.getTimetable(input);
return { data: { stationId: "station", timestamp: new Date(0), entries: [] } };
},
}),
},
timetableSearchStationsRequestHandler: {
handler: (input: unknown) => ({
getDataAsync: async () => {
mocks.searchStations(input);
return { data: [] };
},
}),
},
}));
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCaller = (db: Database, session: Session | null) =>
timetableRouter.createCaller({ db, deviceType: undefined, session });
const createTimetableItemAsync = async (
db: Database,
{ baseUrl, isPublic = true }: { baseUrl: string; isPublic?: boolean },
) => {
const ownerId = createId();
const boardId = createId();
const itemId = createId();
await db.insert(users).values({ id: ownerId });
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic });
await db.insert(items).values({
id: itemId,
boardId,
kind: "timetable",
options: stringify({ baseUrl }),
});
return { boardId, itemId, ownerId };
};
describe("timetableRouter source authorization", () => {
it("allows itemless setup only for the fixed default provider", async () => {
const caller = createCaller(createDb(), null);
await expect(caller.searchStations({ baseUrl: "https://search.ch", query: "Bern" })).resolves.toEqual([]);
await expect(caller.searchStations({ baseUrl: "http://127.0.0.1:8080", query: "Bern" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("loads a persisted private endpoint only through a viewable timetable item", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { itemId } = await createTimetableItemAsync(db, { baseUrl });
const caller = createCaller(db, null);
await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 });
expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
await expect(
caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("rejects a persisted endpoint when its board is not viewable", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { itemId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
await expect(
createCaller(db, null).getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("allows board modifiers to search an unsaved self-hosted endpoint", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
const caller = createCaller(db, createSession(ownerId));
await caller.searchStations({ baseUrl, boardId, query: "Bern" });
expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
});
it("does not treat public board view access as configuration access", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const { boardId, itemId } = await createTimetableItemAsync(db, { baseUrl });
await expect(createCaller(db, null).searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(createCaller(db, null).searchStations({ baseUrl, itemId, query: "Bern" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { everyBeszelIntegrationFailed } from "../beszel-failures";
describe("everyBeszelIntegrationFailed", () => {
it("keeps partial successes", () => {
expect(everyBeszelIntegrationFailed([{ error: "offline" }, {}])).toBe(false);
});
it("detects when every integration failed", () => {
expect(everyBeszelIntegrationFailed([{ error: "offline" }, { error: "timeout" }])).toBe(true);
});
it("allows an empty integration selection", () => {
expect(everyBeszelIntegrationFailed([])).toBe(false);
});
});
@@ -0,0 +1,18 @@
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,5 @@
export const everyBeszelIntegrationFailed = (results: readonly unknown[]) =>
results.length > 0 &&
results.every(
(result) => typeof result === "object" && result !== null && "error" in result && result.error !== undefined,
);
+17
View File
@@ -13,6 +13,7 @@ import {
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { everyBeszelIntegrationFailed } from "./beszel-failures";
import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
@@ -20,6 +21,20 @@ const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
function throwIfEveryBeszelIntegrationFailed(results: readonly unknown[], operation: string) {
if (everyBeszelIntegrationFailed(results)) {
const firstError = results[0];
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Every Beszel integration failed while loading ${operation}: ${
typeof firstError === "object" && firstError !== null && "error" in firstError
? String(firstError.error)
: "unknown error"
}`,
});
}
}
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -63,6 +78,7 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
throwIfEveryBeszelIntegrationFailed(results, "systems");
return results;
}),
@@ -136,6 +152,7 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
throwIfEveryBeszelIntegrationFailed(results, "alerts");
return results;
}),
+13 -36
View File
@@ -1,16 +1,13 @@
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 { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.meta({
@@ -30,39 +27,19 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
return {
events: data,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
};
}),
);
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,
},
{ cause: result.reason },
),
);
return [];
return {
events: data,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
};
});
}),
});
@@ -0,0 +1,21 @@
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024;
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");
}
@@ -33,7 +33,6 @@ const namedRequestInputSchema = itemInputSchema.extend({
requestId: z.string().min(1).max(64),
params: runtimeParamsSchema.default({}),
});
interface CustomWidgetItemOptions {
definitionId: string;
configuration: Record<string, unknown>;
+24 -11
View File
@@ -18,20 +18,33 @@ const queryFirewall = <
},
>(
handler: THandler,
createFallback: () => Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
) =>
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) => {
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"],
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: error instanceof Error ? error.message : "Integration request failed",
}),
throwOnAllFailures: true,
},
),
);
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })),
});
@@ -16,15 +16,29 @@ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.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 as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
indexers: [],
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
@@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({
@@ -18,16 +19,30 @@ export const mediaOrganizerRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.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 as string | undefined,
};
}),
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
missing: [],
missingCount: 0,
queued: [],
queuedCount: 0,
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
return results;
}),
});
@@ -34,6 +34,7 @@ export const mediaRequestsRouter = createTRPCRouter({
data.map((request) => ({
...request,
integrationId: integration.id,
integration,
})),
)
.toSorted((dataA, dataB) => {
@@ -99,5 +100,6 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
return { success: true };
}),
});
@@ -27,7 +27,12 @@ export const mediaServerRouter = createTRPCRouter({
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
return {
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
};
});
}),
});
@@ -13,19 +13,36 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.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 as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(0),
},
data: [],
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -37,6 +37,11 @@ export const rssFeedRouter = createTRPCRouter({
}),
);
const failures = settled.filter((result) => result.status === "rejected");
if (settled.length > 0 && failures.length === settled.length) {
throw failures[0]?.reason;
}
return settled
.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value.data.entries;
+30 -5
View File
@@ -1,8 +1,12 @@
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 {
smartHomeEntityStateRequestHandler,
toSafeEntityDetails,
} from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -25,7 +29,22 @@ export const smartHomeRouter = createTRPCRouter({
.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(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
return toSafeEntityDetails(data);
}),
switchEntity: protectedProcedure
.meta({
@@ -40,8 +59,10 @@ export const smartHomeRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
return success;
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
}
return true;
}),
executeAutomation: protectedProcedure
.meta({
@@ -55,6 +76,10 @@ export const smartHomeRouter = createTRPCRouter({
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.triggerAutomationAsync(input.automationId);
const success = await client.triggerAutomationAsync(input.automationId);
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
}
return true;
}),
});
+65 -11
View File
@@ -1,36 +1,90 @@
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 {
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 resolveTimetableBaseUrlAsync = async (
ctx: { db: Database; session: Session | null },
input: z.infer<typeof timetableSourceSchema>,
allowBoardConfiguration = false,
) => {
const requestedBaseUrl = normalizeTimetableBaseUrl(input.baseUrl);
if (allowBoardConfiguration) {
if (input.boardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return requestedBaseUrl;
}
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" });
}
return 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 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 options = parse<Record<string, unknown>>(item.options);
const savedBaseUrl = normalizeTimetableBaseUrl(
typeof options.baseUrl === "string" ? options.baseUrl : DEFAULT_TIMETABLE_BASE_URL,
);
if (requestedBaseUrl !== savedBaseUrl) {
throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" });
}
return savedBaseUrl;
};
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 = await resolveTimetableBaseUrlAsync(ctx, input);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).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 = await resolveTimetableBaseUrlAsync(ctx, input, true);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableSearchStationsRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
return data;
}),
});
+29 -12
View File
@@ -9,17 +9,34 @@ export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("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) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data as typeof data | null,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(0),
},
summary: null,
error: error instanceof Error ? error.message : String(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+7 -3
View File
@@ -11,6 +11,7 @@ interface IntegrationLike {
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
throwOnAllFailures?: boolean;
}
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
@@ -37,15 +38,18 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
),
);
errors.push(result.reason);
if (options?.fallback && integration) {
results.push(options.fallback(integration, result.reason));
return;
}
errors.push(result.reason);
});
if (results.length === 0 && errors.length > 0) {
if (
errors.length > 0 &&
(results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length))
) {
throw errors[0];
}
@@ -47,4 +47,21 @@ describe("settleIntegrationQueries", () => {
}),
).resolves.toEqual(["First", "Second"]);
});
test("can retain partial fallbacks while rejecting total failure", async () => {
const error = new Error("Integration unavailable");
const options = {
fallback: (integration: (typeof integrations)[number]) => integration.name,
throwOnAllFailures: true,
};
await expect(
settleIntegrationQueries(
integrations,
(integration) => (integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name)),
options,
),
).resolves.toEqual(["First", "Second"]);
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toBe(error);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { getBoardSaveScopeId, shouldRefetchAfterSaveError } from "./updater";
describe("board persistence coordination", () => {
it("uses one mutation scope for every save of the same board", () => {
expect(getBoardSaveScopeId("board-1")).toBe(getBoardSaveScopeId("board-1"));
expect(getBoardSaveScopeId("board-1")).not.toBe(getBoardSaveScopeId("board-2"));
});
it("only refetches after a failed save when no newer local board exists", () => {
const failedBoard = { id: "board-1", items: ["first"] };
const newerBoard = { id: "board-1", items: ["first", "second"] };
expect(shouldRefetchAfterSaveError(failedBoard, failedBoard)).toBe(true);
expect(shouldRefetchAfterSaveError(newerBoard, failedBoard)).toBe(false);
});
});
+48 -4
View File
@@ -5,13 +5,19 @@ import { useCallback } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
type Board = RouterOutputs["board"]["getHomeBoard"];
type UpdateCallback = (previous: Board) => Board;
let boardName: string | null = null;
export const updateBoardName = (name: string | null) => {
boardName = name;
};
type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
export const getBoardSaveScopeId = (boardId: string) => `board-save:${boardId}`;
export const shouldRefetchAfterSaveError = <T>(currentBoard: T | undefined, failedBoard: T) =>
currentBoard === failedBoard;
export const useUpdateBoard = () => {
const utils = clientApi.useUtils();
@@ -21,9 +27,13 @@ export const useUpdateBoard = () => {
if (!boardName) {
throw new Error("Board name is not set");
}
utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
previous ? updaterWithoutUndefined(previous) : previous,
);
let updatedBoard: Board | undefined;
utils.board.getBoardByName.setData({ name: boardName }, (previous) => {
if (!previous) return previous;
updatedBoard = updaterWithoutUndefined(previous);
return updatedBoard;
});
return updatedBoard;
},
[utils],
);
@@ -32,3 +42,37 @@ export const useUpdateBoard = () => {
updateBoard,
};
};
interface PersistBoardOptions {
onError?: (error: unknown) => void;
}
export const usePersistBoard = ({ id, name }: Pick<Board, "id" | "name">) => {
const utils = clientApi.useUtils();
const { updateBoard } = useUpdateBoard();
const { mutateAsync: saveBoardAsync } = clientApi.board.saveBoard.useMutation({
scope: { id: getBoardSaveScopeId(id) },
});
const updateAndPersistBoard = useCallback(
(updater: UpdateCallback, options?: PersistBoardOptions) => {
const updatedBoard = updateBoard(updater);
if (!updatedBoard) return undefined;
const persistedBoard = utils.board.getBoardByName.getData({ name });
if (!persistedBoard) return undefined;
void saveBoardAsync(persistedBoard).catch((error: unknown) => {
const currentBoard = utils.board.getBoardByName.getData({ name });
if (shouldRefetchAfterSaveError(currentBoard, persistedBoard)) {
void utils.board.getBoardByName.invalidate({ name });
}
options?.onError?.(error);
});
return persistedBoard;
},
[name, saveBoardAsync, updateBoard, utils],
);
return { updateAndPersistBoard };
};
+45
View File
@@ -0,0 +1,45 @@
interface ReadableResponse {
headers: { get: (name: string) => string | null };
body: {
getReader: () => {
read: () => Promise<{ done: boolean; value?: Uint8Array }>;
cancel: () => Promise<void>;
};
} | null;
}
export async function readBoundedJsonResponseAsync(
response: ReadableResponse,
maxBytes: number,
label = "Response",
): Promise<unknown> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new Error(`${label} exceeds ${maxBytes} bytes`);
}
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let byteLength = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
byteLength += value.byteLength;
if (byteLength > maxBytes) {
await reader.cancel();
throw new Error(`${label} exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
const body = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return JSON.parse(new TextDecoder().decode(body)) as unknown;
}
+1
View File
@@ -2,3 +2,4 @@ export * from "./security";
export * from "./encryption";
export * from "./request";
export * from "./errors";
export * from "./bounded-response";
@@ -54,6 +54,7 @@ export class AudiobookshelfIntegration extends Integration {
const statsByLibrary = await Promise.all(
libraries.map(async (library) => ({
library,
mediaType: library.mediaType,
stats: await this.getLibraryStatsAsync(library.id),
})),
@@ -75,6 +76,14 @@ export class AudiobookshelfIntegration extends Integration {
totalPodcasts: counts.totalPodcasts,
totalListeningTimeSeconds: listeningStats.totalTime,
activeSessions,
libraries: statsByLibrary.map(({ library, stats }) => ({
id: library.id,
name: library.name,
mediaType: library.mediaType,
totalItems: stats.totalItems,
totalDurationSeconds: stats.totalDuration ?? null,
totalSizeBytes: stats.totalSize ?? null,
})),
};
}
@@ -30,6 +30,14 @@ export interface AudiobookshelfDashboardData {
totalPodcasts: number;
totalListeningTimeSeconds: number;
activeSessions: number;
libraries?: {
id: string;
name: string;
mediaType: "book" | "podcast";
totalItems: number;
totalDurationSeconds: number | null;
totalSizeBytes: number | null;
}[];
}
export type AudiobookshelfLibrary = z.infer<typeof audiobookshelfLibrarySchema>;
@@ -36,7 +36,6 @@ export interface ImmichAlbum {
export interface ImmichAsset {
id: string;
originalPath: string;
thumbhash: string | null;
fileModifiedAt: string;
fileCreatedAt: string;
@@ -156,7 +155,6 @@ export class ImmichIntegration extends Integration {
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
+1
View File
@@ -62,6 +62,7 @@ export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/heal
export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
export type { StreamSession } from "./interfaces/media-server/media-server-types";
export type { EntityState } from "./interfaces/smart-home/smart-home-types";
export type {
TdarrQueue,
TdarrPieSegment,
@@ -1,4 +1,4 @@
interface EntityState {
export interface EntityState {
attributes: Record<string, string | number | boolean | null | (string | number)[]>;
entity_id: string;
last_changed: Date;
@@ -0,0 +1,30 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { OverseerrIntegration } from "./overseerr-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new OverseerrIntegration({
id: "overseerr-1",
name: "Overseerr",
url: "https://overseerr.example.com",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "test-key" }],
});
describe("Overseerr media request actions", () => {
beforeEach(() => vi.clearAllMocks());
it.each([
["approve", () => integration.approveRequestAsync(42)],
["decline", () => integration.declineRequestAsync(42)],
])("rejects %s when Overseerr returns a non-success response", async (_action, run) => {
mockFetch.mockResolvedValue(new Response(null, { status: 500 }));
await expect(run()).rejects.toThrow("Response from integration did not indicate success");
});
});
@@ -1,8 +1,8 @@
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -259,49 +259,27 @@ export class OverseerrIntegration
public async approveRequestAsync(requestId: number): Promise<void> {
logger.info("Approving media request", { requestId, integration: this.integration.name });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to approve media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
});
if (!response.ok) throw new ResponseError(response);
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
}
public async declineRequestAsync(requestId: number): Promise<void> {
logger.info("Declining media request", { requestId, integration: this.integration.name });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to decline media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
});
if (!response.ok) throw new ResponseError(response);
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
}
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
@@ -0,0 +1,12 @@
import { Response } from "undici";
import { describe, expect, it } from "vitest";
import { readBoundedTraefikJsonAsync } from "./traefik-bounds";
describe("readBoundedTraefikJsonAsync", () => {
it("rejects a response that exceeds its byte budget", async () => {
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
await expect(readBoundedTraefikJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
});
});
@@ -0,0 +1,12 @@
import type { Response } from "undici";
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const MAX_TRAEFIK_RESPONSE_BYTES = 2 * 1024 * 1024;
export async function readBoundedTraefikJsonAsync(
response: Response,
maxBytes = MAX_TRAEFIK_RESPONSE_BYTES,
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Traefik response");
}
@@ -0,0 +1,43 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { TraefikIntegration } from "./traefik-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new TraefikIntegration({
id: "traefik-1",
name: "Traefik",
url: "https://traefik.example.com",
externalUrl: null,
decryptedSecrets: [],
});
describe("TraefikIntegration response bounds", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockImplementation((url) => {
const path = new URL(url.toString()).pathname;
if (path === "/api/version") return Promise.resolve(jsonResponse({ Version: "3.0.0" }));
if (path === "/api/http/routers") {
return Promise.resolve(
jsonResponse(Array.from({ length: 250 }, (_, index) => ({ name: `router-${index}`, status: "enabled" }))),
);
}
return Promise.resolve(jsonResponse([]));
});
});
it("caps detailed resource allocation while retaining bounded summary counts", async () => {
const result = await integration.getDashboardDataAsync();
expect(result.resources).toHaveLength(200);
expect(result.resources.at(-1)?.name).toBe("router-199");
expect(result.http.routers.total).toBe(250);
});
});
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
@@ -4,10 +4,12 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import { readBoundedTraefikJsonAsync } from "./traefik-bounds";
import type {
TraefikDashboardData,
TraefikProtocolSummary,
TraefikResource,
TraefikResourceDetail,
TraefikResourceStatus,
TraefikResourceSummary,
} from "./traefik-types";
@@ -24,6 +26,8 @@ type ResourcePath =
| "/api/udp/services"
| "/api/entrypoints";
const MAX_RESOURCE_DETAILS = 200;
export class TraefikIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() });
@@ -32,7 +36,7 @@ export class TraefikIntegration extends Integration {
throw new ResponseError(response);
}
traefikVersionSchema.parse(await response.json());
traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response));
return { success: true };
}
@@ -60,6 +64,38 @@ export class TraefikIntegration extends Integration {
this.getResourcesAsync("/api/udp/routers"),
this.getResourcesAsync("/api/udp/services"),
]);
const allResults = [
versionResult,
entryPointsResult,
httpRoutersResult,
httpServicesResult,
httpMiddlewaresResult,
tcpRoutersResult,
tcpServicesResult,
tcpMiddlewaresResult,
udpRoutersResult,
udpServicesResult,
];
if (allResults.every((result) => result.status === "rejected")) {
throw versionResult.status === "rejected" ? versionResult.reason : new Error("Every Traefik endpoint failed");
}
const endpointResults = [
["version", versionResult],
["entrypoints", entryPointsResult],
["http/routers", httpRoutersResult],
["http/services", httpServicesResult],
["http/middlewares", httpMiddlewaresResult],
["tcp/routers", tcpRoutersResult],
["tcp/services", tcpServicesResult],
["tcp/middlewares", tcpMiddlewaresResult],
["udp/routers", udpRoutersResult],
["udp/services", udpServicesResult],
] as const;
const failedEndpoints = endpointResults
.filter(([, result]) => result.status === "rejected")
.map(([endpoint]) => endpoint);
const version = versionResult.status === "fulfilled" ? versionResult.value : null;
const entryPoints = entryPointsResult.status === "fulfilled" ? entryPointsResult.value : [];
const httpRouters = httpRoutersResult.status === "fulfilled" ? httpRoutersResult.value : [];
@@ -71,9 +107,30 @@ export class TraefikIntegration extends Integration {
const udpRouters = udpRoutersResult.status === "fulfilled" ? udpRoutersResult.value : [];
const udpServices = udpServicesResult.status === "fulfilled" ? udpServicesResult.value : [];
const resources: TraefikResourceDetail[] = [];
const resourceGroups = [
["http", "router", httpRouters],
["http", "service", httpServices],
["http", "middleware", httpMiddlewares],
["tcp", "router", tcpRouters],
["tcp", "service", tcpServices],
["tcp", "middleware", tcpMiddlewares],
["udp", "router", udpRouters],
["udp", "service", udpServices],
] as const;
for (const [protocol, type, entries] of resourceGroups) {
const remaining = MAX_RESOURCE_DETAILS - resources.length;
if (remaining === 0) break;
resources.push(...this.createResourceDetails(protocol, type, entries.slice(0, remaining)));
}
return {
version,
entryPoints: entryPoints.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`),
entryPoints: entryPoints
.slice(0, MAX_RESOURCE_DETAILS)
.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`),
resources,
failedEndpoints,
http: this.createProtocolSummary(httpRouters, httpServices, httpMiddlewares),
tcp: this.createProtocolSummary(tcpRouters, tcpServices, tcpMiddlewares),
udp: {
@@ -83,15 +140,29 @@ export class TraefikIntegration extends Integration {
};
}
private createResourceDetails(
protocol: TraefikResourceDetail["protocol"],
type: TraefikResourceDetail["type"],
resources: TraefikResource[],
): TraefikResourceDetail[] {
return resources.map((resource, index) => ({
protocol,
type,
name: resource.name ?? `${type}-${index + 1}`,
provider: resource.provider ?? null,
status: this.normalizeStatus(resource.status),
}));
}
private async getVersionAsync() {
const response = await this.fetchOkAsync("/api/version");
const version = traefikVersionSchema.parse(await response.json());
const version = traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response));
return version.Version ?? null;
}
private async getResourcesAsync(path: ResourcePath) {
const response = await this.fetchOkAsync(path);
return traefikResourcesSchema.parse(await response.json());
return traefikResourcesSchema.parse(await readBoundedTraefikJsonAsync(response));
}
private createProtocolSummary(
@@ -137,6 +208,7 @@ export class TraefikIntegration extends Integration {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
timeout: 10_000,
bodyTimeout: 10_000,
});
if (!response.ok) {
@@ -31,9 +31,19 @@ export interface TraefikProtocolSummary {
middlewares: TraefikResourceSummary;
}
export interface TraefikResourceDetail {
protocol: "http" | "tcp" | "udp";
type: "router" | "service" | "middleware";
name: string;
provider: string | null;
status: TraefikResourceStatus;
}
export interface TraefikDashboardData {
version: string | null;
entryPoints: string[];
resources: TraefikResourceDetail[];
failedEndpoints: string[];
http: TraefikProtocolSummary;
tcp: TraefikProtocolSummary;
udp: Omit<TraefikProtocolSummary, "middlewares">;
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { toSafeEntityDetails } from "./smart-home-entity-state";
describe("toSafeEntityDetails", () => {
it("keeps display attributes and removes sensitive Home Assistant metadata", () => {
const details = toSafeEntityDetails({
entity_id: "camera.front_door",
state: "streaming",
last_changed: new Date("2026-01-01T00:00:00Z"),
last_updated: new Date("2026-01-01T00:00:01Z"),
attributes: {
friendly_name: "Front door",
icon: "mdi:camera",
access_token: "secret-camera-token",
latitude: 48.85,
},
});
expect(details.attributes).toEqual({ friendly_name: "Front door", icon: "mdi:camera" });
expect(details.attributes).not.toHaveProperty("access_token");
expect(details.attributes).not.toHaveProperty("latitude");
});
});
@@ -1,10 +1,29 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { EntityState } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const safeAttributeKeys = ["friendly_name", "unit_of_measurement", "device_class", "icon"] as const;
export const toSafeEntityDetails = (entity: EntityState) => {
const attributes: Partial<Record<(typeof safeAttributeKeys)[number], string>> = {};
for (const key of safeAttributeKeys) {
const value = entity.attributes[key];
if (typeof value === "string") attributes[key] = value;
}
return {
attributes,
entity_id: entity.entity_id,
last_changed: entity.last_changed,
last_updated: entity.last_updated,
state: entity.state,
};
};
export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler<
string,
EntityState,
IntegrationKindByCategory<"smartHomeServer">,
{ entityId: string }
>({
@@ -16,6 +35,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
throw new Error(`Unable to fetch data from Home Assistant error='${result.error as string}'`);
}
return result.data.state;
return result.data;
},
});
@@ -0,0 +1,26 @@
import type { Response } from "undici";
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const DEFAULT_TIMETABLE_BASE_URL = "https://search.ch";
export const MAX_TIMETABLE_RESPONSE_BYTES = 512 * 1024;
export const normalizeTimetableBaseUrl = (baseUrl: string) => {
if (baseUrl.length > 2_048) throw new Error("Timetable base URL is too long");
const url = new URL(baseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Timetable base URL must use HTTP or HTTPS");
}
if (url.username || url.password) throw new Error("Timetable base URL must not contain credentials");
if (url.search || url.hash) throw new Error("Timetable base URL must not contain a query or fragment");
return url.toString().replace(/\/$/, "");
};
export async function readBoundedTimetableJsonAsync(
response: Response,
maxBytes = MAX_TIMETABLE_RESPONSE_BYTES,
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Timetable response");
}
@@ -0,0 +1,79 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
beforeEach(() => vi.clearAllMocks());
describe("normalizeTimetableBaseUrl", () => {
it("normalizes an HTTP endpoint", () => {
expect(normalizeTimetableBaseUrl("https://search.ch/")).toBe("https://search.ch");
});
it.each(["file:///etc/passwd", "https://user:secret@example.com", "https://example.com?redirect=/admin"])(
"rejects unsafe base URL %s",
(url) => expect(() => normalizeTimetableBaseUrl(url)).toThrow(),
);
});
describe("timetable response bounds", () => {
it("rejects a response once its byte budget is exceeded", async () => {
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
await expect(readBoundedTimetableJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
});
it("caps and caches station results and rejects redirects", async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify(
Array.from({ length: 125 }, (_, index) => ({
id: String(index),
label: `Station ${index}`,
iconclass: "train",
})),
),
),
);
const input = { baseUrl: "http://timetable.internal", query: "bounded-stations" };
const first = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
const second = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
expect(first.data).toHaveLength(100);
expect(second.data).toHaveLength(100);
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ redirect: "error", bodyTimeout: 10_000 }),
);
});
it("never caches more departures than requested", async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify({
connections: Array.from({ length: 5 }, (_, index) => ({
time: `2026-01-01T00:0${index}:00Z`,
line: "1",
color: "ffffff",
terminal: { name: `Stop ${index}` },
dep_delay: "0",
})),
}),
),
);
const result = await timetableGetTimetableRequestHandler
.handler({ baseUrl: "http://timetable.internal", stationId: "bounded-departures", limit: 2 })
.getDataAsync();
expect(result.data.entries).toHaveLength(2);
});
});
+16 -3
View File
@@ -4,6 +4,7 @@ import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createRequestHandler } from "./lib/request-handler";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
export interface Station {
id: string;
@@ -31,6 +32,9 @@ export interface Timetable {
}
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
const MAX_STATION_RESULTS = 100;
const MAX_TIMETABLE_ENTRIES = 100;
const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const;
export const timetableSearchStationsRequestHandler = createRequestHandler<
Station[],
@@ -53,7 +57,7 @@ export const timetableGetTimetableRequestHandler = createRequestHandler<
});
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<string, string | number>) => {
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
const url = new URL(`${normalizeTimetableBaseUrl(baseUrl)}${path}`);
for (const [key, value] of Object.entries(queryParams)) {
url.searchParams.set(key, value.toString());
}
@@ -63,10 +67,12 @@ const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<strin
const searchStationsAsync = async (baseUrl: string, query: string): Promise<Station[]> => {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
timetableFetchOptions,
);
if (!response.ok) throw new ResponseError(response);
const data = await searchSchema.parseAsync(await response.json());
const body = await readBoundedTimetableJsonAsync(response);
const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
return data
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
@@ -99,10 +105,15 @@ const getTimetableAsync = async (
date,
time,
}),
timetableFetchOptions,
);
if (!response.ok) throw new ResponseError(response);
const data = await timetableSchema.parseAsync(await response.json());
const body = await readBoundedTimetableJsonAsync(response);
const envelope = timetableEnvelopeSchema.parse(body);
const data = await timetableSchema.parseAsync({
connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)),
});
return {
stationId: options.stationId,
timestamp: now,
@@ -145,6 +156,8 @@ const timetableSchema = z.object({
),
});
const timetableEnvelopeSchema = z.object({ connections: z.array(z.unknown()) });
const searchSchema = z.array(
z.object({
id: z.string().optional(),
+3 -15
View File
@@ -1,28 +1,16 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const logger = createLogger({ module: "vpnRequestHandler" });
export const vpnSummaryHandler = createIntegrationRequestHandler<
VpnSummary | null,
VpnSummary,
IntegrationKindByCategory<"vpn">,
Record<string, never>
>({
async requestAsync(integration, _input) {
try {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
} catch (error) {
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
integrationId: integration.id,
integrationKind: integration.kind,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
},
});
+528 -31
View File
@@ -741,7 +741,7 @@
},
"create": {
"title": "Create Custom Widget",
"subtitle": "Build a safe JSX widget with inline API sources, options, queries, and actions."
"subtitle": "Define an API URL, authentication, and how to display the response data."
},
"edit": {
"title": "Edit Custom Widget"
@@ -766,12 +766,62 @@
"migrate": "Migrate Custom Widget",
"copyMigrationPrompt": "Copy LLM migration prompt",
"pasteMigration": "Paste migrated widget",
"importMigration": "Import migrated JSON file"
"importMigration": "Import migrated JSON file",
"addMapping": "Add Mapping",
"addColumn": "Add Column",
"removeMapping": "Remove mapping",
"removeColumn": "Remove column",
"addItem": "Add Item",
"removeItem": "Remove item",
"addBar": "Add Bar"
},
"field": {
"name": "Name",
"description": "Description",
"iconUrl": "Icon URL"
"iconUrl": "Icon URL",
"url": "URL",
"authType": "Auth Type",
"headerName": "Header/Query Parameter Name",
"method": "Method",
"requestBody": "Request Body",
"displayType": "Display Type",
"jsonPath": "JSON Path",
"jsonPathHint": "Use JSONPath syntax to extract values, e.g. $.data.temperature or $.items[0].name",
"label": "Label",
"unit": "Unit",
"mappings": "Mappings",
"tablePath": "Array JSON Path",
"tablePathHint": "Path to the array in the response, e.g. $.data.items",
"columns": "Columns",
"header": "Header",
"items": "Items",
"gridColumns": "Grid Columns",
"cardStyle": "Card Style",
"bars": "Bars",
"valuePath": "Value JSON Path",
"maxPath": "Max Value JSON Path",
"showPercentage": "Show Percentage",
"barSize": "Bar Size",
"goodValues": "Good Values (comma-separated)",
"dotSize": "Dot Size",
"valueSize": "Value Size",
"labelPosition": "Label Position",
"kvLayout": "Layout",
"striped": "Striped Rows",
"compact": "Compact Mode",
"maxHeight": "Max Height (px)",
"color": "Color",
"buttonLabel": "Button Label",
"buttonColor": "Button Color",
"confirmText": "Confirmation Text",
"confirmTextHint": "If set, a confirmation dialog will appear before executing the request",
"successMessage": "Success Message",
"successMessageHint": "Message shown after successful execution",
"enabled": "Enabled",
"template": {
"label": "JSX Template",
"description": "Mantine JSX template. Access API response fields via data bindings (e.g. data.name). Use whitelisted components only. Forbidden: constructor, __proto__, eval, Function, window, document."
}
},
"editor": {
"errorBoundary": {
@@ -1026,9 +1076,9 @@
"journal": "Redacted request journal",
"actionParameters": "Action parameters (JSON)",
"invalidParameters": "Use the declared string, number, and boolean parameter types.",
"runDestructive": "Run destructive action",
"runAction": "Run action",
"deleteConfirmation": "This preview action sends a DELETE request to the configured API.",
"runDestructive": "Run destructive action",
"runAction": "Run action",
"deleteConfirmation": "This preview action sends a DELETE request to the configured API.",
"simulated": "Action simulated",
"httpStatus": "HTTP {status, number}",
"tab": {
@@ -1059,7 +1109,11 @@
"modify": "Modify",
"full": "Full"
}
}
},
"title": "Preview",
"test": "Test",
"hint": "Click Test to preview the API response with your current configuration.",
"rawResponse": "Raw JSON response"
},
"notification": {
"created": "Widget \"{name}\" created successfully.",
@@ -1072,15 +1126,21 @@
"migrationPromptCopyError": "Failed to copy the migration prompt.",
"aiPromptCopied": "AI prompt copied to clipboard",
"aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).",
"aiPromptCopyError": "Failed to copy the prompt to the clipboard.",
"aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",
"updateError": "Failed to update widget. Your changes were not saved.",
"importError": "Failed to import widget. Ensure the file uses the homarr-custom-widget-v2 schema.",
"importError": "Failed to import widget. Ensure the file is a valid custom widget JSON export.",
"exportError": "Failed to export widget.",
"duplicated": "Widget \"{name}\" duplicated.",
"duplicateError": "Failed to duplicate widget.",
"toggleError": "Failed to toggle widget status.",
"deleteError": "Failed to delete widget.",
"previewError": "Failed to create the preview. Check the API source and try again."
"previewError": "Failed to fetch preview. Check the URL and try again.",
"aiPromptDescription": "Copy a pre-built prompt to paste into any AI chatbot (ChatGPT, Claude, Gemini...). It includes the full widget schema and your API response so the AI can generate a ready-to-import JSON config for you.",
"aiPromptCopy": "Copy to clipboard",
"aiPromptNoResponse": "Tip: Run a test first to include your API response in the prompt for better results.",
"createError": "Failed to create widget. Check your API endpoint is reachable.",
"executed": "Action executed successfully.",
"executeError": "Action failed: {error}"
},
"importReview": {
"title": "Review custom widget import",
@@ -1122,6 +1182,91 @@
"cancel": "Cancel import",
"confirm": "Import widget",
"confirmMigration": "Confirm v2 replacement"
},
"placeholder": {
"exampleLabel": "CPU Temperature",
"exampleLabelShort": "CPU Temp",
"exampleHeader": "Name",
"exampleUnit": "°C",
"iconUrl": "https://...",
"url": "https://api.example.com/api/v1/status",
"headerName": "X-API-Key",
"jsonPath": "$.data.value",
"tablePath": "$.data.items",
"columnJsonPath": "$.name",
"valuePath": "$.data.used",
"maxPath": "$.data.total",
"goodValues": "online, true, up",
"color": "blue"
},
"cardStyleOption": {
"filled": "Filled",
"outline": "Outline",
"subtle": "Subtle"
},
"layoutOption": {
"list": "List",
"grid": "Grid"
},
"sizeOption": {
"sm": "Small",
"md": "Medium",
"lg": "Large",
"xl": "Extra Large"
},
"labelPositionOption": {
"above": "Above",
"below": "Below"
},
"fieldset": {
"general": "General",
"connection": "API Connection",
"display": "Display Configuration"
},
"displayTypeDescription": {
"singleValue": "Show one extracted value with a label and unit",
"keyValue": "Show multiple labeled values side by side",
"table": "Show rows of data in a table format",
"statGrid": "Colored stat cards in a grid — like Pi-Hole or Speedtest widgets",
"progressBars": "Progress bars showing usage or completion — for disk, memory, quotas",
"statusIndicator": "Colored status dots showing online/offline or health states",
"countGrid": "Compact number + label counters in a tight grid",
"raw": "Pretty-printed JSON output for debugging",
"actionButton": "A button that triggers a request when clicked",
"customJsx": "Custom JSX layout using whitelisted Mantine components with full API data binding"
},
"authType": {
"none": "None",
"bearer": "Bearer Token",
"basic": "Basic Auth",
"apiKeyHeader": "API Key (Header)",
"apiKeyQuery": "API Key (Query)"
},
"displayType": {
"singleValue": "Single Value",
"keyValue": "Key-Value",
"table": "Table",
"statGrid": "Stat Grid",
"progressBars": "Progress Bars",
"statusIndicator": "Status Indicator",
"countGrid": "Count Grid",
"raw": "Raw JSON",
"actionButton": "Action Button",
"customJsx": "Custom JSX"
},
"secret": {
"apiKey": "API Key / Token",
"username": "Username",
"password": "Password",
"savedPlaceholder": "••••••• (saved, leave empty to keep)"
},
"table": {
"name": "Name",
"url": "URL",
"display": "Display"
},
"widget": {
"disabled": "This widget is disabled"
}
},
"workshop": {
@@ -1325,7 +1470,8 @@
"analytics": "Analytics",
"vpn": "VPN",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring"
"uptimeMonitoring": "Uptime Monitoring",
"beszel": "Beszel"
},
"testConnection": {
"action": {
@@ -1858,7 +2004,13 @@
"kilometersPerHour": "km/h",
"milesPerHour": "mph"
}
}
},
"widgetQuery": {
"loading": "Loading widget data",
"stale": "Could not refresh {widget}; showing saved data"
},
"total": "Total",
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}"
},
"section": {
"option": {
@@ -1906,6 +2058,67 @@
"title": "Remove container",
"message": "Remove this container? Its contents will move into the surrounding area."
}
},
"dynamic": {
"action": {
"create": "New dynamic section",
"remove": "Remove dynamic section"
},
"option": {
"title": {
"label": "Title"
},
"customCssClasses": {
"label": "Custom css classes"
},
"borderColor": {
"label": "Border color"
}
},
"remove": {
"title": "Remove dynamic section",
"message": "Are you sure you want to remove this dynamic section? Items will be moved at the same location in the parent section."
}
},
"category": {
"field": {
"name": {
"label": "Name"
}
},
"action": {
"create": "New category",
"edit": "Rename category",
"remove": "Remove category",
"moveUp": "Move up",
"moveDown": "Move down",
"createAbove": "New category above",
"createBelow": "New category below",
"openAllInNewTabs": "Open all in tabs"
},
"create": {
"title": "New category",
"submit": "Add category"
},
"remove": {
"title": "Remove category",
"message": "Are you sure you want to remove the category {name}?"
},
"edit": {
"title": "Rename category",
"submit": "Rename category"
},
"menu": {
"label": {
"create": "New category",
"changePosition": "Change position",
"settings": "Settings"
}
},
"openAllInNewTabs": {
"title": "Open all in tabs",
"text": "Some browsers may block the bulk-opening of tabs for security reasons. Homarr was unable to open all windows, because your browser blocked this action. Please allow \"Open pop-up windows\" and re-try."
}
}
},
"item": {
@@ -1930,12 +2143,25 @@
"error": "Error",
"loading": "Loading",
"idle": "Idle"
},
"notification": {
"saveError": {
"title": "Unable to save widget",
"message": "The widget changes were rolled back"
}
}
},
"create": {
"title": "Choose item to add",
"search": "Filter items",
"addToBoard": "Add to board"
"addToBoard": "Add to board",
"addAtPosition": "Add item here",
"notification": {
"error": {
"title": "Unable to add item",
"message": "The item could not be added to this board"
}
}
},
"moveResize": {
"title": "Move / resize item",
@@ -1999,6 +2225,11 @@
"remove": {
"title": "Remove item",
"message": "Are you sure you want to remove this item?"
},
"advancedFocus": {
"open": "Open advanced view",
"close": "Close advanced view",
"label": "{widget} advanced view"
}
},
"widget": {
@@ -2103,6 +2334,32 @@
"showBroadcastMessage": {
"label": "Show broadcast messages"
}
},
"noProjectSelected": "No project selected",
"runnerStatus": "Runner: {status}",
"updatedAt": "Updated {date}",
"projectId": "Project: {id}",
"sessionReceived": "Session received: {value}",
"sessionSent": "Session sent: {value}",
"sessionId": "Session: {id}",
"startedAt": "Started {date}",
"emptyItems": "No active or recent jobs in this snapshot",
"metric": {
"running": "Running",
"completed": "Done",
"failed": "Failed",
"canceled": "Canceled",
"download": "Download",
"upload": "Upload"
},
"status": {
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"canceled": "Canceled",
"stopped": "Stopped",
"idle": "Idle",
"unknown": "Unknown"
}
},
"wud": {
@@ -2169,7 +2426,12 @@
"dnsQueriesToday": "Queries today",
"domainsBeingBlocked": "Domains on blocklist"
},
"domainsTooltip": "Due to multiple integrations Homarr can't calculate the exact number of domains being blocked"
"domainsTooltip": "Due to multiple integrations Homarr can't calculate the exact number of domains being blocked",
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
"unknown": "Unknown"
}
},
"dnsHoleControls": {
"name": "DNS Hole Controls",
@@ -2194,7 +2456,8 @@
}
},
"error": {
"internalServerError": "Failed to control DNS Hole"
"internalServerError": "Failed to control DNS Hole",
"bulkActionsFailed": "{count, plural, one {# action failed} other {# actions failed}}"
},
"controls": {
"enableAll": "Enable All",
@@ -2288,11 +2551,20 @@
"noStation": "No station selected",
"option": {
"baseUrl": {
"label": "Search.ch URL"
"label": "Search.ch URL",
"description": "Custom URLs must resolve to a public IP address. Private and local hosts are rejected."
},
"station": {
"label": "Station"
}
},
"error": {
"customSourceUnavailable": "The timetable source is unavailable. Check that its URL resolves to a public IP address.",
"stationSearch": "Unable to search stations. Check the custom URL and your board access."
},
"warning": {
"stale": "The timetable could not be refreshed. Showing departures last updated at {time}.",
"staleCompact": "Stale data"
}
},
"notebook": {
@@ -2365,7 +2637,9 @@
"discard": "Discard changes",
"keepEditing": "Keep editing"
}
}
},
"saveFailed": "Unable to save note",
"documentStats": "{words, plural, one {# word} other {# words}} · {characters, plural, one {# character} other {# characters}}"
},
"anchorNote": {
"name": "Anchor Note",
@@ -2393,7 +2667,17 @@
"cancel": "Cancel",
"readOnlyViewer": "You have viewer access to this note. Editing is disabled.",
"saveForbidden": "You do not have permission to edit this note.",
"saveFailed": "Failed to save this note. Please try again."
"saveFailed": "Failed to save this note. Please try again.",
"createdAt": "Created {date}",
"permission": {
"owner": "Owner",
"editor": "Editor",
"viewer": "Viewer"
},
"status": {
"pinned": "Pinned",
"archived": "Archived"
}
},
"iframe": {
"name": "iFrame",
@@ -2452,6 +2736,26 @@
"clickable": {
"label": "Clickable"
}
},
"state": {
"on": "On",
"off": "Off",
"unavailable": "Unavailable",
"unknown": "Unknown"
},
"advanced": {
"entityId": "Entity ID: {id}",
"lastChanged": "Changed {date}",
"lastUpdated": "Updated {date}",
"attribute": {
"unit": "Unit",
"deviceClass": "Device class",
"icon": "Icon"
}
},
"error": {
"loadFailed": "Unable to load entity",
"toggleFailed": "Unable to update entity"
}
},
"smartHome-executeAutomation": {
@@ -2467,11 +2771,32 @@
},
"spotlightAction": {
"run": "Run {name}"
},
"advanced": {
"lastExecuted": "Last run at {time}",
"automationId": "Automation ID: {id}",
"ready": "Ready to run",
"noPermission": "No permission to run this automation"
},
"status": {
"running": "Running automation",
"success": "Automation completed"
},
"error": {
"executeFailed": "Unable to run automation"
}
},
"stockPrice": {
"name": "Stock Price",
"description": "Displays the current stock price of a company",
"advanced": {
"currentPrice": "Current price",
"previousClose": "Previous close",
"change": "Change",
"changePercentage": "Change (%)",
"minimum": "Minimum",
"maximum": "Maximum"
},
"option": {
"stock": {
"label": "Stock symbol"
@@ -2569,6 +2894,13 @@
"showUnmonitored": {
"label": "Show unmonitored"
}
},
"advanced": {
"previousMonth": "Show previous month",
"nextMonth": "Show next month",
"eventCount": "{count, plural, one {# event} other {# events}}",
"loading": "Loading calendar events...",
"noEvents": "No events in this month"
}
},
"weather": {
@@ -2629,6 +2961,18 @@
"thunderstorm": "Thunderstorm",
"thunderstormWithHail": "Thunderstorm with hail",
"unknown": "Unknown"
},
"details": "Show weather details",
"detailsFor": "Show weather details for {date}",
"advanced": {
"forecast": "7-day forecast",
"day": "Day",
"conditions": "Conditions",
"temperature": "High / low",
"humidity": "Humidity",
"wind": "Wind / gusts ({unit})",
"sun": "Sunrise / sunset",
"noForecast": "No forecast data available"
}
},
"indexerManager": {
@@ -2641,6 +2985,11 @@
},
"title": "Indexer manager",
"testAll": "Test all",
"status": {
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"disabled": "Disabled"
},
"error": {
"internalServerError": "Failed to fetch indexers status"
}
@@ -2698,7 +3047,10 @@
"minutes": "{count} minutes",
"used": "Used",
"available": "Available",
"lastSeen": "Last status update: {lastSeen}"
"lastSeen": "Last status update: {lastSeen}",
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
"rebootRequired": "Reboot required",
"rebootNotRequired": "No reboot required"
},
"memory": {},
"error": {
@@ -2816,17 +3168,50 @@
"internalServerError": "Failed to fetch Coolify data"
},
"server": {
"buildServer": "Build"
"buildServer": "Build",
"resourceCounts": "{applications, plural, one {# app} other {# apps}} / {services, plural, one {# service} other {# services}}",
"reachability": "Reachability",
"usability": "Usability"
},
"source": {
"url": "Source: {url}",
"unavailable": "{source}: unavailable"
},
"instances": {
"title": "Instances"
},
"action": {
"openResource": "Open {name}",
"openLogs": "Open {name} logs",
"showIp": "Show IP address",
"hideIp": "Hide IP address"
},
"status": {
"running": "Running",
"stopped": "Stopped",
"exited": "Exited",
"starting": "Starting",
"restarting": "Restarting",
"unknown": "Unknown",
"online": "Online",
"offline": "Offline",
"reachable": "Reachable",
"unreachable": "Unreachable",
"usable": "Usable",
"unusable": "Unusable"
}
},
"vpn": {
"name": "VPN",
"description": "Overview of your VPN connections",
"serviceUnavailable": "VPN unavailable",
"option": {}
"option": {},
"status": {
"running": "Running",
"notRunning": "Not running",
"unavailable": "Unavailable",
"vpn": "VPN: {status}"
}
},
"ups": {
"name": "UPS",
@@ -2853,7 +3238,13 @@
"runtime": "Runtime",
"load": "Load",
"input": "Input",
"output": "Output"
"output": "Output",
"batteryVoltage": "Battery voltage",
"power": "Power",
"temperature": "Temperature",
"manufacturer": "Manufacturer",
"model": "Model",
"serial": "Serial"
},
"error": {
"internalServerError": "Failed to fetch UPS data"
@@ -3124,7 +3515,13 @@
"resolution": "Resolution",
"target": "Target codec"
}
}
},
"unknownUser": "Unknown user"
},
"summary": {
"sessions": "{count, plural, one {# session} other {# sessions}}",
"playing": "{count} playing",
"transcoding": "{count} transcoding"
}
},
"paperlessNgx": {
@@ -3337,6 +3734,9 @@
"movies": "Missing Movies",
"providers": "Provider Issues",
"status": "Health Issues",
"sonarrSignalr": "Sonarr SignalR",
"radarrSignalr": "Radarr SignalR",
"announcements": "Announcements",
"option": {
"showMissingEpisodes": {
"label": "Show missing episodes",
@@ -3383,7 +3783,8 @@
"users": "Users",
"photos": "Photos",
"videos": "Videos",
"storage": "Used Storage"
"storage": "Used Storage",
"albumLimit": "Largest albums (up to {count} shown)"
},
"immich-albumCarousel": {
"name": "Immich Album",
@@ -3407,7 +3808,16 @@
}
},
"description": "Slideshow of pictures from an Immich album or your full library",
"noPhotos": "No photos found"
"noPhotos": "No photos found",
"albumPhoto": "Album photo",
"actions": {
"previousPhoto": "Previous photo",
"nextPhoto": "Next photo",
"pauseSlideshow": "Pause slideshow",
"resumeSlideshow": "Resume slideshow",
"toggleSlideshow": "Pause or resume slideshow",
"photo": "Photo {number}"
}
},
"downloads": {
"name": "Download Client",
@@ -3782,6 +4192,13 @@
"showPosterImage": {
"label": "Show poster image"
}
},
"error": {
"allFeedsFailed": "Unable to load RSS feeds. Check the feed URLs and try again."
},
"warning": {
"partial": "{count, plural, one {# RSS feed could not be loaded} other {# RSS feeds could not be loaded}}",
"stale": "RSS feeds could not be refreshed. Showing previously loaded entries."
}
},
"releases": {
@@ -3912,14 +4329,31 @@
"noProviderResponse": "No response from provider",
"unexpected": "Unexpected provider error"
}
}
},
"expandDetails": "Show details for {name}",
"collapseDetails": "Hide details for {name}"
},
"networkControllerSummary": {
"option": {},
"advanced": {
"updated": "Updated",
"metric": {
"latency": "Latency",
"ping": "Ping",
"uptime": "Uptime",
"users": "Users",
"guests": "Guests"
}
},
"card": {
"vpn": {
"countConnected": "{count} connected"
}
"countConnected": "{count, plural, one {# connected} other {# connected}}",
"label": "VPN"
},
"wan": "WAN",
"web": "Web",
"wifi": "Wi-Fi",
"lan": "LAN"
},
"error": {
"integrationsDisconnected": "No data available, all integrations disconnected",
@@ -3929,6 +4363,9 @@
"description": "Displays the summary of a Network Controller (like UniFi Controller)"
},
"networkControllerStatus": {
"advanced": {
"updatedAt": "Updated {date}"
},
"card": {
"variants": {
"wired": {
@@ -3997,6 +4434,16 @@
"recv": "Received",
"title": "Network Interfaces"
}
},
"empty": {
"noInterfaces": "No network interface data yet"
},
"versionUnknown": "Unknown version",
"metricAccessible": "{metric}: {value}%, {status}",
"status": {
"normal": "Normal",
"warning": "Warning",
"critical": "Critical"
}
},
"notifications": {
@@ -4046,6 +4493,12 @@
"noSectionsEnabled": "Enable at least one section in widget options.",
"error": {
"internalServerError": "Failed to fetch speedtest data"
},
"range": {
"twelveHours": "12h",
"oneDay": "24h",
"all": "All",
"lastThirty": "Last 30"
}
},
"uptimeKuma": {
@@ -4086,6 +4539,11 @@
},
"error": {
"internalServerError": "Failed to fetch uptime data"
},
"status": {
"up": "Up",
"down": "Down",
"paused": "Paused"
}
},
"traefik": {
@@ -4120,7 +4578,20 @@
},
"error": {
"internalServerError": "Failed to fetch Traefik data"
}
},
"resourceType": {
"router": "Router",
"service": "Service",
"middleware": "Middleware"
},
"resourceStatus": {
"enabled": "Enabled",
"disabled": "Disabled",
"warning": "Warning",
"error": "Error",
"unknown": "Unknown"
},
"failedEndpoint": "Failed: {endpoint}"
},
"audioStats": {
"name": "Audio stats",
@@ -4242,7 +4713,9 @@
}
},
"status": {
"unhealthy": "Unhealthy"
"unhealthy": "Unhealthy",
"smart": "SMART",
"free": "{percentage}% free"
}
},
"customApi": {
@@ -4363,6 +4836,12 @@
},
"column": {
"system": "System",
"source": "Source",
"hostname": "Hostname",
"os": "OS",
"cpuModel": "CPU model",
"cores": "Cores",
"memoryTotal": "Memory total",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
@@ -4423,6 +4902,14 @@
"empty": {
"noSystems": "No systems found"
},
"metadata": {
"hostname": "Hostname",
"os": "OS",
"cpuModel": "CPU model",
"cores": "Cores",
"memoryTotal": "Memory total",
"source": "Source"
},
"metric": {
"cpu": "CPU",
"memory": "Memory",
@@ -4503,7 +4990,9 @@
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
"switchToHistorical": "Switch to historical view",
"systemNotFound": "Selected system was not found. Choose a different system."
"systemNotFound": "Selected system was not found. Choose a different system.",
"selectionSaveTitle": "Unable to save selection",
"selectionSaveMessage": "Your system or time period selection could not be saved. Please try again."
},
"chart": {
"cpu": {
@@ -4551,6 +5040,14 @@
"title": "Docker Network I/O",
"subtitle": "Network traffic of docker containers"
}
},
"period": {
"live": "Live",
"oneHour": "1h",
"twelveHours": "12h",
"oneDay": "24h",
"oneWeek": "1w",
"thirtyDays": "30d"
}
}
},
+1
View File
@@ -9,6 +9,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
initialOptions: Record<string, unknown>;
itemId?: string;
boardId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
@@ -17,7 +17,13 @@ export interface DynamicSelectOption {
label: string;
}
export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"dynamicSelect">) => {
export const WidgetDynamicSelectInput = ({
property,
kind,
options,
itemId,
boardId,
}: CommonWidgetInputProps<"dynamicSelect">) => {
const t = useI18n();
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -28,6 +34,8 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
debouncedSearch,
form.values.integrationIds,
form.values.options,
itemId,
boardId,
);
const currentOption = inputProps.value as DynamicSelectOption | null;
const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void;
+115 -21
View File
@@ -3,13 +3,14 @@
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import dynamic from "next/dynamic";
import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
import { Badge, Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
import { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type ReactQuillComponent from "react-quill-new";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
@@ -106,6 +107,13 @@ const stringifyDelta = (delta: unknown): string => {
return JSON.stringify(emptyDelta());
};
export const storedContentToPlainText = (content?: string | null): string => {
return parseStoredContent(content)
.ops.map(({ insert }) => (typeof insert === "string" ? insert : ""))
.join("")
.trim();
};
const canEditPermission = (permission: AnchorNotePermission) => {
return permission === "owner" || permission === "editor";
};
@@ -119,7 +127,14 @@ const isForbiddenError = (error: unknown): boolean => {
return (data as { code?: unknown }).code === "FORBIDDEN";
};
export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) {
export default function AnchorNoteWidget({
options,
integrationIds,
width,
height,
displayMode,
widgetStateRef,
}: WidgetComponentProps<"anchorNote">) {
const t = useScopedI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
@@ -134,27 +149,74 @@ export default function AnchorNoteWidget({ options, integrationIds }: WidgetComp
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
return <AnchorNoteWidgetContent options={options} integrationId={integrationId} noteId={noteId} />;
return (
<AnchorNoteWidgetContent
options={options}
integrationId={integrationId}
noteId={noteId}
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
widgetStateRef={widgetStateRef}
/>
);
}
interface AnchorNotePersistedState {
integrationId: string;
noteId: string;
isEditing: boolean;
draftTitle: string;
draftContent: string;
saveError: string | null;
}
interface AnchorNoteWidgetContentProps {
options: WidgetComponentProps<"anchorNote">["options"];
integrationId: string;
noteId: string;
width: number;
height: number;
isAdvanced: boolean;
widgetStateRef?: WidgetComponentProps<"anchorNote">["widgetStateRef"];
}
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
const AnchorNoteWidgetContent = ({
options,
integrationId,
noteId,
width,
height,
isAdvanced,
widgetStateRef,
}: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const persistedState = widgetStateRef?.current?.anchorNote as AnchorNotePersistedState | undefined;
const canHydratePersistedState = persistedState?.integrationId === integrationId && persistedState.noteId === noteId;
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
noteId,
});
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState(note?.title ?? "");
const [draftContent, setDraftContent] = useState(note?.content ?? "");
const [saveError, setSaveError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false);
const [draftTitle, setDraftTitle] = useState(
canHydratePersistedState ? (persistedState?.draftTitle ?? "") : (note?.title ?? ""),
);
const [draftContent, setDraftContent] = useState(
canHydratePersistedState ? (persistedState?.draftContent ?? "") : (note?.content ?? ""),
);
const [saveError, setSaveError] = useState<string | null>(
canHydratePersistedState ? (persistedState?.saveError ?? null) : null,
);
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
anchorNote: { integrationId, noteId, isEditing, draftTitle, draftContent, saveError },
};
}, [draftContent, draftTitle, integrationId, isEditing, noteId, saveError, widgetStateRef]);
useEffect(() => {
if (isEditing || !note) return;
@@ -165,7 +227,8 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
});
}, [isEditing, note]);
const canEdit = canEditPermission(note?.permission ?? "viewer");
const hasInteractAccess = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
const canEdit = canEditPermission(note?.permission ?? "viewer") && hasInteractAccess;
const isViewer = !note || note.permission === "viewer";
const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]);
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
@@ -178,6 +241,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
const handleEditorChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
@@ -260,7 +324,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
if (!note) return <WidgetEmptyState />;
return (
<Stack h="100%" gap="xs" p="sm">
<Stack h="100%" gap="xs" p={height < 120 ? "xs" : "sm"}>
<Group justify="space-between" align="flex-start">
<Stack gap={2} style={{ flex: 1 }}>
{isEditing ? (
@@ -278,6 +342,22 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
{t("readOnlyViewer")}
</Text>
)}
{!isEditing && isAdvanced && (
<Group gap={4}>
<Badge size="xs" variant="light">
{t(`permission.${note.permission}`)}
</Badge>
{note.isPinned && <Badge size="xs">{t("status.pinned")}</Badge>}
{note.isArchived && (
<Badge size="xs" color="gray">
{t("status.archived")}
</Badge>
)}
<Text size="xs" c="dimmed">
{t("createdAt", { date: new Date(note.createdAt).toLocaleDateString() })}
</Text>
</Group>
)}
{saveError && (
<Text size="xs" c="red">
{saveError}
@@ -301,17 +381,31 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
)}
</Group>
</Group>
<div className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`} style={{ flex: 1 }}>
<ReactQuill
theme="snow"
readOnly={!isEditing}
value={isEditing ? editorValue : readOnlyValue}
onChange={handleEditorChange}
modules={isEditing ? quillModules : readOnlyModules}
formats={quillFormats}
placeholder={t("emptyContent")}
/>
</div>
{isEditing || isAdvanced ? (
<div
className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`}
style={{ flex: 1, minHeight: 0 }}
>
<ReactQuill
theme="snow"
readOnly={!isEditing}
value={isEditing ? editorValue : readOnlyValue}
onChange={handleEditorChange}
modules={isEditing ? quillModules : readOnlyModules}
formats={quillFormats}
placeholder={t("emptyContent")}
/>
</div>
) : (
<Text
size={width < 180 || height < 100 ? "xs" : "sm"}
c={plainText ? undefined : "dimmed"}
style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}
lineClamp={Math.max(2, Math.floor((height - 70) / 18))}
>
{plainText || t("emptyContent")}
</Text>
)}
</Stack>
);
};
@@ -5,7 +5,9 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
supportsAdvancedFocus: true,
queryKey: [["widget", "anchorNotes"]],
maxIntegrations: 1,
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
+19 -5
View File
@@ -19,7 +19,7 @@ import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) {
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
@@ -51,6 +51,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isAdvanced = displayMode === "advanced";
const isColumnLayout = options.layout.startsWith("column");
return (
@@ -83,7 +84,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
{(options.showTitle || isAdvanced) && (
<Text
className="app-title"
fw={700}
@@ -93,13 +94,13 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && (
{(options.descriptionDisplayMode === "normal" || isAdvanced) && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={4}
lineClamp={isAdvanced ? 10 : Math.max(1, Math.floor((height - 48) / 18))}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
@@ -109,6 +110,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
))}
</Text>
)}
{isAdvanced && app.href && (
<Text size="xs" c="dimmed" lineClamp={1}>
{getDisplayHost(app.href)}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
@@ -119,7 +125,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : "50%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
}}
/>
</Flex>
@@ -154,3 +160,11 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
) : (
children
);
const getDisplayHost = (href: string) => {
try {
return new URL(href).host;
} catch {
return href;
}
};
@@ -1,16 +1,20 @@
"use client";
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const integrationId = integrationIds[0];
@@ -18,23 +22,39 @@ export default function ArchiveTeamWarriorWidget({
return null;
}
return <ArchiveTeamWarriorWidgetContent integrationId={integrationId} options={options} />;
return (
<ArchiveTeamWarriorWidgetContent
integrationId={integrationId}
options={options}
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
/>
);
}
const ArchiveTeamWarriorWidgetContent = ({
integrationId,
options,
width,
height,
isAdvanced,
}: {
integrationId: string;
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
width: number;
height: number;
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.archiveTeamWarrior");
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
const projectName = status.project?.title ?? status.selectedProject ?? "No project selected";
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
const statusKey = getStatusKey(status.status);
return (
<Stack p="xs" gap="xs" h="100%">
<Stack p="xs" gap="xs" h="100%" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}>
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
@@ -43,12 +63,12 @@ const ArchiveTeamWarriorWidgetContent = ({
</Text>
</Group>
<Badge size="sm" color={status.status === "Running" ? "green" : "gray"}>
{status.status}
<Badge size="sm" color={getStatusColor(statusKey)}>
{t(`status.${statusKey}`)}
</Badge>
</Group>
{options.showBroadcastMessage && status.broadcastMessage && (
{options.showBroadcastMessage && status.broadcastMessage && (isAdvanced || height >= 160) && (
<Card withBorder p="xs">
<Text size="xs" lineClamp={3}>
{status.broadcastMessage}
@@ -56,19 +76,52 @@ const ArchiveTeamWarriorWidgetContent = ({
</Card>
)}
<SimpleGrid cols={2} spacing="xs">
<Metric label="Running" value={status.counts.running} />
<Metric label="Done" value={status.counts.completed} />
<Metric label="Failed" value={status.counts.failed} />
<Metric label="Canceled" value={status.counts.canceled} />
<SimpleGrid cols={width < 180 || height < 120 ? 4 : 2} spacing="xs">
<Metric label={t("metric.running")} value={status.counts.running} />
<Metric label={t("metric.completed")} value={status.counts.completed} />
<Metric label={t("metric.failed")} value={status.counts.failed} />
<Metric label={t("metric.canceled")} value={status.counts.canceled} />
</SimpleGrid>
{status.bandwidth && (
<SimpleGrid cols={2} spacing="xs">
<Metric label="Download" value={formatBandwidth(status.bandwidth.receiving)} />
<Metric label="Upload" value={formatBandwidth(status.bandwidth.sending)} />
<Metric label={t("metric.download")} value={formatBandwidth(status.bandwidth.receiving)} />
<Metric label={t("metric.upload")} value={formatBandwidth(status.bandwidth.sending)} />
</SimpleGrid>
)}
{isAdvanced && (
<>
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
</Text>
<Text size="xs" c="dimmed">
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString() })}
</Text>
</Group>
<ScrollArea style={{ flex: 1 }}>
<Stack gap={4}>
{status.items.map((item) => (
<Card key={item.id} withBorder p="xs">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text size="sm" fw={600} lineClamp={1}>
{item.name}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{item.project ?? projectName}
</Text>
</Stack>
<Badge size="xs" color={getStatusColor(item.status)}>
{t(`status.${item.status}`)}
</Badge>
</Group>
</Card>
))}
</Stack>
</ScrollArea>
</>
)}
</Stack>
);
};
@@ -85,3 +138,33 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
);
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
const getStatusKey = (status?: string | null): WarriorStatusKey => {
switch (status?.trim().toLowerCase()) {
case "running":
return "running";
case "done":
case "completed":
return "completed";
case "failed":
return "failed";
case "canceled":
case "cancelled":
return "canceled";
case "stopped":
return "stopped";
case "idle":
return "idle";
default:
return "unknown";
}
};
const getStatusColor = (status: WarriorStatusKey): string => {
if (status === "running") return "green";
if (status === "failed") return "red";
if (status === "canceled" || status === "stopped") return "yellow";
return "gray";
};
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
queryKey: [["widget", "archiveTeamWarrior"]],
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showBroadcastMessage: factory.switch({
+76 -2
View File
@@ -1,17 +1,91 @@
"use client";
import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatBytes, formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
export default function AudioStatsWidget({
integrationIds,
options,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"audioStats">) {
const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
{ integrationIds, showOnlyPlaying: false },
{ enabled: displayMode === "advanced" && response?.kind === "navidrome" },
);
if (!response) return <WidgetEmptyState />;
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
const summary = (
<AudioStatsContent
backend={response.kind}
stats={response.data}
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
width={width}
/>
);
if (displayMode === "compact") return summary;
const sessions = streamResults.flatMap((result) =>
result.sessions.map((session) => ({ integrationId: result.integrationId, session })),
);
const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null;
return (
<Stack h="100%" gap="lg" p="md">
<div style={{ minHeight: 150 }}>{summary}</div>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
{response.kind === "navidrome" ? (
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="xs">
{sessions.map(({ integrationId, session }) => {
const currentlyPlaying = session.currentlyPlaying;
if (!currentlyPlaying) return null;
return (
<Group key={`${integrationId}:${session.sessionId}`} justify="space-between" wrap="nowrap" p="sm">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{currentlyPlaying.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
</Text>
</Stack>
<Badge variant="light">{session.sessionName}</Badge>
</Group>
);
})}
</SimpleGrid>
) : (
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="xs">
{(audiobookStats?.libraries ?? []).map((library) => (
<Stack key={library.id} gap={2} p="sm">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{library.name}
</Text>
<Badge size="xs" variant="light">
{library.totalItems}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{library.totalDurationSeconds === null ? "—" : formatDuration(library.totalDurationSeconds * 1000)} ·{" "}
{library.totalSizeBytes === null ? "—" : formatBytes(library.totalSizeBytes)}
</Text>
</Stack>
))}
</SimpleGrid>
)}
</ScrollArea>
</Stack>
);
}
@@ -13,6 +13,7 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
queryKeys: [[["widget", "audioStats"]], [["widget", "mediaServer"]]],
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
+19 -8
View File
@@ -36,7 +36,13 @@ const iconSizeByWidth = [
{ minWidth: 0, size: 16 },
] as const;
export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) {
export default function BazarrWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"bazarr">) {
const t = useScopedI18n("widget.bazarr");
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
@@ -53,11 +59,11 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
} as const;
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const gridCols = getGridCols(width);
const iconSize = getIconSize(width);
const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced");
const iconSize = getIconSize(Math.min(width, height));
if (visibleStatKeys.length === 0) {
return (
@@ -92,12 +98,17 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
);
}
function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number {
if (itemCount <= 1) return 1;
if (isAdvanced && width >= 640) return Math.min(itemCount, 4);
const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
const rowsAtWidth = Math.ceil(itemCount / widthMatch);
const rowHeight = height / rowsAtWidth;
return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2));
}
function getIconSize(width: number): number {
export function getIconSize(width: number): number {
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 16;
}
+2
View File
@@ -5,8 +5,10 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
icon: IconBadgeCc,
queryKey: [["widget", "bazarr"]],
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showMissingEpisodes: factory.switch({
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { getGridCols, getIconSize } from "./component";
describe("Bazarr responsive layout", () => {
test("uses two columns for a short widget so all stats remain visible", () => {
expect(getGridCols(220, 100, 4)).toBe(2);
});
test("uses four columns in a wide advanced surface", () => {
expect(getGridCols(800, 400, 4, true)).toBe(4);
});
test("sizes icons from the constrained dimension", () => {
expect(getIconSize(180)).toBe(16);
expect(getIconSize(340)).toBe(22);
});
});
@@ -35,6 +35,8 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
height,
displayMode = "compact",
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
@@ -67,6 +69,8 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260;
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
if (alertsError) throw alertsError;
@@ -117,9 +121,9 @@ export default function BeszelAlertsWidget({
</Stack>
)}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
{triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && <Divider />}
{okAlerts.length > 0 && (
{showOkAlerts && okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
@@ -140,7 +144,7 @@ export default function BeszelAlertsWidget({
</Stack>
)}
{options.showHistory && history.length > 0 && (
{showHistory && history.length > 0 && (
<>
<Divider />
<Stack gap={6}>
@@ -330,7 +330,9 @@ const metricRenderers = [
] as const;
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options));
const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
return (
<Card
@@ -362,6 +364,11 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
</Stack>
{hiddenMetricCount > 0 && (
<Text size="10px" c="dimmed" ta="right">
+{hiddenMetricCount}
</Text>
)}
</Card>
);
};
@@ -372,6 +379,7 @@ export default function BeszelSystemGridWidget({
isEditMode,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard();
@@ -411,14 +419,18 @@ export default function BeszelSystemGridWidget({
);
}
const cols = getColCount(width, height, filteredSystems.length);
const advanced = displayMode === "advanced";
const cols = advanced
? Math.min(filteredSystems.length, width >= 900 ? 2 : 1)
: getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight;
const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT;
const scrollEnabled = rawCellHeight < minimumCellHeight;
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
const maxMetrics = advanced ? metricRenderers.length : getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
@@ -428,7 +440,7 @@ export default function BeszelSystemGridWidget({
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo } from "react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -16,12 +16,15 @@ import type { WidgetComponentProps } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
height,
displayMode = "compact",
boardId,
itemId,
setOptions,
@@ -37,13 +40,11 @@ export default function BeszelSystemStatsWidget({
error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
const selectedValue = selectedSystem?.value ?? "";
const selectedLabel = selectedSystem?.label;
const systemExists = selectedSystem !== undefined;
const handleSelectSystem = useCallback(
(value: string) => {
@@ -55,6 +56,16 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
const handleTimePeriod = useCallback(
(value: string) => {
setOptions({ newOptions: { timePeriod: value as BeszelTimePeriod } });
if (hasChangeAccess && boardId && itemId) {
saveItemOptions({ boardId, itemId, newOptions: { timePeriod: value } });
}
},
[boardId, hasChangeAccess, itemId, saveItemOptions, setOptions],
);
if (systemsError) throw systemsError;
if (systemsPending)
return (
@@ -90,7 +101,7 @@ export default function BeszelSystemStatsWidget({
<Select
size="xs"
data={systems}
value={selectedSystem}
value={selectedValue}
placeholder={t("selectSystem")}
onChange={(value) => value && handleSelectSystem(value)}
/>
@@ -127,8 +138,8 @@ export default function BeszelSystemStatsWidget({
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedSystem ? 600 : 400}
c={system.value !== selectedSystem ? "dimmed" : undefined}
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
@@ -137,20 +148,35 @@ export default function BeszelSystemStatsWidget({
</Menu.Dropdown>
</Menu>
)}
{!isEditMode && displayMode === "advanced" && (
<SegmentedControl
size="xs"
value={options.timePeriod}
onChange={handleTimePeriod}
data={[
{ value: "1m", label: t("period.live") },
{ value: "1h", label: t("period.oneHour") },
{ value: "12h", label: t("period.twelveHours") },
{ value: "24h", label: t("period.oneDay") },
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
]}
/>
)}
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
columns={displayMode === "advanced" || width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDiskIO,
network: options.showNetwork,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
diskIO: options.showDiskIO && (displayMode === "advanced" || height >= 260),
network: options.showNetwork && (displayMode === "advanced" || height >= 260),
dockerCpu: options.showDockerCpu && (displayMode === "advanced" || height >= 380),
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
@@ -4,6 +4,7 @@ import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { createBeszelSystemChoices } from "./selection";
const timePeriodOptions = [
{ value: "1m", label: "Live" },
@@ -30,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
isPending,
isError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { enabled: integrationIds.length > 0 });
const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name })));
const selectData = createBeszelSystemChoices(data).map(({ value, label }) => ({ value, label }));
return { data: selectData, isPending, isError };
},
}),
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
const choices = createBeszelSystemChoices([
{ integrationId: "one", integrationName: "Primary", systems: [{ id: "same", name: "Host A" }] },
{ integrationId: "two", integrationName: "Backup", systems: [{ id: "same", name: "Host B" }] },
]);
describe("Beszel system selection", () => {
it("uses a composite integration and system identity", () => {
expect(choices.map((choice) => choice.value)).toEqual(["one:same", "two:same"]);
expect(resolveBeszelSystemChoice(choices, "two:same")?.integrationId).toBe("two");
});
it("keeps legacy plain system ids working", () => {
expect(resolveBeszelSystemChoice(choices, "same")?.integrationId).toBe("one");
});
});
@@ -0,0 +1,32 @@
export interface BeszelSystemChoice {
value: string;
label: string;
integrationId: string;
systemId: string;
}
interface BeszelSystemsResult {
integrationId: string;
integrationName: string;
systems: { id: string; name: string }[];
}
export function createBeszelSystemChoices(results: BeszelSystemsResult[]): BeszelSystemChoice[] {
const includeIntegrationName = results.length > 1;
return results.flatMap((result) =>
result.systems.map((system) => ({
value: `${result.integrationId}:${system.id}`,
label: includeIntegrationName ? `${result.integrationName} · ${system.name}` : system.name,
integrationId: result.integrationId,
systemId: system.id,
})),
);
}
export function resolveBeszelSystemChoice(choices: BeszelSystemChoice[], storedValue: string) {
return (
choices.find((choice) => choice.value === storedValue) ??
choices.find((choice) => choice.systemId === storedValue) ??
choices[0]
);
}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import { Box, Center, Group, Loader, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import {
Activity,
@@ -20,12 +20,14 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { getUsableWidgetQueryData } from "../common/query-state";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
@@ -38,16 +40,24 @@ import {
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { getBeszelTableVisibleMetricKeys } from "./display";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
type SystemRowWithKey = BeszelSystemRow & { rowKey: string; integrationName: string };
const columnAccessors = [
"name",
"integrationName",
"hostname",
"osName",
"cpuModel",
"cores",
"memoryTotal",
"cpu",
"memory",
"disk",
@@ -59,7 +69,7 @@ const columnAccessors = [
"services",
"uptime",
"agentVersion",
] as const satisfies readonly (keyof BeszelSystemRow)[];
] as const satisfies readonly (keyof SystemRowWithKey)[];
interface SizeConfig {
iconSize: number;
@@ -96,18 +106,23 @@ export default function BeszelSystemTableWidget({
boardId,
itemId,
setOptions,
displayMode,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal);
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsData = getUsableWidgetQueryData(systemsQuery);
const results = useMemo(() => systemsData ?? [], [systemsData]);
const { isPending } = systemsQuery;
const isAdvanced = displayMode === "advanced";
const size = useMemo(() => getSizeConfig(width), [width]);
const visibleMetricKeys = useMemo(
() => getBeszelTableVisibleMetricKeys(options, width, isAdvanced),
[options, width, isAdvanced],
);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
@@ -125,6 +140,18 @@ export default function BeszelSystemTableWidget({
});
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const integrationNames = useMemo(
() => new Map(results.map((result) => [result.integrationId, result.integrationName])),
[results],
);
const systemsWithSource = useMemo(
() =>
filteredSystems.map((system) => ({
...system,
integrationName: integrationNames.get(system.rowKey.split(":")[0] ?? "") ?? "—",
})),
[filteredSystems, integrationNames],
);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
columnAccessor: options.sortBy,
@@ -138,16 +165,16 @@ export default function BeszelSystemTableWidget({
}, [options.sortBy, options.sortDirection]);
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
const accessor = sortStatus.columnAccessor as keyof SystemRowWithKey;
const dir = directionMultiplier[sortStatus.direction] ?? 1;
return [...filteredSystems].toSorted((a, b) => {
return [...systemsWithSource].toSorted((a, b) => {
const aVal = a[accessor] ?? 0;
const bVal = b[accessor] ?? 0;
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir;
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir;
return 0;
});
}, [filteredSystems, sortStatus]);
}, [systemsWithSource, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
@@ -178,14 +205,62 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
<Indicator color={statusColorMap[record.status]} size={7} />
<Box w={7} h={7} bg={statusColorMap[record.status]} style={{ borderRadius: "50%", flexShrink: 0 }} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
</Text>
</Group>
),
},
options.showCpu && {
isAdvanced && {
accessor: "integrationName",
width: 140,
ellipsis: true,
title: t("column.source"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.integrationName}</Text>,
},
isAdvanced && {
accessor: "hostname",
width: 150,
ellipsis: true,
title: t("column.hostname"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.hostname || "—"}</Text>,
},
isAdvanced && {
accessor: "osName",
width: 160,
ellipsis: true,
title: t("column.os"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.osName || "—"}</Text>,
},
isAdvanced && {
accessor: "cpuModel",
width: 220,
ellipsis: true,
title: t("column.cpuModel"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cpuModel || "—"}</Text>,
},
isAdvanced && {
accessor: "cores",
width: 80,
title: t("column.cores"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cores}</Text>,
},
isAdvanced && {
accessor: "memoryTotal",
width: 120,
title: t("column.memoryTotal"),
sortable: true,
render: (record) => (
<Text size={size.fontSize}>{record.memoryTotal > 0 ? formatBytes(record.memoryTotal) : "—"}</Text>
),
},
visibleMetricKeys.has("showCpu") && {
accessor: "cpu",
width: 140,
title: (
@@ -197,7 +272,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
},
options.showMemory && {
visibleMetricKeys.has("showMemory") && {
accessor: "memory",
width: 140,
title: (
@@ -209,7 +284,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
},
options.showDisk && {
visibleMetricKeys.has("showDisk") && {
accessor: "disk",
width: 160,
title: (
@@ -229,7 +304,7 @@ export default function BeszelSystemTableWidget({
/>
),
},
options.showGpu && {
visibleMetricKeys.has("showGpu") && {
accessor: "gpu",
width: 140,
title: (
@@ -241,7 +316,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
},
options.showLoadAvg && {
visibleMetricKeys.has("showLoadAvg") && {
accessor: "loadAvg",
width: 100,
title: (
@@ -253,12 +328,17 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Indicator color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"} size={7} />
<Box
w={7}
h={7}
bg={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"}
style={{ borderRadius: "50%", flexShrink: 0 }}
/>
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
</Group>
),
},
options.showNet && {
visibleMetricKeys.has("showNet") && {
accessor: "netBytes",
width: 100,
title: (
@@ -274,7 +354,7 @@ export default function BeszelSystemTableWidget({
</Text>
),
},
options.showTemp && {
visibleMetricKeys.has("showTemp") && {
accessor: "temp",
width: 80,
title: (
@@ -286,7 +366,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
},
options.showBattery && {
visibleMetricKeys.has("showBattery") && {
accessor: "battery",
width: 70,
title: (
@@ -297,7 +377,7 @@ export default function BeszelSystemTableWidget({
),
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
},
options.showServices && {
visibleMetricKeys.has("showServices") && {
accessor: "services",
width: 80,
title: (
@@ -309,7 +389,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
},
options.showUptime && {
visibleMetricKeys.has("showUptime") && {
accessor: "uptime",
width: 90,
title: (
@@ -321,7 +401,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
},
options.showAgent && {
visibleMetricKeys.has("showAgent") && {
accessor: "agentVersion",
width: 90,
title: (
@@ -336,21 +416,7 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [
options.showAgent,
options.showBattery,
options.showCpu,
options.showDisk,
options.showGpu,
options.showLoadAvg,
options.showMemory,
options.showNet,
options.showServices,
options.showTemp,
options.showUptime,
size,
t,
]);
}, [t, size, visibleMetricKeys, isAdvanced]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
@@ -364,14 +430,12 @@ export default function BeszelSystemTableWidget({
const handleRowClick = useCallback(
({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
const integrationId = record.rowKey.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
},
[openModal],
);
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
@@ -380,10 +444,13 @@ export default function BeszelSystemTableWidget({
);
}
return (
const table = (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
</div>
<HomarrDataTable
isEditMode={isEditMode}
@@ -394,10 +461,12 @@ export default function BeszelSystemTableWidget({
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
idAccessor="rowKey"
storeColumnsKey={storeKey}
onRowClick={isEditMode ? undefined : handleRowClick}
/>
</div>
);
return table;
}
+88 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
@@ -12,11 +12,11 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
export default function BookmarksWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
select(data) {
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
select(apps) {
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
@@ -48,7 +48,16 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
{options.title}
</Title>
)}
{(options.layout === "grid" || options.layout === "gridHorizontal") && (
{displayMode === "advanced" && (
<AdvancedBookmarksLayout
data={data}
width={width}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
{displayMode !== "advanced" && (options.layout === "grid" || options.layout === "gridHorizontal") && (
<GridLayout
data={data}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
@@ -60,7 +69,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
hasIconColor={board.iconColor !== null}
/>
)}
{options.layout !== "grid" && options.layout !== "gridHorizontal" && (
{displayMode !== "advanced" && options.layout !== "grid" && options.layout !== "gridHorizontal" && (
<FlexLayout
data={data}
direction={options.layout}
@@ -144,6 +153,77 @@ const FlexLayout = ({
);
};
interface AdvancedBookmarksLayoutProps {
data: RouterOutputs["app"]["byIds"];
width: number;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
}
export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number =>
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
export const getBookmarkHostname = (href: string | null): string | undefined => {
if (!href) return undefined;
try {
return new URL(href).hostname;
} catch {
return href;
}
};
const AdvancedBookmarksLayout = ({
data,
width,
openNewTab,
withBorder,
hasIconColor,
}: AdvancedBookmarksLayoutProps) => {
const board = useRequiredBoard();
return (
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
{data.map((app) => (
<UnstyledButton
key={app.id}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
>
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
<Group align="flex-start" wrap="nowrap">
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm" truncate="end">
{app.name}
</Text>
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{app.description}
</Text>
)}
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
</SimpleGrid>
</ScrollArea>
);
};
interface GridLayoutProps {
data: RouterOutputs["app"]["byIds"];
hideTitle: boolean;
@@ -247,7 +327,7 @@ const VerticalItem = ({
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
@@ -294,7 +374,7 @@ const HorizontalItem = ({
{!hideHostname && (
<Anchor component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
+2 -1
View File
@@ -10,7 +10,8 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
queryKeys: [[["app", "byIds"]]],
supportsAdvancedFocus: true,
queryKey: [["app", "byIds"]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { getAdvancedBookmarkColumns, getBookmarkHostname } from "./component";
describe("bookmark display helpers", () => {
test("uses bounded advanced columns", () => {
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
});
test("does not crash on malformed bookmark URLs", () => {
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
expect(getBookmarkHostname("not a URL")).toBe("not a URL");
expect(getBookmarkHostname(null)).toBeUndefined();
});
});
@@ -1,3 +1,4 @@
import { useId } from "react";
import {
Badge,
Box,
@@ -18,15 +19,25 @@ import { isNullOrWhitespace } from "@homarr/common";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { groupEventsByDate } from "./calendar-events";
import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
events: CalendarEvent[];
groupByDate?: boolean;
locale?: string;
}
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
export const CalendarEventList = ({ events, groupByDate = false, locale }: CalendarEventListProps) => {
const headingIdPrefix = useId();
const dateFormatter = new Intl.DateTimeFormat(locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const groups = groupByDate ? [...groupEventsByDate(events).entries()] : null;
return (
<ScrollArea
offsetScrollbars
@@ -38,129 +49,155 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
},
}}
>
<Stack>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
{event.image !== null && (
<Box pos="relative">
<Image
src={event.image.src}
w={70}
mah={150}
style={{
aspectRatio: event.image.aspectRatio
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
: "1/1",
}}
radius="sm"
fallbackSrc="https://placehold.co/400x400?text=No%20image"
/>
{event.image.badge !== undefined && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
{event.image.badge.content}
</Badge>
)}
</Box>
)}
<Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
<Stack gap={0}>
{event.subTitle !== null && (
<Text lineClamp={1} size="sm">
{event.subTitle}
</Text>
)}
<Text fw={"bold"} lineClamp={1} size="sm">
{event.title}
</Text>
</Stack>
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
<Text c="dimmed" size="sm">
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
</Text>
</Group>
)}
<Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
{t("widget.calendar.duration.allDay")}
</Text>
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.startDate).format("HH:mm")}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
</Text>
</>
)}
</>
)}
</Group>
</Group>
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
{event.location}
</Text>
</Group>
)}
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
{event.description}
<Stack gap="md">
{groups ? (
groups.map(([date, groupedEvents]) => {
const headingId = `${headingIdPrefix}-${date}`;
return (
<Box key={date} component="section" aria-labelledby={headingId}>
<Text id={headingId} component="h3" size="sm" fw={600} tt="capitalize">
{dateFormatter.format(groupedEvents[0]?.startDate)}
</Text>
)}
{event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => (
<Button
key={link.href}
component={"a"}
href={link.href.toString()}
target={"_blank"}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
))}
</Group>
)}
</Stack>
</Group>
))}
<Stack mt="xs">
<CalendarEventRows events={groupedEvents} />
</Stack>
</Box>
);
})
) : (
<CalendarEventRows events={events} />
)}
</Stack>
</ScrollArea>
);
};
const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
return (
<>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
{event.image !== null && (
<Box pos="relative">
<Image
src={event.image.src}
w={70}
mah={150}
style={{
aspectRatio: event.image.aspectRatio
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
: "1/1",
}}
radius="sm"
fallbackSrc="https://placehold.co/400x400?text=No%20image"
/>
{event.image.badge !== undefined && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
{event.image.badge.content}
</Badge>
)}
</Box>
)}
<Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
<Stack gap={0}>
{event.subTitle !== null && (
<Text lineClamp={1} size="sm">
{event.subTitle}
</Text>
)}
<Text fw={"bold"} lineClamp={1} size="sm">
{event.title}
</Text>
</Stack>
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
<Text c="dimmed" size="sm">
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
</Text>
</Group>
)}
<Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
{t("widget.calendar.duration.allDay")}
</Text>
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.startDate).format("HH:mm")}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
</Text>
</>
)}
</>
)}
</Group>
</Group>
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
{event.location}
</Text>
</Group>
)}
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
{event.description}
</Text>
)}
{event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => (
<Button
key={link.href}
component={"a"}
href={link.href.toString()}
target={"_blank"}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
))}
</Group>
)}
</Stack>
</Group>
))}
</>
);
};
const isAllDay = (event: Pick<CalendarEvent, "startDate" | "endDate">) => {
if (!event.endDate) return false;
@@ -0,0 +1,77 @@
import dayjs from "dayjs";
import type { CalendarEvent } from "@homarr/integrations/types";
export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD");
const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) =>
event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType);
export const getCalendarAgendaEvents = (
events: CalendarEvent[],
viewedMonth: Date,
releaseTypes: readonly string[],
): CalendarEvent[] => {
const monthStart = dayjs(viewedMonth).startOf("month");
const monthEnd = monthStart.add(1, "month");
return events
.filter((event) => {
if (!matchesReleaseType(event, releaseTypes)) return false;
const start = dayjs(event.startDate);
if (!event.endDate) return !start.isBefore(monthStart) && start.isBefore(monthEnd);
// Calendar intervals are half-open: an all-day event ending at midnight does not occupy the next day.
return start.isBefore(monthEnd) && dayjs(event.endDate).isAfter(monthStart);
})
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
};
export const groupEventsByDate = (events: CalendarEvent[]): Map<string, CalendarEvent[]> => {
const result = new Map<string, CalendarEvent[]>();
for (const event of events) {
const key = toCalendarDateKey(event.startDate);
const entries = result.get(key) ?? [];
entries.push(event);
result.set(key, entries);
}
return new Map(
[...result.entries()]
.toSorted(([dateA], [dateB]) => dateA.localeCompare(dateB))
.map(([date, entries]) => [
date,
entries.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()),
]),
);
};
/** Splits multi-day events into single-day calendar tiles. */
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
const splitEventEntries: CalendarEvent[] = [];
for (const event of events) {
if (!event.endDate) {
splitEventEntries.push(event);
continue;
}
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
splitEventEntries.push(event);
continue;
}
if (dayjs(event.startDate).isAfter(event.endDate)) continue;
let currentStart = dayjs(event.startDate);
while (currentStart.isBefore(event.endDate)) {
splitEventEntries.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
}
}
return splitEventEntries;
};
+40 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { CalendarEvent } from "@homarr/integrations/types";
import { splitEvents } from "./component";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents } from "./calendar-events";
describe("splitEvents should split multi-day events into multiple single-day events", () => {
test("2 day all-day event should be split up into two all-day events", () => {
@@ -53,6 +53,45 @@ describe("splitEvents should split multi-day events into multiple single-day eve
});
});
describe("groupEventsByDate", () => {
test("groups once per day and sorts each bucket", () => {
const later = createEvent(new Date(2025, 0, 1, 18));
const earlier = createEvent(new Date(2025, 0, 1, 9));
const nextDay = createEvent(new Date(2025, 0, 2, 9));
const result = groupEventsByDate([later, nextDay, earlier]);
expect(result.size).toBe(2);
expect([...result.values()][0]).toEqual([earlier, later]);
});
});
describe("getCalendarAgendaEvents", () => {
test("keeps overlapping original events once and excludes adjacent-month events", () => {
const spansIntoMonth = createEvent(new Date(2024, 11, 31, 22), new Date(2025, 0, 2, 8));
const inMonth = createEvent(new Date(2025, 0, 20, 9));
const endsAtMonthStart = createEvent(new Date(2024, 11, 31), new Date(2025, 0, 1));
const nextMonth = createEvent(new Date(2025, 1, 1));
const result = getCalendarAgendaEvents(
[nextMonth, spansIntoMonth, endsAtMonthStart, inMonth],
new Date(2025, 0, 15),
[],
);
expect(result).toEqual([spansIntoMonth, inMonth]);
});
test("applies Radarr release filters without splitting multi-day events", () => {
const cinema = createEvent(new Date(2025, 0, 5), new Date(2025, 0, 8));
cinema.metadata = { type: "radarr", releaseType: "inCinemas" };
const digital = createEvent(new Date(2025, 0, 7));
digital.metadata = { type: "radarr", releaseType: "digitalRelease" };
expect(getCalendarAgendaEvents([cinema, digital], new Date(2025, 0, 1), ["inCinemas"])).toEqual([cinema]);
});
});
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
title: "Test",
subTitle: null,
+123 -122
View File
@@ -1,8 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core";
import { Box, Stack, Text, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
@@ -14,10 +14,24 @@ import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
import { CalendarEventList } from "./calendar-event-list";
import classes from "./component.module.css";
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(new Date());
const persistedState = props.widgetStateRef?.current?.calendar as { monthTimestamp?: number } | undefined;
const [month, setMonth] = useState(() => {
const timestamp = persistedState?.monthTimestamp;
return typeof timestamp === "number" && Number.isFinite(timestamp) ? new Date(timestamp) : new Date();
});
useEffect(() => {
if (!props.widgetStateRef) return;
props.widgetStateRef.current = {
...props.widgetStateRef.current,
calendar: { monthTimestamp: month.getTime() },
};
}, [month, props.widgetStateRef]);
if (props.integrationIds.length === 0) {
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
@@ -31,7 +45,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -43,7 +57,16 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
return (
<CalendarBase
isEditMode={isEditMode}
events={events}
month={month}
setMonth={setMonth}
options={options}
displayMode={displayMode}
/>
);
};
interface CalendarBaseProps {
@@ -52,9 +75,10 @@ interface CalendarBaseProps {
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
}
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -65,124 +89,101 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const visibleEvents = useMemo(
() =>
normalizedEvents.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
),
[normalizedEvents, options.releaseType],
);
const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
const agendaEvents = useMemo(
() => getCalendarAgendaEvents(events, month, options.releaseType),
[events, month, options.releaseType],
);
return (
<Calendar
defaultDate={new Date()}
onPreviousMonth={(month) => setMonth(new Date(month))}
onNextMonth={(month) => setMonth(new Date(month))}
highlightToday
locale={locale}
hideWeekdays={false}
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
static={isEditMode}
className={classes.calendar}
w="100%"
h="100%"
ref={ref}
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
borderRadius: "md",
height: isSmall ? "1.5rem" : undefined,
width: isSmall ? "1.5rem" : undefined,
},
calendarHeaderLevel: {
pointerEvents: "none",
fontSize: isSmall ? "0.75rem" : undefined,
height: "100%",
},
levelsGroup: {
height: "100%",
padding: "md",
},
calendarHeader: {
maxWidth: "unset",
marginBottom: 0,
},
monthCell: {
textAlign: "center",
position: "relative",
},
day: {
borderRadius: actualItemRadius,
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
},
month: {
height: "100%",
},
weekday: {
padding: 0,
},
weekdaysRow: {
height: 22,
},
}}
renderDay={(tileDate) => {
const eventsForDate = normalizedEvents
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
)
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
<Calendar
defaultDate={new Date()}
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
onNextMonth={(nextMonth) => setMonth(new Date(nextMonth))}
highlightToday
locale={locale}
hideWeekdays={false}
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
static={isEditMode}
className={classes.calendar}
w="100%"
h={displayMode === "advanced" ? "62%" : "100%"}
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
borderRadius: "md",
height: isSmall ? "1.5rem" : undefined,
width: isSmall ? "1.5rem" : undefined,
},
calendarHeaderLevel: {
pointerEvents: "none",
fontSize: isSmall ? "0.75rem" : undefined,
height: "100%",
},
levelsGroup: {
height: "100%",
padding: "md",
},
calendarHeader: {
maxWidth: "unset",
marginBottom: 0,
},
monthCell: {
textAlign: "center",
position: "relative",
},
day: {
borderRadius: actualItemRadius,
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
},
month: {
height: "100%",
},
weekday: {
padding: 0,
},
weekdaysRow: {
height: 22,
},
}}
renderDay={(tileDate) => {
const eventsForDate = eventsByDate.get(toCalendarDateKey(dayjs(tileDate).toDate())) ?? [];
return (
<CalendarDay
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
/>
);
}}
/>
return (
<CalendarDay
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
/>
);
}}
/>
{displayMode === "advanced" && (
<Box px="md" pb="sm" style={{ flex: 1, minHeight: 0 }}>
<Text size="sm" fw={600} mb={4}>
{new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)} · {agendaEvents.length}
</Text>
<CalendarEventList events={agendaEvents} groupByDate locale={locale} />
</Box>
)}
</Stack>
);
};
/**
* Splits multi-day events into multiple single-day events.
* @param events The events to split.
* @returns The split events.
*/
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
const splitEvents: CalendarEvent[] = [];
for (const event of events) {
if (!event.endDate) {
splitEvents.push(event);
continue;
}
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
splitEvents.push(event);
continue;
}
if (dayjs(event.startDate).isAfter(event.endDate)) {
// Invalid event, skip it
continue;
}
// Event spans multiple days, split it
let currentStart = dayjs(event.startDate);
while (currentStart.isBefore(event.endDate)) {
splitEvents.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
}
}
return splitEvents;
};
+1
View File
@@ -9,6 +9,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
queryKey: [["widget", "calendar"]],
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
+90 -16
View File
@@ -1,32 +1,39 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Stack, Text, Title } from "@mantine/core";
import { Box, Group, Loader, Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezones from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { clientApi } from "@homarr/api/client";
import { metricToImperial } from "@homarr/common";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "../weather/animated-icon";
import { WeatherDescription } from "../weather/icon";
dayjs.extend(advancedFormat);
dayjs.extend(utc);
dayjs.extend(timezones);
export default function ClockWidget({ options, width }: WidgetComponentProps<"clock">) {
const secondsFormat = options.showSeconds ? ":ss" : "";
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
const isAdvanced = displayMode === "advanced";
const secondsFormat = options.showSeconds || isAdvanced ? ":ss" : "";
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
const dateFormat = options.dateFormat;
const customTimeFormat = options.customTimeFormat;
const customDateFormat = options.customDateFormat;
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
const time = useCurrentTime(options);
const time = useCurrentTime({ showSeconds: options.showSeconds || isAdvanced });
const zonedTime = time === null ? null : dayjs(time).tz(timezone);
const sizing = width < 128 ? "xs" : width < 196 ? "sm" : "md";
const minimumAxis = Math.min(width, height * 1.5);
const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
const showWeatherCorner = options.showWeather && sizing !== "xs";
return (
@@ -35,7 +42,9 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
<ClockWeatherCorner
latitude={options.weatherLocation.latitude}
longitude={options.weatherLocation.longitude}
locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit}
showDetails={isAdvanced}
/>
)}
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
@@ -45,13 +54,20 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
</Text>
)}
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
<time dateTime={zonedTime?.toISOString()}>
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
</time>
</Title>
{options.showDate && (
{(isAdvanced || options.showDate) && (
<Text className="clock-date-text" size={sizing} lineClamp={1}>
{zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)}
</Text>
)}
{isAdvanced && (
<Text size="xs" c="dimmed" lineClamp={1}>
{zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`}
</Text>
)}
</Stack>
</Box>
);
@@ -60,28 +76,86 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
interface ClockWeatherCornerProps {
latitude: number;
longitude: number;
locationName: string;
isFahrenheit: boolean;
showDetails: boolean;
}
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherCornerProps) => {
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const ClockWeatherCorner = ({
latitude,
longitude,
locationName,
isFahrenheit,
showDetails,
}: ClockWeatherCornerProps) => {
const t = useI18n();
const tCommon = useScopedI18n("common");
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const weather = weatherQuery.data;
if (!weather) return null;
if (!weather) {
return (
<Box pos="absolute" top={4} left={4}>
{isInitialWidgetQueryPending(weatherQuery) ? (
<Loader size="xs" aria-label={tCommon("action.loading")} />
) : (
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
)}
</Box>
);
}
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
const unit = isFahrenheit ? "°F" : "°C";
const today = weather.daily[0];
const formatTemperature = (value: number | undefined) => {
if (value === undefined) return "—";
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
return `${Math.round(preferred)}${unit}`;
};
return (
<Stack className="clock-weather-corner" pos="absolute" top={4} left={4} gap={0} align="center">
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
{Math.round(temp)}
{unit}
</Text>
<Stack
className="clock-weather-corner"
pos="absolute"
top={4}
left={4}
gap={showDetails ? 4 : 0}
align="flex-start"
>
<Group gap={4} wrap="nowrap">
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
{Math.round(temp)}
{unit}
</Text>
{showDetails && <WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />}
</Group>
{showDetails && (
<Stack gap={1}>
<Text size="xs" fw={600}>
{locationName}
</Text>
<Text size="xs" c="dimmed">
{formatTemperature(today?.maxTemp)} / {formatTemperature(today?.minTemp)} · {today?.humidity ?? "—"}%
</Text>
<Text size="xs" c="dimmed">
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
</Text>
<Text size="xs" c="dimmed">
{t("widget.weather.dailyForecast.sunrise")}: {formatClockWeatherTime(today?.sunrise)} ·{" "}
{t("widget.weather.dailyForecast.sunset")}: {formatClockWeatherTime(today?.sunset)}
</Text>
</Stack>
)}
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
</Stack>
);
};
const formatClockWeatherTime = (value: string | undefined) => value?.split("T")[1] ?? "—";
interface UseCurrentTimeProps {
showSeconds: boolean;
}
+30 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { ScrollArea, Stack, Text } from "@mantine/core";
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
@@ -10,7 +10,12 @@ import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
export default function CoolifyWidget({
options,
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -21,29 +26,45 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget
);
}
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} />;
return (
<CoolifyContent
integrationIds={integrationIds}
options={options}
width={width}
isAdvanced={displayMode === "advanced"}
/>
);
}
interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
isAdvanced: boolean;
}
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
const isTiny = width < 256;
const isTiny = !isAdvanced && width < 256;
const [firstInstance] = instancesData;
const widgetKey = createWidgetKey(integrationIds);
if (instancesData.length === 1 && firstInstance) {
return <SingleInstanceLayout instance={firstInstance} options={options} isTiny={isTiny} widgetKey={widgetKey} />;
return (
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
/>
);
}
return (
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
<SimpleGrid cols={isAdvanced && width >= 760 ? 2 : 1} spacing="sm" p="xs">
{instancesData.map((instance) => (
<InstanceCard
key={instance.integrationId}
@@ -51,9 +72,10 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps)
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
/>
))}
</Stack>
</SimpleGrid>
</ScrollArea>
);
}
+1
View File
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
icon: IconCloud,
queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
+16 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -18,9 +19,10 @@ interface InstanceCardProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isAdvanced: boolean;
}
export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
@@ -31,6 +33,12 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
const visibleSections = [
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
@@ -90,7 +98,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
</Group>
</Group>
<Accordion variant="filled" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
<Accordion
variant="filled"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -18,9 +19,10 @@ interface SingleInstanceLayoutProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isAdvanced: boolean;
}
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isAdvanced }: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -30,6 +32,12 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const visibleSections = [
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
@@ -56,7 +64,13 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
</Anchor>
</Group>
<Accordion variant="contained" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
<Accordion
variant="contained"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
@@ -87,7 +87,6 @@ export default function CustomApiWidget({
/>
);
}
function Unavailable({
message,
danger = false,
+17 -1
View File
@@ -1,4 +1,4 @@
import { IconApi } from "@tabler/icons-react";
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
@@ -6,6 +6,22 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi"]],
contextActions({ options, widgetStateRef }) {
return [
{
key: "togglePolling",
label: (t) => t("widget.customApi.actions.togglePolling"),
icon: IconPlayerPause,
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
disabled: typeof widgetStateRef.current?.togglePolling !== "function",
onClick: () => {
const action = widgetStateRef.current?.togglePolling;
if (typeof action === "function") action();
},
},
];
},
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import { getWidgetQueryKeys } from "./definition";
describe("getWidgetQueryKeys", () => {
test("falls back to the widget router namespace", () => {
expect(getWidgetQueryKeys({ kind: "example" })).toEqual([[["widget", "example"]]]);
});
test("wraps a singular explicit key", () => {
expect(getWidgetQueryKeys({ kind: "example", queryKey: [["app", "byId"]] })).toEqual([[["app", "byId"]]]);
});
test("preserves every explicit key for multi-root widgets", () => {
const queryKeys = [[["widget", "audioStats"]], [["widget", "mediaServer"]]] as const;
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
});
});
+88 -13
View File
@@ -1,6 +1,7 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { hashKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -10,7 +11,7 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
@@ -26,18 +27,50 @@ export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
canInteractWithSelectedIntegrations: boolean;
}
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
> {
kind: TKind;
options: inferOptionsFromDefinition<TOptions>;
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
export interface WidgetRuntimeActions {
togglePolling?: () => void;
testAllIndexers?: () => void;
previousPhoto?: () => void;
nextPhoto?: () => void;
toggleSlideshow?: () => void;
}
export interface WidgetRuntimeState {
queries: readonly NormalizedWidgetQuery[];
actions: WidgetRuntimeActions;
}
export type WidgetRuntimeRef = React.MutableRefObject<WidgetRuntimeState>;
export const createWidgetRuntimeState = (): WidgetRuntimeState => ({
queries: [],
actions: {},
});
export interface NormalizedWidgetQuery {
path: readonly string[];
input: unknown;
}
export interface WidgetQueryMatcherScope {
itemId: string;
boardId: string | undefined;
integrationIds: readonly string[];
options: Record<string, unknown>;
runtimeQueries: readonly NormalizedWidgetQuery[];
}
export type WidgetQueryMatcher = (query: NormalizedWidgetQuery, scope: WidgetQueryMatcherScope) => boolean;
export interface WidgetContextActionProps {
options: Record<string, unknown>;
setOptions: (partial: Record<string, unknown>) => void;
integrationIds: string[];
context: WidgetContextMenuContext;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
}
const createWithDynamicImport =
@@ -69,8 +102,10 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
supportsAdvancedFocus?: boolean;
queryKey?: QueryKey;
queryKeys?: QueryKey[];
queryKeys?: readonly QueryKey[];
queryMatcher?: WidgetQueryMatcher;
refetchInterval?: number | null;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
@@ -88,11 +123,49 @@ export interface WidgetDefinition {
}
>
>;
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
contextActions?: (props: WidgetContextActionProps) => WidgetContextMenuAction[];
}
export const getWidgetQueryKeys = (definition: WidgetDefinition, kind: WidgetKind): QueryKey[] =>
definition.queryKeys ?? [definition.queryKey ?? [["widget", kind]]];
export const supportsAdvancedFocus = (definition: object) =>
"supportsAdvancedFocus" in definition && definition.supportsAdvancedFocus === true;
export const getWidgetQueryKeys = (
definition: {
kind?: string;
queryKey?: QueryKey;
queryKeys?: readonly QueryKey[];
},
kind?: WidgetKind,
): readonly QueryKey[] => {
if (definition.queryKeys && definition.queryKeys.length > 0) return definition.queryKeys;
return [definition.queryKey ?? [["widget", definition.kind ?? kind]]];
};
export const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
const path = queryKey[0];
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
const queryKeyOptions = queryKey[1];
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
return { path, input };
};
export const getWidgetRuntimeQueries = (widgetRuntimeRef: WidgetRuntimeRef): readonly NormalizedWidgetQuery[] =>
widgetRuntimeRef.current.queries;
export const matchesWidgetRuntimeQuery: WidgetQueryMatcher = (query, scope) =>
scope.runtimeQueries.some(
(runtimeQuery) =>
widgetQueryValueEquals(runtimeQuery.path, query.path) && widgetQueryValueEquals(runtimeQuery.input, query.input),
);
export const widgetQueryInputMatches = (input: unknown, expected: Record<string, unknown>) =>
isRecord(input) && Object.entries(expected).every(([key, value]) => widgetQueryValueEquals(input[key], value));
export const widgetQueryValueEquals = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
@@ -103,9 +176,11 @@ export interface WidgetProps<TKind extends WidgetKind> {
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
displayMode?: "compact" | "advanced";
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
removeItem?: () => void;
};
@@ -44,6 +44,8 @@ export default function DnsHoleControlsWidget({
integrationIds,
isEditMode,
width,
height,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
@@ -56,13 +58,21 @@ export default function DnsHoleControlsWidget({
});
const utils = clientApi.useUtils();
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
const {
mutateAsync: enableDns,
isPending: isEnabling,
error: enableError,
} = clientApi.widget.dnsHole.enable.useMutation({
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
const {
mutateAsync: disableDns,
isPending: isDisabling,
error: disableError,
} = clientApi.widget.dnsHole.disable.useMutation({
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const toggleDns = (integrationId: string) => {
const toggleDns = async (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (!integrationStatus?.summary.status) return;
utils.widget.dnsHole.summary.setData(
@@ -86,9 +96,9 @@ export default function DnsHoleControlsWidget({
},
);
if (integrationStatus.summary.status === "enabled") {
disableDns({ integrationId, duration: 0 });
await disableDns({ integrationId, duration: 0 });
} else {
enableDns({ integrationId });
await enableDns({ integrationId });
}
};
@@ -103,9 +113,21 @@ export default function DnsHoleControlsWidget({
// Timer modal setup
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
const [bulkPending, setBulkPending] = useState(false);
const [bulkFailureCount, setBulkFailureCount] = useState(0);
const [opened, { close, open }] = useDisclosure(false);
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
const controlAllButtonsVisible =
(options.showToggleAllButtons || displayMode === "advanced") && integrationsWithInteractions.length > 0;
const actionsPending = bulkPending || isEnabling || isDisabling;
const actionError = enableError ?? disableError;
const runBulkToggle = async (integrationIdsToToggle: string[]) => {
setBulkPending(true);
setBulkFailureCount(0);
const results = await Promise.allSettled(integrationIdsToToggle.map(toggleDns));
setBulkFailureCount(results.filter(({ status }) => status === "rejected").length);
setBulkPending(false);
};
return (
<Stack
@@ -123,8 +145,8 @@ export default function DnsHoleControlsWidget({
size="xs"
p={0}
className="dns-hole-controls-enable-all-button"
onClick={() => integrationsSummaries.disabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.disabled.length === 0}
onClick={() => void runBulkToggle(integrationsSummaries.disabled)}
disabled={integrationsSummaries.disabled.length === 0 || actionsPending}
variant="light"
color="green"
bd={0}
@@ -144,7 +166,7 @@ export default function DnsHoleControlsWidget({
setSelectedIntegrationIds(integrationsSummaries.enabled);
open();
}}
disabled={integrationsSummaries.enabled.length === 0}
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="yellow"
bd={0}
@@ -160,8 +182,8 @@ export default function DnsHoleControlsWidget({
size="xs"
p={0}
className="dns-hole-controls-disable-all-button"
onClick={() => integrationsSummaries.enabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.enabled.length === 0}
onClick={() => void runBulkToggle(integrationsSummaries.enabled)}
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="red"
bd={0}
@@ -192,16 +214,32 @@ export default function DnsHoleControlsWidget({
t={t}
hasIconColor={board.iconColor !== null}
rootWidth={width}
rootHeight={height}
isAdvanced={displayMode === "advanced"}
actionsPending={actionsPending}
/>
))}
</Stack>
</ScrollArea.Autosize>
{bulkFailureCount > 0 && (
<Text size="xs" c="red" ta="center">
{t("widget.dnsHoleControls.error.bulkActionsFailed", { count: bulkFailureCount })}
</Text>
)}
{actionError && (
<Tooltip label={actionError.message} multiline>
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
{t("widget.dnsHoleControls.error.internalServerError")}
</Text>
</Tooltip>
)}
<TimerModal
opened={opened}
close={close}
selectedIntegrationIds={selectedIntegrationIds}
disableDns={disableDns}
disableDns={(input) => void disableDns(input).catch(() => undefined)}
/>
</Stack>
);
@@ -209,13 +247,16 @@ export default function DnsHoleControlsWidget({
interface ControlsCardProps {
integrationsWithInteractions: string[];
toggleDns: (integrationId: string) => void;
toggleDns: (integrationId: string) => Promise<void>;
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: TranslationFunction;
hasIconColor: boolean;
rootWidth: number;
rootHeight: number;
isAdvanced: boolean;
actionsPending: boolean;
}
const ControlsCard: React.FC<ControlsCardProps> = ({
@@ -227,16 +268,19 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
t,
hasIconColor,
rootWidth,
rootHeight,
isAdvanced,
actionsPending,
}) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
// Use all factors to infer the state of the action buttons
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected && !actionsPending;
const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
const layout = rootWidth < 256 ? "sm" : "md";
const layout = !isAdvanced && (rootWidth < 256 || rootHeight < 112) ? "sm" : "md";
return (
<Indicator
@@ -296,7 +340,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
<Group gap="xs" grow wrap="nowrap" w="100%">
{!isEnabled ? (
<ActionIcon
onClick={() => toggleDns(data.integration.id)}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
color="green"
@@ -306,7 +350,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</ActionIcon>
) : (
<ActionIcon
onClick={() => toggleDns(data.integration.id)}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
color="red"
@@ -323,6 +367,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
size="sm"
color="yellow"
variant="light"
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
>
<IconClockPause size={12} />
</ActionIcon>
@@ -334,7 +380,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
disabled={!controlEnabled}
display="contents"
style={{ cursor: controlEnabled ? "pointer" : "default" }}
onClick={() => toggleDns(data.integration.id)}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
>
<Badge
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
@@ -2,7 +2,19 @@
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Card, Flex, SimpleGrid, Stack, Text, Tooltip, TooltipFloating } from "@mantine/core";
import {
Avatar,
AvatarGroup,
Badge,
Card,
Flex,
Group,
SimpleGrid,
Stack,
Text,
Tooltip,
TooltipFloating,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
@@ -19,7 +31,11 @@ import type { TablerIcon } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
export default function DnsHoleSummaryWidget({
options,
integrationIds,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
@@ -27,28 +43,44 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
const t = useI18n();
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const layoutProps = boxPropsByLayout(options.layout);
return (
<SimpleGrid cols={2} spacing="xs" h="100%" p={"xs"} {...boxPropsByLayout(options.layout)}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
))
) : (
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
<AvatarGroup spacing="md">
{summaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
</Tooltip>
))}
</AvatarGroup>
<Text fz="md" ta="center">
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
</Text>
</Stack>
<Stack h="100%" gap={0}>
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
))
) : (
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
<AvatarGroup spacing="md">
{summaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
</Tooltip>
))}
</AvatarGroup>
<Text fz="md" ta="center">
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
</Text>
</Stack>
)}
</SimpleGrid>
{displayMode === "advanced" && summaries.length > 0 && (
<Group px="xs" pb="xs" gap="xs">
{summaries.map(({ integration, summary }) => (
<Badge
key={integration.id}
variant="light"
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
>
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}`)}
</Badge>
))}
</Group>
)}
</SimpleGrid>
</Stack>
);
}
+82 -19
View File
@@ -1,7 +1,21 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core";
import {
ActionIcon,
Avatar,
Badge,
Box,
Center,
getDefaultZIndex,
Group,
Menu,
Portal,
Stack,
Text,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import {
IconBrandDocker,
IconCategoryPlus,
@@ -29,8 +43,11 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
type ContainerAction = "start" | "stop" | "restart" | "remove";
@@ -63,10 +80,32 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
);
};
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
const label = t(state);
return (
<Tooltip label={label} withArrow>
<Box component="span" style={{ display: "inline-flex", flexShrink: 0 }}>
<Box
component="span"
aria-hidden="true"
w={8}
h={8}
bg={`${containerStateColorMap[state]}.6`}
style={{ borderRadius: "50%" }}
/>
<VisuallyHidden>{label}</VisuallyHidden>
</Box>
</Tooltip>
);
};
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
handlers: ContainerActionHandlers,
sortingEnabled: boolean,
inlineState: boolean,
): DataTableColumn<DockerContainer>[] => [
{
accessor: "name",
@@ -76,6 +115,7 @@ const createColumns = (
sortable: sortingEnabled,
render: (container) => (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar
variant="outline"
radius="sm"
@@ -188,6 +228,7 @@ export default function DockerWidget({
options,
width,
isEditMode,
displayMode,
boardId,
itemId,
setOptions,
@@ -198,9 +239,11 @@ export default function DockerWidget({
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const isTiny = width <= 256;
const isAdvanced = displayMode === "advanced";
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
const containersQuery = clientApi.docker.getContainers.useQuery();
const data = getUsableWidgetQueryData(containersQuery);
const { refetch, isFetching } = containersQuery;
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -279,13 +322,18 @@ export default function DockerWidget({
[containers],
);
const selectedColumns = useMemo(() => new Set<string>(options.columns), [options.columns]);
const columnVisibility = useMemo(
() => getDockerColumnVisibility(options.columns, width, isAdvanced),
[isAdvanced, options.columns, width],
);
const inlineState =
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
const columns = useMemo(() => {
const sortingEnabled = options.enableRowSorting && !isEditMode;
return createColumns(t, actionHandlers, sortingEnabled).filter(({ accessor }) =>
selectedColumns.has(String(accessor)),
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
return createColumns(t, actionHandlers, sortingEnabled, inlineState).filter(
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
);
}, [actionHandlers, isEditMode, options.enableRowSorting, selectedColumns, t]);
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
@@ -303,7 +351,7 @@ export default function DockerWidget({
setContextMenu({ x: event.clientX, y: event.clientY, container: record });
}, []);
if (options.columns.length === 0) {
if (!isAdvanced && options.columns.length === 0) {
return (
<Center h="100%">
<Text>{t("error.noColumns")}</Text>
@@ -311,6 +359,8 @@ export default function DockerWidget({
);
}
const footerVisibility = getDockerFooterVisibility(width, isAdvanced);
return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<Box style={{ flex: 1, minHeight: 0 }}>
@@ -325,7 +375,7 @@ export default function DockerWidget({
columns={effectiveColumns}
storeColumnsKey={storeKey}
sortStatus={sortStatus}
onSortStatusChange={options.enableRowSorting && !isEditMode ? setSortStatus : undefined}
onSortStatusChange={(isAdvanced || options.enableRowSorting) && !isEditMode ? setSortStatus : undefined}
idAccessor="id"
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
onScroll={() => {
@@ -334,8 +384,15 @@ export default function DockerWidget({
/>
</Box>
{!isTiny && (
<Group justify="space-between" style={{ borderTop: "0.0625rem solid var(--border-color)" }} p={4} wrap="nowrap">
{footerVisibility.footer && (
<Group
justify="space-between"
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
wrap="nowrap"
>
<Group gap={4} wrap="nowrap">
<IconBrandDocker size={20} style={{ flexShrink: 0 }} />
<Text size="sm" truncate>
@@ -344,14 +401,19 @@ export default function DockerWidget({
</Group>
<Group gap="sm" wrap="nowrap" justify="flex-end" style={{ minWidth: 0 }}>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
{footerVisibility.cpu && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
)}
{footerVisibility.memory && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
)}
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
className={actionTargetClasses.root}
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
@@ -386,6 +448,7 @@ function ContainerMenuButton({
<Menu opened={opened} onChange={setOpened} closeOnItemClick={false} withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
color="gray"
size="sm"
@@ -486,7 +549,7 @@ function ContainerContextMenu({
left={0}
w="100vw"
h="100vh"
style={{ zIndex: 1000 }}
style={{ zIndex: getDefaultZIndex("popover") }}
onClick={onClose}
onContextMenu={(event) => {
event.preventDefault();
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { getDockerColumnVisibility } from "./layout";
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
describe("getDockerColumnVisibility", () => {
test("keeps only essential columns in a narrow compact widget", () => {
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
name: true,
state: true,
host: false,
cpuUsage: false,
memoryUsage: false,
actions: true,
});
});
test("reveals compact metrics as width becomes available", () => {
expect(getDockerColumnVisibility(columns, 380, false)).toMatchObject({
host: false,
cpuUsage: true,
memoryUsage: true,
});
});
test("uses every expert column in advanced mode", () => {
expect(getDockerColumnVisibility([], 240, true)).toEqual({
name: true,
state: true,
host: true,
cpuUsage: true,
memoryUsage: true,
actions: true,
});
});
});
+19
View File
@@ -0,0 +1,19 @@
export type DockerColumn = "name" | "state" | "host" | "cpuUsage" | "memoryUsage" | "actions";
export const getDockerColumnVisibility = (
configuredColumns: readonly DockerColumn[],
width: number,
isAdvanced: boolean,
): Record<DockerColumn, boolean> => {
const configured = new Set(configuredColumns);
const isVisible = (column: DockerColumn) => isAdvanced || configured.has(column);
return {
name: isVisible("name"),
state: isVisible("state"),
host: isVisible("host") && (isAdvanced || width >= 440),
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 280),
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 360),
actions: isVisible("actions"),
};
};
+108 -85
View File
@@ -3,7 +3,7 @@
import "../widgets-common.css";
import "./styles.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Avatar,
@@ -50,11 +50,21 @@ import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/co
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { HomarrDataTable } from "../common/homarr-data-table";
import { formatLocalizedDateTime } from "../common/locale";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import {
DOWNLOAD_COLUMN_ACCESSORS,
filterDownloadItemsByStatus,
getAvailableDownloadStates,
getDownloadColumnAccessors,
} from "./helpers";
dayjs.extend(relativeTime);
@@ -81,6 +91,7 @@ interface ColumnContext {
hasTorrents: boolean;
hasMultipleClients: boolean;
hasMultipleTypes: boolean;
isAdvanced: boolean;
size: SizeConfig;
}
@@ -132,16 +143,16 @@ const stateColorMap: Record<DownloadState, string> = {
};
const rowBgAlpha: Partial<Record<DownloadState, string>> = {
failed: "rgba(255, 0, 0, 0.04)",
paused: "rgba(255, 200, 0, 0.03)",
failed: "var(--mantine-color-red-light)",
paused: "var(--mantine-color-yellow-light)",
};
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
ratio: (ctx) => ctx.hasTorrents,
sent: (ctx) => ctx.hasTorrents,
integration: (ctx) => ctx.hasMultipleClients,
type: (ctx) => ctx.hasMultipleTypes,
integration: (ctx) => ctx.isAdvanced || ctx.hasMultipleClients,
type: (ctx) => ctx.isAdvanced || ctx.hasMultipleTypes,
downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn,
state: (ctx) => ctx.size.showStateColumn,
@@ -152,7 +163,10 @@ const speedColumnConfig = {
up: { Icon: IconUpload, color: "green" as const },
} as const;
function getSizeConfig(width: number): SizeConfig {
export function getSizeConfig(width: number, height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
if (isAdvanced) return DEFAULT_SIZE_CONFIG;
if (height < 120) return SIZE_BREAKPOINTS[0]?.config ?? DEFAULT_SIZE_CONFIG;
if (height < 180) return SIZE_BREAKPOINTS[1]?.config ?? DEFAULT_SIZE_CONFIG;
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
if (width < maxWidth) return config;
}
@@ -165,24 +179,6 @@ function progressColor(state: DownloadState, progress: number): string {
return "blue";
}
const columnAccessors = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio",
"received",
"sent",
"category",
"integration",
"index",
"type",
] as const;
function toCategoryArray(category: string | string[]): string[] {
if (Array.isArray(category)) return category;
return [category];
@@ -298,31 +294,39 @@ export default function DownloadClientsWidget({
boardId,
itemId,
width,
height,
displayMode,
}: WidgetComponentProps<"downloads">) {
const allInteractAccess = useIntegrationsWithInteractAccess();
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const allInteractAccess = useIntegrationsWithInteractAccess();
const integrationInteractSet = useMemo(
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
[allInteractAccess, integrationIds],
);
const {
data: currentItems = [],
isFetching,
isError,
} = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
const downloadsQuery = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
});
const currentItems = getUsableWidgetQueryData(downloadsQuery);
const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]);
const { isFetching } = downloadsQuery;
const t = useScopedI18n("widget.downloads");
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={currentItems ?? []} />
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
</Group>
);
const [clientFilter, setClientFilter] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [showStats, { toggle: toggleStats }] = useDisclosure(false);
const isAdvanced = displayMode === "advanced";
const utils = clientApi.useUtils();
const mutationOptions = {
@@ -336,6 +340,7 @@ export default function DownloadClientsWidget({
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(mutationOptions);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
scope: { id: `downloads-options:${boardId ?? "preview"}:${itemId ?? "preview"}` },
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
});
const persistOption = useTableLayoutPersistence({
@@ -348,26 +353,27 @@ export default function DownloadClientsWidget({
let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc";
if (options.descendingDefaultSort) defaultSortDirection = "desc";
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<ExtendedDownloadClientItem>>({
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<ExtendedDownloadClientItem>>(() => ({
columnAccessor: options.defaultSort,
direction: defaultSortDirection,
});
}));
const hasMountedSortDefaults = useRef(false);
useEffect(() => {
if (!hasMountedSortDefaults.current) {
hasMountedSortDefaults.current = true;
return;
}
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
}, [options.defaultSort, defaultSortDirection]);
const data = useMemo<ExtendedDownloadClientItem[]>(() => {
return currentItems
const eligibleData = useMemo<ExtendedDownloadClientItem[]>(() => {
return availableItems
.filter(({ integration }) => integrationIds.includes(integration.id))
.flatMap((pair) =>
pair.data.items
.filter(({ category }) => matchesCategoryFilter(category, options.categoryFilter, options.filterIsWhitelist))
.filter((item) => showCompletedItem(item, options))
.filter(
({ state }) =>
(clientFilter.length === 0 || clientFilter.includes(pair.integration.id)) &&
(statusFilter.length === 0 || statusFilter.includes(state)),
)
.filter(() => clientFilter.length === 0 || clientFilter.includes(pair.integration.id))
.map((item): ExtendedDownloadClientItem => {
const received = item.received ?? Math.floor(item.size * item.progress);
const ids = [pair.integration.id];
@@ -390,7 +396,7 @@ export default function DownloadClientsWidget({
}),
);
}, [
currentItems,
availableItems,
integrationIds,
integrationInteractSet,
mutateDeleteItem,
@@ -398,9 +404,10 @@ export default function DownloadClientsWidget({
mutateResumeItem,
options,
clientFilter,
statusFilter,
]);
const data = useMemo(() => filterDownloadItemsByStatus(eligibleData, statusFilter), [eligibleData, statusFilter]);
const sortedData = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof ExtendedDownloadClientItem;
const multiplier = getSortMultiplier(sortStatus.direction);
@@ -412,7 +419,7 @@ export default function DownloadClientsWidget({
}, [data, sortStatus]);
const clients = useMemo<ExtendedClientStatus[]>(() => {
return currentItems
return availableItems
.filter(({ integration }) => integrationIds.includes(integration.id))
.map(({ integration, data: clientData }): ExtendedClientStatus => {
const interact = integrationInteractSet.has(integration.id);
@@ -449,7 +456,7 @@ export default function DownloadClientsWidget({
};
});
}, [
currentItems,
availableItems,
integrationIds,
integrationInteractSet,
options.applyFilterToRatio,
@@ -461,11 +468,14 @@ export default function DownloadClientsWidget({
const hasMultipleClients = clients.length > 1;
const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1;
const size = useMemo(() => getSizeConfig(width), [width]);
const optionsColumnSet = useMemo(() => new Set<string>(options.columns), [options.columns]);
const size = useMemo(() => getSizeConfig(width, height, isAdvanced), [height, isAdvanced, width]);
const optionsColumnSet = useMemo(
() => new Set<string>(getDownloadColumnAccessors(options.columns, isAdvanced)),
[isAdvanced, options.columns],
);
const columnContext = useMemo<ColumnContext>(
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }),
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size],
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size }),
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size],
);
let progressColumnWidth = 120;
@@ -479,7 +489,7 @@ export default function DownloadClientsWidget({
title: "",
width: 36,
render: (record) => (
<Tooltip label={record.integration.name} withArrow>
<Tooltip key={displayMode} label={record.integration.name} withArrow>
<Avatar size={20} radius={0} src={getIconUrl(record.integration.kind)} />
</Tooltip>
),
@@ -491,6 +501,7 @@ export default function DownloadClientsWidget({
ellipsis: true,
render: (record) => (
<Tooltip
key={displayMode}
label={buildHoverTooltip(record, t)}
multiline
w={280}
@@ -525,7 +536,14 @@ export default function DownloadClientsWidget({
render: (record) => {
const pct = Math.floor(record.progress * 100);
return (
<Tooltip label={buildProgressTooltip(record)} withArrow openDelay={300} position="top" color="dark">
<Tooltip
key={displayMode}
label={buildProgressTooltip(record)}
withArrow
openDelay={300}
position="top"
color="dark"
>
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size="xs" fw={500} w={36} ta="right" style={{ flexShrink: 0 }}>
{`${pct}%`}
@@ -666,11 +684,11 @@ export default function DownloadClientsWidget({
},
];
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
}, [columnContext, t, size, progressColumnWidth]);
}, [columnContext, t, size, progressColumnWidth, displayMode]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnAccessors: DOWNLOAD_COLUMN_ACCESSORS,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
@@ -701,7 +719,7 @@ export default function DownloadClientsWidget({
return traffic.up / traffic.down;
}, [clients]);
const availableStatuses = useMemo(() => [...new Set(data.map(({ state }) => state))], [data]);
const availableStatuses = useMemo(() => getAvailableDownloadStates(eligibleData), [eligibleData]);
const queueStats = useMemo(() => {
const stateCounts: Record<string, number> = {};
@@ -715,19 +733,16 @@ export default function DownloadClientsWidget({
return { stateCounts, totalSize, completedSize, totalItems: data.length };
}, [data]);
if (options.columns.length === 0) {
if (!isAdvanced && options.columns.length === 0) {
return (
<Center h="100%">
<Text>{t("errors.noColumns")}</Text>
</Center>
);
}
if (isError && currentItems.length === 0) {
return (
<Center h="100%">
<Text c="red">{t("errors.noCommunications")}</Text>
</Center>
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Center h="100%">
<Text>{t("errors.noColumns")}</Text>
</Center>
</Box>
);
}
@@ -736,8 +751,9 @@ export default function DownloadClientsWidget({
return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
{showStats && (
{(showStats || isAdvanced) && (
<GlobalStatsBar
key={displayMode}
queueStats={queueStats}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
@@ -748,10 +764,13 @@ export default function DownloadClientsWidget({
)}
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
fetching={isFetching && currentItems.length === 0}
fetching={isFetching && (currentItems?.length ?? 0) === 0}
fz={size.fontSize}
records={sortedData}
columns={effectiveColumns}
@@ -780,22 +799,25 @@ export default function DownloadClientsWidget({
/>
</Box>
<WidgetFooter
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clientFilter={clientFilter}
setClientFilter={setClientFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
availableStatuses={availableStatuses}
pauseQueue={mutatePauseQueue}
resumeQueue={mutateResumeQueue}
showStats={showStats}
toggleStats={toggleStats}
/>
{(isAdvanced || height >= 96) && (
<WidgetFooter
key={displayMode}
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clientFilter={clientFilter}
setClientFilter={setClientFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
availableStatuses={availableStatuses}
pauseQueue={mutatePauseQueue}
resumeQueue={mutateResumeQueue}
showStats={showStats}
toggleStats={toggleStats}
/>
)}
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />}
</Stack>
@@ -847,6 +869,7 @@ function buildHoverTooltip(record: ExtendedDownloadClientItem, t: DownloadsT): R
function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) {
const t = useScopedI18n("widget.downloads");
const locale = useCurrentIntlLocale();
const progressPercent = Math.floor(item.progress * 100);
const categoryDisplay = formatCategoryDisplay(item.category);
@@ -909,7 +932,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
<DetailPair label={t("items.sent.detailsTitle")} value={formatBytes(item.sent)} />
)}
{item.added !== undefined && (
<DetailPair label={t("items.added.detailsTitle")} value={dayjs(item.added).format("YYYY-MM-DD HH:mm")} />
<DetailPair label={t("items.added.detailsTitle")} value={formatLocalizedDateTime(item.added, locale)} />
)}
{categoryDisplay && <DetailPair label={t("items.category.detailsTitle")} value={categoryDisplay} />}
<DetailPair label={t("items.id.detailsTitle")} value={item.id} />
@@ -1046,7 +1069,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
left={0}
w="100vw"
h="100vh"
style={{ zIndex: 1000 }}
style={{ zIndex: "var(--mantine-z-index-popover)" }}
onClick={onClose}
onContextMenu={(e) => {
e.preventDefault();
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { columnWidthsToRecord, filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
describe("downloads filters", () => {
const eligibleItems = [{ state: "downloading" as const }, { state: "seeding" as const }];
test("keeps all eligible statuses available after applying a status filter", () => {
expect(filterDownloadItemsByStatus(eligibleItems, ["downloading"])).toEqual([{ state: "downloading" }]);
expect(getAvailableDownloadStates(eligibleItems)).toEqual(["downloading", "seeding"]);
});
test("deduplicates available statuses", () => {
expect(getAvailableDownloadStates([...eligibleItems, { state: "downloading" }])).toEqual([
"downloading",
"seeding",
]);
});
});
describe("downloads column widths", () => {
test("serializes numeric and pixel widths while ignoring automatic widths", () => {
expect(columnWidthsToRecord([{ name: 240 }, { progress: "120px" }, { state: "auto" }])).toEqual({
name: 240,
progress: 120,
});
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
type DownloadState = ExtendedDownloadClientItem["state"];
export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(
items: readonly T[],
statusFilter: readonly string[],
): T[] {
if (statusFilter.length === 0) return [...items];
return items.filter(({ state }) => statusFilter.includes(state));
}
export function getAvailableDownloadStates(items: readonly { state: DownloadState }[]): DownloadState[] {
return [...new Set(items.map(({ state }) => state))];
}
export function columnWidthsToRecord(columnsWidth: readonly Record<string, string | number>[]): Record<string, number> {
const result: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const width = entry[key];
if (typeof width === "number") result[key] = width;
else if (typeof width === "string" && width.endsWith("px")) result[key] = parseInt(width, 10);
}
return result;
}
+1
View File
@@ -42,6 +42,7 @@ const sortColumns = [
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
queryKey: [["widget", "downloads"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
@@ -1,10 +1,6 @@
import { describe, expect, test } from "vitest";
import {
filterStorageVolumes,
normalizeStorageDeviceName,
toScopedStorageVolumeValue,
} from "./filter-storage-volumes";
import { filterStorageVolumes, normalizeStorageDeviceName, toScopedStorageVolumeValue } from "./filter-storage-volumes";
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
describe("normalizeStorageDeviceName", () => {
@@ -35,11 +35,7 @@ const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string
);
};
const matchesVisibleStorageVolume = (
visibleVolume: string,
integrationId: string,
deviceName: string,
): boolean => {
const matchesVisibleStorageVolume = (visibleVolume: string, integrationId: string, deviceName: string): boolean => {
const separatorIndex = visibleVolume.indexOf(":");
if (separatorIndex === -1) {
return storageDeviceNamesMatch(visibleVolume, deviceName);
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
describe("calculateBandwidth", () => {
test("sorts samples before calculating a rate", () => {
const result = calculateBandwidth([
{ timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 200 }] },
{ timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 300, transmit: 500 }] },
]);
expect(result.data).toEqual([{ name: "wan", receive: 160, transmit: 240 }]);
});
test("treats counter resets as zero instead of negative traffic", () => {
const result = calculateBandwidth([
{ timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 10, transmit: 10 }] },
{ timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 100 }] },
]);
expect(result.data).toEqual([{ name: "wan", receive: 0, transmit: 0 }]);
});
test("rejects duplicate timestamps", () => {
const timestamp = new Date("2026-01-01T00:00:00Z");
expect(
calculateBandwidth([
{ timestamp, data: [{ name: "wan", receive: 100, transmit: 100 }] },
{ timestamp, data: [{ name: "wan", receive: 50, transmit: 50 }] },
]).data,
).toEqual([]);
});
});
describe("formatBitsPerSec", () => {
test("handles invalid and negative rates", () => {
expect(formatBitsPerSec(Number.NaN, 2)).toBe("0 b/s");
expect(formatBitsPerSec(-1, 2)).toBe("0 b/s");
});
});
@@ -0,0 +1,39 @@
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
export function formatBitsPerSec(bitsPerSecond: number, decimals: number): string {
if (!Number.isFinite(bitsPerSecond) || bitsPerSecond <= 0) return "0 b/s";
const unitSize = 1024;
const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
const unitIndex = Math.min(Math.floor(Math.log(bitsPerSecond) / Math.log(unitSize)), sizes.length - 1);
return `${parseFloat((bitsPerSecond / Math.pow(unitSize, unitIndex)).toFixed(decimals))} ${sizes[unitIndex]}`;
}
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
const samples = data
.filter((sample) => Number.isFinite(new Date(sample.timestamp).getTime()))
.toSorted((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
const latest = samples[0];
const previous = samples[1];
if (!latest || !previous) return { data: [] };
const timeDiffInSeconds = (new Date(latest.timestamp).getTime() - new Date(previous.timestamp).getTime()) / 1000;
if (timeDiffInSeconds <= 0) return { data: [] };
return {
data: latest.data.flatMap((latestInterface) => {
const previousInterface = previous.data.find((item) => item.name === latestInterface.name);
if (!previousInterface) return [];
return [
{
name: latestInterface.name,
receive: Math.max(0, (8 * (latestInterface.receive - previousInterface.receive)) / timeDiffInSeconds),
transmit: Math.max(0, (8 * (latestInterface.transmit - previousInterface.transmit)) / timeDiffInSeconds),
},
];
}),
};
}
+277 -185
View File
@@ -1,32 +1,51 @@
"use client";
import { useCallback, useState } from "react";
import { Accordion, Box, Center, Flex, Group, RingProgress, ScrollArea, Text } from "@mantine/core";
import {
Accordion,
Badge,
Center,
Flex,
Group,
Paper,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconArrowBarDown, IconArrowBarUp, IconBrain, IconCpu, IconTopologyBus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
import type { FirewallInterfacesSummary } from "@homarr/integrations";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu";
import { FirewallVersion } from "./firewall-version";
import { FirewallVersion, formatVersion } from "./firewall-version";
export interface Firewall {
label: string;
value: string;
}
export default function FirewallWidget({ integrationIds, width, itemId }: WidgetComponentProps<"firewall">) {
const [selectedFirewall, setSelectedFirewall] = useState<string>("");
export default function FirewallWidget({
integrationIds,
width,
itemId,
displayMode,
}: WidgetComponentProps<"firewall">) {
const [selectedFirewall, setSelectedFirewall] = useState("");
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && width < 256;
const t = useI18n();
const handleSelect = useCallback((value: string | null) => {
if (value !== null) {
setSelectedFirewall(value);
} else {
setSelectedFirewall("default_value");
}
setSelectedFirewall(value ?? "");
}, []);
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds });
@@ -40,202 +59,275 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget
integrationIds,
});
const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined";
const isTiny = width < 256;
const [accordionValue, setAccordionValue] = useLocalStorage<string | null>({
key: `homarr-${itemId}-firewall`,
defaultValue: "interfaces",
});
const initialSelectedFirewall = firewallsVersionData[0]?.integration.id ?? "";
const activeFirewall = firewallsVersionData.some(({ integration }) => integration.id === selectedFirewall)
? selectedFirewall
: initialSelectedFirewall;
const displayedFirewallIds = isAdvanced
? firewallsVersionData.map(({ integration }) => integration.id)
: activeFirewall
? [activeFirewall]
: [];
const dropdownItems = firewallsVersionData.map((firewall) => ({
label: firewall.integration.name,
value: firewall.integration.id,
}));
const t = useI18n();
return (
<ScrollArea h="100%">
<Group justify="space-between" w="100%" style={{ padding: "8px" }}>
<FirewallMenu
onChange={handleSelect}
selectedFirewall={selectedFirewall || initialSelectedFirewall}
dropdownItems={dropdownItems}
isTiny={isTiny}
/>
<FirewallVersion
firewallsVersionData={firewallsVersionData}
selectedFirewall={selectedFirewall || initialSelectedFirewall}
isTiny={isTiny}
/>
</Group>
<Flex justify="center" align="center" wrap="wrap">
{/* Render CPU and Memory data */}
{firewallsCpuData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary, integration }) => (
<RingProgress
key={`${integration.name}-cpu`}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{`${summary.total.toFixed(2)}%`}</Text>
<IconCpu size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(summary.total.toFixed(1)),
color: summary.total > 50 ? (summary.total < 75 ? "yellow" : "red") : "green",
},
]}
<Stack gap="xs" p={isAdvanced ? "xs" : 0}>
{!isAdvanced && (
<Group justify="space-between" w="100%" p="xs">
<FirewallMenu
onChange={handleSelect}
selectedFirewall={activeFirewall}
dropdownItems={dropdownItems}
isTiny={isTiny}
/>
))}
{firewallsMemoryData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary, integration }) => (
<RingProgress
key={`${integration.name}-memory`}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{`${summary.percent.toFixed(1)}%`}</Text>
<IconBrain size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(summary.percent.toFixed(1)),
color: summary.percent > 50 ? (summary.percent < 75 ? "yellow" : "red") : "green",
},
]}
<FirewallVersion
firewallsVersionData={firewallsVersionData}
selectedFirewall={activeFirewall}
isTiny={isTiny}
/>
))}
</Flex>
{firewallsInterfacesData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary }) => (
<Accordion key="interfaces" value={accordionValue} onChange={setAccordionValue}>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Text size={isTiny ? "8px" : "xs"}> {t("widget.firewall.widget.interfaces.title")} </Text>
</Accordion.Control>
<Accordion.Panel>
<Flex direction="column" key="interfaces">
{Array.isArray(summary) && summary.every((item) => Array.isArray(item.data)) ? (
calculateBandwidth(summary).data.map(({ name, receive, transmit }) => (
<Flex
key={name}
direction={isTiny ? "column" : "row"}
style={{
width: "100%",
padding: isTiny ? "2px" : "0px",
}}
>
<Flex w={isTiny ? "100%" : "33%"} style={{ justifyContent: "flex-start" }}>
<Text
size={isTiny ? "8px" : "xs"}
color="lightblue"
style={{
maxWidth: "100px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textAlign: "left",
}}
>
{name}
</Text>
</Flex>
<Flex
align="center"
gap="4"
w={isTiny ? "100%" : "33%"}
style={{ justifyContent: "flex-start" }}
>
<IconArrowBarUp size={isTiny ? "8" : "12"} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="lightgreen" style={{ textAlign: "left" }}>
{formatBitsPerSec(transmit, 2)}
</Text>
</Flex>
<Flex
align="center"
gap="4"
w={isTiny ? "100%" : "33%"}
style={{ justifyContent: "flex-start" }}
>
<IconArrowBarDown size={isTiny ? "8" : "12"} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow" style={{ textAlign: "left" }}>
{formatBitsPerSec(receive, 2)}
</Text>
</Flex>
</Flex>
))
) : (
<Box>No data available</Box>
)}
</Flex>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
))}
</Group>
)}
<SimpleGrid cols={isAdvanced && width >= 720 ? 2 : 1} spacing="xs">
{displayedFirewallIds.map((firewallId) => {
const cpu = firewallsCpuData.find(({ integration }) => integration.id === firewallId);
const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
const hasError = Boolean(cpu?.error || memory?.error || version?.error || interfaces?.error);
return (
<FirewallPanel
key={firewallId}
name={version?.integration.name ?? cpu?.integration.name ?? firewallId}
kind={version?.integration.kind}
version={version?.summary.version}
cpu={cpu?.summary.total}
memory={memory?.summary.percent}
interfaces={interfaces?.summary}
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
noDataLabel={t("widget.firewall.error.internalServerError")}
interfacesLabel={t("widget.firewall.widget.interfaces.title")}
t={t}
/>
);
})}
</SimpleGrid>
</Stack>
</ScrollArea>
);
}
export function formatBitsPerSec(bytes: number, decimals: number): string {
if (bytes === 0) return "0 b/s";
const kilobyte = 1024;
const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
const i = Math.floor(Math.log(bytes) / Math.log(kilobyte));
return `${parseFloat((bytes / Math.pow(kilobyte, i)).toFixed(decimals))} ${sizes[i]}`;
interface FirewallPanelProps {
name: string;
kind?: string;
version?: string;
cpu?: number;
memory?: number;
interfaces?: FirewallInterfacesSummary[];
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
noDataLabel: string;
interfacesLabel: string;
t: TranslationFunction;
}
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
const result = {
data: [] as FirewallInterface[],
timestamp: new Date().toISOString(),
};
const FirewallPanel = ({
name,
kind,
version,
cpu,
memory,
interfaces,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
noDataLabel,
interfacesLabel,
t,
}: FirewallPanelProps) => (
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}>
<Stack gap="xs">
{isAdvanced && (
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text size="sm" fw={600} truncate="end">
{name}
</Text>
{kind && (
<Text size="xs" c="dimmed">
{kind}
</Text>
)}
</Stack>
<Group gap={4} wrap="nowrap">
{hasError && (
<Tooltip label={noDataLabel}>
<Badge color="red" variant="light" size="xs">
{t("common.error")}
</Badge>
</Tooltip>
)}
<Badge variant="outline" color="gray" size="xs">
{formatVersion(version ?? "", t("widget.firewall.versionUnknown"))}
</Badge>
</Group>
</Group>
)}
if (data.length > 1) {
const firstData = data[0];
const secondData = data[1];
<Flex justify="center" align="center" wrap="wrap">
{cpu !== undefined && (
<MetricRing value={cpu} icon={IconCpu} isTiny={isTiny} label={t("widget.firewall.widget.cpu")} t={t} />
)}
{memory !== undefined && (
<MetricRing
value={memory}
icon={IconBrain}
isTiny={isTiny}
label={t("widget.firewall.widget.memory")}
t={t}
/>
)}
</Flex>
if (firstData && secondData) {
const time1 = new Date(firstData.timestamp);
const time2 = new Date(secondData.timestamp);
const timeDiffInSeconds = (time1.getTime() - time2.getTime()) / 1000;
<InterfacesPanel
summary={interfaces ?? []}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
noDataLabel={noDataLabel}
label={interfacesLabel}
/>
</Stack>
</Paper>
);
firstData.data.forEach((iface) => {
const ifaceName = iface.name;
const recv1 = iface.receive;
const trans1 = iface.transmit;
const iface2 = secondData.data.find((i) => i.name === ifaceName);
if (iface2) {
const recv2 = iface2.receive;
const trans2 = iface2.transmit;
const recvDiff = recv1 - recv2;
const transDiff = trans1 - trans2;
result.data.push({
name: ifaceName,
receive: (8 * recvDiff) / timeDiffInSeconds,
transmit: (8 * transDiff) / timeDiffInSeconds,
});
}
});
}
}
return result;
interface MetricRingProps {
value: number;
icon: typeof IconCpu;
isTiny: boolean;
label: string;
t: TranslationFunction;
}
const MetricRing = ({ value, icon: Icon, isTiny, label, t }: MetricRingProps) => {
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
const status = getMetricStatus(safeValue);
const statusLabel = t(`widget.firewall.status.${status}`);
return (
<RingProgress
aria-label={t("widget.firewall.metricAccessible", {
metric: label,
value: safeValue.toFixed(1),
status: statusLabel,
})}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
<Icon size={isTiny ? 8 : 16} />
{!isTiny && <Text size="8px">{statusLabel}</Text>}
</Center>
}
sections={[
{
value: safeValue,
color: status === "warning" ? "yellow" : status === "critical" ? "red" : "green",
},
]}
/>
);
};
const getMetricStatus = (value: number): "normal" | "warning" | "critical" => {
if (value >= 75) return "critical";
if (value > 50) return "warning";
return "normal";
};
interface InterfacesPanelProps {
summary: FirewallInterfacesSummary[];
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
noDataLabel: string;
label: string;
}
const InterfacesPanel = ({
summary,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
noDataLabel,
label,
}: InterfacesPanelProps) => {
const bandwidth = calculateBandwidth(summary).data;
return (
<Accordion
value={isAdvanced ? "interfaces" : accordionValue}
onChange={isAdvanced ? undefined : setAccordionValue}
variant={isAdvanced ? "contained" : "default"}
>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={4}>
{bandwidth.length > 0 ? (
bandwidth.map(({ name, receive, transmit }) => (
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
{name}
</Text>
<Group gap={4} wrap="nowrap">
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="green.3">
{formatBitsPerSec(transmit, 2)}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
{formatBitsPerSec(receive, 2)}
</Text>
</Group>
</Group>
))
) : (
<Text size="xs" c="dimmed">
{noDataLabel}
</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
};
@@ -1,6 +1,7 @@
import { Badge, Box } from "@mantine/core";
import type { FirewallVersionSummary } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
interface FirewallVersionProps {
firewallsVersionData: {
@@ -18,24 +19,34 @@ export interface FirewallIntegration {
updatedAt: Date;
}
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => (
<Box>
<Badge autoContrast variant="outline" color="lightgray" size={isTiny ? "8px" : "xs"} style={{ minHeight: "24px" }}>
{firewallsVersionData
.filter(({ integration }) => integration.id === selectedFirewall)
.map(({ summary, integration }) => (
<span key={integration.id}>{formatVersion(summary.version)}</span>
))}
</Badge>
</Box>
);
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => {
const t = useI18n();
function formatVersion(inputString: string): string {
return (
<Box>
<Badge
autoContrast
variant="outline"
color="lightgray"
size={isTiny ? "8px" : "xs"}
style={{ minHeight: "24px" }}
>
{firewallsVersionData
.filter(({ integration }) => integration.id === selectedFirewall)
.map(({ summary, integration }) => (
<span key={integration.id}>{formatVersion(summary.version, t("widget.firewall.versionUnknown"))}</span>
))}
</Badge>
</Box>
);
};
export function formatVersion(inputString: string, fallback: string): string {
const regex = /([\d._]+)/;
const match = regex.exec(inputString);
if (match?.[1]) {
return match[1];
} else {
return "Unknown Version";
return fallback;
}
}
+1
View File
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
queryKey: [["widget", "firewall"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(() => ({}));
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { getClusterAccordionDefault } from "./accordion-state";
describe("getClusterAccordionDefault", () => {
test("opens only the first visible section in compact mode", () => {
expect(getClusterAccordionDefault("compact", ["node", "qemu", "storage"])).toEqual(["node"]);
});
test("opens every visible section in advanced mode", () => {
expect(getClusterAccordionDefault("advanced", ["node", "qemu", "storage"])).toEqual(["node", "qemu", "storage"]);
});
test("does not invent a section when none are visible", () => {
expect(getClusterAccordionDefault("compact", [])).toEqual([]);
});
});
@@ -0,0 +1,4 @@
export const getClusterAccordionDefault = (
displayMode: "compact" | "advanced" | undefined,
visibleSections: readonly string[],
): string[] => (displayMode === "advanced" ? [...visibleSections] : visibleSections.slice(0, 1));
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
@@ -8,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
import { getClusterAccordionDefault } from "./accordion-state";
import { ResourceAccordionItem } from "./resource-accordion-item";
import { ResourceTable } from "./resource-table";
@@ -33,11 +35,16 @@ export const ClusterHealthMonitoring = ({
integrationId,
options,
width,
displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n();
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
integrationId,
});
const accordionScope = `${displayMode}:${options.visibleClusterSections.join(",")}`;
const accordionDefault = getClusterAccordionDefault(displayMode, options.visibleClusterSections);
const [accordionValues, setAccordionValues] = useState<Record<string, string[]>>({});
const accordionValue = accordionValues[accordionScope] ?? accordionDefault;
if (!healthData) return <WidgetEmptyState />;
@@ -53,15 +60,13 @@ export const ClusterHealthMonitoring = ({
(sum, item) => (item.isRunning ? item.cpu.utilization * item.cpu.cores + sum : sum),
0,
);
const uptime = healthData.nodes.reduce((sum, { uptime }) => (sum > uptime ? sum : uptime), 0);
const uptime = healthData.nodes.reduce((sum, { uptime: nodeUptime }) => (sum > nodeUptime ? sum : nodeUptime), 0);
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
const defaultValue = [options.visibleClusterSections.at(0) ?? "node"];
const isTiny = width < 256;
const isTiny = displayMode !== "advanced" && width < 256;
return (
<Stack h="100%" p="xs" gap={isTiny ? "xs" : "md"}>
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
{options.showUptime && (
<Group justify="center" wrap="nowrap">
<Text fz={isTiny ? 8 : "xs"} tt="uppercase" fw={700} c="dimmed" ta="center">
@@ -81,7 +86,13 @@ export const ClusterHealthMonitoring = ({
isTiny={isTiny}
/>
{options.visibleClusterSections.length >= 1 && (
<Accordion variant="contained" chevronPosition="right" multiple defaultValue={defaultValue}>
<Accordion
variant="contained"
chevronPosition="right"
multiple
value={accordionValue}
onChange={(value) => setAccordionValues((current) => ({ ...current, [accordionScope]: value }))}
>
{options.visibleClusterSections.includes("node") && (
<ResourceAccordionItem
value="node"
@@ -1,38 +1,54 @@
"use client";
import { ScrollArea, Tabs } from "@mantine/core";
import { Box, ScrollArea, SimpleGrid, Stack, Tabs } from "@mantine/core";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
import { partitionHealthMonitoringIntegrations } from "./integration-selection";
import { SystemHealthMonitoring } from "./system-health";
dayjs.extend(duration);
const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const t = useI18n();
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
const { clusterIntegrationIds, systemIntegrationIds } = partitionHealthMonitoringIntegrations(integrations);
if (!clusterIntegrationId) {
if (clusterIntegrationIds.length === 0) {
return <SystemHealthMonitoring {...props} />;
}
const otherIntegrationIds = integrations
// We want to have the mock integration also in the system tab, so we use it for both
.filter((integration) => integration.kind !== "proxmox")
.map((integration) => integration.id);
if (otherIntegrationIds.length === 0) {
return <ClusterHealthMonitoring {...props} integrationId={clusterIntegrationId} />;
const clusters = (
<Stack gap="sm">
{clusterIntegrationIds.map((integrationId) => (
<ClusterHealthMonitoring key={integrationId} {...props} integrationId={integrationId} />
))}
</Stack>
);
if (systemIntegrationIds.length === 0) {
const onlyClusterIntegrationId = clusterIntegrationIds[0];
if (clusterIntegrationIds.length === 1 && onlyClusterIntegrationId) {
return <ClusterHealthMonitoring {...props} integrationId={onlyClusterIntegrationId} />;
}
return <ScrollArea h="100%">{clusters}</ScrollArea>;
}
if (props.displayMode === "advanced") {
return (
<SimpleGrid cols={props.width >= 900 ? 2 : 1} spacing="sm" p="xs" h="100%">
<Box style={{ minHeight: 0, overflow: "hidden" }}>
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} />
</Box>
<ScrollArea h="100%">{clusters}</ScrollArea>
</SimpleGrid>
);
}
return (
@@ -47,11 +63,9 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="system">
<SystemHealthMonitoring {...props} integrationIds={otherIntegrationIds} />
</Tabs.Panel>
<Tabs.Panel value="cluster">
<ClusterHealthMonitoring integrationId={clusterIntegrationId} {...props} />
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} />
</Tabs.Panel>
<Tabs.Panel value="cluster">{clusters}</Tabs.Panel>
</Tabs>
</ScrollArea>
);
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff. Show more