Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a714420f | ||
|
|
a56ec5d06a | ||
|
|
40e34181e7 | ||
|
|
6548da3772 |
No files matched your search
@@ -36,6 +36,8 @@ pnpm cli harness setup feature-board
|
||||
|
||||
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
|
||||
|
||||
The demo board includes every widget kind. Integration-backed widgets use the bundled mock integration; media widgets use public stock assets.
|
||||
|
||||
```sh
|
||||
pnpm cli harness port feature-board
|
||||
```
|
||||
|
||||
+1
-1
@@ -4387,7 +4387,7 @@
|
||||
"enum": ["input", "icon"]
|
||||
},
|
||||
"logoDisplay": {
|
||||
"default": "logo",
|
||||
"default": "logoAndText",
|
||||
"type": "string",
|
||||
"enum": ["logo", "logoAndText"]
|
||||
},
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding-inline-end: rem(10px);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations";
|
||||
import {
|
||||
anchorNotesListInputSchema,
|
||||
anchorNoteUpdateInputSchema,
|
||||
createIntegrationAsync,
|
||||
mockWidgetData,
|
||||
} from "@homarr/integrations";
|
||||
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
@@ -15,6 +20,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);
|
||||
@@ -40,34 +51,64 @@ const normalizeAnchorContent = (content: string | undefined) => {
|
||||
|
||||
export const anchorNotesRouter = createTRPCRouter({
|
||||
listNotes: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor"))
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
|
||||
.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();
|
||||
|
||||
return data;
|
||||
}),
|
||||
getNote: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor"))
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
|
||||
.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();
|
||||
|
||||
return data;
|
||||
}),
|
||||
updateNote: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "anchor"))
|
||||
.concat(createOneIntegrationMiddleware("interact", "anchor", "mock"))
|
||||
.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,13 +1,18 @@
|
||||
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const archiveTeamWarriorRouter = createTRPCRouter({
|
||||
getStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior", "mock"))
|
||||
.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,14 +1,16 @@
|
||||
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
|
||||
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const audioStatsRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf"))
|
||||
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf", "mock"))
|
||||
.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 { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -12,9 +13,11 @@ export const bazarrRouter = createTRPCRouter({
|
||||
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "bazarr"))
|
||||
.concat(createOneIntegrationMiddleware("query", "bazarr", "mock"))
|
||||
.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 { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
@@ -16,12 +17,22 @@ interface CoolifyInstanceResult {
|
||||
|
||||
export const coolifyRouter = createTRPCRouter({
|
||||
getInstancesInfo: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "coolify"))
|
||||
.concat(createManyIntegrationMiddleware("query", "coolify", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
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,4 +1,6 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import {
|
||||
firewallCpuRequestHandler,
|
||||
firewallInterfacesRequestHandler,
|
||||
@@ -10,29 +12,53 @@ import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
|
||||
const firewallMiddleware = createManyIntegrationMiddleware(
|
||||
"query",
|
||||
...getIntegrationKindsByCategory("firewall"),
|
||||
"mock",
|
||||
);
|
||||
|
||||
const queryFirewall = <
|
||||
THandler extends {
|
||||
handler: (integration: any, input: any) => { getDataAsync: () => Promise<{ data: any; timestamp: Date }> };
|
||||
interface FirewallResult<TSummary> {
|
||||
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
|
||||
summary: TSummary;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const queryFirewall = <TSummary>(
|
||||
handler: {
|
||||
handler: (
|
||||
integration: any,
|
||||
input: Record<string, never>,
|
||||
) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> };
|
||||
},
|
||||
>(
|
||||
handler: THandler,
|
||||
createFallback: () => Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
|
||||
createFallback: () => TSummary,
|
||||
getMockData: () => TSummary,
|
||||
) =>
|
||||
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
|
||||
settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
|
||||
async (integration): Promise<FirewallResult<TSummary>> => {
|
||||
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 as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
|
||||
error: undefined as string | undefined,
|
||||
summary: data,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
fallback: (integration, error): FirewallResult<TSummary> => ({
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
|
||||
summary: createFallback(),
|
||||
error: toPublicIntegrationError(error),
|
||||
@@ -43,8 +69,24 @@ const queryFirewall = <
|
||||
);
|
||||
|
||||
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,6 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import {
|
||||
immichAlbumRequestHandler,
|
||||
immichAlbumsRequestHandler,
|
||||
@@ -12,11 +13,12 @@ import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"));
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"), "mock");
|
||||
|
||||
export const immichRouter = createTRPCRouter({
|
||||
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).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;
|
||||
}),
|
||||
@@ -29,7 +31,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;
|
||||
}),
|
||||
@@ -42,7 +50,13 @@ export const immichRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.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,12 +1,16 @@
|
||||
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const paperlessNgxRouter = createTRPCRouter({
|
||||
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
|
||||
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
getStats: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "paperlessNgx", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
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 { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
@@ -12,9 +13,10 @@ export const patchmonRouter = createTRPCRouter({
|
||||
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "patchmon"))
|
||||
.concat(createOneIntegrationMiddleware("query", "patchmon", "mock"))
|
||||
.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 { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
@@ -6,12 +7,22 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const speedtestTrackerRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
|
||||
.concat(createManyIntegrationMiddleware("query", "speedtestTracker", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
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,37 +1,50 @@
|
||||
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const tracearrRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = tracearrRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "tracearr", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (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 as typeof data | null,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: null,
|
||||
updatedAt: new Date(0),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data as typeof data | null,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: null,
|
||||
updatedAt: new Date(0),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
@@ -13,12 +14,22 @@ export const traefikRouter = createTRPCRouter({
|
||||
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "traefik"))
|
||||
.concat(createManyIntegrationMiddleware("query", "traefik", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
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,7 +2,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync, mockWidgetData } from "@homarr/integrations";
|
||||
import {
|
||||
umamiActiveVisitorsRequestHandler,
|
||||
umamiEventNamesRequestHandler,
|
||||
@@ -19,15 +19,18 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
const logger = createLogger({ module: "umami-router" });
|
||||
|
||||
export const umamiRouter = createTRPCRouter({
|
||||
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
|
||||
try {
|
||||
const instance = await createIntegrationAsync(ctx.integration);
|
||||
return await instance.getWebsitesAsync();
|
||||
} catch (error) {
|
||||
logger.warn(new Error("Failed to load websites", { cause: error }));
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
getWebsites: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
|
||||
try {
|
||||
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
|
||||
return await instance.getWebsitesAsync();
|
||||
} catch (error) {
|
||||
logger.warn(new Error("Failed to load websites", { cause: error }));
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getVisitorStats: publicProcedure
|
||||
.input(
|
||||
@@ -37,14 +40,26 @@ export const umamiRouter = createTRPCRouter({
|
||||
eventName: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", "umami"))
|
||||
.concat(createManyIntegrationMiddleware("query", "umami", "mock"))
|
||||
.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 {
|
||||
@@ -59,10 +74,16 @@ export const umamiRouter = createTRPCRouter({
|
||||
|
||||
getEventNames: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.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) {
|
||||
@@ -81,14 +102,18 @@ export const umamiRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.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) {
|
||||
@@ -111,14 +136,18 @@ export const umamiRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.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) {
|
||||
@@ -141,15 +170,21 @@ export const umamiRouter = createTRPCRouter({
|
||||
eventNames: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.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) {
|
||||
@@ -166,12 +201,16 @@ export const umamiRouter = createTRPCRouter({
|
||||
|
||||
getActiveVisitors: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.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 { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
@@ -6,12 +7,22 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const uptimeKumaRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
|
||||
.concat(createManyIntegrationMiddleware("query", "uptimeKuma", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
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,18 +1,41 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
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 { createManyIntegrationMiddleware } 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(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"), "mock"))
|
||||
.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,
|
||||
@@ -25,7 +48,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 { createOneIntegrationMiddleware } 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(createOneIntegrationMiddleware("query", "wud"))
|
||||
.concat(createOneIntegrationMiddleware("query", "wud", "mock"))
|
||||
.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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","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;
|
||||
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"}}');
|
||||
@@ -2620,7 +2620,7 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logo\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}')"
|
||||
"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\"}]}}')"
|
||||
},
|
||||
"completed_manage_tour": {
|
||||
"name": "completed_manage_tour",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","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;
|
||||
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"}}')
|
||||
|
||||
@@ -2485,7 +2485,7 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logo\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
|
||||
"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\"}]}}'"
|
||||
},
|
||||
"completed_manage_tour": {
|
||||
"name": "completed_manage_tour",
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","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;
|
||||
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"}}')
|
||||
|
||||
@@ -2551,7 +2551,7 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logo\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
|
||||
"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\"}]}}'"
|
||||
},
|
||||
"completed_manage_tour": {
|
||||
"name": "completed_manage_tour",
|
||||
|
||||
@@ -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("native feature capability descriptor", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("supports the mock integration for every integration-backed widget", () => {
|
||||
for (const capability of Object.values(nativeFeatureCapabilities)) {
|
||||
expect(capability.integrations).toContain("mock");
|
||||
}
|
||||
});
|
||||
|
||||
test("derives optional connections and server selection limits", () => {
|
||||
expect(widgetKindsWithOptionalIntegrations).toEqual(new Set(["calendar"]));
|
||||
expect(widgetIntegrationLimits).toEqual({
|
||||
|
||||
@@ -41,32 +41,32 @@ export const nativeFeatureCapabilities = {
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
},
|
||||
firewall: { integrations: getIntegrationKindsByCategory("firewall") },
|
||||
firewall: { integrations: [...getIntegrationKindsByCategory("firewall"), "mock"] },
|
||||
notifications: { integrations: getIntegrationKindsByCategory("notifications") },
|
||||
mediaReleases: { integrations: ["mock", "emby", "jellyfin", "plex"] },
|
||||
systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"] },
|
||||
systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] },
|
||||
systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"] },
|
||||
systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] },
|
||||
beszelAlerts: { integrations: ["beszel", "mock"] },
|
||||
beszelSystemGrid: { integrations: ["beszel", "mock"] },
|
||||
beszelSystemStats: { integrations: ["beszel", "mock"] },
|
||||
beszelSystemTable: { integrations: ["beszel", "mock"] },
|
||||
coolify: { integrations: ["coolify"] },
|
||||
"immich-serverStats": { integrations: ["immich"], serverMaxIntegrations: 1 },
|
||||
"immich-albumCarousel": { integrations: ["immich"], serverMaxIntegrations: 1 },
|
||||
paperlessNgx: { integrations: ["paperlessNgx"], serverMaxIntegrations: 1 },
|
||||
patchmon: { integrations: ["patchmon"], serverMaxIntegrations: 1 },
|
||||
bazarr: { integrations: ["bazarr"], serverMaxIntegrations: 1 },
|
||||
tracearr: { integrations: ["tracearr"] },
|
||||
speedtestTracker: { integrations: ["speedtestTracker"] },
|
||||
uptimeKuma: { integrations: ["uptimeKuma"] },
|
||||
audioStats: { integrations: ["navidrome", "audiobookshelf"], serverMaxIntegrations: 1 },
|
||||
umami: { integrations: ["umami"], serverMaxIntegrations: 1 },
|
||||
vpn: { integrations: getIntegrationKindsByCategory("vpn") },
|
||||
coolify: { integrations: ["coolify", "mock"] },
|
||||
"immich-serverStats": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 },
|
||||
"immich-albumCarousel": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 },
|
||||
paperlessNgx: { integrations: ["paperlessNgx", "mock"], serverMaxIntegrations: 1 },
|
||||
patchmon: { integrations: ["patchmon", "mock"], serverMaxIntegrations: 1 },
|
||||
bazarr: { integrations: ["bazarr", "mock"], serverMaxIntegrations: 1 },
|
||||
tracearr: { integrations: ["tracearr", "mock"] },
|
||||
speedtestTracker: { integrations: ["speedtestTracker", "mock"] },
|
||||
uptimeKuma: { integrations: ["uptimeKuma", "mock"] },
|
||||
audioStats: { integrations: ["navidrome", "audiobookshelf", "mock"], serverMaxIntegrations: 1 },
|
||||
umami: { integrations: ["umami", "mock"], serverMaxIntegrations: 1 },
|
||||
vpn: { integrations: [...getIntegrationKindsByCategory("vpn"), "mock"] },
|
||||
ups: { integrations: getIntegrationKindsByCategory("ups") },
|
||||
archiveTeamWarrior: { integrations: ["archiveTeamWarrior"], serverMaxIntegrations: 1 },
|
||||
anchorNote: { integrations: ["anchor"], serverMaxIntegrations: 1 },
|
||||
traefik: { integrations: ["traefik"] },
|
||||
wud: { integrations: ["wud"], serverMaxIntegrations: 1 },
|
||||
archiveTeamWarrior: { integrations: ["archiveTeamWarrior", "mock"], serverMaxIntegrations: 1 },
|
||||
anchorNote: { integrations: ["anchor", "mock"], serverMaxIntegrations: 1 },
|
||||
traefik: { integrations: ["traefik", "mock"] },
|
||||
wud: { integrations: ["wud", "mock"], serverMaxIntegrations: 1 },
|
||||
} as const satisfies Partial<Record<WidgetKind, NativeFeatureCapabilityDescriptor>>;
|
||||
|
||||
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
@@ -104,7 +104,7 @@ export const headerPreferencesSchema = z
|
||||
version: z.literal(headerPreferencesVersion),
|
||||
visible: z.boolean(),
|
||||
searchDisplay: z.enum(headerSearchDisplayValues),
|
||||
logoDisplay: z.enum(headerLogoDisplayValues).default("logo"),
|
||||
logoDisplay: z.enum(headerLogoDisplayValues).default("logoAndText"),
|
||||
zones: headerZonesSchema,
|
||||
})
|
||||
.strict();
|
||||
@@ -154,7 +154,7 @@ const migrateLegacyItems = (
|
||||
version: headerPreferencesVersion,
|
||||
visible,
|
||||
searchDisplay: "input",
|
||||
logoDisplay: "logo",
|
||||
logoDisplay: "logoAndText",
|
||||
zones: {
|
||||
left: zones.left.map(createBuiltinHeaderItem),
|
||||
center: zones.center.map(createBuiltinHeaderItem),
|
||||
@@ -208,7 +208,7 @@ export const defaultHeaderPreferences = {
|
||||
version: headerPreferencesVersion,
|
||||
visible: true,
|
||||
searchDisplay: "input",
|
||||
logoDisplay: "logo",
|
||||
logoDisplay: "logoAndText",
|
||||
zones: {
|
||||
left: [createBuiltinHeaderItem("logo")],
|
||||
center: [createBuiltinHeaderItem("search")],
|
||||
|
||||
@@ -8,7 +8,7 @@ export const { definition, componentLoader } = createWidgetDefinition("anchorNot
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "anchorNotes"]],
|
||||
maxIntegrations: 1,
|
||||
supportedIntegrations: ["anchor"],
|
||||
supportedIntegrations: ["anchor", "mock"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
noteId: factory.anchorNote(),
|
||||
|
||||
@@ -14,5 +14,5 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: ["archiveTeamWarrior"],
|
||||
supportedIntegrations: ["archiveTeamWarrior", "mock"],
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -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>
|
||||
|
||||
@@ -4,7 +4,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 = {
|
||||
@@ -16,7 +17,7 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat
|
||||
supportsAdvancedFocus: true,
|
||||
queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
|
||||
queryMatcher: matchesWidgetRuntimeQuery,
|
||||
supportedIntegrations: ["navidrome", "audiobookshelf"],
|
||||
supportedIntegrations: ["navidrome", "audiobookshelf", "mock"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconBadgeCc,
|
||||
supportedIntegrations: ["bazarr"],
|
||||
supportedIntegrations: ["bazarr", "mock"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("coolify",
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: ["coolify"],
|
||||
supportedIntegrations: ["coolify", "mock"],
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const { definition, componentLoader } = createWidgetDefinition("firewall"
|
||||
createOptions() {
|
||||
return optionsBuilder.from(() => ({}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("firewall"),
|
||||
supportedIntegrations: [...getIntegrationKindsByCategory("firewall"), "mock"],
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconWallOff,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
@@ -257,7 +258,7 @@ function NoPhotosInAlbum() {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertCircle size={32} />
|
||||
<IconAlertCircle style={iconSizes.xl} />
|
||||
<Text size="sm" fw={500}>
|
||||
{t("noPhotos")}
|
||||
</Text>
|
||||
|
||||
@@ -62,7 +62,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
|
||||
: undefined,
|
||||
}),
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["immich"],
|
||||
supportedIntegrations: ["immich", "mock"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
contextActions: ({ widgetRuntimeRef }) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getQueryKey } from "@trpc/react-query";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
@@ -59,28 +60,28 @@ export default function ImmichServerStatsWidget({
|
||||
{
|
||||
key: "users",
|
||||
enabled: statVisibility.showUsers,
|
||||
icon: <IconUsers size={statsLayout.dense ? 16 : 18} />,
|
||||
icon: <IconUsers style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
|
||||
label: t("users"),
|
||||
value: stats.userCount.toLocaleString(locale),
|
||||
},
|
||||
{
|
||||
key: "photos",
|
||||
enabled: statVisibility.showPhotos,
|
||||
icon: <IconPhoto size={statsLayout.dense ? 16 : 18} />,
|
||||
icon: <IconPhoto style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
|
||||
label: t("photos"),
|
||||
value: stats.photoCount.toLocaleString(locale),
|
||||
},
|
||||
{
|
||||
key: "videos",
|
||||
enabled: statVisibility.showVideos,
|
||||
icon: <IconVideo size={statsLayout.dense ? 16 : 18} />,
|
||||
icon: <IconVideo style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
|
||||
label: t("videos"),
|
||||
value: stats.videoCount.toLocaleString(locale),
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
enabled: statVisibility.showStorage,
|
||||
icon: <IconDatabase size={statsLayout.dense ? 16 : 18} />,
|
||||
icon: <IconDatabase style={zoomCompensatedSize(statsLayout.dense ? 16 : 18)} />,
|
||||
label: t("storage"),
|
||||
value: formatBytes(stats.totalLibraryUsageInBytes),
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-se
|
||||
queryKeys: [[["widget", "immich", "getServerStats"]], [["widget", "immich", "getAlbums"]]],
|
||||
queryMatcher: matchesWidgetRuntimeQuery,
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["immich"],
|
||||
supportedIntegrations: ["immich", "mock"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getQueryKey } from "@trpc/react-query";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
@@ -261,7 +262,11 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "
|
||||
variant="light"
|
||||
color={type === "movie" ? "yellow" : "blue"}
|
||||
>
|
||||
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
|
||||
{type === "movie" ? (
|
||||
<IconMovie style={zoomCompensatedSize(size * 0.5)} />
|
||||
) : (
|
||||
<IconVideo style={zoomCompensatedSize(size * 0.5)} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IconCube, IconUsersGroup } from "@tabler/icons-react";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatNumber } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
@@ -71,7 +72,12 @@ export default function MinecraftServerStatusWidget({
|
||||
showServerIcon &&
|
||||
(data.icon ? (
|
||||
<img
|
||||
style={{ flex: 1, width: iconSize, maxHeight: iconSize, objectFit: "contain" }}
|
||||
style={{
|
||||
flex: 1,
|
||||
width: `calc(${iconSize}px * var(--board-canvas-ui-scale, 1))`,
|
||||
maxHeight: `calc(${iconSize}px * var(--board-canvas-ui-scale, 1))`,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
alt={`minecraft icon ${options.domain}`}
|
||||
src={data.icon}
|
||||
/>
|
||||
@@ -84,12 +90,12 @@ export default function MinecraftServerStatusWidget({
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<IconCube size={iconSize} color="var(--mantine-color-gray-5)" />
|
||||
<IconCube style={zoomCompensatedSize(iconSize)} color="var(--mantine-color-gray-5)" />
|
||||
</Box>
|
||||
))}
|
||||
<Stack gap={4} w={showCapacity ? "min(100%, 420px)" : "auto"} align="stretch">
|
||||
<Group gap={5} c="dimmed" align="center" justify="center">
|
||||
<IconUsersGroup size={showMetadata ? "1.25rem" : "1rem"} />
|
||||
<IconUsersGroup style={zoomCompensatedSize(showMetadata ? 20 : 16)} />
|
||||
<Text size={showMetadata ? "lg" : isDense ? "sm" : "md"}>
|
||||
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
|
||||
</Text>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconWifi } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import { StatRow } from "./stat-row";
|
||||
|
||||
@@ -21,7 +22,7 @@ export const WifiVariant = ({
|
||||
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
|
||||
<Group gap="xs" wrap="nowrap" justify="center">
|
||||
<Center w={24} h={24}>
|
||||
<IconWifi size={20} />
|
||||
<IconWifi style={iconSizes.xl} />
|
||||
</Center>
|
||||
<Text size={"md"} fw={"bold"}>
|
||||
{t("variants.wifi.name")}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import { StatRow } from "./stat-row";
|
||||
|
||||
@@ -21,7 +22,7 @@ export const WiredVariant = ({
|
||||
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
|
||||
<Group gap="xs" wrap="nowrap" justify="center">
|
||||
<Center w={24} h={24}>
|
||||
<IconNetwork size={20} />
|
||||
<IconNetwork style={iconSizes.xl} />
|
||||
</Center>
|
||||
<Text size={"md"} fw={"bold"}>
|
||||
{t("variants.wired.name")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { Badge, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
@@ -10,6 +11,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
@@ -187,7 +189,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={summary.wanStatus}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
|
||||
size={isDense ? 16 : 20}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -198,7 +200,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={summary.www.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -214,7 +216,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={summary.wifi.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -227,7 +229,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={summary.lan.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -239,7 +241,7 @@ export default function NetworkControllerSummaryWidget({
|
||||
<StatusIcon
|
||||
status={summary.vpn.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
style={isDense ? iconSizes.md : iconSizes.xl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -285,17 +287,19 @@ const StatusIcon = ({
|
||||
status,
|
||||
label,
|
||||
size,
|
||||
style,
|
||||
}: {
|
||||
status?: "enabled" | "disabled";
|
||||
label: string;
|
||||
size: number | string;
|
||||
size?: number | string;
|
||||
style?: CSSProperties;
|
||||
}) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
return <IconCircleCheckFilled aria-label={label} size={size} color={mantineTheme.colors.green[6]} />;
|
||||
return <IconCircleCheckFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.green[6]} />;
|
||||
}
|
||||
if (status === "disabled") {
|
||||
return <IconCircleXFilled aria-label={label} size={size} color={mantineTheme.colors.red[6]} />;
|
||||
return <IconCircleXFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.red[6]} />;
|
||||
}
|
||||
return <IconCircleXFilled aria-label={label} size={size} color={mantineTheme.colors.gray[6]} />;
|
||||
return <IconCircleXFilled aria-label={label} size={size} style={style} color={mantineTheme.colors.gray[6]} />;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
@@ -158,7 +159,11 @@ const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; den
|
||||
|
||||
return (
|
||||
<Group gap={5} align="center" wrap="nowrap">
|
||||
<IconClock aria-hidden size={dense ? 12 : "1rem"} color="var(--mantine-color-dimmed)" />
|
||||
<IconClock
|
||||
aria-hidden
|
||||
style={dense ? iconSizes.xs : iconSizes.md}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
<Text size={dense ? "xs" : "sm"} c="dimmed">
|
||||
{timeAgo}
|
||||
</Text>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -184,7 +185,7 @@ export default function PaperlessNgxWidget({
|
||||
const Icon = statIcons[statKey];
|
||||
return (
|
||||
<div key={statKey} className={classes.statTile}>
|
||||
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
|
||||
<span className={classes.statValue}>{statValues[statKey]}</span>
|
||||
<span className={classes.statLabel}>{t(statKey)}</span>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ export const { definition, componentLoader } = createWidgetDefinition("paperless
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconFileText,
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["paperlessNgx"],
|
||||
supportedIntegrations: ["paperlessNgx", "mock"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
|
||||
@@ -17,6 +17,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
@@ -234,7 +235,12 @@ export default function PatchMonWidget({
|
||||
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
|
||||
>
|
||||
{showIcons && (
|
||||
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} color={severityToIconColor(severity)} />
|
||||
<Icon
|
||||
className={classes.statIcon}
|
||||
style={zoomCompensatedSize(iconSize)}
|
||||
stroke={1.5}
|
||||
color={severityToIconColor(severity)}
|
||||
/>
|
||||
)}
|
||||
<span className={classes.statValue}>
|
||||
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
|
||||
@@ -444,7 +450,7 @@ function CompactStatGrid({
|
||||
{showIcon && (
|
||||
<Icon
|
||||
className={classes.statIcon}
|
||||
size="var(--mantine-font-size-lg)"
|
||||
style={iconSizes.lg}
|
||||
stroke={1.5}
|
||||
color={severityToIconColor(severity)}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
|
||||
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconShieldCheck,
|
||||
supportedIntegrations: ["patchmon"],
|
||||
supportedIntegrations: ["patchmon", "mock"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { isSmartHomeTiny } from "../layout";
|
||||
@@ -126,7 +127,7 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
<Center w="100%" h="100%">
|
||||
<Stack align="center" gap={isTiny ? 6 : "md"} p="xs" maw="100%">
|
||||
<ThemeIcon variant="light" size={isTiny ? "md" : "xl"} radius="xl">
|
||||
<IconAutomation size={isTiny ? 14 : 24} />
|
||||
<IconAutomation style={zoomCompensatedSize(isTiny ? 14 : 24)} />
|
||||
</ThemeIcon>
|
||||
<Text ta="center" fw={600} fz={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
|
||||
{options.displayName}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const { definition, componentLoader } = createWidgetDefinition("speedtest
|
||||
},
|
||||
);
|
||||
},
|
||||
supportedIntegrations: ["speedtestTracker"],
|
||||
supportedIntegrations: ["speedtestTracker", "mock"],
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Card, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export interface SpeedStatCardProps {
|
||||
@@ -45,7 +46,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
|
||||
gap={isWide ? 8 : 4}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<Icon size={compact ? 16 : 20} color={accentColor} style={{ flexShrink: 0 }} />
|
||||
<Icon color={accentColor} style={zoomCompensatedSize(compact ? 16 : 20)} />
|
||||
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1} truncate w="100%">
|
||||
{value}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemDis
|
||||
icon: IconServer2,
|
||||
queryKey: [["widget", "healthMonitoring"]],
|
||||
refetchInterval: 10,
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
|
||||
import type { TooltipProps, YAxisProps } from "recharts";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { LabelDisplayModeOption } from "..";
|
||||
@@ -69,7 +70,7 @@ export const CommonChart = ({
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
align="center"
|
||||
>
|
||||
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} size={height > 100 ? 20 : 14} stroke={1.5} />}
|
||||
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} style={zoomCompensatedSize(height > 100 ? 20 : 14)} stroke={1.5} />}
|
||||
{showText && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
|
||||
{title}
|
||||
@@ -92,7 +93,7 @@ export const CommonChart = ({
|
||||
{data.length <= 1 ? (
|
||||
<Center pos="absolute" w="100%" h="100%">
|
||||
<Stack px={"xs"} align={"center"} gap={4}>
|
||||
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} size={height > 100 ? 20 : 14} stroke={1.5} />}
|
||||
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} style={zoomCompensatedSize(height > 100 ? 20 : 14)} stroke={1.5} />}
|
||||
{showText && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
|
||||
{title}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemRes
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "healthMonitoring"]],
|
||||
refetchInterval: 10,
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
hasShadow: factory.switch({ defaultValue: true }),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { IconBell, IconPlayerPause, IconPlayerPlay, IconRefresh, IconSettings, I
|
||||
import { useTiks } from "@rexa-developer/tiks/react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes } from "@homarr/ui";
|
||||
|
||||
import {
|
||||
claimBrowserAlertOccurrence,
|
||||
@@ -427,7 +428,7 @@ const AdvancedTimer = ({
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Paper withBorder p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconSettings size={18} aria-hidden />
|
||||
<IconSettings style={iconSizes.lg} aria-hidden />
|
||||
<Text fw={600}>{t("advanced.settings")}</Text>
|
||||
</Group>
|
||||
<SettingsSummary mode={runtime.mode} durations={durations} />
|
||||
@@ -438,7 +439,7 @@ const AdvancedTimer = ({
|
||||
|
||||
<Paper withBorder p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconBell size={18} aria-hidden />
|
||||
<IconBell style={iconSizes.lg} aria-hidden />
|
||||
<Text fw={600}>{t("advanced.localAlerts")}</Text>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
@@ -453,7 +454,7 @@ const AdvancedTimer = ({
|
||||
disabled={disabled}
|
||||
label={t("advanced.sound")}
|
||||
onChange={(event) => onSoundChange(event.currentTarget.checked)}
|
||||
thumbIcon={<IconVolume size={12} />}
|
||||
thumbIcon={<IconVolume style={iconSizes.sm} />}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("advanced.localAlertsHint")}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const { definition, componentLoader } = createWidgetDefinition("tracearr"
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: ["tracearr"],
|
||||
supportedIntegrations: ["tracearr", "mock"],
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
@@ -176,12 +177,12 @@ function TraefikWidgetContent({
|
||||
|
||||
<div className={classes.hero}>
|
||||
<SummaryMetric
|
||||
icon={<IconRoute size={getHeroIconSize(width)} />}
|
||||
icon={<IconRoute style={zoomCompensatedSize(getHeroIconSize(width))} />}
|
||||
label={t("summary.routers")}
|
||||
value={totalRouters}
|
||||
/>
|
||||
<SummaryMetric
|
||||
icon={<IconDoorEnter size={getHeroIconSize(width)} />}
|
||||
icon={<IconDoorEnter style={zoomCompensatedSize(getHeroIconSize(width))} />}
|
||||
label={t("summary.entryPoints")}
|
||||
value={dedupe(combined.entryPoints).length}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("traefik",
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: ["traefik"],
|
||||
supportedIntegrations: ["traefik", "mock"],
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconRouteOff,
|
||||
|
||||
@@ -18,7 +18,7 @@ export const { definition, componentLoader } = createWidgetDefinition("umami", {
|
||||
],
|
||||
queryMatcher: matchesWidgetRuntimeQuery,
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["umami"],
|
||||
supportedIntegrations: ["umami", "mock"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatNumber } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
@@ -215,7 +216,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
<div key={statKey} className={classes.statTile}>
|
||||
<Icon
|
||||
className={classes.statIcon}
|
||||
size={iconSize}
|
||||
style={zoomCompensatedSize(iconSize)}
|
||||
stroke={1.5}
|
||||
color={`var(--mantine-color-${color}-6)`}
|
||||
/>
|
||||
|
||||
@@ -34,7 +34,7 @@ export const { definition, componentLoader } = createWidgetDefinition("uptimeKum
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: ["uptimeKuma"],
|
||||
supportedIntegrations: ["uptimeKuma", "mock"],
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -12,5 +12,5 @@ export const { componentLoader, definition } = createWidgetDefinition("vpn", {
|
||||
createOptions() {
|
||||
return optionsBuilder.from(() => ({}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("vpn"),
|
||||
supportedIntegrations: [...getIntegrationKindsByCategory("vpn"), "mock"],
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { IconDroplets, IconMapPin, IconSunHigh, IconSunrise, IconSunset, IconWind } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import type { WidgetProps } from "../definition";
|
||||
import { AnimatedWeatherIcon } from "./animated-icon";
|
||||
@@ -206,7 +207,7 @@ export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWea
|
||||
|
||||
<SimpleGrid cols={layout.metricColumns} spacing="xs">
|
||||
<MetricCard
|
||||
icon={<IconDroplets size={18} aria-hidden />}
|
||||
icon={<IconDroplets style={iconSizes.lg} aria-hidden />}
|
||||
label={t("advanced.nextHourPrecipitation")}
|
||||
value={t("advanced.precipitationValue", {
|
||||
amount: nextHour?.precipitation?.toFixed(1) ?? "?",
|
||||
@@ -215,13 +216,13 @@ export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWea
|
||||
/>
|
||||
{options.showHumidity && (
|
||||
<MetricCard
|
||||
icon={<IconDroplets size={18} aria-hidden />}
|
||||
icon={<IconDroplets style={iconSizes.lg} aria-hidden />}
|
||||
label={t("advanced.currentHumidity")}
|
||||
value={`${weather.current.relativeHumidity}%`}
|
||||
/>
|
||||
)}
|
||||
<MetricCard
|
||||
icon={<IconWind size={18} aria-hidden />}
|
||||
icon={<IconWind style={iconSizes.lg} aria-hidden />}
|
||||
label={t("advanced.currentWind")}
|
||||
value={t("advanced.windValue", {
|
||||
gusts: getPreferredWindSpeed(weather.current.windGusts, options.useImperialSpeed),
|
||||
@@ -230,17 +231,17 @@ export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWea
|
||||
})}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<IconSunHigh size={18} aria-hidden />}
|
||||
icon={<IconSunHigh style={iconSizes.lg} aria-hidden />}
|
||||
label={t("advanced.todayUv")}
|
||||
value={today?.uvIndex?.toFixed(1) ?? "?"}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<IconSunrise size={18} aria-hidden />}
|
||||
icon={<IconSunrise style={iconSizes.lg} aria-hidden />}
|
||||
label={t("dailyForecast.sunrise")}
|
||||
value={getPreferredTime(today?.sunriseAt, locale, weather.timezone)}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<IconSunset size={18} aria-hidden />}
|
||||
icon={<IconSunset style={iconSizes.lg} aria-hidden />}
|
||||
label={t("dailyForecast.sunset")}
|
||||
value={getPreferredTime(today?.sunsetAt, locale, weather.timezone)}
|
||||
/>
|
||||
@@ -322,7 +323,11 @@ export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWea
|
||||
{getPreferredDate(day.date, locale, { month: "short", day: "numeric" })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<AnimatedWeatherIcon animated={options.animateIcons} code={day.weatherCode} size={28} />
|
||||
<AnimatedWeatherIcon
|
||||
animated={options.animateIcons}
|
||||
code={day.weatherCode}
|
||||
style={zoomCompensatedSize(28)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
@@ -348,13 +353,13 @@ export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWea
|
||||
|
||||
<Group className={classes.dailyCardFacts} justify="space-between" gap={4} wrap="nowrap">
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconDroplets size={14} aria-hidden />
|
||||
<IconDroplets style={iconSizes.sm} aria-hidden />
|
||||
<Text className={classes.dailyCardFact} fz={11}>
|
||||
{day.precipitationProbability ?? "?"}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconWind size={14} aria-hidden />
|
||||
<IconWind style={iconSizes.sm} aria-hidden />
|
||||
<Text className={classes.dailyCardFact} fz={11}>
|
||||
{getPreferredWindSpeed(day.maxWindSpeed, options.useImperialSpeed)} {speedUnit}
|
||||
</Text>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import "../widgets-common.css";
|
||||
|
||||
import { WeatherIcon } from "./icon";
|
||||
@@ -7,6 +9,7 @@ interface AnimatedWeatherIconProps {
|
||||
code: number;
|
||||
isDay?: boolean;
|
||||
size?: string | number;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const getAnimationClass = (code: number): string => {
|
||||
@@ -19,12 +22,12 @@ const getAnimationClass = (code: number): string => {
|
||||
return "";
|
||||
};
|
||||
|
||||
export const AnimatedWeatherIcon = ({ animated = false, code, isDay, size = 26 }: AnimatedWeatherIconProps) => {
|
||||
export const AnimatedWeatherIcon = ({ animated = false, code, isDay, size = 26, style }: AnimatedWeatherIconProps) => {
|
||||
let animationClass = "";
|
||||
if (animated && (code !== 0 || isDay !== false)) animationClass = getAnimationClass(code);
|
||||
return (
|
||||
<span className={`weather-anim-wrapper ${animationClass}`}>
|
||||
<WeatherIcon code={code} isDay={isDay} size={size} />
|
||||
<WeatherIcon code={code} isDay={isDay} size={size} style={style} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { Group, Popover, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import type { WidgetProps } from "../definition";
|
||||
import { AnimatedWeatherIcon } from "./animated-icon";
|
||||
@@ -55,7 +56,7 @@ export const CompactWeather = ({ height, isEditMode, options, weather, width }:
|
||||
animated={options.animateIcons}
|
||||
code={weather.current.weatherCode}
|
||||
isDay={weather.current.isDay}
|
||||
size={layout.tier === "micro" ? 18 : 32}
|
||||
style={zoomCompensatedSize(layout.tier === "micro" ? 18 : 32)}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
@@ -132,7 +133,11 @@ export const CompactWeather = ({ height, isEditMode, options, weather, width }:
|
||||
<Text component="span" size="sm" fw={600} tt="capitalize">
|
||||
{getPreferredDate(day.date, locale, { weekday: "short" })}
|
||||
</Text>
|
||||
<AnimatedWeatherIcon animated={options.animateIcons} code={day.weatherCode} size={18} />
|
||||
<AnimatedWeatherIcon
|
||||
animated={options.animateIcons}
|
||||
code={day.weatherCode}
|
||||
style={zoomCompensatedSize(18)}
|
||||
/>
|
||||
<Text component="span" size="sm">
|
||||
{getPreferredUnit(
|
||||
day.maxTemperature,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import type { WidgetProps } from "../definition";
|
||||
import { AnimatedWeatherIcon } from "./animated-icon";
|
||||
@@ -88,7 +89,7 @@ export const DailyWeatherDetails = ({
|
||||
<Group justify="space-between" align="center" gap="md" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" miw={0}>
|
||||
<ThemeIcon variant="light" size={46} radius="xl">
|
||||
<AnimatedWeatherIcon animated={animateIcons} code={day.weatherCode} size={28} />
|
||||
<AnimatedWeatherIcon animated={animateIcons} code={day.weatherCode} style={zoomCompensatedSize(28)} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text fw={700} truncate>
|
||||
@@ -109,29 +110,29 @@ export const DailyWeatherDetails = ({
|
||||
|
||||
<SimpleGrid cols={2} spacing={6}>
|
||||
<DetailMetric
|
||||
icon={<IconTemperature size={16} aria-hidden />}
|
||||
icon={<IconTemperature style={iconSizes.md} aria-hidden />}
|
||||
label={t("advanced.detailLabel.feelsLike")}
|
||||
value={`${getPreferredUnit(day.maxApparentTemperature, isFahrenheit, disableTemperatureDecimals)} / ${getPreferredUnit(day.minApparentTemperature, isFahrenheit, disableTemperatureDecimals)}`}
|
||||
/>
|
||||
<DetailMetric
|
||||
icon={<IconDroplets size={16} aria-hidden />}
|
||||
icon={<IconDroplets style={iconSizes.md} aria-hidden />}
|
||||
label={t("advanced.detailLabel.precipitation")}
|
||||
value={`${day.precipitationProbability ?? "?"}% · ${day.precipitation?.toFixed(1) ?? "?"} mm`}
|
||||
/>
|
||||
{showHumidity && (
|
||||
<DetailMetric
|
||||
icon={<IconGauge size={16} aria-hidden />}
|
||||
icon={<IconGauge style={iconSizes.md} aria-hidden />}
|
||||
label={t("advanced.detailLabel.humidity")}
|
||||
value={`${day.averageHumidity ?? "?"}%`}
|
||||
/>
|
||||
)}
|
||||
<DetailMetric
|
||||
icon={<IconWind size={16} aria-hidden />}
|
||||
icon={<IconWind style={iconSizes.md} aria-hidden />}
|
||||
label={t("advanced.detailLabel.wind")}
|
||||
value={`${getPreferredWindSpeed(day.maxWindSpeed, useImperialSpeed)} / ${getPreferredWindSpeed(day.maxWindGusts, useImperialSpeed)} ${speedUnit}`}
|
||||
/>
|
||||
<DetailMetric
|
||||
icon={<IconSunHigh size={16} aria-hidden />}
|
||||
icon={<IconSunHigh style={iconSizes.md} aria-hidden />}
|
||||
label={t("advanced.detailLabel.uv")}
|
||||
value={day.uvIndex?.toFixed(1) ?? "?"}
|
||||
/>
|
||||
@@ -140,17 +141,17 @@ export const DailyWeatherDetails = ({
|
||||
<Paper className={classes.solarDetails} p="xs" radius="md">
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<SolarMetric
|
||||
icon={<IconSunrise size={17} aria-hidden />}
|
||||
icon={<IconSunrise style={iconSizes.md} aria-hidden />}
|
||||
label={t("dailyForecast.sunrise")}
|
||||
value={getPreferredTime(day.sunriseAt, locale, timeZone)}
|
||||
/>
|
||||
<SolarMetric
|
||||
icon={<IconSunset size={17} aria-hidden />}
|
||||
icon={<IconSunset style={iconSizes.md} aria-hidden />}
|
||||
label={t("dailyForecast.sunset")}
|
||||
value={getPreferredTime(day.sunsetAt, locale, timeZone)}
|
||||
/>
|
||||
<SolarMetric
|
||||
icon={<IconSunHigh size={17} aria-hidden />}
|
||||
icon={<IconSunHigh style={iconSizes.md} aria-hidden />}
|
||||
label={t("advanced.detailLabel.daylight")}
|
||||
value={getPreferredDaylightDuration(day.daylightDuration)}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { List, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconCloud,
|
||||
@@ -25,19 +26,23 @@ interface WeatherIconProps {
|
||||
code: number;
|
||||
isDay?: boolean;
|
||||
size?: string | number;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon which should be displayed when specific code is defined
|
||||
* @param code weather code from api
|
||||
* @param size size of the icon, accepts relative sizes too
|
||||
* @param style CSS overrides (e.g. from `zoomCompensatedSize`) - prefer this over `size` for a
|
||||
* board-zoom-safe size, since `size` becomes a raw SVG width/height attribute that `var()` isn't
|
||||
* guaranteed to resolve inside (see `iconSizes` in @homarr/ui).
|
||||
* @returns Icon corresponding to the weather code
|
||||
*/
|
||||
export const WeatherIcon = ({ code, isDay = true, size = 50 }: WeatherIconProps) => {
|
||||
if (code === 0 && !isDay) return <IconMoon style={{ float: "left" }} size={size} />;
|
||||
export const WeatherIcon = ({ code, isDay = true, size = 50, style }: WeatherIconProps) => {
|
||||
if (code === 0 && !isDay) return <IconMoon style={{ float: "left", ...style }} size={size} />;
|
||||
const { icon: Icon } = weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather;
|
||||
|
||||
return <Icon style={{ float: "left" }} size={size} />;
|
||||
return <Icon style={{ float: "left", ...style }} size={size} />;
|
||||
};
|
||||
|
||||
export const getWeatherKind = (code: number) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import type { WudContainerUpdate } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
@@ -64,7 +65,7 @@ const WudWidgetContent = ({
|
||||
<Text size={isTiny ? "8px" : "xs"} fw={700}>
|
||||
{updatePercentage}%
|
||||
</Text>
|
||||
<IconBrandDocker size={isTiny ? 8 : 16} />
|
||||
<IconBrandDocker style={zoomCompensatedSize(isTiny ? 8 : 16)} />
|
||||
</Center>
|
||||
}
|
||||
sections={[{ value: updatePercentage, color: progressColor(updatePercentage) }]}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const { definition, componentLoader } = createWidgetDefinition("wud", {
|
||||
icon: IconBrandDocker,
|
||||
supportsAdvancedFocus: true,
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["wud"],
|
||||
supportedIntegrations: ["wud", "mock"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
|
||||
Reference in new issue
Block a user