feat: modernize dashboard widgets
This commit is contained in:
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,
|
||||
);
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "" }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user