Merge remote-tracking branch 'origin/release/v2' into feat/pr-6691-hardening
# Conflicts: # apps/nextjs/src/app/[locale]/layout.tsx # packages/api/src/router/widgets/anchor-notes.ts # packages/api/src/router/widgets/archive-team-warrior.ts # packages/api/src/router/widgets/audio-stats.ts # packages/api/src/router/widgets/bazarr.ts # packages/api/src/router/widgets/coolify.ts # packages/api/src/router/widgets/firewall.ts # packages/api/src/router/widgets/immich.ts # packages/api/src/router/widgets/paperless-ngx.ts # packages/api/src/router/widgets/patchmon.ts # packages/api/src/router/widgets/speedtest-tracker.ts # packages/api/src/router/widgets/tracearr.ts # packages/api/src/router/widgets/traefik.ts # packages/api/src/router/widgets/umami.ts # packages/api/src/router/widgets/uptime-kuma.ts # packages/api/src/router/widgets/vpn.ts # packages/api/src/router/widgets/wud.ts # packages/definitions/src/widget-integration-map.ts # packages/widgets/src/anchor-note/index.ts # packages/widgets/src/archive-team-warrior/index.ts # packages/widgets/src/audio-stats/index.ts # packages/widgets/src/bazarr/index.ts # packages/widgets/src/coolify/index.ts # packages/widgets/src/firewall/index.ts # packages/widgets/src/immich/album-carousel/index.ts # packages/widgets/src/immich/server-stats/index.ts # packages/widgets/src/paperless-ngx/index.ts # packages/widgets/src/patchmon/index.ts # packages/widgets/src/speedtest-tracker/index.ts # packages/widgets/src/system-disks/index.ts # packages/widgets/src/system-resources/index.ts # packages/widgets/src/tracearr/index.ts # packages/widgets/src/traefik/index.ts # packages/widgets/src/umami/index.ts # packages/widgets/src/uptime-kuma/index.ts # packages/widgets/src/vpn/index.ts # packages/widgets/src/wud/index.ts
This commit is contained in:
commit
ac7e3f2ef4
201 files changed
+13795
-981
No files matched your search
@@ -495,7 +495,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
models: [
|
||||
{
|
||||
id: "homarr-assistant",
|
||||
name: "Homarr Assistant (Demo)",
|
||||
name: "Assistant (Demo)",
|
||||
description: null,
|
||||
contextLength: null,
|
||||
promptPrice: null,
|
||||
@@ -507,7 +507,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
if (!configuration?.enabled || !configuration.modelId) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
|
||||
}
|
||||
const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
|
||||
const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
|
||||
@@ -708,7 +708,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
getConfigurationAsync(ctx.db),
|
||||
]);
|
||||
if (!configuration?.enabled || !configuration.modelId) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
|
||||
}
|
||||
const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
|
||||
if (input.modelId !== configuration.modelId && !selectedModel) {
|
||||
|
||||
@@ -85,7 +85,9 @@ interface BoardItemPlacementRectangle {
|
||||
|
||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||
const maxManageOverviewPreviewRows = 12;
|
||||
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
|
||||
const manageOverviewInputSchema = z
|
||||
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
|
||||
.optional();
|
||||
|
||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||
@@ -207,6 +209,7 @@ const searchAccessibleBoardsAsync = async (
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
permissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -363,9 +366,14 @@ export const boardRouter = createTRPCRouter({
|
||||
}));
|
||||
}),
|
||||
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const sessionUserId = ctx.session?.user.id;
|
||||
const userId = input?.userId ?? sessionUserId;
|
||||
if (userId !== sessionUserId && !ctx.session?.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "You are not allowed to view boards for this user" });
|
||||
}
|
||||
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
|
||||
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
|
||||
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
|
||||
|
||||
const dbBoards = await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
@@ -415,7 +423,7 @@ export const boardRouter = createTRPCRouter({
|
||||
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
|
||||
},
|
||||
},
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
where: getAccessibleBoardsWhere(canViewAllBoards, userId, boardIds),
|
||||
});
|
||||
|
||||
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
|
||||
@@ -2615,6 +2623,7 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
permissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2638,6 +2647,24 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
|
||||
return { boardIds, currentUser, groupMemberships };
|
||||
};
|
||||
|
||||
const getCanViewAllBoards = (
|
||||
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
|
||||
) => {
|
||||
const permissions = new Set(
|
||||
groupMemberships.flatMap((membership) => membership.group.permissions.map(({ permission }) => permission)),
|
||||
);
|
||||
return getPermissionsWithChildren([...permissions]).includes("board-view-all");
|
||||
};
|
||||
|
||||
export const getAccessibleBoardIdsForUserAsync = async (db: Database, userId: string) => {
|
||||
const { boardIds, groupMemberships } = await getBoardAccessContextAsync(db, userId);
|
||||
const accessibleBoards = await db.query.boards.findMany({
|
||||
columns: { id: true },
|
||||
where: getAccessibleBoardsWhere(getCanViewAllBoards(groupMemberships), userId, boardIds),
|
||||
});
|
||||
return new Set(accessibleBoards.map(({ id }) => id));
|
||||
};
|
||||
|
||||
const getBoardGroupPermissionWhere = (
|
||||
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
|
||||
) =>
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
} from "@homarr/db/queries";
|
||||
import { boards, serverSettings } from "@homarr/db/schema";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import {
|
||||
authBrandingSchema,
|
||||
brandingServerSettingsSchema,
|
||||
defaultServerSettingsKeys,
|
||||
parseBrandingSettings,
|
||||
} from "@homarr/server-settings";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
@@ -22,7 +27,14 @@ const boardServerSettingsSchema = z.object({
|
||||
}) satisfies z.ZodType<ServerSettings["board"]>;
|
||||
|
||||
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
|
||||
|
||||
const brandingServerSettingsUpdateSchema = brandingServerSettingsSchema.partial().extend({
|
||||
authBranding: authBrandingSchema.partial().optional(),
|
||||
});
|
||||
const legacyAuthBrandingUpdateSchema = z.object({
|
||||
showCustomAppNameOnLogin: z.boolean().optional(),
|
||||
showCustomLogoOnLogin: z.boolean().optional(),
|
||||
showCustomGreetingOnLogin: z.boolean().optional(),
|
||||
});
|
||||
export const serverSettingsRouter = createTRPCRouter({
|
||||
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
||||
@@ -30,6 +42,14 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
return await getServerSettingsAsync(ctx.db);
|
||||
}),
|
||||
getBranding: publicProcedure
|
||||
.meta({
|
||||
mcp: { enabled: true, description: "Returns the public instance branding configuration." },
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const branding = await getServerSettingByKeyAsync(ctx.db, "branding");
|
||||
return parseBrandingSettings(branding);
|
||||
}),
|
||||
getBoardSettings: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
@@ -100,10 +120,27 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.settingsKey === "branding") {
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, "branding");
|
||||
const parsedInput = brandingServerSettingsUpdateSchema.parse(input.value);
|
||||
const legacyInput = legacyAuthBrandingUpdateSchema.parse(input.value);
|
||||
const authBranding = { ...current.authBranding };
|
||||
authBranding.showAppName = legacyInput.showCustomAppNameOnLogin ?? authBranding.showAppName;
|
||||
authBranding.showLogo = legacyInput.showCustomLogoOnLogin ?? authBranding.showLogo;
|
||||
authBranding.showGreeting = legacyInput.showCustomGreetingOnLogin ?? authBranding.showGreeting;
|
||||
Object.assign(authBranding, parsedInput.authBranding);
|
||||
const value = brandingServerSettingsSchema.parse({
|
||||
...parseBrandingSettings(current),
|
||||
...parsedInput,
|
||||
authBranding,
|
||||
});
|
||||
await updateServerSettingByKeyAsync(ctx.db, "branding", value);
|
||||
return;
|
||||
}
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||
...current,
|
||||
...input.value,
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
} as ServerSettings[typeof input.settingsKey]);
|
||||
}),
|
||||
});
|
||||
@@ -79,7 +79,7 @@ describe("saveSettings", () => {
|
||||
});
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
expect(dbSettings.filter(({ settingKey }) => settingKey === "analytics")).toStrictEqual([
|
||||
{
|
||||
settingKey: "analytics",
|
||||
value: stringify({
|
||||
@@ -124,7 +124,7 @@ describe("board settings API", () => {
|
||||
forceDisableStatus: true,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify(result),
|
||||
@@ -155,7 +155,7 @@ describe("board settings API", () => {
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify(result),
|
||||
|
||||
@@ -15,8 +15,11 @@ import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
||||
import type { HeaderPreferences, userBaseCreateSchema } from "@homarr/validation/user";
|
||||
import {
|
||||
getHeaderItems,
|
||||
headerPreferencesMutationSchema,
|
||||
parseHeaderPreferences,
|
||||
userChangeColorSchemeSchema,
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangePasswordApiSchema,
|
||||
@@ -30,6 +33,7 @@ import {
|
||||
userPingIconsEnabledSchema,
|
||||
userRegistrationApiSchema,
|
||||
} from "@homarr/validation/user";
|
||||
import { serializeHeaderPreferences } from "@homarr/validation/header-preferences";
|
||||
|
||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||
import {
|
||||
@@ -40,6 +44,7 @@ import {
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
import { getAccessibleBoardIdsForUserAsync } from "./board";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
|
||||
@@ -368,6 +373,7 @@ export const userRouter = createTRPCRouter({
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
headerPreferences: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -405,6 +411,7 @@ export const userRouter = createTRPCRouter({
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
headerPreferences: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
@@ -719,6 +726,63 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeHeaderPreferences: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "PATCH",
|
||||
path: "/api/users/header-preferences",
|
||||
tags: ["users"],
|
||||
protect: true,
|
||||
},
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update the header layout preferences of a user. REQUIRED: id (user ID), headerPreferences (zones for left, center and right, visible flag, searchDisplay and logoDisplay). Admins can change any user; other users can only change their own. Board shortcut items must reference boards the target user can view",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string(), headerPreferences: headerPreferencesMutationSchema }))
|
||||
.output(z.void())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = await ctx.db.query.users.findFirst({
|
||||
columns: { headerPreferences: true },
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
if (!targetUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const submittedBoardIds = getBoardShortcutIds(input.headerPreferences);
|
||||
if (submittedBoardIds.length > 0) {
|
||||
const existingBoardIds = new Set(getBoardShortcutIds(parseHeaderPreferences(targetUser.headerPreferences)));
|
||||
const accessibleBoardIds = await getAccessibleBoardIdsForUserAsync(ctx.db, input.id);
|
||||
const hasUnavailableShortcut = submittedBoardIds.some(
|
||||
(boardId) => !accessibleBoardIds.has(boardId) && !existingBoardIds.has(boardId),
|
||||
);
|
||||
if (hasUnavailableShortcut) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "One or more board shortcuts are unavailable to this user",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
headerPreferences: serializeHeaderPreferences(input.headerPreferences),
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeDdgBangs: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
||||
.output(z.void())
|
||||
@@ -829,6 +893,9 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const getBoardShortcutIds = (preferences: HeaderPreferences) =>
|
||||
getHeaderItems(preferences.zones).flatMap((item) => (item.type === "board" ? [item.boardId] : []));
|
||||
|
||||
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
||||
const hashedPassword = await hashPasswordAsync(input.password);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
|
||||
@@ -16,6 +17,12 @@ const noteIdInput = z.object({
|
||||
|
||||
const logger = createLogger({ module: "anchorNotesRouter" });
|
||||
|
||||
const assertMockNoteExists = (noteId: string) => {
|
||||
if (noteId === mockWidgetData.anchorNote.id) return;
|
||||
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Note not found" });
|
||||
};
|
||||
|
||||
const isJsonDeltaString = (value: string) => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
@@ -44,7 +51,19 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
|
||||
.input(anchorNotesListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
|
||||
if (ctx.integration.kind === "mock") {
|
||||
const search = input.search?.toLowerCase();
|
||||
const notes = mockWidgetData.anchorNotes.filter((note) => !search || note.title.toLowerCase().includes(search));
|
||||
return notes.slice(0, input.limit ?? 50);
|
||||
}
|
||||
|
||||
const handler = anchorNotesListRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "anchor" },
|
||||
{
|
||||
...input,
|
||||
limit: input.limit ?? 50,
|
||||
},
|
||||
);
|
||||
|
||||
const { data } = await handler.getDataAsync();
|
||||
|
||||
@@ -54,7 +73,15 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
|
||||
.input(noteIdInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
if (ctx.integration.kind === "mock") {
|
||||
assertMockNoteExists(input.noteId);
|
||||
return mockWidgetData.anchorNote;
|
||||
}
|
||||
|
||||
const handler = anchorNoteRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "anchor" },
|
||||
{ noteId: input.noteId },
|
||||
);
|
||||
|
||||
const { data } = await handler.getDataAsync();
|
||||
|
||||
@@ -64,11 +91,21 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
|
||||
.input(anchorNoteUpdateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
const normalizedContent = normalizeAnchorContent(input.content);
|
||||
const isContentNormalized =
|
||||
input.content !== undefined && normalizedContent !== undefined && input.content !== normalizedContent;
|
||||
|
||||
if (ctx.integration.kind === "mock") {
|
||||
assertMockNoteExists(input.noteId);
|
||||
return {
|
||||
...mockWidgetData.anchorNote,
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const integrationInstance = await createIntegrationAsync({ ...ctx.integration, kind: "anchor" });
|
||||
|
||||
try {
|
||||
const updatedNote = await integrationInstance.updateNoteAsync({
|
||||
noteId: input.noteId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -7,7 +8,11 @@ export const archiveTeamWarriorRouter = createTRPCRouter({
|
||||
getStatus: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
||||
if (ctx.integration.kind === "mock") {
|
||||
return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) };
|
||||
}
|
||||
|
||||
const handler = archiveTeamWarriorRequestHandler.handler({ ...ctx.integration, kind: "archiveTeamWarrior" }, {});
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
|
||||
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -9,6 +10,7 @@ export const audioStatsRouter = createTRPCRouter({
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
|
||||
.query(async ({ ctx }) => {
|
||||
const { kind } = ctx.integration;
|
||||
if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
|
||||
|
||||
const fetchByKind = {
|
||||
navidrome: async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -14,7 +15,9 @@ export const bazarrRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
|
||||
|
||||
const innerHandler = bazarrBadgesRequestHandler.handler({ ...ctx.integration, kind: "bazarr" }, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
@@ -21,7 +22,17 @@ export const coolifyRouter = createTRPCRouter({
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration): Promise<CoolifyInstanceResult> => {
|
||||
const innerHandler = coolifyRequestHandler.handler(integration, {});
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
instanceInfo: mockWidgetData.coolify,
|
||||
updatedAt: new Date(mockWidgetData.timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
const innerHandler = coolifyRequestHandler.handler({ ...integration, kind: "coolify" }, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import {
|
||||
firewallCpuRequestHandler,
|
||||
firewallInterfacesRequestHandler,
|
||||
@@ -24,19 +25,31 @@ interface FirewallQueryResult<TData> {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: FirewallIntegration["kind"];
|
||||
kind: FirewallIntegration["kind"] | "mock";
|
||||
updatedAt: Date;
|
||||
};
|
||||
summary: TData;
|
||||
error: string | undefined;
|
||||
}
|
||||
|
||||
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData) =>
|
||||
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData, getMockData: () => TData) =>
|
||||
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
|
||||
settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration): Promise<FirewallQueryResult<TData>> => {
|
||||
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: new Date(mockWidgetData.timestamp),
|
||||
},
|
||||
summary: getMockData(),
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
const { data, timestamp } = await handler.handler({ ...integration, kind: "opnsense" }, {}).getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
summary: data,
|
||||
@@ -44,7 +57,7 @@ const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: (
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
fallback: (integration, error): FirewallQueryResult<TData> => ({
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
|
||||
summary: createFallback(),
|
||||
error: toPublicIntegrationError(error),
|
||||
@@ -55,8 +68,24 @@ const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: (
|
||||
);
|
||||
|
||||
export const firewallRouter = createTRPCRouter({
|
||||
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })),
|
||||
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []),
|
||||
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })),
|
||||
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })),
|
||||
getFirewallCpuStatus: queryFirewall(
|
||||
firewallCpuRequestHandler,
|
||||
() => ({ total: 0 }),
|
||||
() => mockWidgetData.firewallCpu,
|
||||
),
|
||||
getFirewallInterfacesStatus: queryFirewall(
|
||||
firewallInterfacesRequestHandler,
|
||||
() => [],
|
||||
() => mockWidgetData.firewallInterfaces,
|
||||
),
|
||||
getFirewallVersionStatus: queryFirewall(
|
||||
firewallVersionRequestHandler,
|
||||
() => ({ version: "Unknown" }),
|
||||
() => mockWidgetData.firewallVersion,
|
||||
),
|
||||
getFirewallMemoryStatus: queryFirewall(
|
||||
firewallMemoryRequestHandler,
|
||||
() => ({ used: 0, total: 0, percent: 0 }),
|
||||
() => mockWidgetData.firewallMemory,
|
||||
),
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import {
|
||||
immichAlbumRequestHandler,
|
||||
immichAlbumsRequestHandler,
|
||||
@@ -13,7 +14,8 @@ export const immichRouter = createTRPCRouter({
|
||||
getServerStats: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
|
||||
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
@@ -26,7 +28,13 @@ export const immichRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbum;
|
||||
const innerHandler = immichAlbumRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "immich" },
|
||||
{
|
||||
albumId: input.albumId,
|
||||
},
|
||||
);
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
@@ -39,7 +47,13 @@ export const immichRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit);
|
||||
const innerHandler = immichAlbumsRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "immich" },
|
||||
{
|
||||
limit: input.limit,
|
||||
},
|
||||
);
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -7,7 +8,8 @@ export const paperlessNgxRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
|
||||
const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
@@ -14,7 +15,8 @@ export const patchmonRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "patchmon"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.patchmon;
|
||||
const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
@@ -11,7 +12,17 @@ export const speedtestTrackerRouter = createTRPCRouter({
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: mockWidgetData.speedtestTracker,
|
||||
updatedAt: new Date(mockWidgetData.timestamp),
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
const innerHandler = speedtestTrackerRequestHandler.handler({ ...integration, kind: "speedtestTracker" }, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
@@ -11,16 +12,26 @@ export const tracearrRouter = createTRPCRouter({
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = tracearrRequestHandler.handler(integration, {});
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: mockWidgetData.tracearr,
|
||||
updatedAt: new Date(mockWidgetData.timestamp),
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
const innerHandler = tracearrRequestHandler.handler({ ...integration, kind: "tracearr" }, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
dashboard: data as typeof data | null,
|
||||
updatedAt: timestamp,
|
||||
error: undefined,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
@@ -18,7 +19,17 @@ export const traefikRouter = createTRPCRouter({
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = traefikRequestHandler.handler(integration, {});
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: mockWidgetData.traefik,
|
||||
updatedAt: new Date(mockWidgetData.timestamp),
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
const innerHandler = traefikRequestHandler.handler({ ...integration, kind: "traefik" }, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import {
|
||||
umamiActiveVisitorsRequestHandler,
|
||||
@@ -23,8 +24,9 @@ const logger = createLogger({ module: "umami-router" });
|
||||
|
||||
export const umamiRouter = createTRPCRouter({
|
||||
getWebsites: publicProcedure.concat(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
|
||||
try {
|
||||
const instance = await createIntegrationAsync(ctx.integration);
|
||||
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
|
||||
return await instance.getWebsitesAsync();
|
||||
} catch (error) {
|
||||
logger.warn(new Error("Failed to load websites", { cause: error }));
|
||||
@@ -43,11 +45,23 @@ export const umamiRouter = createTRPCRouter({
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = umamiRequestHandler.handler(integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventName: input.eventName,
|
||||
});
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
visitorStats: { ...mockWidgetData.umamiVisitorStats, timeFrame: input.timeFrame },
|
||||
updatedAt: new Date(mockWidgetData.timestamp),
|
||||
};
|
||||
}
|
||||
const innerHandler = umamiRequestHandler.handler(
|
||||
{ ...integration, kind: "umami" },
|
||||
{
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventName: input.eventName,
|
||||
},
|
||||
);
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
@@ -64,8 +78,14 @@ export const umamiRouter = createTRPCRouter({
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames;
|
||||
try {
|
||||
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
|
||||
const innerHandler = umamiEventNamesRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "umami" },
|
||||
{
|
||||
websiteId: input.websiteId,
|
||||
},
|
||||
);
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
@@ -86,12 +106,16 @@ export const umamiRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit);
|
||||
try {
|
||||
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
});
|
||||
const innerHandler = umamiTopPagesRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "umami" },
|
||||
{
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
},
|
||||
);
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
@@ -116,12 +140,16 @@ export const umamiRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit);
|
||||
try {
|
||||
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
});
|
||||
const innerHandler = umamiTopReferrersRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "umami" },
|
||||
{
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
},
|
||||
);
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
@@ -146,13 +174,19 @@ export const umamiRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName));
|
||||
}
|
||||
try {
|
||||
const sortedNames = [...input.eventNames].toSorted();
|
||||
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventNames: sortedNames,
|
||||
});
|
||||
const innerHandler = umamiMultiEventRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "umami" },
|
||||
{
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventNames: sortedNames,
|
||||
},
|
||||
);
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
@@ -171,10 +205,14 @@ export const umamiRouter = createTRPCRouter({
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") return 7;
|
||||
try {
|
||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
});
|
||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "umami" },
|
||||
{
|
||||
websiteId: input.websiteId,
|
||||
},
|
||||
);
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
@@ -11,7 +12,17 @@ export const uptimeKumaRouter = createTRPCRouter({
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: mockWidgetData.uptimeKuma,
|
||||
updatedAt: new Date(mockWidgetData.timestamp),
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
const innerHandler = uptimeKumaRequestHandler.handler({ ...integration, kind: "uptimeKuma" }, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import type { VpnSummary } from "@homarr/integrations/types";
|
||||
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
||||
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
interface VpnResult {
|
||||
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
|
||||
summary: VpnSummary | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const vpnRouter = createTRPCRouter({
|
||||
getSummaries: publicProcedure
|
||||
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
|
||||
async (integration): Promise<VpnResult> => {
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: new Date(mockWidgetData.timestamp),
|
||||
},
|
||||
summary: mockWidgetData.vpn,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
const { data, timestamp } = await vpnSummaryHandler
|
||||
.handler({ ...integration, kind: "gluetun" }, {})
|
||||
.getDataAsync();
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
@@ -24,7 +47,7 @@ export const vpnRouter = createTRPCRouter({
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
fallback: (integration, error): VpnResult => ({
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -9,12 +10,15 @@ export const wudRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
|
||||
"Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "wud"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
|
||||
if (ctx.integration.kind === "mock") {
|
||||
return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) };
|
||||
}
|
||||
const handler = wudStatsRequestHandler.handler({ ...ctx.integration, kind: "wud" }, {});
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
return {
|
||||
|
||||
@@ -65,6 +65,7 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"mediaServer_getCurrentStreams",
|
||||
"patchmon_getStats",
|
||||
"serverSettings_getBoardSettings",
|
||||
"serverSettings_getBranding",
|
||||
"smartHome_entityDetails",
|
||||
"smartHome_entityState",
|
||||
"widgetSecrets_getConfiguredKinds",
|
||||
|
||||
@@ -13,6 +13,7 @@ export const getRscUserSettingsAsync = cache(async (userId: string) =>
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
enableRightClickOnWidgets: true,
|
||||
headerPreferences: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
|
||||
@@ -144,7 +144,8 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] =
|
||||
type: "function",
|
||||
function: {
|
||||
name: "customWidget_getComponent",
|
||||
description: "Get installed documentation and safety rules for one named Custom JSX component.",
|
||||
description:
|
||||
"Get installed documentation and safety rules for one named Custom JSX component. Fetch only a selected component, one tool call at a time, after any optional example.",
|
||||
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
|
||||
},
|
||||
},
|
||||
@@ -162,7 +163,8 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] =
|
||||
type: "function",
|
||||
function: {
|
||||
name: "customWidget_getExample",
|
||||
description: "Get one installed Custom JSX example by catalog ID.",
|
||||
description:
|
||||
"Optionally get one installed Custom JSX example by catalog ID before requesting any component documentation.",
|
||||
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
|
||||
},
|
||||
},
|
||||
@@ -251,11 +253,24 @@ export function executeAssistantEvaluationTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
): unknown {
|
||||
const componentDocumentCount = state.calledTools.filter(
|
||||
(toolName) => toolName === "customWidget_getComponent",
|
||||
).length;
|
||||
if (name === "customWidget_getExample") {
|
||||
if (componentDocumentCount > 0) {
|
||||
return {
|
||||
error:
|
||||
"An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.",
|
||||
};
|
||||
}
|
||||
const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null;
|
||||
if (!example) return { error: "Custom JSX example not found" };
|
||||
state.calledTools.push(name);
|
||||
const { template, ...widget } = example.widget;
|
||||
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
|
||||
}
|
||||
const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8;
|
||||
if (
|
||||
name === "customWidget_getComponent" &&
|
||||
state.calledTools.filter((toolName) => toolName === "customWidget_getComponent").length >= componentDocumentLimit
|
||||
) {
|
||||
if (name === "customWidget_getComponent" && componentDocumentCount >= componentDocumentLimit) {
|
||||
return {
|
||||
error:
|
||||
"The targeted component-document budget is exhausted. Continue with validation using the documentation already loaded.",
|
||||
@@ -277,12 +292,6 @@ export function executeAssistantEvaluationTool(
|
||||
? getCustomWidgetSharedProps(names)
|
||||
: { error: "At least one shared prop name is required" };
|
||||
}
|
||||
if (name === "customWidget_getExample") {
|
||||
const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null;
|
||||
if (!example) return { error: "Custom JSX example not found" };
|
||||
const { template, ...widget } = example.widget;
|
||||
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
|
||||
}
|
||||
if (name === "customWidget_validate") {
|
||||
const parsed = parseDefinition(input.widget);
|
||||
if (!parsed.success) return { valid: false, issues: parsed.issues };
|
||||
@@ -576,11 +585,11 @@ async function runAssistantAttempt(args: {
|
||||
process.stdout.write(` tool: ${toolCall.function.name}\n`);
|
||||
let output: unknown;
|
||||
try {
|
||||
const parsed = JSON.parse(toolCall.function.arguments) as unknown;
|
||||
const input =
|
||||
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const parsed: unknown = JSON.parse(toolCall.function.arguments);
|
||||
let input: Record<string, unknown> = {};
|
||||
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
input = Object.fromEntries(Object.entries(parsed));
|
||||
}
|
||||
output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input);
|
||||
} catch (error) {
|
||||
output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" };
|
||||
|
||||
@@ -219,7 +219,7 @@ export function buildRepairPrompt(
|
||||
export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string {
|
||||
return `You are a hostile-but-fair product review panel evaluating a safe dashboard widget. Most competent drafts should score 55-75, not 90. Judge only evidence present in the manifest and JSX. Never reward unsupported capabilities, invented API routes, aspirational claims, or code that merely validates.
|
||||
|
||||
The installed Homarr skill and runtime references below are authoritative. In particular, request state is exposed as status.<requestId> with loading/ok/status/error fields while successful payloads are exposed as data.<requestId>. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility.
|
||||
The installed Homarr skill and runtime references below are authoritative. The verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes unless the validated manifest contradicts them. Do not invent external endpoint or authentication objections from outside assumptions. Decorative icons paired with equivalent adjacent visible status text need no separate aria-label. A Badge containing explicit visible status text is not color-only. A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper. In particular, request state is exposed as status.<requestId> with loading/ok/status/error fields while successful payloads are exposed as data.<requestId>. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility.
|
||||
|
||||
Installed Homarr Custom Widget authoring contract:
|
||||
${getCustomWidgetSkillContent()}
|
||||
@@ -254,16 +254,14 @@ Required review behavior:
|
||||
- Give a concrete evidence sentence for every category. List all score-capping issues under fatalProblems.
|
||||
- Return an empty fatalProblems array when there are no fatal problems; never put "none" or an explanation of their absence in that array.
|
||||
|
||||
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 90, every category at least 75, goalFulfillment/visualQuality/dailyUsefulness at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
|
||||
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
|
||||
}
|
||||
|
||||
export function judgePasses(result: CustomWidgetJudgeResult): boolean {
|
||||
return (
|
||||
result.total >= 90 &&
|
||||
result.total >= 85 &&
|
||||
Object.values(result.categories).every((score) => score >= 75) &&
|
||||
result.categories.goalFulfillment >= 85 &&
|
||||
result.categories.visualQuality >= 85 &&
|
||||
result.categories.dailyUsefulness >= 85 &&
|
||||
result.categories.complexityDiscipline >= 80 &&
|
||||
result.dailyUseDecision === "would-use-daily" &&
|
||||
result.fatalProblems.length === 0
|
||||
|
||||
@@ -15,58 +15,55 @@ if (!apiKey) {
|
||||
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length);
|
||||
const assistantMode = process.argv.includes("--assistant");
|
||||
const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10);
|
||||
const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
|
||||
const selectableCases = assistantMode
|
||||
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter(
|
||||
(testCase) =>
|
||||
(testCase.sampleResponse !== undefined || testCase.previewResponses?.length) &&
|
||||
testCase.expectations !== undefined,
|
||||
)
|
||||
: CUSTOM_WIDGET_AI_EVALUATION_CASES;
|
||||
const selectedCases = requestedCase
|
||||
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase)
|
||||
: selectableCases;
|
||||
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
|
||||
if (totalLoopBudget < selectedCases.length) {
|
||||
throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`);
|
||||
const maxLoops = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
|
||||
let selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES;
|
||||
if (assistantMode) {
|
||||
selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter(
|
||||
(testCase) =>
|
||||
(testCase.sampleResponse !== undefined || testCase.previewResponses?.length) &&
|
||||
testCase.expectations !== undefined,
|
||||
);
|
||||
}
|
||||
let selectedCases = selectableCases;
|
||||
if (requestedCase)
|
||||
selectedCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase);
|
||||
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
|
||||
|
||||
const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-");
|
||||
const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId);
|
||||
await mkdir(outputRoot, { recursive: true });
|
||||
|
||||
const results: Array<AiEvaluationResult | CustomWidgetAssistantEvaluationResult> = [];
|
||||
let remainingLoopBudget = totalLoopBudget;
|
||||
for (const testCase of selectedCases) {
|
||||
const remainingCases = selectedCases.length - results.length;
|
||||
const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases));
|
||||
process.stdout.write(`Evaluating ${testCase.id}...\n`);
|
||||
const result = assistantMode
|
||||
? await evaluateCustomWidgetAssistantCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
})
|
||||
: await evaluateCustomWidgetCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
});
|
||||
let result: AiEvaluationResult | CustomWidgetAssistantEvaluationResult;
|
||||
if (assistantMode) {
|
||||
result = await evaluateCustomWidgetAssistantCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
});
|
||||
} else {
|
||||
result = await evaluateCustomWidgetCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
});
|
||||
}
|
||||
results.push(result);
|
||||
remainingLoopBudget -= result.attempts;
|
||||
process.stdout.write(
|
||||
result.judge
|
||||
? ` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`
|
||||
: ` failed after ${result.attempts} attempt(s)\n`,
|
||||
);
|
||||
if (result.judge) {
|
||||
process.stdout.write(` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`);
|
||||
} else {
|
||||
process.stdout.write(` failed after ${result.attempts} attempt(s)\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
|
||||
import { customJsxAuthoringCatalog } from "./component-catalog";
|
||||
import {
|
||||
redactCustomWidgetAiContext,
|
||||
redactCustomWidgetAiResponse,
|
||||
redactCustomWidgetAiText,
|
||||
redactCustomWidgetAiUrl,
|
||||
} from "./definition-security";
|
||||
import { customJsxExamples } from "./examples";
|
||||
|
||||
export interface CustomWidgetAiDraft {
|
||||
@@ -106,19 +112,28 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
|
||||
|
||||
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
|
||||
|
||||
First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release.
|
||||
Mandatory sequence — follow it exactly:
|
||||
1. First call customWidget_getSkill and follow the complete installed skill and bundled references.
|
||||
2. Call customWidget_schema, then customWidget_getComponentCatalog.
|
||||
3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call. Then choose at most four component names total and never request a fifth. Fetch only those named docs and, once, any named shared props with customWidget_getSharedProps.
|
||||
4. Before validation, quickly reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span; use catalog components instead. Immediately call customWidget_validate with the complete definition and repair every issue.
|
||||
5. Call customWidget_previewCreate with that exact validated definition.
|
||||
6. Call customWidget_previewQuery once for every query returned by the preview and inspect every response.
|
||||
7. Call customWidget_createFromPreview with the exact fully tested preview session ID.
|
||||
|
||||
Make one documentation tool call at a time. Never issue batched customWidget_getComponent calls, speculate about components, or walk the catalog. If preview evidence requires a definition change, restart at step 4 with the revised complete definition. The live resources are authoritative for this Homarr release.
|
||||
|
||||
Keep the documentation phase bounded. Fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another component only to resolve a concrete validation issue or missing interaction; never walk the catalog component-by-component. Move promptly from the smallest sufficient documentation set to a complete customWidget_validate call.
|
||||
|
||||
Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_createFromPreview with the exact final tested preview session ID. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs.
|
||||
Construct one credential-free definition. Supply required manual parameters to preview queries, inspect the real response shape and HTTP status, and open or provide the returned previewPath for visual review. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs.
|
||||
|
||||
Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions.
|
||||
|
||||
Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show concise freshness context when the response supplies a timestamp. Give recoverable load errors and empty states a clear refresh or retry path.
|
||||
Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show timestamps as concise human-readable absolute dates and times; do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper. Give recoverable load errors and empty states a clear refresh or retry path.
|
||||
|
||||
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
|
||||
|
||||
Create distinctive polish with one semantic accent, clear surface contrast, purposeful typography, and compact narrow-tile rows rather than decorative gradients or excessive nested cards. Label standalone icons for assistive technology or pair them with visible text.
|
||||
Create distinctive polish with one restrained accent for decorative chrome, while operational, degraded, and down states retain distinct semantic colors. Use clear surface contrast and purposeful typography rather than decorative gradients or excessive nested cards. Do not wrap every metric or service row in Paper or Card. Prefer one dominant surface with clearly separated service rows, a one-column compact narrow layout, and an intentionally more spacious wide layout. Label standalone icons for assistive technology or pair them with visible text.
|
||||
|
||||
Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
|
||||
|
||||
@@ -129,7 +144,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio
|
||||
if (documentationUrl) {
|
||||
const documentationSection = formatBudgetedContextSection(
|
||||
"API documentation URL (reference only)",
|
||||
documentationUrl,
|
||||
redactCustomWidgetAiUrl(documentationUrl),
|
||||
700,
|
||||
"data",
|
||||
);
|
||||
@@ -138,7 +153,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio
|
||||
if (request?.trim()) {
|
||||
const requestSection = formatBudgetedContextSection(
|
||||
"User-authored widget request (product intent only)",
|
||||
request.trim(),
|
||||
redactCustomWidgetAiText(request.trim()),
|
||||
1_700,
|
||||
"request",
|
||||
);
|
||||
@@ -201,7 +216,7 @@ function buildCustomWidgetPromptSections(
|
||||
const sections = [CUSTOM_WIDGET_CONTEXT_BOUNDARY_INSTRUCTION];
|
||||
const requestSection = formatBudgetedContextSection(
|
||||
"User-authored widget request (product intent only)",
|
||||
request?.trim() || "Describe the widget you want to create.",
|
||||
redactCustomWidgetAiText(request?.trim() || "Describe the widget you want to create."),
|
||||
1_700,
|
||||
"request",
|
||||
);
|
||||
@@ -209,7 +224,7 @@ function buildCustomWidgetPromptSections(
|
||||
if (documentationUrl) {
|
||||
const documentationSection = formatBudgetedContextSection(
|
||||
"API documentation URL (reference only)",
|
||||
documentationUrl,
|
||||
redactCustomWidgetAiUrl(documentationUrl),
|
||||
700,
|
||||
"data",
|
||||
);
|
||||
@@ -218,7 +233,7 @@ function buildCustomWidgetPromptSections(
|
||||
if (currentConfig) {
|
||||
const draftSection = formatBudgetedContextSection(
|
||||
"Current raw widget draft",
|
||||
JSON.stringify(currentConfig, null, 2),
|
||||
JSON.stringify(redactCustomWidgetAiContext(currentConfig), null, 2),
|
||||
4_000,
|
||||
"data",
|
||||
);
|
||||
@@ -227,14 +242,19 @@ function buildCustomWidgetPromptSections(
|
||||
if (diagnostics?.length) {
|
||||
const diagnosticSection = formatBudgetedContextSection(
|
||||
"Current normalized diagnostics",
|
||||
JSON.stringify(diagnostics),
|
||||
JSON.stringify(redactCustomWidgetAiContext(diagnostics)),
|
||||
1_500,
|
||||
"data",
|
||||
);
|
||||
if (diagnosticSection) sections.push(diagnosticSection);
|
||||
}
|
||||
if (rawResponse) {
|
||||
const responseSection = formatBudgetedContextSection("Sample API response", rawResponse, 1_000, "data");
|
||||
const responseSection = formatBudgetedContextSection(
|
||||
"Sample API response",
|
||||
redactCustomWidgetAiResponse(rawResponse),
|
||||
1_000,
|
||||
"data",
|
||||
);
|
||||
if (responseSection) sections.push(responseSection);
|
||||
}
|
||||
const authoringBudget = CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - footerLength - 8;
|
||||
|
||||
@@ -191,6 +191,70 @@ export function redactCustomWidgetCredentialLiterals(value: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function redactCustomWidgetAiContext(value: unknown, path: DefinitionPath = []): unknown {
|
||||
if (Array.isArray(value)) return value.map((entry, index) => redactCustomWidgetAiContext(entry, [...path, index]));
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => {
|
||||
const entryPath = [...path, key];
|
||||
const risk = getCustomWidgetCredentialKeyRisk(key);
|
||||
if (risk === "strong" && !isSchemaAuthenticationControl(entryPath)) {
|
||||
if (!isHarmlessCustomWidgetCredentialSetting(entry)) return [key, "[REDACTED]"];
|
||||
}
|
||||
if (risk === "ambiguous" && typeof entry === "string") {
|
||||
if (redactCustomWidgetCredentialLiterals(entry) !== entry) return [key, "[REDACTED]"];
|
||||
}
|
||||
return [key, redactCustomWidgetAiContext(entry, entryPath)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (typeof value !== "string") return value;
|
||||
|
||||
const lastPathPart = path.at(-1);
|
||||
let key = "";
|
||||
if (typeof lastPathPart === "string") key = lastPathPart;
|
||||
if (["sources", "requests", "options"].includes(key)) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return redactCustomWidgetAiContext(parsed, path);
|
||||
} catch {
|
||||
/* Invalid editor JSON is still useful prompt context. */
|
||||
}
|
||||
}
|
||||
if (key === "baseUrl" || key === "iconUrl") {
|
||||
return redactCustomWidgetCredentialLiterals(redactCustomWidgetAiUrl(value));
|
||||
}
|
||||
return redactCustomWidgetAiText(value);
|
||||
}
|
||||
|
||||
export function redactCustomWidgetAiResponse(value: string): string {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return JSON.stringify(redactCustomWidgetAiContext(parsed), null, 2);
|
||||
} catch {
|
||||
return redactCustomWidgetAiText(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function redactCustomWidgetAiUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "[REDACTED_URL]";
|
||||
}
|
||||
}
|
||||
|
||||
export function redactCustomWidgetAiText(value: string): string {
|
||||
return redactCustomWidgetCredentialLiterals(
|
||||
value.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactCustomWidgetAiUrl(candidate)),
|
||||
);
|
||||
}
|
||||
|
||||
function isSchemaAuthenticationControl(path: DefinitionPath): boolean {
|
||||
return (
|
||||
path.length === 3 &&
|
||||
|
||||
@@ -36,8 +36,8 @@ export const customJsxExamples: readonly CustomJsxExample[] = [
|
||||
requests: { overview: { path: "/api/overview", cacheSeconds: 30 } },
|
||||
options: {},
|
||||
template: `<Stack gap="md" p="md">
|
||||
<Group justify="space-between"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton /></Group>
|
||||
{status.overview?.loading ? <Skeleton height={120} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">{status.overview.error}</Alert> : <SimpleGrid cols={{ base: 1, xs: 3 }}>{(data.overview?.services ?? []).map(service => <Paper key={service.name} withBorder p="sm" radius="md"><Group justify="space-between"><Text size="sm" fw={600}>{service.name}</Text><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group><Text mt="xs" size="xl" fw={700}>{service.latency ?? "—"}<Text span size="xs" c="dimmed"> ms</Text></Text></Paper>)}</SimpleGrid>}
|
||||
<Group justify="space-between" wrap="wrap"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton label="Refresh service health" /></Group>
|
||||
{status.overview?.loading ? <Skeleton height={180} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">Service health is unavailable. Use Refresh service health to try again. {status.overview.error}</Alert> : <Paper withBorder p={{ base: "sm", sm: "md" }} radius="lg"><Stack gap="md"><SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing={{ base: "xs", sm: "lg" }}><Stack gap={2}><Text size="xs" c="dimmed">Services</Text><Text fw={700}>{data.overview?.services?.length ?? 0}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Online</Text><Text fw={700} c="green">{(data.overview?.services ?? []).filter(service => service.online).length}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Offline</Text><Text fw={700} c="red">{(data.overview?.services ?? []).filter(service => !service.online).length}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Last checked</Text><Text size="sm">{(data.overview?.checkedAt ?? data.overview?.lastCheckedAt) ? Date.toLocaleDateString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") + " " + Date.toLocaleTimeString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") : "Not reported"}</Text></Stack></SimpleGrid><Divider />{(data.overview?.services?.length ?? 0) > 0 ? <Stack gap={0}>{data.overview.services.map(service => <Group key={service.name} justify="space-between" wrap="wrap" py={{ base: "xs", sm: "sm" }}><Stack gap={2}><Text size="sm" fw={600}>{service.name}</Text><Text size="xs" c="dimmed">{service.latency != null ? service.latency + " ms" : "Latency unavailable"}</Text></Stack><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group>)}</Stack> : <Alert color="gray" title="No services">No services were returned. Use Refresh service health to try again.</Alert>}</Stack></Paper>}
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { customJsxCatalogComponentByName } from "../core/component-catalog";
|
||||
import {
|
||||
customJsxBindableComponentNames,
|
||||
customJsxComponentByName,
|
||||
@@ -31,6 +32,20 @@ export function analyzeCustomJsxElement(
|
||||
const name = nameNode ? customJsxTagName(nameNode) : null;
|
||||
const resolvedName = name ? resolveCustomJsxComponentName(name) : null;
|
||||
const descriptor = resolvedName ? customJsxComponentByName.get(resolvedName) : undefined;
|
||||
const attributes = nodesOf(opening.attributes);
|
||||
if (resolvedName === "TablerIcon") {
|
||||
const attributeNames = new Set<string>();
|
||||
for (const attribute of attributes) {
|
||||
if (attribute.type !== "JSXAttribute") continue;
|
||||
const attributeName = nodeOf(attribute.name);
|
||||
if (attributeName?.type === "JSXIdentifier") attributeNames.add(String(attributeName.name));
|
||||
}
|
||||
for (const prop of customJsxCatalogComponentByName.get(resolvedName)?.props ?? []) {
|
||||
if (prop.required && !attributeNames.has(prop.name)) {
|
||||
context.add(opening, `MISSING_REQUIRED_PROP: '${prop.name}' on ${name} is required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (descriptor?.safety === "denied") {
|
||||
context.add(
|
||||
opening,
|
||||
@@ -46,7 +61,7 @@ export function analyzeCustomJsxElement(
|
||||
);
|
||||
}
|
||||
|
||||
for (const attribute of nodesOf(opening.attributes)) {
|
||||
for (const attribute of attributes) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const argument = nodeOf(attribute.argument);
|
||||
if (argument) context.visit(argument, depth + 1, bindings);
|
||||
@@ -71,7 +86,11 @@ export function analyzeCustomJsxElement(
|
||||
attributeName !== "bind" &&
|
||||
!customJsxSupportedPropsByName.get(resolvedName)?.has(attributeName)
|
||||
) {
|
||||
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
|
||||
if (resolvedName === "TablerIcon") {
|
||||
context.add(attribute, `UNKNOWN_COMPONENT_PROP: '${attributeName}' on ${name} is not supported`);
|
||||
} else {
|
||||
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
|
||||
}
|
||||
} else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) {
|
||||
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge
|
||||
import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy";
|
||||
import { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
type Namespace = Readonly<Record<string, unknown>>;
|
||||
type Namespace = object;
|
||||
|
||||
function resolveExport(namespace: Namespace, name: string): ComponentType<never> | undefined {
|
||||
let value: unknown = namespace;
|
||||
@@ -26,7 +26,7 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
|
||||
if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) {
|
||||
return undefined;
|
||||
}
|
||||
value = (value as Record<string, unknown>)[segment];
|
||||
value = Reflect.get(value, segment);
|
||||
}
|
||||
return typeof value === "function" || (typeof value === "object" && value !== null)
|
||||
? (value as ComponentType<never>)
|
||||
@@ -233,9 +233,9 @@ export interface CustomJsxComponentAdapters {
|
||||
|
||||
export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record<string, ComponentType<never>> {
|
||||
const namespaces: Record<string, Namespace> = {
|
||||
"@mantine/core": Core as unknown as Namespace,
|
||||
"@mantine/charts": Charts as unknown as Namespace,
|
||||
"@mantine/dates": Dates as unknown as Namespace,
|
||||
"@mantine/core": Core,
|
||||
"@mantine/charts": Charts,
|
||||
"@mantine/dates": Dates,
|
||||
};
|
||||
const components: Record<string, ComponentType<never>> = {};
|
||||
for (const descriptor of enabledCustomJsxComponents) {
|
||||
@@ -253,7 +253,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
|
||||
components[descriptor.name] = wrap(descriptor.name, component, additions);
|
||||
}
|
||||
}
|
||||
const core = Core as unknown as Namespace;
|
||||
const core: Namespace = Core;
|
||||
for (const name of [
|
||||
"Notification",
|
||||
"LoadingOverlay",
|
||||
|
||||
@@ -146,6 +146,24 @@ describe("Custom Widget assistant live evaluation harness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a late example without reducing the existing component-document budget", () => {
|
||||
const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
|
||||
if (!testCase) throw new Error("Fake service-health case is missing");
|
||||
const state = createAssistantEvaluationState();
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
executeAssistantEvaluationTool(testCase, state, "customWidget_getComponent", { name: "Text" });
|
||||
}
|
||||
expect(
|
||||
executeAssistantEvaluationTool(testCase, state, "customWidget_getExample", { name: "service-dashboard" }),
|
||||
).toEqual({
|
||||
error:
|
||||
"An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.",
|
||||
});
|
||||
expect(state.calledTools).not.toContain("customWidget_getExample");
|
||||
expect(state.calledTools.filter((name) => name === "customWidget_getComponent")).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("enforces explicit multi-iteration preview requests before persistence", () => {
|
||||
const baseCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
|
||||
if (!baseCase) throw new Error("Fake service-health case is missing");
|
||||
|
||||
@@ -226,14 +226,38 @@ describe("AI authoring evaluation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires exceptional goal, design, practicality, and complexity scores", () => {
|
||||
expect(judgePasses(makeJudgeResult(90))).toBe(true);
|
||||
it("requires an excellent result without weak categories", () => {
|
||||
expect(
|
||||
judgePasses({
|
||||
...makeJudgeResult(85),
|
||||
categories: {
|
||||
...makeCategories(85),
|
||||
visualQuality: 76,
|
||||
dailyUsefulness: 76,
|
||||
complexityDiscipline: 80,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(judgePasses(makeJudgeResult(84))).toBe(false);
|
||||
expect(
|
||||
judgePasses({
|
||||
...makeJudgeResult(90),
|
||||
categories: { ...makeCategories(90), accessibility: 74 },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
judgePasses({
|
||||
...makeJudgeResult(92),
|
||||
categories: { ...makeCategories(92), dailyUsefulness: 84 },
|
||||
categories: { ...makeCategories(92), goalFulfillment: 84 },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
judgePasses({
|
||||
...makeJudgeResult(92),
|
||||
categories: { ...makeCategories(92), complexityDiscipline: 79 },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(judgePasses({ ...makeJudgeResult(92), dailyUseDecision: "promising-but-not-daily" })).toBe(false);
|
||||
expect(judgePasses({ ...makeJudgeResult(95), fatalProblems: ["A requested core action is missing."] })).toBe(false);
|
||||
});
|
||||
|
||||
@@ -257,6 +281,20 @@ describe("AI authoring evaluation", () => {
|
||||
expect(prompt).toContain(JSON.stringify(testCase.sampleResponse, null, 2));
|
||||
expect(prompt).toContain("# Bundled file: references/runtime.md");
|
||||
expect(prompt).toContain("does not prove API correctness, visual quality, usefulness, or accessibility");
|
||||
expect(prompt).toContain(
|
||||
"verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes",
|
||||
);
|
||||
expect(prompt).toContain("Do not invent external endpoint or authentication objections from outside assumptions");
|
||||
expect(prompt).toContain(
|
||||
"Decorative icons paired with equivalent adjacent visible status text need no separate aria-label",
|
||||
);
|
||||
expect(prompt).toContain("A Badge containing explicit visible status text is not color-only");
|
||||
expect(prompt).toContain(
|
||||
"A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
'A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily"',
|
||||
);
|
||||
});
|
||||
|
||||
it("computes the weighted score and refuses inflated advisory verdicts", () => {
|
||||
|
||||
@@ -53,11 +53,46 @@ describe("AI prompt", () => {
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most eight named component documents");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most four after loading a complete example");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Treat preview data as the binding contract");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("freshness context");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make initial states actionable");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("compact narrow-tile rows");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Label standalone icons");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make one documentation tool call at a time");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Never issue batched customWidget_getComponent calls");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"choose at most four component names total and never request a fifth",
|
||||
);
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span",
|
||||
);
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("concise human-readable absolute dates and times");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper",
|
||||
);
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one restrained accent for decorative chrome");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"operational, degraded, and down states retain distinct semantic colors",
|
||||
);
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Do not wrap every metric or service row in Paper or Card");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one dominant surface with clearly separated service rows");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
|
||||
"a one-column compact narrow layout, and an intentionally more spacious wide layout",
|
||||
);
|
||||
const orderedSteps = [
|
||||
"1. First call customWidget_getSkill",
|
||||
"2. Call customWidget_schema",
|
||||
"3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call",
|
||||
"4. Before validation",
|
||||
"5. Call customWidget_previewCreate",
|
||||
"6. Call customWidget_previewQuery once for every query",
|
||||
"7. Call customWidget_createFromPreview",
|
||||
];
|
||||
for (const [index, step] of orderedSteps.entries()) {
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(step);
|
||||
if (index === 0) continue;
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(orderedSteps[index - 1] ?? "")).toBeLessThan(
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(step),
|
||||
);
|
||||
}
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
|
||||
);
|
||||
@@ -74,7 +109,7 @@ describe("AI prompt", () => {
|
||||
|
||||
it("preserves raw context and the final instruction when optional context is large", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(undefined, JSON.stringify({ data: "x".repeat(20_000) }), null, "Build it");
|
||||
expect(prompt).toContain('"data":"');
|
||||
expect(prompt).toContain('"data": "');
|
||||
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
|
||||
expect(prompt.length).toBeLessThanOrEqual(12_000);
|
||||
});
|
||||
@@ -102,4 +137,25 @@ describe("AI prompt", () => {
|
||||
|
||||
expect(prompt).toContain("Use compact cards and show the current latency");
|
||||
});
|
||||
|
||||
it("redacts credentials from widget context", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
JSON.stringify({ token: "sensitive" }),
|
||||
{
|
||||
requests: {
|
||||
status: {
|
||||
path: "/status?credential=Bearer-sk-secret-123456",
|
||||
headers: { "X-Auth": "Bearer sk-secret-123456", "X-Feature-Key": "dashboard-layout" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"Use Authorization: Bearer sk-secret-123456",
|
||||
);
|
||||
|
||||
expect(prompt).not.toContain("sensitive");
|
||||
expect(prompt).not.toContain("sk-secret-123456");
|
||||
expect(prompt).toContain('"X-Auth": "[REDACTED]"');
|
||||
expect(prompt).toContain('"X-Feature-Key": "dashboard-layout"');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getCustomWidgetSkill,
|
||||
getCustomWidgetSkillContent,
|
||||
} from "../core/authoring-resources";
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
|
||||
const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
|
||||
|
||||
@@ -55,6 +56,11 @@ describe("authoring resources", () => {
|
||||
sources: { default: { baseUrl: "https://pokeapi.co" } },
|
||||
},
|
||||
});
|
||||
const serviceDashboard = getCustomWidgetExample("service-dashboard");
|
||||
if (!serviceDashboard) throw new Error("Service dashboard example is missing");
|
||||
expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]);
|
||||
expect(serviceDashboard.widget.template.match(/<Paper/gu)).toHaveLength(1);
|
||||
expect(serviceDashboard.widget.template).toContain('RefreshButton label="Refresh service health"');
|
||||
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
|
||||
expect(catalog.sharedProps).toMatchObject({
|
||||
count: expect.any(Number),
|
||||
|
||||
@@ -43,6 +43,21 @@ describe("shared Custom JSX policy", () => {
|
||||
expect(validateCustomJsxTemplate('<ThemeIcon><Icon name="brand-docker" /></ThemeIcon>')).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects TablerIcon without its required name prop", () => {
|
||||
expect(validateCustomJsxTemplate('<TablerIcon icon="IconHeartbeat" />')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
severity: "error",
|
||||
message: expect.stringContaining("MISSING_REQUIRED_PROP: 'name' on TablerIcon"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
severity: "error",
|
||||
message: expect.stringContaining("UNKNOWN_COMPONENT_PROP: 'icon' on TablerIcon"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts common bounded formatting and collection operations", () => {
|
||||
expect(
|
||||
validateCustomJsxTemplate(
|
||||
|
||||
@@ -277,7 +277,8 @@ function EditorLoader() {
|
||||
function formatCode(value: string, language: "jsx" | "json" | "css") {
|
||||
if (language === "json") {
|
||||
try {
|
||||
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logoAndText","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}') NOT NULL;
|
||||
--> statement-breakpoint
|
||||
INSERT IGNORE INTO `serverSetting` (`setting_key`, `value`)
|
||||
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}');
|
||||
File diff suppressed because it is too large.
Load diff
@@ -337,6 +337,13 @@
|
||||
"when": 1786217088950,
|
||||
"tag": "0047_add_assistant_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "5",
|
||||
"when": 1787556966768,
|
||||
"tag": "0048_add_header_preferences_and_branding",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logoAndText","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "serverSetting" ("setting_key", "value")
|
||||
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}')
|
||||
ON CONFLICT ("setting_key") DO NOTHING;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,13 @@
|
||||
"when": 1786217088954,
|
||||
"tag": "0013_add_assistant_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1787556966592,
|
||||
"tag": "0014_add_header_preferences_and_branding",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -730,6 +730,168 @@ const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): D
|
||||
{ kind: "notifications", xOffset: 3, yOffset: 9, width: 2, height: 2, needsIntegration: true },
|
||||
{ kind: "beszelAlerts", xOffset: 5, yOffset: 9, width: 4, height: 2, needsIntegration: true },
|
||||
|
||||
// Extended integrations
|
||||
{ kind: "mediaTranscoding", xOffset: 0, yOffset: 14, width: 4, height: 3, needsIntegration: true },
|
||||
{ kind: "beszelSystemTable", xOffset: 4, yOffset: 14, width: 4, height: 3, needsIntegration: true },
|
||||
{ kind: "networkControllerSummary", xOffset: 8, yOffset: 14, width: 4, height: 3, needsIntegration: true },
|
||||
{
|
||||
kind: "networkControllerStatus",
|
||||
xOffset: 0,
|
||||
yOffset: 17,
|
||||
width: 3,
|
||||
height: 2,
|
||||
needsIntegration: true,
|
||||
options: { content: "wifi" },
|
||||
},
|
||||
{ kind: "ups", xOffset: 3, yOffset: 17, width: 3, height: 2, needsIntegration: true },
|
||||
{
|
||||
kind: "smartHome-entityState",
|
||||
xOffset: 6,
|
||||
yOffset: 17,
|
||||
width: 3,
|
||||
height: 2,
|
||||
needsIntegration: true,
|
||||
options: { entityId: "sensor.homarr_demo", displayName: "Demo environment", entityUnit: "online" },
|
||||
},
|
||||
{
|
||||
kind: "smartHome-executeAutomation",
|
||||
xOffset: 9,
|
||||
yOffset: 17,
|
||||
width: 3,
|
||||
height: 2,
|
||||
needsIntegration: true,
|
||||
options: { automationId: "automation.homarr_demo", displayName: "Run demo routine" },
|
||||
},
|
||||
|
||||
// Everyday tools
|
||||
{ kind: "dnsHoleControls", xOffset: 0, yOffset: 19, width: 2, height: 2, needsIntegration: true },
|
||||
{
|
||||
kind: "bookmarks",
|
||||
xOffset: 2,
|
||||
yOffset: 19,
|
||||
width: 4,
|
||||
height: 2,
|
||||
needsIntegration: false,
|
||||
options: { title: "Launchpad", layout: "grid", items: appIds.slice(0, 6), openNewTab: true },
|
||||
},
|
||||
{
|
||||
kind: "countdown",
|
||||
xOffset: 6,
|
||||
yOffset: 19,
|
||||
width: 2,
|
||||
height: 2,
|
||||
needsIntegration: false,
|
||||
options: {
|
||||
events: [
|
||||
{
|
||||
id: "demo-new-year",
|
||||
label: "New year 2030",
|
||||
targetUtc: "2030-01-01T00:00:00.000Z",
|
||||
startUtc: "2026-01-01T00:00:00.000Z",
|
||||
timeZone: "Europe/Paris",
|
||||
recurrence: "none",
|
||||
},
|
||||
],
|
||||
showProgress: true,
|
||||
showSeconds: false,
|
||||
},
|
||||
},
|
||||
{ kind: "stockPrice", xOffset: 8, yOffset: 19, width: 2, height: 2, needsIntegration: false },
|
||||
{ kind: "releases", xOffset: 10, yOffset: 19, width: 2, height: 2, needsIntegration: false },
|
||||
{
|
||||
kind: "iframe",
|
||||
xOffset: 0,
|
||||
yOffset: 21,
|
||||
width: 4,
|
||||
height: 3,
|
||||
needsIntegration: false,
|
||||
options: {
|
||||
embedUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
allowScrolling: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "video",
|
||||
xOffset: 4,
|
||||
yOffset: 21,
|
||||
width: 4,
|
||||
height: 3,
|
||||
needsIntegration: false,
|
||||
options: {
|
||||
feedUrl: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
|
||||
hasAutoPlay: false,
|
||||
isMuted: true,
|
||||
hasControls: true,
|
||||
},
|
||||
},
|
||||
{ kind: "minecraftServerStatus", xOffset: 8, yOffset: 21, width: 2, height: 3, needsIntegration: false },
|
||||
{
|
||||
kind: "timetable",
|
||||
xOffset: 10,
|
||||
yOffset: 21,
|
||||
width: 2,
|
||||
height: 3,
|
||||
needsIntegration: false,
|
||||
options: { baseUrl: "https://search.ch", station: { value: "8507000", label: "Bern" } },
|
||||
},
|
||||
|
||||
// Complete mock integration gallery
|
||||
{
|
||||
kind: "immich-albumCarousel",
|
||||
xOffset: 0,
|
||||
yOffset: 24,
|
||||
width: 3,
|
||||
height: 3,
|
||||
needsIntegration: true,
|
||||
options: { albumId: "demo-paris", rotationIntervalSeconds: 8, showPhotoInfo: true, randomizePhotos: false },
|
||||
},
|
||||
{ kind: "coolify", xOffset: 3, yOffset: 24, width: 3, height: 3, needsIntegration: true },
|
||||
{ kind: "systemResources", xOffset: 6, yOffset: 24, width: 3, height: 3, needsIntegration: true },
|
||||
{ kind: "systemDisks", xOffset: 9, yOffset: 24, width: 3, height: 3, needsIntegration: true },
|
||||
|
||||
{ kind: "immich-serverStats", xOffset: 0, yOffset: 27, width: 2, height: 3, needsIntegration: true },
|
||||
{ kind: "paperlessNgx", xOffset: 2, yOffset: 27, width: 2, height: 3, needsIntegration: true },
|
||||
{ kind: "patchmon", xOffset: 4, yOffset: 27, width: 2, height: 3, needsIntegration: true },
|
||||
{
|
||||
kind: "speedtestTracker",
|
||||
xOffset: 6,
|
||||
yOffset: 27,
|
||||
width: 4,
|
||||
height: 3,
|
||||
needsIntegration: true,
|
||||
options: { showLatestResult: true, showStats: true, showRecentResults: false, showPingGraph: false },
|
||||
},
|
||||
{ kind: "audioStats", xOffset: 10, yOffset: 27, width: 2, height: 3, needsIntegration: true },
|
||||
|
||||
{ kind: "uptimeKuma", xOffset: 0, yOffset: 30, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "firewall", xOffset: 3, yOffset: 30, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "vpn", xOffset: 6, yOffset: 30, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "bazarr", xOffset: 9, yOffset: 30, width: 3, height: 2, needsIntegration: true },
|
||||
|
||||
{ kind: "archiveTeamWarrior", xOffset: 0, yOffset: 32, width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "wud", xOffset: 3, yOffset: 32, width: 3, height: 2, needsIntegration: true },
|
||||
{
|
||||
kind: "anchorNote",
|
||||
xOffset: 6,
|
||||
yOffset: 32,
|
||||
width: 2,
|
||||
height: 2,
|
||||
needsIntegration: true,
|
||||
options: { noteId: "homarr-demo-note", showTitle: true, showUpdatedAt: true },
|
||||
},
|
||||
{ kind: "tracearr", xOffset: 8, yOffset: 32, width: 4, height: 2, needsIntegration: true },
|
||||
|
||||
{ kind: "traefik", xOffset: 0, yOffset: 34, width: 6, height: 2, needsIntegration: true },
|
||||
{
|
||||
kind: "umami",
|
||||
xOffset: 6,
|
||||
yOffset: 34,
|
||||
width: 6,
|
||||
height: 2,
|
||||
needsIntegration: true,
|
||||
options: { websiteId: "homarr-demo", timeFrame: "24h", viewMode: "chart", chartType: "bar" },
|
||||
},
|
||||
|
||||
// Right app rail
|
||||
...appIds.map(
|
||||
(appId, index): DemoWidget => ({
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logoAndText","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `serverSetting` (`setting_key`, `value`)
|
||||
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}')
|
||||
ON CONFLICT (`setting_key`) DO NOTHING;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -323,6 +323,13 @@
|
||||
"when": 1786217088950,
|
||||
"tag": "0045_add_assistant_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "6",
|
||||
"when": 1787556956665,
|
||||
"tag": "0046_add_header_preferences_and_branding",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys, parseBrandingSettings } from "@homarr/server-settings";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
@@ -18,10 +18,15 @@ export const getServerSettingsAsync = async (db: Database) => {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const parsedSetting = SuperJSON.parse<Record<string, unknown>>(setting.value);
|
||||
if (settingKey === "branding") {
|
||||
acc[settingKey] = parseBrandingSettings(parsedSetting) as never;
|
||||
return acc;
|
||||
}
|
||||
acc[settingKey] = {
|
||||
...defaultServerSettings[settingKey],
|
||||
...SuperJSON.parse(setting.value),
|
||||
};
|
||||
...parsedSetting,
|
||||
} as never;
|
||||
return acc;
|
||||
}, {} as ServerSettings);
|
||||
};
|
||||
@@ -35,7 +40,14 @@ export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettin
|
||||
return defaultServerSettings[key];
|
||||
}
|
||||
|
||||
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
|
||||
const parsedSetting = SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
|
||||
if (key === "branding") {
|
||||
return parseBrandingSettings(parsedSetting) as ServerSettings[TKey];
|
||||
}
|
||||
return {
|
||||
...defaultServerSettings[key],
|
||||
...parsedSetting,
|
||||
} as ServerSettings[TKey];
|
||||
};
|
||||
|
||||
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
WidgetKind,
|
||||
} from "@homarr/definitions";
|
||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||
import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences";
|
||||
|
||||
const customBlob = customType<{ data: Buffer }>({
|
||||
dataType() {
|
||||
@@ -85,6 +86,7 @@ export const users = mysqlTable("user", {
|
||||
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
WidgetKind,
|
||||
} from "@homarr/definitions";
|
||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||
import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences";
|
||||
|
||||
const customBlob = customType<{ data: Buffer }>({
|
||||
dataType() {
|
||||
@@ -83,6 +84,7 @@ export const users = pgTable("user", {
|
||||
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
enableRightClickOnWidgets: boolean().default(true).notNull(),
|
||||
headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(),
|
||||
completedManageTour: boolean().default(false).notNull(),
|
||||
completedBoardTour: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
WidgetKind,
|
||||
} from "@homarr/definitions";
|
||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||
import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences";
|
||||
|
||||
export * from "@homarr/core/infrastructure/certificates/hostnames/db/sqlite";
|
||||
|
||||
@@ -65,6 +66,7 @@ export const users = sqliteTable("user", {
|
||||
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
||||
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
|
||||
headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(),
|
||||
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import SuperJSON from "superjson";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { seedDataAsync } from "../migrations/seed";
|
||||
@@ -45,7 +46,7 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("SQLite migrations seed the redesigned demo dashboard", async () => {
|
||||
test("SQLite migrations seed the complete demo widget gallery", async () => {
|
||||
const previousDemoMode = process.env.DEMO_MODE;
|
||||
process.env.DEMO_MODE = "true";
|
||||
const connection = new BetterSqlite3(":memory:");
|
||||
@@ -65,7 +66,7 @@ test("SQLite migrations seed the redesigned demo dashboard", async () => {
|
||||
with: {
|
||||
layouts: true,
|
||||
sections: { with: { collapseStates: true, layouts: true } },
|
||||
items: { with: { layouts: true } },
|
||||
items: { with: { integrations: true, layouts: true } },
|
||||
},
|
||||
});
|
||||
if (!board) throw new Error("Demo board was not seeded");
|
||||
@@ -126,6 +127,18 @@ test("SQLite migrations seed the redesigned demo dashboard", async () => {
|
||||
expectItemLayout("notifications", 3, 9, 2, 2);
|
||||
expectItemLayout("beszelAlerts", 5, 9, 4, 2);
|
||||
|
||||
expect(new Set(board.items.map((item) => item.kind))).toEqual(new Set(widgetKinds));
|
||||
const mockIntegration = await database.query.integrations.findFirst({
|
||||
where: (table, { eq }) => eq(table.kind, "mock"),
|
||||
});
|
||||
if (!mockIntegration) throw new Error("Demo mock integration was not seeded");
|
||||
for (const item of board.items) {
|
||||
if (widgetIntegrationSupport[item.kind] === undefined) continue;
|
||||
expect(item.integrations).toEqual([
|
||||
expect.objectContaining({ integrationId: mockIntegration.id, itemId: item.id }),
|
||||
]);
|
||||
}
|
||||
|
||||
for (const kind of ["weather", "airQuality"]) {
|
||||
const item = itemByKind(kind);
|
||||
if (!item?.options) throw new Error(`Demo ${kind} options were not seeded`);
|
||||
|
||||
@@ -26,6 +26,12 @@ describe("widget integration config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("supports the mock integration for every integration-backed widget", () => {
|
||||
for (const config of Object.values(widgetIntegrationConfigs)) {
|
||||
expect(config.supportedIntegrations).toContain("mock");
|
||||
}
|
||||
});
|
||||
|
||||
test("derives optional connections and server selection limits", () => {
|
||||
expect(widgetKindsWithOptionalIntegrations).toEqual(new Set(["calendar"]));
|
||||
expect(widgetIntegrationLimits).toEqual({
|
||||
|
||||
@@ -59,34 +59,34 @@ export const widgetIntegrationConfigs = {
|
||||
healthMonitoring: {
|
||||
supportedIntegrations: [...healthMonitoringGeneralIntegrationKinds, ...healthMonitoringClusterIntegrationKinds],
|
||||
},
|
||||
firewall: { supportedIntegrations: getIntegrationKindsByCategory("firewall") },
|
||||
firewall: { supportedIntegrations: [...getIntegrationKindsByCategory("firewall"), "mock"] },
|
||||
notifications: { supportedIntegrations: getIntegrationKindsByCategory("notifications") },
|
||||
mediaReleases: { supportedIntegrations: ["mock", "emby", "jellyfin", "plex"] },
|
||||
systemResources: {
|
||||
supportedIntegrations: [...healthMonitoringGeneralIntegrationKinds],
|
||||
supportedIntegrations: [...healthMonitoringSystemIntegrationKinds],
|
||||
},
|
||||
systemDisks: { supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] },
|
||||
systemDisks: { supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] },
|
||||
beszelAlerts: { supportedIntegrations: ["beszel", "mock"] },
|
||||
beszelSystemGrid: { supportedIntegrations: ["beszel", "mock"] },
|
||||
beszelSystemStats: { supportedIntegrations: ["beszel", "mock"] },
|
||||
beszelSystemTable: { supportedIntegrations: ["beszel", "mock"] },
|
||||
coolify: { supportedIntegrations: ["coolify"] },
|
||||
"immich-serverStats": { supportedIntegrations: ["immich"], maxIntegrations: 1 },
|
||||
"immich-albumCarousel": { supportedIntegrations: ["immich"], maxIntegrations: 1 },
|
||||
paperlessNgx: { supportedIntegrations: ["paperlessNgx"], maxIntegrations: 1 },
|
||||
patchmon: { supportedIntegrations: ["patchmon"], maxIntegrations: 1 },
|
||||
bazarr: { supportedIntegrations: ["bazarr"], maxIntegrations: 1 },
|
||||
tracearr: { supportedIntegrations: ["tracearr"] },
|
||||
speedtestTracker: { supportedIntegrations: ["speedtestTracker"] },
|
||||
uptimeKuma: { supportedIntegrations: ["uptimeKuma"] },
|
||||
audioStats: { supportedIntegrations: ["navidrome", "audiobookshelf"], maxIntegrations: 1 },
|
||||
umami: { supportedIntegrations: ["umami"], maxIntegrations: 1 },
|
||||
vpn: { supportedIntegrations: getIntegrationKindsByCategory("vpn") },
|
||||
coolify: { supportedIntegrations: ["coolify", "mock"] },
|
||||
"immich-serverStats": { supportedIntegrations: ["immich", "mock"], maxIntegrations: 1 },
|
||||
"immich-albumCarousel": { supportedIntegrations: ["immich", "mock"], maxIntegrations: 1 },
|
||||
paperlessNgx: { supportedIntegrations: ["paperlessNgx", "mock"], maxIntegrations: 1 },
|
||||
patchmon: { supportedIntegrations: ["patchmon", "mock"], maxIntegrations: 1 },
|
||||
bazarr: { supportedIntegrations: ["bazarr", "mock"], maxIntegrations: 1 },
|
||||
tracearr: { supportedIntegrations: ["tracearr", "mock"] },
|
||||
speedtestTracker: { supportedIntegrations: ["speedtestTracker", "mock"] },
|
||||
uptimeKuma: { supportedIntegrations: ["uptimeKuma", "mock"] },
|
||||
audioStats: { supportedIntegrations: ["navidrome", "audiobookshelf", "mock"], maxIntegrations: 1 },
|
||||
umami: { supportedIntegrations: ["umami", "mock"], maxIntegrations: 1 },
|
||||
vpn: { supportedIntegrations: [...getIntegrationKindsByCategory("vpn"), "mock"] },
|
||||
ups: { supportedIntegrations: getIntegrationKindsByCategory("ups") },
|
||||
archiveTeamWarrior: { supportedIntegrations: ["archiveTeamWarrior"], maxIntegrations: 1 },
|
||||
anchorNote: { supportedIntegrations: ["anchor"], maxIntegrations: 1 },
|
||||
traefik: { supportedIntegrations: ["traefik"] },
|
||||
wud: { supportedIntegrations: ["wud"], maxIntegrations: 1 },
|
||||
archiveTeamWarrior: { supportedIntegrations: ["archiveTeamWarrior", "mock"], maxIntegrations: 1 },
|
||||
anchorNote: { supportedIntegrations: ["anchor", "mock"], maxIntegrations: 1 },
|
||||
traefik: { supportedIntegrations: ["traefik", "mock"] },
|
||||
wud: { supportedIntegrations: ["wud", "mock"], maxIntegrations: 1 },
|
||||
} satisfies Partial<Record<WidgetKind, WidgetIntegrationConfig>>;
|
||||
|
||||
export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs;
|
||||
@@ -206,6 +206,18 @@ export const generalWidgets: WidgetKind[] = defaultWidgetConfigs
|
||||
.filter((config) => !config.skip)
|
||||
.map((config) => config.kind);
|
||||
|
||||
export const featuredIntegrations: readonly IntegrationKind[] = [
|
||||
"sonarr",
|
||||
"radarr",
|
||||
"prowlarr",
|
||||
"sabNzbd",
|
||||
"qBittorrent",
|
||||
"seerr",
|
||||
"jellyfin",
|
||||
];
|
||||
|
||||
export const hiddenFromOnboarding = new Set<IntegrationKind>(["mock"]);
|
||||
|
||||
export const defaultBookmarkApps = [
|
||||
{
|
||||
name: "Homarr Docs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler, KeyboardEventHandler } from "react";
|
||||
import type { FocusEventHandler, KeyboardEventHandler, ReactNode } from "react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -48,6 +48,7 @@ interface IconPickerProps {
|
||||
onFocus?: FocusEventHandler;
|
||||
onBlur?: FocusEventHandler;
|
||||
label?: string;
|
||||
description?: ReactNode;
|
||||
placeholder?: string;
|
||||
suggestedSearch?: string | null;
|
||||
withAsterisk?: boolean;
|
||||
@@ -63,6 +64,7 @@ export const IconPicker = ({
|
||||
onBlur,
|
||||
withAsterisk = true,
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
suggestedSearch,
|
||||
}: IconPickerProps) => {
|
||||
@@ -272,6 +274,7 @@ export const IconPicker = ({
|
||||
(query.data
|
||||
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
|
||||
: tCommon("iconPicker.headerLoading"));
|
||||
const inputDescription = description === undefined ? tCommon("iconPicker.description") : description;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
@@ -359,7 +362,7 @@ export const IconPicker = ({
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
description={tCommon("iconPicker.description")}
|
||||
description={inputDescription}
|
||||
styles={{
|
||||
description: { color: "var(--mantine-color-text)" },
|
||||
input: { color: "var(--mantine-color-text)" },
|
||||
|
||||
@@ -45,6 +45,7 @@ export { UmamiIntegration } from "./umami/umami-integration";
|
||||
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
||||
export { BazarrIntegration } from "./bazarr/bazarr-integration";
|
||||
export { TraefikIntegration } from "./traefik/traefik-integration";
|
||||
export { mockWidgetData } from "./mock/widget-data";
|
||||
|
||||
// Types
|
||||
export type { IntegrationInput } from "./base/integration";
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { AnchorNote, AnchorNoteSummary } from "../anchor/anchor-types";
|
||||
import type { ArchiveTeamWarriorStatus } from "../archive-team-warrior/archive-team-warrior-types";
|
||||
import type { BazarrBadges } from "../bazarr/bazarr-types";
|
||||
import type { CoolifyInstanceInfo } from "../coolify/coolify-types";
|
||||
import type { ImmichAlbum, ImmichServerStats } from "../immich/immich-integration";
|
||||
import type { NavidromeDashboardData } from "../navidrome/navidrome-types";
|
||||
import type { PaperlessNgxStats } from "../paperless-ngx/paperless-ngx-types";
|
||||
import type { PatchMonStats } from "../patchmon/patchmon-types";
|
||||
import type { SpeedtestTrackerDashboardData } from "../speedtest-tracker/speedtest-tracker-types";
|
||||
import type { TracearrDashboardData } from "../tracearr/tracearr-types";
|
||||
import type { TraefikDashboardData } from "../traefik/traefik-types";
|
||||
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats, UmamiWebsite } from "../umami/umami-types";
|
||||
import type { UptimeKumaDashboardData } from "../uptime-kuma/uptime-kuma-types";
|
||||
import type { WudStats } from "../wud/wud-types";
|
||||
import type {
|
||||
FirewallCpuSummary,
|
||||
FirewallInterfacesSummary,
|
||||
FirewallMemorySummary,
|
||||
FirewallVersionSummary,
|
||||
} from "../interfaces/firewall-summary/firewall-summary-types";
|
||||
import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
|
||||
|
||||
const demoTimestamp = "2026-08-24T12:00:00.000Z";
|
||||
|
||||
const anchorNote: AnchorNote = {
|
||||
id: "homarr-demo-note",
|
||||
title: "Homarr demo runbook",
|
||||
content: JSON.stringify({
|
||||
ops: [
|
||||
{ insert: "Welcome to the complete Homarr widget gallery.\n", attributes: { bold: true } },
|
||||
{ insert: "Every integration-backed card below is powered by safe mock data.\n" },
|
||||
],
|
||||
}),
|
||||
createdAt: demoTimestamp,
|
||||
updatedAt: demoTimestamp,
|
||||
isPinned: true,
|
||||
isArchived: false,
|
||||
tagIds: ["demo"],
|
||||
permission: "editor",
|
||||
background: null,
|
||||
userId: "demo",
|
||||
};
|
||||
|
||||
export const mockWidgetData = {
|
||||
timestamp: demoTimestamp,
|
||||
anchorNote,
|
||||
anchorNotes: [
|
||||
{
|
||||
id: anchorNote.id,
|
||||
title: anchorNote.title,
|
||||
updatedAt: anchorNote.updatedAt,
|
||||
isPinned: anchorNote.isPinned,
|
||||
tagIds: anchorNote.tagIds,
|
||||
permission: anchorNote.permission,
|
||||
},
|
||||
] satisfies AnchorNoteSummary[],
|
||||
archiveTeamWarrior: {
|
||||
status: "running",
|
||||
runnerStatus: "running",
|
||||
project: { id: "urlteam2", title: "URLTeam 2" },
|
||||
selectedProject: "urlteam2",
|
||||
broadcastMessage: "Archiving the open web, one URL at a time.",
|
||||
bandwidth: {
|
||||
received: 8_942_000_000,
|
||||
sent: 1_284_000_000,
|
||||
receiving: 4_800_000,
|
||||
sending: 720_000,
|
||||
session_id: "homarr-demo-session",
|
||||
},
|
||||
items: [
|
||||
{ id: "demo-1", name: "urls-20260824-01", status: "running", project: "urlteam2", startTime: 1_777_000_000 },
|
||||
{ id: "demo-2", name: "urls-20260824-00", status: "completed", project: "urlteam2" },
|
||||
],
|
||||
counts: { running: 1, completed: 284, failed: 2, canceled: 0 },
|
||||
updatedAt: demoTimestamp,
|
||||
} satisfies ArchiveTeamWarriorStatus,
|
||||
audioStats: { artistCount: 842, albumCount: 1_976, songCount: 24_681 } satisfies NavidromeDashboardData,
|
||||
bazarr: {
|
||||
episodes: 12,
|
||||
movies: 4,
|
||||
providers: 6,
|
||||
status: 0,
|
||||
sonarr_signalr: "connected",
|
||||
radarr_signalr: "connected",
|
||||
announcements: 1,
|
||||
} satisfies BazarrBadges,
|
||||
coolify: {
|
||||
version: "4.0.0-beta.420",
|
||||
servers: [
|
||||
{
|
||||
id: 1,
|
||||
uuid: "homarr-demo-edge",
|
||||
name: "Paris edge",
|
||||
ip: "10.0.0.20",
|
||||
is_reachable: true,
|
||||
is_usable: true,
|
||||
settings: { server_id: 1, is_build_server: true, is_reachable: true, is_usable: true },
|
||||
},
|
||||
],
|
||||
applications: [
|
||||
{
|
||||
id: 1,
|
||||
uuid: "homarr-demo-app",
|
||||
name: "Homarr",
|
||||
status: "running:healthy",
|
||||
fqdn: "https://homarr.dev",
|
||||
projectName: "Home cloud",
|
||||
environmentName: "Production",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: "media-demo-app",
|
||||
name: "Media stack",
|
||||
status: "running:healthy",
|
||||
fqdn: null,
|
||||
projectName: "Home cloud",
|
||||
environmentName: "Production",
|
||||
},
|
||||
],
|
||||
services: [
|
||||
{
|
||||
id: 3,
|
||||
uuid: "postgres-demo-service",
|
||||
name: "Postgres",
|
||||
status: "running:healthy",
|
||||
fqdn: null,
|
||||
applications: [],
|
||||
projectName: "Home cloud",
|
||||
environmentName: "Production",
|
||||
},
|
||||
],
|
||||
} satisfies CoolifyInstanceInfo,
|
||||
firewallCpu: { total: 23 } satisfies FirewallCpuSummary,
|
||||
firewallInterfaces: [
|
||||
{
|
||||
data: [
|
||||
{ name: "WAN", receive: 18_400_000, transmit: 4_200_000 },
|
||||
{ name: "LAN", receive: 46_700_000, transmit: 21_300_000 },
|
||||
],
|
||||
timestamp: new Date(demoTimestamp),
|
||||
},
|
||||
] satisfies FirewallInterfacesSummary[],
|
||||
firewallMemory: { used: 3_220_000_000, total: 8_590_000_000, percent: 37.5 } satisfies FirewallMemorySummary,
|
||||
firewallVersion: { version: "24.7.11" } satisfies FirewallVersionSummary,
|
||||
immichStats: {
|
||||
userCount: 4,
|
||||
photoCount: 48_392,
|
||||
videoCount: 2_418,
|
||||
totalLibraryUsageInBytes: 684_510_412_800,
|
||||
} satisfies ImmichServerStats,
|
||||
immichAlbums: [
|
||||
{ id: "demo-paris", albumName: "Paris weekend", assetCount: 128 },
|
||||
{ id: "demo-mountains", albumName: "Alpine trails", assetCount: 84 },
|
||||
],
|
||||
immichAlbum: {
|
||||
id: "demo-paris",
|
||||
albumName: "Paris weekend",
|
||||
assets: [
|
||||
{
|
||||
id: "demo-photo-1",
|
||||
thumbhash: null,
|
||||
fileModifiedAt: demoTimestamp,
|
||||
fileCreatedAt: demoTimestamp,
|
||||
updatedAt: demoTimestamp,
|
||||
type: "IMAGE",
|
||||
publicLink: "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1200&q=80",
|
||||
},
|
||||
{
|
||||
id: "demo-photo-2",
|
||||
thumbhash: null,
|
||||
fileModifiedAt: demoTimestamp,
|
||||
fileCreatedAt: demoTimestamp,
|
||||
updatedAt: demoTimestamp,
|
||||
type: "IMAGE",
|
||||
publicLink: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1200&q=80",
|
||||
},
|
||||
],
|
||||
} satisfies ImmichAlbum,
|
||||
paperlessNgx: {
|
||||
documentsTotal: 2_847,
|
||||
documentsInbox: 14,
|
||||
correspondentsCount: 96,
|
||||
tagsCount: 38,
|
||||
documentTypesCount: 12,
|
||||
} satisfies PaperlessNgxStats,
|
||||
patchmon: {
|
||||
totalHosts: 18,
|
||||
hostsNeedingUpdates: 5,
|
||||
securityUpdates: 7,
|
||||
upToDateHosts: 13,
|
||||
hostsWithSecurityUpdates: 3,
|
||||
recentUpdates24h: 11,
|
||||
totalOutdatedPackages: 26,
|
||||
totalRepos: 42,
|
||||
lastUpdated: demoTimestamp,
|
||||
osDistribution: [
|
||||
{ name: "Ubuntu 24.04", count: 9, osType: "linux", osVersion: "24.04" },
|
||||
{ name: "Debian 13", count: 6, osType: "linux", osVersion: "13" },
|
||||
{ name: "Fedora 42", count: 3, osType: "linux", osVersion: "42" },
|
||||
],
|
||||
} satisfies PatchMonStats,
|
||||
speedtestTracker: {
|
||||
latestResult: {
|
||||
id: 42,
|
||||
ping: 8.4,
|
||||
download_bits: 936_000_000,
|
||||
upload_bits: 487_000_000,
|
||||
healthy: true,
|
||||
created_at: new Date(demoTimestamp),
|
||||
},
|
||||
stats: {
|
||||
ping: { avg: 9.1, min: 6.8, max: 14.2 },
|
||||
download: { avg: 901, avg_bits: 901_000_000, min: 822, max: 948 },
|
||||
upload: { avg: 472, avg_bits: 472_000_000, min: 431, max: 495 },
|
||||
total_results: 128,
|
||||
},
|
||||
recentResults: [
|
||||
{
|
||||
id: 42,
|
||||
ping: 8.4,
|
||||
download_bits: 936_000_000,
|
||||
upload_bits: 487_000_000,
|
||||
healthy: true,
|
||||
created_at: new Date(demoTimestamp),
|
||||
},
|
||||
],
|
||||
} satisfies SpeedtestTrackerDashboardData,
|
||||
tracearr: {
|
||||
stats: { activeStreams: 2, totalUsers: 14, totalSessions: 1_248, recentViolations: 1, timestamp: demoTimestamp },
|
||||
streams: {
|
||||
data: [],
|
||||
summary: {
|
||||
total: 2,
|
||||
transcodes: 1,
|
||||
directStreams: 0,
|
||||
directPlays: 1,
|
||||
totalBitrate: "24.8 Mbps",
|
||||
byServer: [
|
||||
{
|
||||
serverId: "demo",
|
||||
serverName: "Homarr Media",
|
||||
total: 2,
|
||||
transcodes: 1,
|
||||
directStreams: 0,
|
||||
directPlays: 1,
|
||||
totalBitrate: "24.8 Mbps",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
violations: { data: [], meta: { total: 1, page: 1, pageSize: 10 } },
|
||||
recentActivity: { data: [], meta: { total: 1_248, page: 1, pageSize: 10 } },
|
||||
} satisfies TracearrDashboardData,
|
||||
traefik: {
|
||||
version: "3.5.0",
|
||||
entryPoints: ["web", "websecure", "metrics"],
|
||||
failedEndpoints: [],
|
||||
resources: [
|
||||
{ protocol: "http", type: "router", name: "homarr@docker", provider: "docker", status: "enabled" },
|
||||
{ protocol: "http", type: "service", name: "homarr@docker", provider: "docker", status: "enabled" },
|
||||
{ protocol: "http", type: "middleware", name: "secure-headers@file", provider: "file", status: "enabled" },
|
||||
],
|
||||
http: {
|
||||
routers: { total: 12, enabled: 12, warnings: 0, errors: 0 },
|
||||
services: { total: 10, enabled: 10, warnings: 0, errors: 0 },
|
||||
middlewares: { total: 8, enabled: 8, warnings: 0, errors: 0 },
|
||||
},
|
||||
tcp: {
|
||||
routers: { total: 2, enabled: 2, warnings: 0, errors: 0 },
|
||||
services: { total: 2, enabled: 2, warnings: 0, errors: 0 },
|
||||
middlewares: { total: 0, enabled: 0, warnings: 0, errors: 0 },
|
||||
},
|
||||
udp: {
|
||||
routers: { total: 1, enabled: 1, warnings: 0, errors: 0 },
|
||||
services: { total: 1, enabled: 1, warnings: 0, errors: 0 },
|
||||
},
|
||||
} satisfies TraefikDashboardData,
|
||||
umamiWebsites: [{ id: "homarr-demo", name: "Homarr demo", domain: "demo.homarr.dev" }] satisfies UmamiWebsite[],
|
||||
umamiVisitorStats: {
|
||||
domain: "demo.homarr.dev",
|
||||
websiteId: "homarr-demo",
|
||||
websiteName: "Homarr demo",
|
||||
totalVisitors: 8_429,
|
||||
totalPageviews: 21_384,
|
||||
totalVisits: 11_207,
|
||||
bounceRate: 32.4,
|
||||
avgDuration: 284,
|
||||
timeFrame: "24h",
|
||||
dataPoints: [
|
||||
{ timestamp: "2026-08-24T08:00:00.000Z", visitors: 280, events: 0 },
|
||||
{ timestamp: "2026-08-24T10:00:00.000Z", visitors: 410, events: 0 },
|
||||
{ timestamp: "2026-08-24T12:00:00.000Z", visitors: 536, events: 0 },
|
||||
],
|
||||
eventCount: 0,
|
||||
} satisfies UmamiVisitorStats,
|
||||
umamiEventNames: ["dashboard_opened", "widget_added", "board_shared"],
|
||||
umamiTopPages: [
|
||||
{ x: "/", y: 8_240 },
|
||||
{ x: "/boards/home", y: 5_812 },
|
||||
{ x: "/manage/integrations", y: 1_942 },
|
||||
] satisfies UmamiMetricItem[],
|
||||
umamiTopReferrers: [
|
||||
{ x: "github.com", y: 3_842 },
|
||||
{ x: "google.com", y: 2_106 },
|
||||
{ x: "docs.homarr.dev", y: 1_284 },
|
||||
] satisfies UmamiMetricItem[],
|
||||
umamiEventSeries: [
|
||||
{
|
||||
eventName: "dashboard_opened",
|
||||
dataPoints: [
|
||||
{ x: "2026-08-24T08:00:00.000Z", y: 92 },
|
||||
{ x: "2026-08-24T10:00:00.000Z", y: 134 },
|
||||
{ x: "2026-08-24T12:00:00.000Z", y: 188 },
|
||||
],
|
||||
},
|
||||
] satisfies UmamiEventSeries[],
|
||||
uptimeKuma: {
|
||||
totalMonitors: 6,
|
||||
upCount: 5,
|
||||
downCount: 1,
|
||||
pausedCount: 0,
|
||||
averageUptimePercent: 99.72,
|
||||
monitors: [
|
||||
{ id: 1, name: "Homarr", status: "up", uptimePercent24h: 100 },
|
||||
{ id: 2, name: "Media server", status: "up", uptimePercent24h: 99.98 },
|
||||
{ id: 3, name: "Home cloud", status: "up", uptimePercent24h: 99.91 },
|
||||
{ id: 4, name: "Backups", status: "up", uptimePercent24h: 99.62 },
|
||||
{ id: 5, name: "VPN", status: "up", uptimePercent24h: 99.8 },
|
||||
{ id: 6, name: "Lab service", status: "down", uptimePercent24h: 98.99 },
|
||||
],
|
||||
} satisfies UptimeKumaDashboardData,
|
||||
vpn: {
|
||||
vpnStatus: "running",
|
||||
dnsStatus: "running",
|
||||
publicIp: "198.51.100.42",
|
||||
country: "France",
|
||||
city: "Paris",
|
||||
vpnProvider: { protocol: "wireguard", provider: "Mullvad" },
|
||||
} satisfies VpnSummary,
|
||||
wud: {
|
||||
totalContainers: 24,
|
||||
updatesAvailable: 3,
|
||||
updates: [
|
||||
{
|
||||
id: "homarr",
|
||||
name: "Homarr",
|
||||
currentVersion: "v1.35.0",
|
||||
newVersion: "v1.36.0",
|
||||
link: "https://github.com/homarr-labs/homarr",
|
||||
},
|
||||
{ id: "postgres", name: "Postgres", currentVersion: "17.5", newVersion: "17.6", link: null },
|
||||
{ id: "redis", name: "Redis", currentVersion: "8.0.2", newVersion: "8.0.3", link: null },
|
||||
],
|
||||
} satisfies WudStats,
|
||||
} as const;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
|
||||
import { OnboardingBackdrop } from "./onboarding-backdrop";
|
||||
@@ -11,17 +11,72 @@ interface OnboardingAuthShellProps {
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
appName?: string;
|
||||
showAppName?: boolean;
|
||||
showAppLogo?: boolean;
|
||||
primaryColor?: string;
|
||||
secondaryColor?: string;
|
||||
wordmarkPrimaryColor?: string;
|
||||
wordmarkSecondaryColor?: string;
|
||||
logoImageUrl?: string;
|
||||
backgroundImageUrl?: string;
|
||||
backgroundOverlay?: number;
|
||||
radius?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
}
|
||||
|
||||
export const OnboardingAuthShell = ({ title, description, icon, children, footer }: OnboardingAuthShellProps) => (
|
||||
<main className={classes.page}>
|
||||
export const OnboardingAuthShell = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
footer,
|
||||
appName,
|
||||
showAppName = false,
|
||||
showAppLogo = false,
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
wordmarkPrimaryColor,
|
||||
wordmarkSecondaryColor,
|
||||
logoImageUrl,
|
||||
backgroundImageUrl,
|
||||
backgroundOverlay = 0.55,
|
||||
radius = "lg",
|
||||
}: OnboardingAuthShellProps) => (
|
||||
<main
|
||||
className={classes.page}
|
||||
data-background-image={backgroundImageUrl ? true : undefined}
|
||||
style={
|
||||
{
|
||||
"--studio-glow-color": primaryColor,
|
||||
"--studio-secondary-glow-color": secondaryColor,
|
||||
"--auth-background-overlay": backgroundOverlay,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{backgroundImageUrl ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classes.authBackground}
|
||||
style={{ backgroundImage: `url(${JSON.stringify(backgroundImageUrl)})` }}
|
||||
/>
|
||||
) : null}
|
||||
<OnboardingBackdrop />
|
||||
<div className={`${classes.shell} ${classes.authShell}`}>
|
||||
<Stack className={classes.authLayout} gap="xl" align="center" justify="center">
|
||||
<div className={classes.authBrand}>
|
||||
<OnboardingWordmark large />
|
||||
</div>
|
||||
<Paper className={`${classes.studio} ${classes.authCard}`} radius="lg" p={{ base: "lg", sm: "xl" }}>
|
||||
{showAppName || showAppLogo ? (
|
||||
<div className={classes.authBrand}>
|
||||
<OnboardingWordmark
|
||||
large
|
||||
appName={appName}
|
||||
showAppName={showAppName}
|
||||
showAppLogo={showAppLogo}
|
||||
primaryColor={wordmarkPrimaryColor}
|
||||
secondaryColor={wordmarkSecondaryColor}
|
||||
logoImageUrl={logoImageUrl}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Paper className={`${classes.studio} ${classes.authCard}`} radius={radius} p={{ base: "lg", sm: "xl" }}>
|
||||
<Stack gap="xl">
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size="xl" radius="lg" variant="light">
|
||||
|
||||
@@ -88,6 +88,33 @@
|
||||
max-width: min(92vw, 42rem);
|
||||
}
|
||||
|
||||
.customBrand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--mantine-spacing-md);
|
||||
max-width: min(92vw, 42rem);
|
||||
color: var(--mantine-color-text);
|
||||
font-size: clamp(2rem, 6vw, 4rem);
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.customBrand[data-large="true"] {
|
||||
font-size: clamp(2.5rem, 8vw, 5.5rem);
|
||||
}
|
||||
|
||||
.customBrandImage {
|
||||
width: auto;
|
||||
height: clamp(4rem, 11vw, 7rem);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.customBrand[data-large="true"] .customBrandImage {
|
||||
height: clamp(5rem, 16vw, 9rem);
|
||||
}
|
||||
|
||||
.celebratingWordmark {
|
||||
animation: wordmarkJump 360ms ease-out;
|
||||
}
|
||||
@@ -140,6 +167,25 @@
|
||||
padding-block: clamp(1rem, 4vw, 3rem) calc(clamp(1rem, 4vw, 3rem) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.authBackground {
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.authBackground::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
background: rgb(0 0 0 / var(--auth-background-overlay, 0.55));
|
||||
}
|
||||
|
||||
.page[data-background-image] .marqueeBackdrop {
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.authLayout {
|
||||
width: min(100%, 36rem);
|
||||
margin: auto;
|
||||
|
||||
@@ -9,6 +9,10 @@ import classes from "./onboarding-studio.module.css";
|
||||
import { useOnboardingSounds } from "./use-onboarding-sounds";
|
||||
|
||||
interface OnboardingWordmarkProps {
|
||||
appName?: string;
|
||||
logoImageUrl?: string;
|
||||
showAppName?: boolean;
|
||||
showAppLogo?: boolean;
|
||||
primaryColor?: string;
|
||||
secondaryColor?: string;
|
||||
large?: boolean;
|
||||
@@ -16,7 +20,15 @@ interface OnboardingWordmarkProps {
|
||||
|
||||
const defaultWordmarkUrl = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg";
|
||||
|
||||
export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false }: OnboardingWordmarkProps) => {
|
||||
export const OnboardingWordmark = ({
|
||||
appName,
|
||||
logoImageUrl,
|
||||
showAppName,
|
||||
showAppLogo,
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
large = false,
|
||||
}: OnboardingWordmarkProps) => {
|
||||
const [celebrating, setCelebrating] = useState(false);
|
||||
const sounds = useOnboardingSounds();
|
||||
const className = `${classes.wordmark} ${classes.onboardingWordmark} ${large ? classes.welcomeWordmark : ""} ${celebrating ? classes.celebratingWordmark : ""}`;
|
||||
@@ -52,14 +64,36 @@ export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false
|
||||
}
|
||||
};
|
||||
|
||||
const hasVisibilityControls = showAppName !== undefined || showAppLogo !== undefined;
|
||||
const resolvedAppName = appName?.trim() || "Homarr";
|
||||
const shouldRenderDefaultWordmark = showAppLogo && showAppName && !logoImageUrl && resolvedAppName === "Homarr";
|
||||
|
||||
if (hasVisibilityControls && !shouldRenderDefaultWordmark) {
|
||||
const visibleLogoImageUrl = showAppLogo ? (logoImageUrl ?? "/logo/logo.png") : undefined;
|
||||
if (!visibleLogoImageUrl && !showAppName) return null;
|
||||
|
||||
return (
|
||||
<div className={classes.customBrand} data-large={large || undefined} aria-label={resolvedAppName}>
|
||||
{visibleLogoImageUrl ? (
|
||||
<img
|
||||
className={classes.customBrandImage}
|
||||
src={visibleLogoImageUrl}
|
||||
alt={showAppName ? "" : resolvedAppName}
|
||||
/>
|
||||
) : null}
|
||||
{showAppName ? <span>{resolvedAppName}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (primaryColor === undefined && secondaryColor === undefined) {
|
||||
return <img src={defaultWordmarkUrl} alt="Homarr" width={1477} height={1054} className={className} />;
|
||||
return <img src={defaultWordmarkUrl} alt={resolvedAppName} width={1477} height={1054} className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<HomarrWordmarkLight
|
||||
role="img"
|
||||
aria-label="Homarr"
|
||||
aria-label={resolvedAppName}
|
||||
style={colors}
|
||||
className={className}
|
||||
onMouseEnter={(event) => celebrate(event.currentTarget)}
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
UnstyledButton,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import {
|
||||
IconAdjustments,
|
||||
IconAlertCircle,
|
||||
@@ -91,7 +90,16 @@ import {
|
||||
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
import { BoardColorInput, ColorSchemeCombobox, IntegrationAvatar, LanguageCombobox, Link } from "@homarr/ui";
|
||||
import type { CornerStyle } from "@homarr/ui";
|
||||
import {
|
||||
BoardColorInput,
|
||||
ColorSchemeCombobox,
|
||||
CornerStylePicker,
|
||||
cornerStyleValues,
|
||||
IntegrationAvatar,
|
||||
LanguageCombobox,
|
||||
Link,
|
||||
} from "@homarr/ui";
|
||||
import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
|
||||
|
||||
import type { OnboardingStudioProps } from "./types";
|
||||
@@ -146,7 +154,6 @@ const sectionDefinitions = [
|
||||
{ id: "review", icon: IconCheck },
|
||||
] as const;
|
||||
|
||||
const radiusValues = ["xs", "sm", "md", "lg", "xl"] as const;
|
||||
const initialPrimaryColor = "#fa5252";
|
||||
const initialSecondaryColor = "#fd7e14";
|
||||
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
|
||||
@@ -176,7 +183,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
|
||||
const [boardName, setBoardName] = useState(initialBoard?.name ?? "dashboard");
|
||||
const [primaryColor, setPrimaryColor] = useState(initialPrimaryColor);
|
||||
const [secondaryColor, setSecondaryColor] = useState(initialSecondaryColor);
|
||||
const [itemRadius, setItemRadius] = useState<MantineSize>("lg");
|
||||
const [itemRadius, setItemRadius] = useState<CornerStyle>("lg");
|
||||
const [columnCount, setColumnCount] = useState(10);
|
||||
const [leftSidebar, setLeftSidebar] = useState(false);
|
||||
const [rightSidebar, setRightSidebar] = useState(false);
|
||||
@@ -799,8 +806,8 @@ interface StudioSectionProps {
|
||||
setPrimaryColor: (value: string) => void;
|
||||
secondaryColor: string;
|
||||
setSecondaryColor: (value: string) => void;
|
||||
itemRadius: MantineSize;
|
||||
setItemRadius: (value: MantineSize) => void;
|
||||
itemRadius: CornerStyle;
|
||||
setItemRadius: (value: CornerStyle) => void;
|
||||
columnCount: number;
|
||||
setColumnCount: (value: number) => void;
|
||||
leftSidebar: boolean;
|
||||
@@ -1454,32 +1461,16 @@ const BoardBuilder = (props: StudioSectionProps) => {
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("radius")}
|
||||
</Text>
|
||||
<OnboardingFloatingControl
|
||||
ariaLabel={t("radius")}
|
||||
value={props.itemRadius}
|
||||
onChange={props.setItemRadius}
|
||||
options={radiusValues.map((value) => ({ value, label: value.toUpperCase() }))}
|
||||
/>
|
||||
<Group gap="xs" aria-hidden>
|
||||
{radiusValues.map((radius) => (
|
||||
<div
|
||||
key={radius}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 34,
|
||||
borderRadius: `var(--mantine-radius-${radius})`,
|
||||
background: "var(--mantine-color-default)",
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
opacity: props.itemRadius === radius ? 1 : 0.55,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
<CornerStylePicker
|
||||
label={t("radius")}
|
||||
value={props.itemRadius}
|
||||
labels={
|
||||
Object.fromEntries(
|
||||
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`radiusOptions.${cornerStyle}`)]),
|
||||
) as Record<CornerStyle, string>
|
||||
}
|
||||
onChange={props.setItemRadius}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="lg">
|
||||
@@ -1545,7 +1536,7 @@ const BoardPreview = ({
|
||||
appCount,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
itemRadius: MantineSize;
|
||||
itemRadius: CornerStyle;
|
||||
baseColumnCount: number;
|
||||
leftSidebar: boolean;
|
||||
rightSidebar: boolean;
|
||||
|
||||
@@ -34,7 +34,7 @@ const getIntegrationCacheIdentity = <TKind extends IntegrationKind, TInput exten
|
||||
integration: IntegrationOfKind<TKind>;
|
||||
options: TInput;
|
||||
}) => {
|
||||
const secretRevisions = integration.decryptedSecrets
|
||||
const secretRevisions = (integration.decryptedSecrets ?? [])
|
||||
.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
fingerprint: createKeyedFingerprint(JSON.stringify([secret.kind, secret.value])),
|
||||
|
||||
@@ -322,7 +322,7 @@ describe("createRequestHandler", () => {
|
||||
});
|
||||
|
||||
describe("createIntegrationRequestHandler", () => {
|
||||
it("keys requests by integration id and options without retaining secret values", async () => {
|
||||
it("does not deduplicate requests across rotated integration secrets", async () => {
|
||||
let calls = 0;
|
||||
const request = deferred<string>();
|
||||
const handler = createIntegrationRequestHandler<string, "sonarr", { page: number }>({
|
||||
@@ -332,11 +332,16 @@ describe("createIntegrationRequestHandler", () => {
|
||||
},
|
||||
});
|
||||
const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
|
||||
const duplicate = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
|
||||
const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync();
|
||||
expect(calls).toBe(1);
|
||||
expect(calls).toBe(2);
|
||||
|
||||
request.resolve("value");
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
|
||||
await expect(Promise.all([first, duplicate, second])).resolves.toMatchObject([
|
||||
{ data: "value" },
|
||||
{ data: "value" },
|
||||
{ data: "value" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -162,7 +162,12 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
const controller = new AbortController();
|
||||
const timeoutError = new RequestHandlerTimeoutError();
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
const upstreamRequest = Promise.resolve().then(async () => await options.requestAsync(input, controller.signal));
|
||||
let upstreamRequest: Promise<TData>;
|
||||
try {
|
||||
upstreamRequest = Promise.resolve(options.requestAsync(input, controller.signal));
|
||||
} catch (error) {
|
||||
upstreamRequest = Promise.reject(error);
|
||||
}
|
||||
const settledAsync = upstreamRequest.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
@@ -239,8 +244,9 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
const baseKey = options.getCacheKey?.(input) ?? JSON.stringify(input);
|
||||
const requestGeneration = generation;
|
||||
let sharedCache: SharedCacheAdapter<TData> | undefined;
|
||||
if (ttl > 0) {
|
||||
sharedCache = await resolveSharedCacheAsync(input, baseKey, requestGeneration);
|
||||
const sharedCacheResolution = ttl > 0 ? resolveSharedCacheAsync(input, baseKey, requestGeneration) : undefined;
|
||||
if (sharedCacheResolution) {
|
||||
sharedCache = await sharedCacheResolution;
|
||||
throwIfDeadlineExceeded(deadlineAt);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const defaultServerSettingsKeys = [
|
||||
"analytics",
|
||||
@@ -7,12 +8,120 @@ export const defaultServerSettingsKeys = [
|
||||
"board",
|
||||
"user",
|
||||
"appearance",
|
||||
"branding",
|
||||
"culture",
|
||||
"search",
|
||||
] as const;
|
||||
|
||||
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
||||
|
||||
export const brandingRadiusOptions = ["xs", "sm", "md", "lg", "xl"] as const;
|
||||
export type BrandingRadius = (typeof brandingRadiusOptions)[number];
|
||||
|
||||
export const authBrandingSchema = z.object({
|
||||
showAppName: z.boolean(),
|
||||
showLogo: z.boolean(),
|
||||
showGreeting: z.boolean(),
|
||||
});
|
||||
|
||||
const brandingImageUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2_048)
|
||||
.refine((value) => {
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Image must be an HTTP(S) URL or an app-relative path")
|
||||
.nullable();
|
||||
|
||||
export const brandingServerSettingsSchema = z.object({
|
||||
appName: z.string().trim().min(1).max(80),
|
||||
greeting: z.string().trim().max(160),
|
||||
logoImageUrl: brandingImageUrlSchema,
|
||||
faviconImageUrl: brandingImageUrlSchema,
|
||||
primaryColor: z.string().regex(/^#[\da-f]{6}$/iu, "Primary color must be a 6-digit hex color"),
|
||||
secondaryColor: z.string().regex(/^#[\da-f]{6}$/iu, "Secondary color must be a 6-digit hex color"),
|
||||
lockPrimaryColor: z.boolean(),
|
||||
signInBackgroundImageUrl: brandingImageUrlSchema,
|
||||
signInBackgroundOverlay: z.number().min(0).max(0.9),
|
||||
authBranding: authBrandingSchema,
|
||||
defaultRadius: z.enum(brandingRadiusOptions),
|
||||
});
|
||||
|
||||
export type BrandingSettings = z.infer<typeof brandingServerSettingsSchema>;
|
||||
|
||||
export const defaultBrandingSettings: BrandingSettings = {
|
||||
appName: "Homarr",
|
||||
greeting: "",
|
||||
logoImageUrl: null,
|
||||
faviconImageUrl: null,
|
||||
primaryColor: "#fa5252",
|
||||
secondaryColor: "#fd7e14",
|
||||
lockPrimaryColor: false,
|
||||
signInBackgroundImageUrl: null,
|
||||
signInBackgroundOverlay: 0.55,
|
||||
authBranding: {
|
||||
showAppName: true,
|
||||
showLogo: true,
|
||||
showGreeting: true,
|
||||
},
|
||||
defaultRadius: "md",
|
||||
};
|
||||
|
||||
const hasDefaultBrandingColors = (branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">) =>
|
||||
branding.primaryColor.toLowerCase() === defaultBrandingSettings.primaryColor &&
|
||||
branding.secondaryColor.toLowerCase() === defaultBrandingSettings.secondaryColor;
|
||||
|
||||
export const getBrandingColorOverrides = (
|
||||
branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">,
|
||||
): Partial<Pick<BrandingSettings, "primaryColor" | "secondaryColor">> => {
|
||||
if (hasDefaultBrandingColors(branding)) return {};
|
||||
return {
|
||||
primaryColor: branding.primaryColor,
|
||||
secondaryColor: branding.secondaryColor,
|
||||
};
|
||||
};
|
||||
|
||||
const legacyLoginBrandingSchema = z.object({
|
||||
showCustomAppNameOnLogin: z.boolean().optional(),
|
||||
showCustomLogoOnLogin: z.boolean().optional(),
|
||||
showCustomGreetingOnLogin: z.boolean().optional(),
|
||||
showCustomAppNameOnInvite: z.boolean().optional(),
|
||||
showCustomLogoOnInvite: z.boolean().optional(),
|
||||
showCustomGreetingOnInvite: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const parseBrandingSettings = (value: unknown): BrandingSettings => {
|
||||
const partialValue = z.record(z.string(), z.unknown()).safeParse(value);
|
||||
if (!partialValue.success) return defaultBrandingSettings;
|
||||
|
||||
const legacyBranding = legacyLoginBrandingSchema.safeParse(partialValue.data);
|
||||
const partialAuthBranding = authBrandingSchema.partial().safeParse(partialValue.data.authBranding);
|
||||
const authBranding = { ...defaultBrandingSettings.authBranding };
|
||||
|
||||
if (legacyBranding.success) {
|
||||
const legacy = legacyBranding.data;
|
||||
authBranding.showAppName = legacy.showCustomAppNameOnLogin ?? authBranding.showAppName;
|
||||
authBranding.showLogo = legacy.showCustomLogoOnLogin ?? authBranding.showLogo;
|
||||
authBranding.showGreeting = legacy.showCustomGreetingOnLogin ?? authBranding.showGreeting;
|
||||
}
|
||||
if (partialAuthBranding.success) Object.assign(authBranding, partialAuthBranding.data);
|
||||
|
||||
const result = brandingServerSettingsSchema.safeParse({
|
||||
...defaultBrandingSettings,
|
||||
...partialValue.data,
|
||||
authBranding,
|
||||
});
|
||||
if (!result.success) return defaultBrandingSettings;
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const defaultServerSettings = {
|
||||
analytics: {
|
||||
enableGeneral: true,
|
||||
@@ -36,6 +145,7 @@ export const defaultServerSettings = {
|
||||
appearance: {
|
||||
defaultColorScheme: "auto" as ColorScheme,
|
||||
},
|
||||
branding: defaultBrandingSettings,
|
||||
culture: {
|
||||
defaultLocale: "en" as SupportedLanguage,
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"dependencies": {
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/dates": "catalog:",
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import type { User } from "@homarr/db/schema";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { parseBrandingSettings } from "@homarr/server-settings";
|
||||
import type { HeaderPreferences } from "@homarr/validation/user";
|
||||
import { parseHeaderPreferences } from "@homarr/validation/user";
|
||||
|
||||
export type SettingsContextProps = Pick<
|
||||
User,
|
||||
| "firstDayOfWeek"
|
||||
| "defaultSearchEngineId"
|
||||
| "homeBoardId"
|
||||
| "mobileHomeBoardId"
|
||||
| "openSearchInNewTab"
|
||||
| "ddgBangs"
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
> &
|
||||
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["user"], "enableGravatar">;
|
||||
export type SettingsContextProps = Omit<
|
||||
Pick<
|
||||
User,
|
||||
| "firstDayOfWeek"
|
||||
| "defaultSearchEngineId"
|
||||
| "homeBoardId"
|
||||
| "mobileHomeBoardId"
|
||||
| "openSearchInNewTab"
|
||||
| "ddgBangs"
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
| "headerPreferences"
|
||||
>,
|
||||
"headerPreferences"
|
||||
> & { headerPreferences: HeaderPreferences } & Pick<
|
||||
ServerSettings["board"],
|
||||
"enableStatusByDefault" | "forceDisableStatus"
|
||||
> &
|
||||
Pick<ServerSettings["user"], "enableGravatar"> & {
|
||||
branding: ServerSettings["branding"];
|
||||
};
|
||||
|
||||
export interface PublicServerSettings {
|
||||
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
|
||||
@@ -22,6 +33,7 @@ export interface PublicServerSettings {
|
||||
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
|
||||
>;
|
||||
user: Pick<ServerSettings["user"], "enableGravatar">;
|
||||
branding: ServerSettings["branding"];
|
||||
}
|
||||
|
||||
export type UserSettings = Pick<
|
||||
@@ -34,6 +46,7 @@ export type UserSettings = Pick<
|
||||
| "ddgBangs"
|
||||
| "pingIconsEnabled"
|
||||
| "enableRightClickOnWidgets"
|
||||
| "headerPreferences"
|
||||
>;
|
||||
|
||||
export const createSettings = ({
|
||||
@@ -51,7 +64,9 @@ export const createSettings = ({
|
||||
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
||||
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
||||
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
|
||||
headerPreferences: parseHeaderPreferences(user?.headerPreferences),
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
enableGravatar: serverSettings.user.enableGravatar,
|
||||
branding: parseBrandingSettings(serverSettings.branding),
|
||||
});
|
||||
@@ -142,6 +142,13 @@
|
||||
"secondaryColor": "Secondary color",
|
||||
"resetColors": "Reset colors",
|
||||
"radius": "Widget corner radius",
|
||||
"radiusOptions": {
|
||||
"xs": "Tight",
|
||||
"sm": "Subtle",
|
||||
"md": "Balanced",
|
||||
"lg": "Rounded",
|
||||
"xl": "Soft"
|
||||
},
|
||||
"columns": "Board columns",
|
||||
"columnsDescription": "Columns define the horizontal grid used to size and position items. 12 is plenty for most people.",
|
||||
"columnCount": "{count, plural, one {# column} other {# columns}}",
|
||||
@@ -242,7 +249,9 @@
|
||||
},
|
||||
"invite": {
|
||||
"title": "Join Homarr",
|
||||
"titleWithAppName": "Join {appName}",
|
||||
"subtitle": "Welcome to Homarr! Please create your account",
|
||||
"subtitleWithAppName": "Welcome to {appName}! Please create your account",
|
||||
"description": "You were invited by {username}"
|
||||
}
|
||||
},
|
||||
@@ -1847,13 +1856,14 @@
|
||||
},
|
||||
"userAvatar": {
|
||||
"menu": {
|
||||
"open": "Open account menu",
|
||||
"management": "Management",
|
||||
"preferences": "Your preferences",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
"homeBoard": "Your home board",
|
||||
"updateAvailable": "{countUpdates} updates available: {tag}",
|
||||
"assistant": "Homarr Assistant",
|
||||
"assistant": "Assistant",
|
||||
"assistantThinking": "Thinking",
|
||||
"modifier": "Mod"
|
||||
}
|
||||
@@ -5336,7 +5346,8 @@
|
||||
}
|
||||
},
|
||||
"primaryColor": {
|
||||
"label": "Primary color"
|
||||
"label": "Primary color",
|
||||
"locked": "Your administrator keeps the instance brand color on every board."
|
||||
},
|
||||
"secondaryColor": {
|
||||
"label": "Secondary color"
|
||||
@@ -5676,6 +5687,65 @@
|
||||
"setting": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"header": {
|
||||
"title": "Header studio",
|
||||
"badge": "Personal",
|
||||
"description": "Drag controls anywhere in the row. Left, Center, and Right stay fixed while controls shift around them.",
|
||||
"device": {
|
||||
"desktop": "Desktop",
|
||||
"mobile": "Mobile"
|
||||
},
|
||||
"zones": {
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right"
|
||||
},
|
||||
"action": {
|
||||
"hide": "Hide the header",
|
||||
"show": "Show the header",
|
||||
"configure": "Move or configure {item}",
|
||||
"remove": "Remove {item}",
|
||||
"dropIntoZone": "Move into the {zone} section"
|
||||
},
|
||||
"preview": {
|
||||
"board": "Dashboard preview for {name}",
|
||||
"empty": "No dashboard layout is available"
|
||||
},
|
||||
"available": {
|
||||
"title": "Available controls",
|
||||
"description": "Add a control, then drag its full button into position above.",
|
||||
"requiredAlways": "This control is required, but you can move it anywhere in the header.",
|
||||
"allAdded": "Every built-in control is already in your header.",
|
||||
"add": "Add {item}",
|
||||
"boardShortcut": "Board shortcut",
|
||||
"chooseBoard": "Choose a board"
|
||||
},
|
||||
"configuration": {
|
||||
"description": "This control stays the same size as it does in the live header.",
|
||||
"searchDisplay": {
|
||||
"input": "Search bar",
|
||||
"icon": "Icon only"
|
||||
},
|
||||
"logoDisplay": {
|
||||
"logo": "Logo only",
|
||||
"logoAndText": "Logo + text"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"logo": "Logo",
|
||||
"search": "Search",
|
||||
"home": "Home board",
|
||||
"boardSwitcher": "Boards",
|
||||
"assistant": "Assistant",
|
||||
"docker": "Docker",
|
||||
"boardEdit": "Edit board",
|
||||
"boardSettings": "Board settings",
|
||||
"settings": "Your settings",
|
||||
"themeToggle": "Toggle theme",
|
||||
"user": "Account",
|
||||
"boardUnavailable": "Board unavailable"
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"language": "Language & Region",
|
||||
"board": {
|
||||
@@ -5874,6 +5944,80 @@
|
||||
"label": "Default color scheme"
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"title": "Instance branding",
|
||||
"groups": {
|
||||
"identity": "Identity",
|
||||
"appearance": "Appearance",
|
||||
"authentication": "Authentication"
|
||||
},
|
||||
"identity": {
|
||||
"description": "The application logo is the larger mark used inside the app, on sign-in pages, and for installed apps. The browser icon (favicon) is the small square used in tabs and bookmarks; when empty, it falls back to the application logo."
|
||||
},
|
||||
"appName": {
|
||||
"label": "Application name"
|
||||
},
|
||||
"greeting": {
|
||||
"label": "Custom greeting"
|
||||
},
|
||||
"logo": {
|
||||
"label": "Application logo",
|
||||
"placeholder": "/logo/logo.png"
|
||||
},
|
||||
"favicon": {
|
||||
"label": "Browser icon (favicon)",
|
||||
"placeholder": "Uses application logo"
|
||||
},
|
||||
"primaryColor": {
|
||||
"label": "Primary color"
|
||||
},
|
||||
"secondaryColor": {
|
||||
"label": "Secondary accent"
|
||||
},
|
||||
"lockPrimaryColor": {
|
||||
"label": "Keep this color on every board",
|
||||
"description": "Prevents board colors from replacing the application accent."
|
||||
},
|
||||
"signInBackground": {
|
||||
"label": "Authentication background",
|
||||
"placeholder": "https://example.com/background.jpg"
|
||||
},
|
||||
"signInOverlay": {
|
||||
"label": "Background overlay"
|
||||
},
|
||||
"defaultRadius": {
|
||||
"label": "Corner style",
|
||||
"options": {
|
||||
"xs": "Tight",
|
||||
"sm": "Subtle",
|
||||
"md": "Balanced",
|
||||
"lg": "Rounded",
|
||||
"xl": "Soft"
|
||||
}
|
||||
},
|
||||
"authVisibility": {
|
||||
"description": "Choose what appears on every authentication page.",
|
||||
"appName": "Application name",
|
||||
"logo": "Application logo",
|
||||
"greeting": "Greeting"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Current branding",
|
||||
"cornerStyle": "Corner style",
|
||||
"palette": "Palette",
|
||||
"backgroundConfigured": "Custom background · {overlay}% overlay",
|
||||
"backgroundDefault": "Default background",
|
||||
"greetingConfigured": "Custom greeting",
|
||||
"greetingDefault": "Default greeting",
|
||||
"boardColorLocked": "Board color locked",
|
||||
"boardColorFlexible": "Board colors allowed",
|
||||
"appName": "Name",
|
||||
"logo": "Logo",
|
||||
"greeting": "Greeting",
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
}
|
||||
},
|
||||
"culture": {
|
||||
"title": "Culture",
|
||||
"defaultLocale": {
|
||||
@@ -6318,7 +6462,7 @@
|
||||
"openSpotlightApps": "Open app search",
|
||||
"openSpotlightAssistant": "Open Assistant mode",
|
||||
"openSpotlightPreferences": "Open preferences mode",
|
||||
"openAssistant": "Open Homarr Assistant"
|
||||
"openAssistant": "Open Assistant"
|
||||
},
|
||||
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
|
||||
}
|
||||
@@ -6802,7 +6946,7 @@
|
||||
},
|
||||
"assistant": {
|
||||
"label": "Assistant",
|
||||
"placeholder": "Ask Homarr Assistant"
|
||||
"placeholder": "Ask Assistant"
|
||||
},
|
||||
"external": {
|
||||
"label": "Web",
|
||||
@@ -7508,12 +7652,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"description": "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
"dashboardTitle": "{appName} Dashboard"
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Homarr Assistant",
|
||||
"open": "Open Homarr Assistant",
|
||||
"title": "Assistant",
|
||||
"open": "Open Assistant",
|
||||
"close": "Close assistant",
|
||||
"minimize": "Minimize assistant",
|
||||
"spotlight": "Ask Homarr Assistant",
|
||||
"spotlight": "Ask Assistant",
|
||||
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
||||
"spotlightPrompt": {
|
||||
"title": "Ask Assistant",
|
||||
@@ -7525,7 +7673,7 @@
|
||||
"checking": "Checking whether the assistant is available…",
|
||||
"error": "The assistant's availability could not be checked. Try again.",
|
||||
"notConfigured": "An administrator must configure and enable the assistant first.",
|
||||
"signIn": "Sign in to use Homarr Assistant."
|
||||
"signIn": "Sign in to use Assistant."
|
||||
},
|
||||
"newConversation": "New conversation",
|
||||
"conversations": "Conversations",
|
||||
@@ -7568,7 +7716,7 @@
|
||||
"prompt": "Help me improve a board's appearance or custom CSS. Show me my boards first, then preserve my current settings while we make the change."
|
||||
}
|
||||
},
|
||||
"composerPlaceholder": "Ask Homarr Assistant…",
|
||||
"composerPlaceholder": "Ask Assistant…",
|
||||
"runtime": {
|
||||
"model": "Model",
|
||||
"searchModels": "Search models",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { Icon123, IconProps } from "@tabler/icons-react";
|
||||
|
||||
export * from "./src";
|
||||
@@ -5,3 +6,31 @@ export * from "./src";
|
||||
export type TablerIcon = typeof Icon123;
|
||||
|
||||
export type TablerIconProps = IconProps;
|
||||
|
||||
/**
|
||||
* Icon sizes tied to Mantine's font-size scale instead of raw pixel
|
||||
* numbers, so icons stay legible under the board's CSS zoom scaling.
|
||||
* Pass as the `style` prop (not `size`) - tabler icons convert `size` into
|
||||
* raw SVG width/height attributes, and CSS `var()` isn't guaranteed to
|
||||
* resolve there (Firefox especially). `style` is real CSS, so it does.
|
||||
*/
|
||||
export const iconSizes = {
|
||||
xs: { width: "var(--mantine-font-size-xs)", height: "var(--mantine-font-size-xs)", flexShrink: 0 },
|
||||
sm: { width: "var(--mantine-font-size-sm)", height: "var(--mantine-font-size-sm)", flexShrink: 0 },
|
||||
md: { width: "var(--mantine-font-size-md)", height: "var(--mantine-font-size-md)", flexShrink: 0 },
|
||||
lg: { width: "var(--mantine-font-size-lg)", height: "var(--mantine-font-size-lg)", flexShrink: 0 },
|
||||
xl: { width: "var(--mantine-font-size-xl)", height: "var(--mantine-font-size-xl)", flexShrink: 0 },
|
||||
} as const satisfies Record<string, CSSProperties>;
|
||||
|
||||
/**
|
||||
* For icon/element sizes that don't fit one of `iconSizes`' fixed tokens (e.g. a deliberately
|
||||
* oversized icon). Same reasoning as `iconSizes`: pass as `style`, not a raw `size` number - a
|
||||
* bare pixel value never re-scales when the board's zoom level changes, so it renders at a
|
||||
* fraction of its intended size on any board that isn't at 100% zoom (which is most boards with
|
||||
* more than a couple of widgets).
|
||||
*/
|
||||
export const zoomCompensatedSize = (px: number): CSSProperties => ({
|
||||
width: `calc(${px}px * var(--board-canvas-ui-scale, 1))`,
|
||||
height: `calc(${px}px * var(--board-canvas-ui-scale, 1))`,
|
||||
flexShrink: 0,
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Box, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { SelectableCard } from "./selectable-card";
|
||||
|
||||
export const cornerStyleValues = ["xs", "sm", "md", "lg", "xl"] as const;
|
||||
|
||||
export type CornerStyle = (typeof cornerStyleValues)[number];
|
||||
|
||||
interface CornerStylePickerProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: CornerStyle;
|
||||
labels: Record<CornerStyle, string>;
|
||||
onChange: (value: CornerStyle) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const CornerStylePicker = ({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
labels,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: CornerStylePickerProps) => {
|
||||
const previewHeight = compact ? 24 : 34;
|
||||
|
||||
return (
|
||||
<Box component="fieldset" m={0} p={0} style={{ border: 0, minWidth: 0 }}>
|
||||
<Text component="legend" fw={500} size="sm" p={0}>
|
||||
{label}
|
||||
</Text>
|
||||
<Stack gap="xs" mt={2}>
|
||||
{description ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
<SimpleGrid cols={{ base: 3, xs: 5 }} spacing="xs">
|
||||
{cornerStyleValues.map((cornerStyle) => (
|
||||
<SelectableCard
|
||||
key={cornerStyle}
|
||||
selected={value === cornerStyle}
|
||||
onClick={() => onChange(cornerStyle)}
|
||||
radius={cornerStyle}
|
||||
p="xs"
|
||||
aria-label={labels[cornerStyle]}
|
||||
style={compact ? { minHeight: "auto", height: "auto" } : undefined}
|
||||
>
|
||||
<Box
|
||||
h={previewHeight}
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ borderRadius: `var(--mantine-radius-${cornerStyle})` }}
|
||||
/>
|
||||
<Text size="xs" ta="center" mt="xs">
|
||||
{labels[cornerStyle]}
|
||||
</Text>
|
||||
</SelectableCard>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,8 @@ export { LanguageIcon } from "./language-icon";
|
||||
export { LanguageCombobox } from "./language-combobox";
|
||||
export { ColorSchemeCombobox } from "./color-scheme-combobox";
|
||||
export { BoardColorInput } from "./board-color-input";
|
||||
export { CornerStylePicker, cornerStyleValues } from "./corner-style-picker";
|
||||
export type { CornerStyle } from "./corner-style-picker";
|
||||
export { IntegrationMarquee } from "./integration-marquee";
|
||||
export { Link } from "./link";
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const headerPreferencesVersion = 3 as const;
|
||||
|
||||
export const headerBuiltinItemIds = [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user",
|
||||
] as const;
|
||||
|
||||
export const requiredHeaderBuiltinItemIds = ["boardEdit", "boardSettings", "user"] as const;
|
||||
|
||||
export const headerPreferenceItemIds = headerBuiltinItemIds;
|
||||
export const headerItemIds = headerBuiltinItemIds;
|
||||
|
||||
export type HeaderBuiltinItemId = (typeof headerBuiltinItemIds)[number];
|
||||
export type HeaderPreferenceItemId = HeaderBuiltinItemId;
|
||||
|
||||
export const headerZoneIds = ["left", "center", "right"] as const;
|
||||
export const headerSearchDisplayValues = ["input", "icon"] as const;
|
||||
export const headerLogoDisplayValues = ["logo", "logoAndText"] as const;
|
||||
|
||||
export type HeaderZoneId = (typeof headerZoneIds)[number];
|
||||
export type HeaderSearchDisplay = (typeof headerSearchDisplayValues)[number];
|
||||
export type HeaderLogoDisplay = (typeof headerLogoDisplayValues)[number];
|
||||
|
||||
const headerBuiltinItemSchema = z
|
||||
.object({
|
||||
type: z.literal("builtin"),
|
||||
id: z.enum(headerBuiltinItemIds),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const headerBoardItemSchema = z
|
||||
.object({
|
||||
type: z.literal("board"),
|
||||
boardId: z.string().min(1).max(255),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const headerItemSchema = z.discriminatedUnion("type", [headerBuiltinItemSchema, headerBoardItemSchema]);
|
||||
|
||||
export type HeaderBuiltinItem = z.infer<typeof headerBuiltinItemSchema>;
|
||||
export type HeaderBoardItem = z.infer<typeof headerBoardItemSchema>;
|
||||
export type HeaderItem = z.infer<typeof headerItemSchema>;
|
||||
export type HeaderItemId = HeaderItem;
|
||||
export type HeaderZones = Record<HeaderZoneId, HeaderItem[]>;
|
||||
|
||||
export const createBuiltinHeaderItem = (id: HeaderBuiltinItemId): HeaderBuiltinItem => ({ type: "builtin", id });
|
||||
|
||||
export const createBoardHeaderItem = (boardId: string): HeaderBoardItem => ({ type: "board", boardId });
|
||||
|
||||
export const isRequiredHeaderItem = (item: HeaderItem) =>
|
||||
item.type === "builtin" && requiredHeaderBuiltinItemIds.some((itemId) => itemId === item.id);
|
||||
|
||||
export const getHeaderItemKey = (item: HeaderItem): string => {
|
||||
if (item.type === "board") return `board:${item.boardId}`;
|
||||
return `builtin:${item.id}`;
|
||||
};
|
||||
|
||||
export const getHeaderItems = (zones: HeaderZones): HeaderItem[] => headerZoneIds.flatMap((zone) => zones[zone]);
|
||||
|
||||
export const getHeaderItemZone = (zones: HeaderZones, item: HeaderItem): HeaderZoneId | undefined => {
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
return headerZoneIds.find((zone) => zones[zone].some((candidate) => getHeaderItemKey(candidate) === itemKey));
|
||||
};
|
||||
|
||||
export const getDefaultHeaderZone = (item: HeaderItem): HeaderZoneId => {
|
||||
if (item.type === "board") return "right";
|
||||
if (item.id === "logo") return "left";
|
||||
if (item.id === "search") return "center";
|
||||
return "right";
|
||||
};
|
||||
|
||||
const headerItemListSchema = z.array(headerItemSchema).max(100);
|
||||
|
||||
const headerZonesSchema = z
|
||||
.object({
|
||||
left: headerItemListSchema,
|
||||
center: headerItemListSchema,
|
||||
right: headerItemListSchema,
|
||||
})
|
||||
.strict()
|
||||
.refine((zones) => getHeaderItems(zones).length > 0, "At least one header item is required")
|
||||
.refine((zones) => {
|
||||
const itemKeys = getHeaderItems(zones).map(getHeaderItemKey);
|
||||
return new Set(itemKeys).size === itemKeys.length;
|
||||
}, "Header items must be unique")
|
||||
.refine(
|
||||
(zones) => getHeaderItems(zones).some((item) => item.type === "builtin" && item.id === "user"),
|
||||
"Account access must remain in the header",
|
||||
);
|
||||
|
||||
export const headerPreferencesSchema = z
|
||||
.object({
|
||||
version: z.literal(headerPreferencesVersion),
|
||||
visible: z.boolean(),
|
||||
searchDisplay: z.enum(headerSearchDisplayValues),
|
||||
logoDisplay: z.enum(headerLogoDisplayValues).default("logoAndText"),
|
||||
zones: headerZonesSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type HeaderPreferences = z.infer<typeof headerPreferencesSchema>;
|
||||
|
||||
const legacyHeaderItemIds = ["logo", "search", "boardSwitcher", "assistant", "docker", "user"] as const;
|
||||
type LegacyHeaderItemId = (typeof legacyHeaderItemIds)[number];
|
||||
|
||||
const legacyHeaderItemListSchema = z
|
||||
.array(z.enum(legacyHeaderItemIds))
|
||||
.min(1)
|
||||
.max(legacyHeaderItemIds.length)
|
||||
.refine((items) => new Set(items).size === items.length, "Header items must be unique")
|
||||
.refine((items) => items.includes("user"), "Account access must remain in the header");
|
||||
|
||||
const legacyHeaderPreferencesV1Schema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
visible: z.boolean(),
|
||||
items: legacyHeaderItemListSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const legacyHeaderPreferencesV2Schema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
visible: z.boolean(),
|
||||
zones: z
|
||||
.object({
|
||||
left: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
|
||||
center: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
|
||||
right: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
|
||||
})
|
||||
.strict()
|
||||
.refine((zones) => {
|
||||
const items = headerZoneIds.flatMap((zone) => zones[zone]);
|
||||
return items.length > 0 && new Set(items).size === items.length && items.includes("user");
|
||||
}, "Header items must be unique and include account access"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const migrateLegacyItems = (
|
||||
visible: boolean,
|
||||
zones: Record<HeaderZoneId, LegacyHeaderItemId[]>,
|
||||
): HeaderPreferences => ({
|
||||
version: headerPreferencesVersion,
|
||||
visible,
|
||||
searchDisplay: "input",
|
||||
logoDisplay: "logoAndText",
|
||||
zones: {
|
||||
left: zones.left.map(createBuiltinHeaderItem),
|
||||
center: zones.center.map(createBuiltinHeaderItem),
|
||||
right: zones.right.map(createBuiltinHeaderItem),
|
||||
},
|
||||
});
|
||||
|
||||
const migrateLegacyHeaderPreferencesV1 = (
|
||||
legacy: z.infer<typeof legacyHeaderPreferencesV1Schema>,
|
||||
): HeaderPreferences => {
|
||||
const zones: Record<HeaderZoneId, LegacyHeaderItemId[]> = { left: [], center: [], right: [] };
|
||||
for (const itemId of legacy.items) {
|
||||
const item = createBuiltinHeaderItem(itemId);
|
||||
zones[getDefaultHeaderZone(item)].push(itemId);
|
||||
}
|
||||
return migrateLegacyItems(legacy.visible, zones);
|
||||
};
|
||||
|
||||
const migrateLegacyHeaderPreferencesV2 = (legacy: z.infer<typeof legacyHeaderPreferencesV2Schema>): HeaderPreferences =>
|
||||
migrateLegacyItems(legacy.visible, legacy.zones);
|
||||
|
||||
const includeRequiredHeaderItems = (preferences: HeaderPreferences): HeaderPreferences => {
|
||||
const itemKeys = new Set(getHeaderItems(preferences.zones).map(getHeaderItemKey));
|
||||
const missingItems = requiredHeaderBuiltinItemIds
|
||||
.map(createBuiltinHeaderItem)
|
||||
.filter((item) => !itemKeys.has(getHeaderItemKey(item)));
|
||||
if (missingItems.length === 0) return preferences;
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
zones: {
|
||||
...preferences.zones,
|
||||
right: [...preferences.zones.right, ...missingItems],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const headerPreferencesMutationSchema = z
|
||||
.discriminatedUnion("version", [
|
||||
headerPreferencesSchema,
|
||||
legacyHeaderPreferencesV1Schema,
|
||||
legacyHeaderPreferencesV2Schema,
|
||||
])
|
||||
.transform((preferences): HeaderPreferences => {
|
||||
if (preferences.version === 1) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV1(preferences));
|
||||
if (preferences.version === 2) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV2(preferences));
|
||||
return includeRequiredHeaderItems(preferences);
|
||||
});
|
||||
|
||||
export const defaultHeaderPreferences = {
|
||||
version: headerPreferencesVersion,
|
||||
visible: true,
|
||||
searchDisplay: "input",
|
||||
logoDisplay: "logoAndText",
|
||||
zones: {
|
||||
left: [createBuiltinHeaderItem("logo")],
|
||||
center: [createBuiltinHeaderItem("search")],
|
||||
right: [
|
||||
createBuiltinHeaderItem("boardEdit"),
|
||||
createBuiltinHeaderItem("boardSettings"),
|
||||
createBuiltinHeaderItem("user"),
|
||||
],
|
||||
},
|
||||
} satisfies HeaderPreferences;
|
||||
|
||||
export const defaultHeaderPreferencesSerialized = JSON.stringify(defaultHeaderPreferences);
|
||||
|
||||
export const parseHeaderPreferences = (value: unknown): HeaderPreferences => {
|
||||
try {
|
||||
const decoded = typeof value === "string" ? JSON.parse(value) : value;
|
||||
const parsed = headerPreferencesMutationSchema.safeParse(decoded);
|
||||
if (parsed.success) return parsed.data;
|
||||
return defaultHeaderPreferences;
|
||||
} catch {
|
||||
return defaultHeaderPreferences;
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeHeaderPreferences = (value: HeaderPreferences) =>
|
||||
JSON.stringify(includeRequiredHeaderItems(value));
|
||||
@@ -8,6 +8,42 @@ import { zodEnumFromArray } from "./enums";
|
||||
import { createCustomErrorParams } from "./form/i18n";
|
||||
import { nullableEmailSchema, optionalEmailSchema } from "./email";
|
||||
|
||||
export {
|
||||
createBoardHeaderItem,
|
||||
createBuiltinHeaderItem,
|
||||
defaultHeaderPreferences,
|
||||
getDefaultHeaderZone,
|
||||
getHeaderItemKey,
|
||||
getHeaderItems,
|
||||
getHeaderItemZone,
|
||||
headerBuiltinItemIds,
|
||||
headerItemIds,
|
||||
headerItemSchema,
|
||||
headerLogoDisplayValues,
|
||||
headerPreferenceItemIds,
|
||||
headerPreferencesMutationSchema,
|
||||
headerPreferencesSchema,
|
||||
headerPreferencesVersion,
|
||||
headerSearchDisplayValues,
|
||||
headerZoneIds,
|
||||
isRequiredHeaderItem,
|
||||
parseHeaderPreferences,
|
||||
requiredHeaderBuiltinItemIds,
|
||||
} from "./header-preferences";
|
||||
export type {
|
||||
HeaderBoardItem,
|
||||
HeaderBuiltinItem,
|
||||
HeaderBuiltinItemId,
|
||||
HeaderItem,
|
||||
HeaderItemId,
|
||||
HeaderLogoDisplay,
|
||||
HeaderPreferenceItemId,
|
||||
HeaderPreferences,
|
||||
HeaderSearchDisplay,
|
||||
HeaderZoneId,
|
||||
HeaderZones,
|
||||
} from "./header-preferences";
|
||||
|
||||
// We always want the lowercase version of the username to compare it in a case-insensitive way
|
||||
export const usernameSchema = z.string().trim().toLowerCase().min(3).max(255);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Text } from "@mantine/core";
|
||||
|
||||
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
|
||||
@@ -66,7 +67,7 @@ export function AudioStatsContent({
|
||||
>
|
||||
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
|
||||
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
|
||||
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
|
||||
<span className={classes.statValue}>{value}</span>
|
||||
<span className={classes.statLabel}>{t(statKey as never)}</span>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const hideUnlessNavidrome = {
|
||||
shouldHide: (_: unknown, integrationKinds: string[]) => !integrationKinds.includes("navidrome"),
|
||||
shouldHide: (_: unknown, integrationKinds: string[]) =>
|
||||
!integrationKinds.includes("navidrome") && !integrationKinds.includes("mock"),
|
||||
};
|
||||
|
||||
const hideUnlessAudiobookshelf = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IconAlertTriangle, IconBell, IconDeviceTv, IconMovie, IconPlugConnected
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -122,7 +123,7 @@ export default function BazarrWidget({
|
||||
|
||||
return (
|
||||
<div key={statKey} className={`${classes.statTile} ${isWarning ? classes.statTileWarning : ""}`}>
|
||||
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
|
||||
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>
|
||||
{typeof value === "string" && value.trim() === "" ? "—" : value}
|
||||
</span>
|
||||
|
||||
@@ -35,6 +35,7 @@ import { formatBytes } from "@homarr/common";
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -207,7 +208,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="cpu"
|
||||
icon={<Cpu size={sz.iconSize} />}
|
||||
icon={<Cpu style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={invariantTechnicalLabels.cpu}
|
||||
value={formatPercent(s.cpu)}
|
||||
progress={{ value: s.cpu, color: thresholdColor(s.cpu) }}
|
||||
@@ -222,7 +223,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="mem"
|
||||
icon={<MemoryStick size={sz.iconSize} />}
|
||||
icon={<MemoryStick style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={t("metric.memory")}
|
||||
value={formatPercent(s.memory)}
|
||||
progress={{ value: s.memory, color: thresholdColor(s.memory) }}
|
||||
@@ -236,7 +237,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
key: "showDisk",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
|
||||
<HardDrive size={sz.iconSize} />
|
||||
<HardDrive style={zoomCompensatedSize(sz.iconSize)} />
|
||||
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{t("metric.disk")}
|
||||
</Text>
|
||||
@@ -251,7 +252,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="gpu"
|
||||
icon={<Monitor size={sz.iconSize} />}
|
||||
icon={<Monitor style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={invariantTechnicalLabels.gpu}
|
||||
value={formatPercent(s.gpu)}
|
||||
progress={{ value: s.gpu, color: thresholdColor(s.gpu) }}
|
||||
@@ -266,7 +267,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="la"
|
||||
icon={<Activity size={sz.iconSize} />}
|
||||
icon={<Activity style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={t("metric.loadAvg")}
|
||||
value={formatLoadAvg(s.loadAvg)}
|
||||
size={sz}
|
||||
@@ -280,7 +281,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="net"
|
||||
icon={<Network size={sz.iconSize} />}
|
||||
icon={<Network style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={t("metric.net")}
|
||||
value={formatByteRate(s.netBytes)}
|
||||
size={sz}
|
||||
@@ -294,7 +295,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="temp"
|
||||
icon={<Thermometer size={sz.iconSize} />}
|
||||
icon={<Thermometer style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={t("metric.temp")}
|
||||
value={formatTemp(s.temp, false)}
|
||||
size={sz}
|
||||
@@ -311,7 +312,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
return (
|
||||
<MetricRow
|
||||
key="bat"
|
||||
icon={<Icon size={sz.iconSize} />}
|
||||
icon={<Icon style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={t("metric.battery")}
|
||||
value={`${s.battery?.[0] ?? 0}%`}
|
||||
size={sz}
|
||||
@@ -326,7 +327,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
render: (s: BeszelSystemRow, _t: SystemCardProps["t"], sz: SizeConfig, tCommon: SystemCardProps["tCommon"]) => (
|
||||
<MetricRow
|
||||
key="svc"
|
||||
icon={<Server size={sz.iconSize} />}
|
||||
icon={<Server style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={tCommon("services")}
|
||||
value={String(s.services)}
|
||||
size={sz}
|
||||
@@ -340,7 +341,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="up"
|
||||
icon={<Activity size={sz.iconSize} />}
|
||||
icon={<Activity style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={t("metric.uptime")}
|
||||
value={formatUptime(s.uptime)}
|
||||
size={sz}
|
||||
@@ -354,7 +355,7 @@ const metricRenderers: BeszelMetricRenderer[] = [
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="agent"
|
||||
icon={<Wifi size={sz.iconSize} />}
|
||||
icon={<Wifi style={zoomCompensatedSize(sz.iconSize)} />}
|
||||
label={t("metric.agent")}
|
||||
value={s.agentVersion}
|
||||
size={sz}
|
||||
@@ -487,7 +488,7 @@ export default function BeszelSystemGridWidget({
|
||||
</Group>
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
<IconServerOff style={zoomCompensatedSize(28)} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty.noSystems")}
|
||||
</Text>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -147,7 +148,7 @@ export default function BeszelSystemStatsWidget({
|
||||
</Group>
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
<IconServerOff style={zoomCompensatedSize(28)} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{tBeszel("empty.noSystems")}
|
||||
</Text>
|
||||
@@ -161,7 +162,7 @@ export default function BeszelSystemStatsWidget({
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="sm" p="md">
|
||||
<IconQuestionMark size={40} />
|
||||
<IconQuestionMark style={zoomCompensatedSize(40)} />
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("error.systemNotFound")}
|
||||
</Text>
|
||||
@@ -199,7 +200,7 @@ export default function BeszelSystemStatsWidget({
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer size="var(--mantine-font-size-sm)" />}
|
||||
leftSection={<IconServer style={iconSizes.sm} />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
disabled={isSelectionSavePending}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
@@ -202,7 +203,7 @@ export default function BeszelSystemTableWidget({
|
||||
ellipsis: true,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Server size={size.iconSize} />
|
||||
<Server style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tBeszel("metric.system")}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -269,7 +270,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 140,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Cpu size={size.iconSize} />
|
||||
<Cpu style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{invariantTechnicalLabels.cpu}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -281,7 +282,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 140,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<MemoryStick size={size.iconSize} />
|
||||
<MemoryStick style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tBeszel("metric.memory")}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -293,7 +294,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 160,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<HardDrive size={size.iconSize} />
|
||||
<HardDrive style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tBeszel("metric.disk")}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -313,7 +314,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 140,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Monitor size={size.iconSize} />
|
||||
<Monitor style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{invariantTechnicalLabels.gpu}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -325,7 +326,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 100,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Activity size={size.iconSize} />
|
||||
<Activity style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tBeszel("metric.loadAvg")}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -347,7 +348,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 100,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Network size={size.iconSize} />
|
||||
<Network style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tBeszel("metric.net")}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -363,7 +364,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 80,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Thermometer size={size.iconSize} />
|
||||
<Thermometer style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tBeszel("metric.temp")}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -375,7 +376,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 70,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Battery size={size.iconSize} />
|
||||
<Battery style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tBeszel("metric.battery")}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -386,7 +387,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 80,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Server size={size.iconSize} />
|
||||
<Server style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tCommon("services")}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -398,7 +399,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 90,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Activity size={size.iconSize} />
|
||||
<Activity style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tBeszel("metric.uptime")}</Text>
|
||||
</Group>
|
||||
),
|
||||
@@ -410,7 +411,7 @@ export default function BeszelSystemTableWidget({
|
||||
width: 90,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Wifi size={size.iconSize} />
|
||||
<Wifi style={zoomCompensatedSize(size.iconSize)} />
|
||||
<Text inherit>{tBeszel("metric.agent")}</Text>
|
||||
</Group>
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { containerColors } from "./colors";
|
||||
@@ -209,7 +210,7 @@ export function BeszelStatsView({
|
||||
return (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Stack align="center" gap="sm" p="md">
|
||||
<IconPlugConnectedX size={40} />
|
||||
<IconPlugConnectedX style={zoomCompensatedSize(40)} />
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("error.liveConnectionFailed")}
|
||||
</Text>
|
||||
@@ -238,7 +239,7 @@ export function BeszelStatsView({
|
||||
return (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={24} opacity={0.5} />
|
||||
<IconServerOff style={zoomCompensatedSize(24)} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{tBeszel("error.internalServerError")}
|
||||
</Text>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
@@ -141,7 +142,7 @@ export default function BookmarksWidget({
|
||||
<Center flex={1}>
|
||||
<Stack align="center" gap={6}>
|
||||
<ThemeIcon variant="light" size="lg" radius="xl">
|
||||
<IconBookmark size={20} />
|
||||
<IconBookmark style={iconSizes.xl} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("empty")}
|
||||
@@ -262,7 +263,7 @@ const BookmarkCard = ({
|
||||
transition: reduceMotion ? undefined : "opacity 120ms ease, transform 120ms ease",
|
||||
}}
|
||||
>
|
||||
<IconArrowUpRight size={14} />
|
||||
<IconArrowUpRight style={iconSizes.sm} />
|
||||
</ThemeIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
@@ -339,7 +340,7 @@ const BookmarkAvatar = ({ bookmark, iconUrl, size }: { bookmark: BookmarkItem; i
|
||||
imageProps={{ referrerPolicy: "no-referrer" }}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
>
|
||||
<IconLink size={Math.max(14, size / 2)} />
|
||||
<IconLink style={zoomCompensatedSize(Math.max(14, size / 2))} />
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
|
||||
@@ -95,7 +95,14 @@ const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorP
|
||||
>
|
||||
{notificationEvents.map((notificationEvent) => {
|
||||
return (
|
||||
<Box key={notificationEvent} bg={notificationEvent} h={size} w={size} p={0} style={{ borderRadius: 999 }} />
|
||||
<Box
|
||||
key={notificationEvent}
|
||||
bg={notificationEvent}
|
||||
h={size}
|
||||
w={size * 2}
|
||||
p={0}
|
||||
style={{ borderRadius: 999 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { IconAlertTriangle, IconClock } from "@tabler/icons-react";
|
||||
import type { Dayjs } from "dayjs";
|
||||
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -102,7 +103,7 @@ const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "option
|
||||
<Stack gap={5} miw={0}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon color={getPhaseColor(primary.phase)} variant="light" radius="xl" size="md">
|
||||
<IconClock size={16} />
|
||||
<IconClock style={iconSizes.md} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} size="h4" fw={600}>
|
||||
{primary.label}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
||||
return (
|
||||
<Box className="clock-widget-container" h="100%" pos="relative">
|
||||
{showWeatherCorner && (
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
<Box pos="absolute" top={2} left={4}>
|
||||
<ClockWeatherSummary
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Box, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
@@ -46,20 +47,35 @@ export const ClockWeatherSummary = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={detailed ? 3 : 0} align="flex-start">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<AnimatedWeatherIcon
|
||||
animated={animateIcon}
|
||||
code={weather.current.weatherCode}
|
||||
isDay={weather.current.isDay}
|
||||
size={detailed ? 28 : 22}
|
||||
/>
|
||||
<Text size={detailed ? "sm" : "xs"} fw={detailed ? 600 : undefined} c={detailed ? undefined : "dimmed"}>
|
||||
{Math.round(temperature)}
|
||||
{unit}
|
||||
</Text>
|
||||
{detailed && <WeatherDescription weatherOnly weatherCode={weather.current.weatherCode} />}
|
||||
</Group>
|
||||
<Stack gap={detailed ? 3 : 2} align={detailed ? "flex-start" : "center"}>
|
||||
{detailed ? (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<AnimatedWeatherIcon
|
||||
animated={animateIcon}
|
||||
code={weather.current.weatherCode}
|
||||
isDay={weather.current.isDay}
|
||||
style={zoomCompensatedSize(28)}
|
||||
/>
|
||||
<Text size="sm" fw={600}>
|
||||
{Math.round(temperature)}
|
||||
{unit}
|
||||
</Text>
|
||||
<WeatherDescription weatherOnly weatherCode={weather.current.weatherCode} />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap={1} align="center">
|
||||
<AnimatedWeatherIcon
|
||||
animated={animateIcon}
|
||||
code={weather.current.weatherCode}
|
||||
isDay={weather.current.isDay}
|
||||
style={zoomCompensatedSize(26)}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(temperature)}
|
||||
{unit}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{detailed && (
|
||||
<Box>
|
||||
<Text size="xs" fw={600} lineClamp={1}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
@@ -73,7 +74,11 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
|
||||
<StatusIcon aria-label={statusLabel} size={isTiny ? 12 : 16} color={`var(--mantine-color-${statusColor}-6)`} />
|
||||
<StatusIcon
|
||||
aria-label={statusLabel}
|
||||
style={zoomCompensatedSize(isTiny ? 12 : 16)}
|
||||
color={`var(--mantine-color-${statusColor}-6)`}
|
||||
/>
|
||||
{resourceUrl ? (
|
||||
<Anchor
|
||||
className={actionTargetClasses.root}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { IconBell, IconBellOff, IconHourglass } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import {
|
||||
claimBrowserAlertOccurrence,
|
||||
@@ -339,7 +340,13 @@ const AlertControls = ({
|
||||
disabled={disabled || notificationPermission === "unsupported" || notificationPermission === "denied"}
|
||||
onChange={(event) => void onNotificationsChange(event.currentTarget.checked)}
|
||||
label={t("notifications")}
|
||||
thumbIcon={preferences.notifications ? <IconBell size={12} /> : <IconBellOff size={12} />}
|
||||
thumbIcon={
|
||||
preferences.notifications ? (
|
||||
<IconBell style={iconSizes.sm} />
|
||||
) : (
|
||||
<IconBellOff style={iconSizes.sm} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{notificationPermission === "denied" && (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { Link, zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
|
||||
@@ -107,7 +107,10 @@ function Unavailable({
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={32} color={`var(--mantine-color-${danger ? "red" : "yellow"}-6)`} />
|
||||
<IconAlertTriangle
|
||||
style={zoomCompensatedSize(32)}
|
||||
color={`var(--mantine-color-${danger ? "red" : "yellow"}-6)`}
|
||||
/>
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
import type { stringOrTranslation, TranslationFunction } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { widgetKind } from ".";
|
||||
@@ -214,7 +215,14 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
direction={isLong ? "row" : "column"}
|
||||
gap={0}
|
||||
>
|
||||
<item.icon className="summary-card-icon" size={24} style={{ minWidth: 24, minHeight: 24 }} />
|
||||
<item.icon
|
||||
className="summary-card-icon"
|
||||
style={{
|
||||
...zoomCompensatedSize(24),
|
||||
minWidth: "calc(24px * var(--board-canvas-ui-scale, 1))",
|
||||
minHeight: "calc(24px * var(--board-canvas-ui-scale, 1))",
|
||||
}}
|
||||
/>
|
||||
<Flex
|
||||
className="summary-card-texts"
|
||||
justify="center"
|
||||
|
||||
@@ -44,6 +44,7 @@ import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
@@ -82,7 +83,7 @@ const containerMenuWidth = 240;
|
||||
const rowActionButtonSize = 22;
|
||||
const rowActionIconVisualSize = 32;
|
||||
const footerRefreshButtonSize = 24;
|
||||
const footerRefreshIconVisualSize = 30;
|
||||
const footerRefreshIconVisualSize = 18;
|
||||
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
|
||||
@@ -493,8 +494,13 @@ export default function DockerWidget({
|
||||
style={{ position: "relative", overflow: "visible" }}
|
||||
>
|
||||
<IconRefresh
|
||||
size={footerRefreshIconVisualSize}
|
||||
style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }}
|
||||
style={{
|
||||
...zoomCompensatedSize(footerRefreshIconVisualSize),
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSession } from "@homarr/auth/client";
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { Link, zoomCompensatedSize } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export interface BaseWidgetErrorProps {
|
||||
@@ -23,7 +23,7 @@ export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error>
|
||||
<props.icon size={40} />
|
||||
<props.icon style={zoomCompensatedSize(40)} />
|
||||
<Stack gap={0}>
|
||||
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
|
||||
{props.showLogsLink && session?.user.permissions.includes("other-view-logs") && (
|
||||
|
||||
@@ -22,6 +22,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import type { FirewallInterfacesSummary } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
|
||||
@@ -329,7 +330,7 @@ const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size="xs">{safeValue.toFixed(1)}%</Text>
|
||||
{showIcon && <Icon size={size < 96 ? 12 : 16} />}
|
||||
{showIcon && <Icon style={zoomCompensatedSize(size < 96 ? 12 : 16)} />}
|
||||
{showStatus && <Text size="xs">{statusLabel}</Text>}
|
||||
</Center>
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import type { Resource } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
@@ -185,7 +186,7 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
|
||||
thickness={isTiny ? 2 : 4}
|
||||
label={
|
||||
<Center>
|
||||
<IconCpu size={isTiny ? 12 : 20} />
|
||||
<IconCpu style={zoomCompensatedSize(isTiny ? 12 : 20)} />
|
||||
</Center>
|
||||
}
|
||||
sections={[{ value: cpu.value, color: cpu.value > 75 ? "orange" : "green" }]}
|
||||
@@ -208,7 +209,7 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
|
||||
thickness={isTiny ? 2 : 4}
|
||||
label={
|
||||
<Center>
|
||||
<IconBrain size={isTiny ? 12 : 20} />
|
||||
<IconBrain style={zoomCompensatedSize(isTiny ? 12 : 20)} />
|
||||
</Center>
|
||||
}
|
||||
sections={[{ value: memory.value, color: memory.value > 75 ? "orange" : "green" }]}
|
||||
|
||||
@@ -19,6 +19,7 @@ import duration from "dayjs/plugin/duration";
|
||||
import { capitalize, formatBytes, formatBytesPair } from "@homarr/common";
|
||||
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
@@ -197,17 +198,18 @@ const StorageType = ({ item }: { item: StorageResource }) => {
|
||||
};
|
||||
|
||||
const ResourceIcon = ({ type, size }: { type: Resource["type"]; size: number }) => {
|
||||
const style = zoomCompensatedSize(size);
|
||||
switch (type) {
|
||||
case "node":
|
||||
return <IconServer size={size} />;
|
||||
return <IconServer style={style} />;
|
||||
case "lxc":
|
||||
return <IconDeviceLaptop size={size} />;
|
||||
return <IconDeviceLaptop style={style} />;
|
||||
case "qemu":
|
||||
return <IconDeviceLaptop size={size} />;
|
||||
return <IconDeviceLaptop style={style} />;
|
||||
case "storage":
|
||||
return <IconDatabase size={size} />;
|
||||
return <IconDatabase style={style} />;
|
||||
default:
|
||||
console.error(`Unknown resource type: ${type as string}`);
|
||||
return <IconQuestionMark size={size} />;
|
||||
return <IconQuestionMark style={style} />;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Center, RingProgress, Text } from "@mantine/core";
|
||||
import { IconCpu } from "@tabler/icons-react";
|
||||
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { progressColor } from "../system-health";
|
||||
|
||||
export const CpuRing = ({ cpuUtilization, isTiny }: { cpuUtilization: number; isTiny: boolean }) => {
|
||||
@@ -16,7 +18,7 @@ export const CpuRing = ({ cpuUtilization, isTiny }: { cpuUtilization: number; is
|
||||
className="health-monitoring-cpu-utilization-value"
|
||||
size={isTiny ? "8px" : "xs"}
|
||||
>{`${cpuUtilization.toFixed(2)}%`}</Text>
|
||||
<IconCpu className="health-monitoring-cpu-utilization-icon" size={isTiny ? 8 : 16} />
|
||||
<IconCpu className="health-monitoring-cpu-utilization-icon" style={zoomCompensatedSize(isTiny ? 8 : 16)} />
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Center, RingProgress, Text } from "@mantine/core";
|
||||
import { IconCpu } from "@tabler/icons-react";
|
||||
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { progressColor } from "../system-health";
|
||||
|
||||
export const CpuTempRing = ({
|
||||
@@ -27,7 +29,7 @@ export const CpuTempRing = ({
|
||||
<Text className="health-monitoring-cpu-temp-value" size={isTiny ? "8px" : "xs"}>
|
||||
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
|
||||
</Text>
|
||||
<IconCpu className="health-monitoring-cpu-temp-icon" size={isTiny ? 8 : 16} />
|
||||
<IconCpu className="health-monitoring-cpu-temp-icon" style={zoomCompensatedSize(isTiny ? 8 : 16)} />
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Center, RingProgress, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconDeviceDesktop } from "@tabler/icons-react";
|
||||
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { progressColor } from "../system-health";
|
||||
|
||||
interface GpuRingProps {
|
||||
@@ -49,7 +51,10 @@ export const GpuRing = ({ gpu, isTiny, fahrenheit }: GpuRingProps) => {
|
||||
className="health-monitoring-gpu-utilization-value"
|
||||
size={isTiny ? "8px" : "xs"}
|
||||
>{`${gpu.processorUtilization.toFixed(0)}%`}</Text>
|
||||
<IconDeviceDesktop className="health-monitoring-gpu-utilization-icon" size={isTiny ? 8 : 16} />
|
||||
<IconDeviceDesktop
|
||||
className="health-monitoring-gpu-utilization-icon"
|
||||
style={zoomCompensatedSize(isTiny ? 8 : 16)}
|
||||
/>
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Center, RingProgress, Text } from "@mantine/core";
|
||||
import { IconBrain } from "@tabler/icons-react";
|
||||
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { progressColor } from "../system-health";
|
||||
|
||||
export const MemoryRing = ({ available, used, isTiny }: { available: number; used: number; isTiny: boolean }) => {
|
||||
@@ -17,7 +19,7 @@ export const MemoryRing = ({ available, used, isTiny }: { available: number; use
|
||||
<Text className="health-monitoring-memory-value" size={isTiny ? "8px" : "xs"}>
|
||||
{memoryUsage.memUsed.GB}GiB
|
||||
</Text>
|
||||
<IconBrain className="health-monitoring-memory-icon" size={isTiny ? 8 : 16} />
|
||||
<IconBrain className="health-monitoring-memory-icon" style={zoomCompensatedSize(isTiny ? 8 : 16)} />
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
@@ -156,7 +157,10 @@ export const SystemHealthMonitoring = ({
|
||||
onClick={() => setOpenedIntegrationId(integrationId)}
|
||||
aria-label={t("popover.information")}
|
||||
>
|
||||
<IconInfoCircle className="health-monitoring-information-icon" size={30} />
|
||||
<IconInfoCircle
|
||||
className="health-monitoring-information-icon"
|
||||
style={zoomCompensatedSize(30)}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
<Modal
|
||||
@@ -284,45 +288,76 @@ const SystemInformationList = ({
|
||||
memoryUsage: ReturnType<typeof formatMemoryUsage>;
|
||||
t: ScopedTranslationFunction<"widget.healthMonitoring">;
|
||||
compact?: boolean;
|
||||
}) => (
|
||||
<List
|
||||
className="health-monitoring-information-list"
|
||||
center
|
||||
spacing={compact ? 4 : "xs"}
|
||||
size={compact ? "sm" : undefined}
|
||||
>
|
||||
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={compact ? 18 : 30} />}>
|
||||
{t("popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={compact ? 18 : 30} />}>
|
||||
{t("popover.memory", { memory: memoryUsage.memTotal.GB })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={compact ? 18 : 30} />}>
|
||||
{t("popover.memoryAvailable", {
|
||||
memoryAvailable: memoryUsage.memFree.GB,
|
||||
percent: String(memoryUsage.memFree.percent),
|
||||
})}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={compact ? 18 : 30} />}>
|
||||
{t("popover.version", { version: healthInfo.version })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={compact ? 18 : 30} />}>
|
||||
{formatUptime(healthInfo.uptime, t)}
|
||||
</List.Item>
|
||||
{healthInfo.loadAverage && (
|
||||
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={compact ? 18 : 30} />}>
|
||||
{t("popover.loadAverage")}: {healthInfo.loadAverage["1min"]}% / {healthInfo.loadAverage["5min"]}% /{" "}
|
||||
{healthInfo.loadAverage["15min"]}%
|
||||
}) => {
|
||||
// compact is only true for the inline on-board card; the non-compact usage renders inside a portaled Modal,
|
||||
// which sits outside the zoomed canvas and must not be zoom-compensated.
|
||||
const iconSize = compact ? 18 : 30;
|
||||
const iconSizeProp = compact ? undefined : iconSize;
|
||||
const iconStyle = compact ? zoomCompensatedSize(iconSize) : undefined;
|
||||
return (
|
||||
<List
|
||||
className="health-monitoring-information-list"
|
||||
center
|
||||
spacing={compact ? 4 : "xs"}
|
||||
size={compact ? "sm" : undefined}
|
||||
>
|
||||
<List.Item
|
||||
className="health-monitoring-information-processor"
|
||||
icon={<IconCpu2 size={iconSizeProp} style={iconStyle} />}
|
||||
>
|
||||
{t("popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item className="health-monitoring-information-updates" icon={<IconPackages size={compact ? 18 : 30} />}>
|
||||
{t("popover.updatesAvailable", { count: healthInfo.availablePkgUpdates })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-reboot" icon={<IconRefreshAlert size={compact ? 18 : 30} />}>
|
||||
{healthInfo.rebootRequired ? t("popover.rebootRequired") : t("popover.rebootNotRequired")}
|
||||
</List.Item>
|
||||
</List>
|
||||
);
|
||||
<List.Item
|
||||
className="health-monitoring-information-memory"
|
||||
icon={<IconBrain size={iconSizeProp} style={iconStyle} />}
|
||||
>
|
||||
{t("popover.memory", { memory: memoryUsage.memTotal.GB })}
|
||||
</List.Item>
|
||||
<List.Item
|
||||
className="health-monitoring-information-memory"
|
||||
icon={<IconBrain size={iconSizeProp} style={iconStyle} />}
|
||||
>
|
||||
{t("popover.memoryAvailable", {
|
||||
memoryAvailable: memoryUsage.memFree.GB,
|
||||
percent: String(memoryUsage.memFree.percent),
|
||||
})}
|
||||
</List.Item>
|
||||
<List.Item
|
||||
className="health-monitoring-information-version"
|
||||
icon={<IconVersions size={iconSizeProp} style={iconStyle} />}
|
||||
>
|
||||
{t("popover.version", { version: healthInfo.version })}
|
||||
</List.Item>
|
||||
<List.Item
|
||||
className="health-monitoring-information-uptime"
|
||||
icon={<IconClock size={iconSizeProp} style={iconStyle} />}
|
||||
>
|
||||
{formatUptime(healthInfo.uptime, t)}
|
||||
</List.Item>
|
||||
{healthInfo.loadAverage && (
|
||||
<List.Item
|
||||
className="health-monitoring-information-load-average"
|
||||
icon={<IconCpu size={iconSizeProp} style={iconStyle} />}
|
||||
>
|
||||
{t("popover.loadAverage")}: {healthInfo.loadAverage["1min"]}% / {healthInfo.loadAverage["5min"]}% /{" "}
|
||||
{healthInfo.loadAverage["15min"]}%
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item
|
||||
className="health-monitoring-information-updates"
|
||||
icon={<IconPackages size={iconSizeProp} style={iconStyle} />}
|
||||
>
|
||||
{t("popover.updatesAvailable", { count: healthInfo.availablePkgUpdates })}
|
||||
</List.Item>
|
||||
<List.Item
|
||||
className="health-monitoring-information-reboot"
|
||||
icon={<IconRefreshAlert size={iconSizeProp} style={iconStyle} />}
|
||||
>
|
||||
{healthInfo.rebootRequired ? t("popover.rebootRequired") : t("popover.rebootNotRequired")}
|
||||
</List.Item>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export const formatUptime = (uptimeInSeconds: number, t: ScopedTranslationFunction<"widget.healthMonitoring">) => {
|
||||
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
|
||||
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user