Compare commits

...
109 changed files with 2765 additions and 223 deletions

No files matched your search

+1 -1
View File
@@ -37,4 +37,4 @@ Orient from the current checkout before editing. Treat package manifests, export
- Cron jobs: `packages/cron-jobs/src/jobs/`
- App routes: `apps/nextjs/src/app/[locale]/`
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
When a change affects setup, usage, troubleshooting, or the accuracy of existing docs, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
+13 -5
View File
@@ -1,13 +1,21 @@
---
name: documentation-sync
description: Keep Homarr's Docusaurus documentation aligned with user-facing code. Use when adding or changing integrations, widgets, APIs, environment variables, CLI commands, authentication, permissions, cron jobs, settings, or UI behavior that users must understand.
description: Keep Homarr's Docusaurus documentation useful and accurate. Use when a change affects what users need to know to configure, use, or troubleshoot Homarr, or makes existing guidance inaccurate.
---
# Documentation Sync
Update `apps/docs/` in the same change as user-facing behavior. Inspect adjacent pages and the docs types before copying a pattern; the source and docs evolve together.
Update `apps/docs/` when users need new or corrected guidance to configure, use, or troubleshoot Homarr. Keep documentation straight to the point.
## Map the change
## Decide whether documentation is needed
Identify the user action, decision, prerequisite, limitation, or troubleshooting step that changes. Update the relevant docs when that information is missing or inaccurate. If nothing users need to know changes, leave the docs untouched.
Routine visual fixes such as icon sizing, spacing, alignment, responsive layout, and animation usually need no documentation. Describe them in the PR when useful for review. Document a visual change only when it changes a user workflow, setting, or meaningful limitation.
Write the minimum information needed to complete the user's task. Inspect adjacent pages and the docs types before editing.
## Map changes that need documentation
| Code change | Documentation target |
| ----------------------------------- | -------------------------------------------------------------------- |
@@ -44,8 +52,8 @@ For a widget:
## Completion criteria
1. Describe the shipped behavior, not an implementation plan.
1. Every addition helps a user configure, use, or troubleshoot Homarr; omit incidental rendering and implementation details.
2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code.
3. Update every affected page and remove superseded guidance.
3. Correct affected guidance and remove superseded instructions. A code change alone does not require a docs edit.
4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
5. Treat broken links and anchors as failures; Docusaurus checks them strictly.
@@ -1,4 +1,4 @@
interface:
display_name: "Homarr Documentation Sync"
short_description: "Keep user-facing docs aligned with code"
default_prompt: "Use $documentation-sync to map this Homarr code change to the required documentation updates."
short_description: "Keep user docs concise, useful, and accurate"
default_prompt: "Use $documentation-sync to decide whether this Homarr change needs user guidance and update only what users need to know."
+1 -1
View File
@@ -10,7 +10,7 @@
- [ ] Pull request targets the intended base branch (`dev` by default; `release/v2` for v2 release work)
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (for example, `x`, `y`, `i`, or abbreviations)
- [ ] User-facing changes include matching documentation updates under `apps/docs`
- [ ] Changes to setup, usage, troubleshooting, or existing guidance include relevant documentation updates under `apps/docs` when needed
- [ ] When using AI, no temporary files are checked in and the code style follows the rest of the project
**When contributing a new integration or widget:**
+2 -2
View File
@@ -59,7 +59,7 @@ homarr/
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
When modifying user-facing code, you MUST also update the corresponding documentation:
Update documentation when a change affects what users need to know to configure, use, or troubleshoot Homarr, or makes existing guidance inaccurate. Follow `.agents/skills/documentation-sync/SKILL.md` to decide whether an update is needed and keep it concise. For changes that need documentation:
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
@@ -99,6 +99,6 @@ When modifying user-facing code, you MUST also update the corresponding document
Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`.
- `codebase-context` — architecture, package boundaries, and shared utilities
- `documentation-sync` — required user-facing documentation updates
- `documentation-sync` — concise, relevant user guidance
- `mcp-integration` — safe tRPC-to-MCP exposure
- `homarr-custom-widget` — safe Custom JSX v2 authoring
@@ -0,0 +1,55 @@
---
title: "llama.cpp"
description: "Connect Homarr to a llama.cpp llama-server to monitor your local LLM: health, loaded model, generation speed and request status."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { llamacppIntegration } from ".";
import { llamacppWidget } from "@site/docs/widgets/llama-cpp";
<IntegrationHeader integration={llamacppIntegration} categories={["System Monitoring"]} />
The llama.cpp integration lets Homarr connect to a [llama-server](https://github.com/ggml-org/llama.cpp) instance so you can see the health of your local LLM, which model is loaded, and how fast it is generating — all from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: llamacppWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your `llama-server`, for example `http://192.168.1.50:8080`. Homarr calls the `/health`, `/v1/models`, `/metrics` and `/slots` endpoints under that URL automatically.
:::
:::tip Metrics
Generation speed and token counters are read from the Prometheus endpoint at `/metrics`. Start `llama-server` with the `--metrics` flag so these values are available:
```shell
llama-server --model /path/to/model.gguf --metrics
```
Without `--metrics` the widget still shows health, model information, and active-request speed from `/slots`; metrics-backed counters and aggregate speed stay empty.
:::
:::info Context and per-request speed
Context (KV cache) usage and the in-flight request data are read from the `/slots` endpoint, which is available on every recent llama-server build and requires no extra flags. The per-request generation speed shown while a request is running is derived by tracking the request's decoded token count across polls, so it reflects the average speed of the request that is currently being generated.
:::
### Secrets
llama-server does not require authentication by default. The integration only needs the URL of your server.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
]}
/>
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const llamacppIntegration: IntegrationDefinition = {
name: "llama.cpp",
description:
"Connect to a local llama.cpp llama-server to show model, throughput and request status on your dashboard.",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
path: "../../integrations/llama-cpp",
};
+4 -3
View File
@@ -63,10 +63,11 @@ Tools use the permissions of the API key owner or OAuth user. Queries read data;
Inspect the **AI / MCP** tab or your client's tool list for available actions.
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
For Custom Widgets, follow [Connect an agent](./custom-widgets/agent-authoring).
Secret values are configured in Homarr and are never returned through MCP.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
The built-in [Assistant](./assistant) shares the MCP tool catalog, uses your Homarr session, and asks for approval
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
## Verify the connection
@@ -18,6 +18,9 @@ password. Decimal units are the default. Change them from the user form or **Com
Preferences → Data size units**. Byte rates follow the same preference; bit rates stay decimal and use labels such as
Mb/s. Deleting a user does not delete boards they created.
In demo mode (`DEMO_MODE=true`), user deletion and password changes are disabled for everyone, including administrators,
even when `DEMO_READ_ONLY=false` allows other changes. This applies to the UI and API.
![The Users management page](../img/users.png)
## Groups
+2 -1
View File
@@ -12,7 +12,8 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The App widget opens an application or website and can show whether Homarr can reach it. Configure the target and status
check from the tile's edit actions.
check from the tile's edit actions. Very small tiles shorten the title and description to leave room for the icon;
enlarge the tile to show more text.
### Adding the widget
@@ -0,0 +1,43 @@
---
title: "llama.cpp"
description: "Displays the health, loaded model and generation speed of a local llama.cpp llama-server."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { llamacppWidget } from ".";
import { llamacppIntegration } from "@site/docs/integrations/llama-cpp";
<WidgetHeader widget={llamacppWidget} categories={["System Monitoring"]} />
This widget shows the live status of your local [llama.cpp](https://github.com/ggml-org/llama.cpp) `llama-server`: whether it is online, which model is loaded (with quantization, context size, file size and parameter count), the generation speed in tokens per second, and how many requests are being processed. When the server is started with `--metrics`, it also reports total token counts and the prompt cache hit rate.
### Generation speed
The widget picks the most meaningful speed for the current state (hover the speed for an explanation of which one is shown):
- **While a request is being generated** — the average speed of that request since it started, derived from the token count reported by `/slots`.
- **While the server is busy but the per-request data is unavailable** — the live speed from the `/metrics` rate gauge.
- **While the server is idle** — the average generation speed since the server started (cumulative tokens ÷ seconds), so the value stays meaningful instead of dropping to zero.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: llamacppIntegration,
note: "Displays health, model details, generation speed and request status",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={llamacppWidget.configuration} />
+37
View File
@@ -0,0 +1,37 @@
import { WidgetDefinition } from "@site/src/types";
import { IconCpu } from "@tabler/icons-react";
export const llamacppWidget: WidgetDefinition = {
icon: IconCpu,
name: "llama.cpp",
description: "Shows the health, loaded model and generation speed of a local llama.cpp llama-server.",
path: "../../widgets/llama-cpp",
configuration: {
items: [
{
name: "Show title",
description: "Displays the llama.cpp label and status badge at the top of the widget",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show model info",
description: "Displays the loaded model name, quantization, context size and file size",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show cache hit rate",
description: "Displays the prompt cache hit rate as a percentage bar",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show context usage",
description: "Displays the context (KV cache) token usage as a progress bar",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+2 -1
View File
@@ -2304,7 +2304,8 @@
"traefik",
"customApi",
"assistant",
"wud"
"wud",
"llamacpp"
]
},
"options": {
@@ -27,7 +27,7 @@ import { WidgetCardShell, WidgetTitleBadge } from "@homarr/widgets/widget-card-s
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize } from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { useBoardContentScale } from "~/components/board/layout/scaled-board-canvas";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
@@ -148,7 +148,7 @@ const LoadedBoardItemContent = ({
const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null);
const board = useRequiredBoard();
const boardCanvasScale = useBoardCanvasScale();
const boardContentScale = useBoardContentScale();
const t = useI18n();
const [isEditMode] = useEditMode();
const settings = useSettings();
@@ -163,8 +163,8 @@ const LoadedBoardItemContent = ({
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = getWidgetName(item.kind, t);
const previewDimensions = useMemo(
() => ({ width, height, scale: boardCanvasScale }),
[boardCanvasScale, height, width],
() => ({ width, height, scale: boardContentScale }),
[boardContentScale, height, width],
);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
@@ -314,7 +314,7 @@ const LoadedBoardItemContent = ({
item={item}
width={width}
height={height}
displayScale={boardCanvasScale}
displayScale={boardContentScale}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"}
@@ -17,7 +17,10 @@
.canvas {
position: absolute;
inset: 0 auto auto 0;
}
.canvas,
.contentScale {
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
@@ -8,6 +8,10 @@ import { useElementSize } from "@mantine/hooks";
import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1);
// Widget content can also be scaled by containers inside the board canvas.
const BoardContentScaleContext = createContext(1);
export const BoardContentScaleProvider = BoardContentScaleContext.Provider;
export const useBoardContentScale = () => useContext(BoardContentScaleContext);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -86,7 +90,9 @@ export const ScaledBoardCanvas = ({
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
<BoardCanvasScaleContext.Provider value={scale}>
<BoardContentScaleProvider value={scale}>{children}</BoardContentScaleProvider>
</BoardCanvasScaleContext.Provider>
</Box>
</Box>
</Box>
@@ -18,7 +18,13 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import {
BoardContentScaleProvider,
calculateBoardUiScale,
useBoardCanvasScale,
useBoardContentScale,
} from "~/components/board/layout/scaled-board-canvas";
import canvasClasses from "~/components/board/layout/scaled-board-canvas.module.css";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -50,6 +56,7 @@ export const SectionGrid = ({
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const parentContentScale = useBoardContentScale();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
@@ -144,10 +151,16 @@ export const SectionGrid = ({
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
// Convert that height so the label reserves the same space at every board scale.
let effectiveParentContentScale = effectiveCanvasScale;
if (Number.isFinite(parentContentScale) && parentContentScale > 0) {
effectiveParentContentScale = parentContentScale;
}
// Compensate every ancestor container while retaining the board's enlargement above 100%.
const parentUiScale =
(calculateBoardUiScale(effectiveCanvasScale) * effectiveCanvasScale) / effectiveParentContentScale;
// Match the label's Mantine height in its parent's logical coordinate system.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT * parentUiScale : 0;
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth;
@@ -172,7 +185,8 @@ export const SectionGrid = ({
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
const combinedUiScale = parentUiScale / containerContentScale;
const contentScale = effectiveParentContentScale * containerContentScale;
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -282,21 +296,23 @@ export const SectionGrid = ({
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
>
<Box
className={classes.staticGrid}
className={combineClasses(classes.staticGrid, canvasClasses.contentScale)}
style={
{
width: fullGridWidth,
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
"--board-canvas-ui-scale": combinedUiScale,
} as CSSProperties
}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
<BoardContentScaleProvider value={contentScale}>
<SectionContent />
</BoardContentScaleProvider>
</Box>
<div ref={editorHostRef} className={classes.editorPortalHost} />
</Box>
+14
View File
@@ -497,6 +497,13 @@ export const userRouter = createTRPCRouter({
mcp: { enabled: true, description: "Delete a user by ID. REQUIRED: userId (string)" },
})
.mutation(async ({ input, ctx }) => {
if (isDemoMode) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User deletion is disabled in demo mode",
});
}
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
@@ -519,6 +526,13 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ ctx, input }) => {
if (isDemoMode) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Password changes are disabled in demo mode",
});
}
const user = ctx.session.user;
// Only admins can change other users' passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
+1
View File
@@ -46,4 +46,5 @@ export const widgetRouter = createTRPCRouter({
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
llamacpp: lazy(() => import("./llamacpp").then((mod) => mod.llamacppRouter)),
});
@@ -0,0 +1,29 @@
import { llamacppStatsRequestHandler } from "@homarr/request-handler/llamacpp";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const llamacppRouter = createTRPCRouter({
getStats: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Returns health, loaded model and Prometheus metrics (generation speed, token counts, request queue) from a llama.cpp (llama-server) integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneWidgetIntegrationMiddleware("query", "llamacpp"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") {
return { stats: mockWidgetData.llamacpp, updatedAt: new Date(mockWidgetData.timestamp) };
}
const handler = llamacppStatsRequestHandler.handler({ ...ctx.integration, kind: "llamacpp" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
stats: data,
updatedAt: timestamp,
};
}),
});
+1
View File
@@ -505,6 +505,7 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
+1
View File
@@ -503,6 +503,7 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
+1
View File
@@ -488,6 +488,7 @@ export const assistantConfigurations = sqliteTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
+10
View File
@@ -11,6 +11,7 @@ export const assistantProviderIds = [
"together",
"ollama",
"lm-studio",
"llama-cpp",
"custom",
] as const;
@@ -156,6 +157,15 @@ export const assistantProviderPresets = {
discoveryAuthentication: "bearer",
iconUrl: `${providerIconBaseUrl}/lmstudio.svg`,
},
"llama-cpp": {
label: "llama.cpp",
baseUrl: "http://localhost:8080/v1",
modelDiscoveryPath: "/models",
requiresApiKey: false,
category: "local",
discoveryAuthentication: "bearer",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
},
custom: {
label: undefined,
baseUrl: "",
@@ -333,6 +333,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/traefik"
| "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/whats-up-docker"
| "/docs/integrations/llama-cpp"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/air-quality"
@@ -390,4 +391,5 @@ export type HomarrDocumentationPath =
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api"
| "/docs/widgets/assistant"
| "/docs/widgets/whats-up-docker";
| "/docs/widgets/whats-up-docker"
| "/docs/widgets/llama-cpp";
@@ -60,4 +60,5 @@ export const widgetDocSlugs = {
customApi: "custom-api",
assistant: "assistant",
wud: "whats-up-docker",
llamacpp: "llama-cpp",
} satisfies Record<WidgetKind, string | null>;
+9
View File
@@ -545,6 +545,14 @@ export const integrationDefs = {
documentationSlug: "whats-up-docker",
defaultPort: 3000,
},
llamacpp: {
name: "llama.cpp",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
category: ["systemMonitoring"],
documentationSlug: "llama-cpp",
defaultPort: 8080,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -713,6 +721,7 @@ export const integrationCategories = [
"uptimeMonitoring",
"subtitleManager",
"reverseProxy",
"systemMonitoring",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
@@ -48,6 +48,7 @@ describe("widget integration config", () => {
archiveTeamWarrior: 1,
anchorNote: 1,
wud: 1,
llamacpp: 1,
});
});
});
@@ -87,6 +87,7 @@ export const widgetIntegrationConfigs = {
anchorNote: { supportedIntegrations: ["anchor", "mock"], maxIntegrations: 1 },
traefik: { supportedIntegrations: ["traefik", "mock"] },
wud: { supportedIntegrations: ["wud", "mock"], maxIntegrations: 1 },
llamacpp: { supportedIntegrations: ["llamacpp", "mock"], maxIntegrations: 1 },
} satisfies Partial<Record<WidgetKind, WidgetIntegrationConfig>>;
export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs;
+1
View File
@@ -58,6 +58,7 @@ export const widgetKinds = [
"customApi",
"assistant",
"wud",
"llamacpp",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -110,6 +110,8 @@ const integrationCreators = {
traefik: async (input: IntegrationInput) =>
new (await import("../traefik/traefik-integration")).TraefikIntegration(input),
wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input),
llamacpp: async (input: IntegrationInput) =>
new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input),
} satisfies Record<IntegrationKind, (input: IntegrationInput) => Promise<Integration>>;
type IntegrationCreators = typeof integrationCreators;
+2
View File
@@ -1,6 +1,7 @@
// General integrations
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
export { WudIntegration } from "./wud/wud-integration";
export { LlamacppIntegration } from "./llama-cpp/llamacpp-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration";
@@ -95,6 +96,7 @@ export type {
ArchiveTeamWarriorStatus,
} from "./archive-team-warrior/archive-team-warrior-types";
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
export type { LlamacppStats, LlamacppModel } from "./llama-cpp/llamacpp-types";
// Schemas
export { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
@@ -0,0 +1,83 @@
import { ParseError, ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { LlamacppStats } from "./llamacpp-types";
import {
mapContextUsage,
mapLlamacppModel,
mapLlamacppPerRequest,
mapLlamacppStats,
mapSlotsSummary,
parseLlamacppHealthAsync,
parseLlamacppMetricsAsync,
parseLlamacppModelsAsync,
parseLlamacppSlotsAsync,
} from "./llamacpp-types";
const LLMACPP_REQUEST_TIMEOUT_MS = 10_000;
export class LlamacppIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/health"), {
signal: AbortSignal.timeout(LLMACPP_REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
// Validate the health payload shape, not just that it is parseable JSON: an
// unrelated JSON body must not pass connection testing.
try {
await parseLlamacppHealthAsync(response);
} catch (error) {
if (error instanceof ParseError) {
return TestConnectionError.ParseResult(error);
}
return TestConnectionError.UnknownResult(error);
}
return { success: true };
}
public async getStatsAsync(): Promise<LlamacppStats> {
const [healthResponse, modelsResponse, metricsResponse, slotsResponse] = await Promise.all([
fetchWithTrustedCertificatesAsync(this.url("/health"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }),
fetchWithTrustedCertificatesAsync(this.url("/v1/models"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }),
fetchWithTrustedCertificatesAsync(this.url("/metrics"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }),
// /slots is not present on every build; a missing endpoint degrades to no context usage.
fetchWithTrustedCertificatesAsync(this.url("/slots"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }).catch(() => null),
]);
if (!healthResponse.ok) {
throw new ResponseError(healthResponse);
}
if (!modelsResponse.ok) {
throw new ResponseError(modelsResponse);
}
// /metrics only exists when llama-server was started with --metrics; a non-OK
// response (typically 501 Not Implemented) degrades to no rate counters instead
// of dropping the health and model data that the other endpoints still provide.
const metricsAvailable = metricsResponse.ok;
const [health, models, metrics, slots] = await Promise.all([
parseLlamacppHealthAsync(healthResponse),
parseLlamacppModelsAsync(modelsResponse),
metricsAvailable ? parseLlamacppMetricsAsync(metricsResponse) : Promise.resolve([]),
slotsResponse?.ok ? parseLlamacppSlotsAsync(slotsResponse) : Promise.resolve(null),
]);
const firstModel = models.data[0] ? mapLlamacppModel(models.data[0]) : null;
const contextUsage = mapContextUsage(slots);
const slotsSummary = mapSlotsSummary(slots);
const perRequest = mapLlamacppPerRequest(slots);
return mapLlamacppStats(health, firstModel, metrics, contextUsage, slotsSummary, perRequest);
}
}
@@ -0,0 +1,381 @@
import { ParseError } from "@homarr/common/server";
import { z } from "zod/v4";
export const LLMACPP_HEALTH_PARSE_ERROR_MESSAGE = "Invalid llama.cpp /health response";
export const LLMACPP_MODELS_PARSE_ERROR_MESSAGE = "Invalid llama.cpp /v1/models response";
export const parsePrometheusMetrics = (text: string): { name: string; value: number }[] => {
const metrics: { name: string; value: number }[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) {
continue;
}
const match = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{[^}]*\})?\s+(-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|NaN)$/.exec(
line,
);
if (!match) {
continue;
}
const name = match[1];
const rawValue = match[2];
if (!name || rawValue === undefined) {
continue;
}
const value = Number.parseFloat(rawValue);
if (!Number.isFinite(value)) {
continue;
}
metrics.push({ name, value });
}
return metrics;
};
export const llamacppHealthSchema = z.object({
status: z.string(),
});
const llamacppModelMetaSchema = z
.object({
n_ctx: z.number().optional(),
n_params: z.number().optional(),
size: z.number().optional(),
ftype: z.string().optional(),
})
.optional();
const llamacppModelSchema = z.object({
id: z.string(),
meta: llamacppModelMetaSchema,
});
export const llamacppModelsSchema = z.object({
data: z.array(llamacppModelSchema),
});
export interface LlamacppModel {
id: string;
name: string;
contextSize: number | null;
parameterCount: number | null;
fileSizeBytes: number | null;
quantization: string | null;
}
export interface LlamacppPerRequest {
taskId: number | null;
decodedTokens: number | null;
}
export interface LlamacppSlotsSummary {
total: number;
processing: number;
decoding: number;
speculative: boolean | null;
}
export interface LlamacppStats {
health: string;
model: LlamacppModel | null;
contextUsage: LlamacppContextUsage | null;
slots: LlamacppSlotsSummary | null;
metrics: {
generationSpeedTps: number | null;
promptSpeedTps: number | null;
avgGenerationSpeedTps: number | null;
avgPromptSpeedTps: number | null;
requestsProcessing: number | null;
requestsDeferred: number | null;
tokensProcessed: number | null;
tokensGenerated: number | null;
promptCacheHitRate: number | null;
requestDecodedTokens: number | null;
taskId: number | null;
specDraftTokens: number | null;
specAcceptedTokens: number | null;
specDrafts: number | null;
};
}
const shortModelName = (id: string): string =>
id
.split("/")
.pop()
?.replace(/\.gguf$/i, "") ?? id;
export const mapLlamacppModel = (model: z.infer<typeof llamacppModelSchema>): LlamacppModel => ({
id: model.id,
name: shortModelName(model.id),
contextSize: model.meta?.n_ctx ?? null,
parameterCount: model.meta?.n_params ?? null,
fileSizeBytes: model.meta?.size ?? null,
quantization: model.meta?.ftype ?? null,
});
export const getMetricValue = (metrics: readonly { name: string; value: number }[], name: string): number | null =>
metrics.find((metric) => metric.name === name)?.value ?? null;
/**
* Averages cumulative counters (tokens ÷ seconds) so a stable number is available while
* the server is idle, mirroring how llama-monitor derives its always-on speeds.
*/
export const avgTokensPerSecond = (tokens: number | null, seconds: number | null): number | null =>
tokens !== null && seconds !== null && seconds > 0 ? tokens / seconds : null;
interface LlamacppSlot {
n_ctx?: number;
n_prompt_tokens?: number;
id_task?: number;
is_processing?: boolean;
speculative?: boolean;
next_token?: { n_decoded?: number }[];
}
export interface LlamacppContextUsage {
usedTokens: number;
contextSize: number;
percent: number;
}
const clampPercent = (value: number): number => Math.min(100, Math.max(0, value));
/**
* Computes context (KV) usage from the /slots endpoint. The /metrics endpoint has no
* KV metrics, so the largest used prompt tokens over the slot context size is used.
*/
export const mapContextUsage = (slots: readonly unknown[] | null): LlamacppContextUsage | null => {
if (!Array.isArray(slots)) {
return null;
}
let largest: LlamacppContextUsage | null = null;
for (const slot of slots) {
if (typeof slot !== "object" || slot === null) {
continue;
}
const record = slot as Partial<LlamacppSlot>;
const contextSize = record.n_ctx;
const usedTokens = record.n_prompt_tokens;
if (typeof contextSize === "number" && contextSize > 0 && typeof usedTokens === "number" && usedTokens >= 0) {
const usage: LlamacppContextUsage = {
usedTokens,
contextSize,
percent: clampPercent(Math.round((usedTokens / contextSize) * 1000) / 10),
};
if (largest === null || usage.usedTokens > largest.usedTokens) {
largest = usage;
}
}
}
return largest;
};
/**
* Summarizes the /slots endpoint: total slot count, how many are currently
* processing, how many are actively decoding, and whether any slot has
* speculative decoding enabled. Null when /slots is unavailable.
*/
export const mapSlotsSummary = (slots: readonly unknown[] | null): LlamacppSlotsSummary | null => {
if (!Array.isArray(slots) || slots.length === 0) {
return null;
}
let total = 0;
let processing = 0;
let decoding = 0;
let speculative: boolean | null = null;
for (const slot of slots) {
if (typeof slot !== "object" || slot === null) {
continue;
}
total += 1;
const record = slot as Partial<LlamacppSlot>;
if (record.is_processing === true) {
processing += 1;
if (Array.isArray(record.next_token) && record.next_token.length > 0) {
decoding += 1;
}
}
if (record.speculative === true) {
speculative = true;
} else if (typeof record.speculative === "boolean" && speculative !== true) {
speculative = record.speculative;
}
}
return total > 0
? {
total,
processing,
decoding,
speculative,
}
: null;
};
/**
* Extracts the active request's identity and generated-token count from /slots. `id_task`
* identifies the in-flight request (llama.cpp task IDs are monotonically increasing and
* never reused) and `next_token[0].n_decoded` is `stats.n_gen`, the tokens this request has
* generated so far, which the server resets to 0 whenever a new generation starts. The widget
* takes deltas between polls to derive the per-request average speed.
*/
export const mapLlamacppPerRequest = (slots: readonly unknown[] | null): LlamacppPerRequest => {
if (!Array.isArray(slots)) {
return { taskId: null, decodedTokens: null };
}
for (const slot of slots) {
if (typeof slot !== "object" || slot === null) {
continue;
}
const record = slot as LlamacppSlot;
if (record.is_processing !== true) {
continue;
}
const taskId = typeof record.id_task === "number" ? record.id_task : null;
const nextToken = record.next_token?.[0];
const decodedTokens = typeof nextToken?.n_decoded === "number" ? nextToken.n_decoded : null;
return { taskId, decodedTokens };
}
return { taskId: null, decodedTokens: null };
};
export const requestSpeedTps = (tokens: number | null, elapsedSeconds: number | null): number | null => {
if (tokens === null || elapsedSeconds === null || elapsedSeconds <= 0) {
return null;
}
const speed = tokens / elapsedSeconds;
return Number.isFinite(speed) && speed > 0 ? speed : null;
};
export const parseLlamacppSlotsAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<unknown[] | null> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError("Invalid llama.cpp /slots response", {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
return Array.isArray(json) ? json : null;
};
export const mapLlamacppStats = (
health: z.infer<typeof llamacppHealthSchema>,
model: LlamacppModel | null,
metrics: readonly { name: string; value: number }[],
contextUsage: LlamacppContextUsage | null,
slotsSummary: LlamacppSlotsSummary | null,
perRequest: LlamacppPerRequest,
): LlamacppStats => {
const promptTokens = getMetricValue(metrics, "llamacpp:prompt_tokens_total");
const cachedTokens = getMetricValue(metrics, "llamacpp:prompt_tokens_cached_total");
const promptCacheHitRate =
promptTokens !== null && cachedTokens !== null && promptTokens + cachedTokens > 0
? Math.round((cachedTokens / (promptTokens + cachedTokens)) * 100)
: null;
return {
health: health.status,
model,
contextUsage,
slots: slotsSummary,
metrics: {
generationSpeedTps: getMetricValue(metrics, "llamacpp:predicted_tokens_seconds"),
promptSpeedTps: getMetricValue(metrics, "llamacpp:prompt_tokens_seconds"),
avgGenerationSpeedTps: avgTokensPerSecond(
getMetricValue(metrics, "llamacpp:tokens_predicted_total"),
getMetricValue(metrics, "llamacpp:tokens_predicted_seconds_total"),
),
avgPromptSpeedTps: avgTokensPerSecond(
getMetricValue(metrics, "llamacpp:prompt_tokens_total"),
getMetricValue(metrics, "llamacpp:prompt_seconds_total"),
),
requestsProcessing: getMetricValue(metrics, "llamacpp:requests_processing"),
requestsDeferred: getMetricValue(metrics, "llamacpp:requests_deferred"),
tokensProcessed: getMetricValue(metrics, "llamacpp:prompt_tokens_total"),
tokensGenerated: getMetricValue(metrics, "llamacpp:tokens_predicted_total"),
promptCacheHitRate,
requestDecodedTokens: perRequest.decodedTokens,
taskId: perRequest.taskId,
specDraftTokens: getMetricValue(metrics, "llamacpp:spec_decode_num_draft_tokens_total"),
specAcceptedTokens: getMetricValue(metrics, "llamacpp:spec_decode_num_accepted_tokens_total"),
specDrafts: getMetricValue(metrics, "llamacpp:spec_decode_num_drafts_total"),
},
};
};
export const parseLlamacppHealthAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<z.infer<typeof llamacppHealthSchema>> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError(LLMACPP_HEALTH_PARSE_ERROR_MESSAGE, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const result = await llamacppHealthSchema.safeParseAsync(json);
if (!result.success) {
throw new ParseError(LLMACPP_HEALTH_PARSE_ERROR_MESSAGE, { cause: result.error });
}
return result.data;
};
export const parseLlamacppModelsAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<z.infer<typeof llamacppModelsSchema>> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError(LLMACPP_MODELS_PARSE_ERROR_MESSAGE, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const result = await llamacppModelsSchema.safeParseAsync(json);
if (!result.success) {
throw new ParseError(LLMACPP_MODELS_PARSE_ERROR_MESSAGE, { cause: result.error });
}
return result.data;
};
export const parseLlamacppMetricsAsync = async (response: {
text: () => Promise<string>;
}): Promise<{ name: string; value: number }[]> => {
let text: string;
try {
text = await response.text();
} catch (error) {
throw new ParseError("Invalid llama.cpp /metrics response", {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
return parsePrometheusMetrics(text);
};
@@ -0,0 +1,489 @@
// @vitest-environment node
import { ParseError } from "@homarr/common/server";
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
import { LlamacppIntegration } from "../llamacpp-integration";
import {
avgTokensPerSecond,
mapContextUsage,
mapLlamacppModel,
mapLlamacppPerRequest,
mapSlotsSummary,
parsePrometheusMetrics,
requestSpeedTps,
} from "../llamacpp-types";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "http://llamacpp.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const sampleModel = {
id: "/root/models/unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
meta: {
n_ctx: 131072,
n_params: 27320697856,
size: 17912397824,
ftype: "Q4_K - Small",
},
};
const sampleModelsResponse = {
data: [sampleModel],
};
const sampleMetricsText = [
"# HELP llamacpp:prompt_tokens_total Total number of tokens processed",
"# TYPE llamacpp:prompt_tokens_total counter",
"llamacpp:prompt_tokens_total 69834",
"llamacpp:prompt_seconds_total 23278",
"llamacpp:prompt_tokens_cached_total 12000",
"llamacpp:tokens_predicted_total 12161",
"llamacpp:tokens_predicted_seconds_total 380.03125",
"llamacpp:requests_processing 1",
"llamacpp:requests_deferred 0",
"llamacpp:predicted_tokens_seconds 32.8472",
"llamacpp:prompt_tokens_seconds 1500.5",
"llamacpp:spec_decode_num_draft_tokens_total 128",
"llamacpp:spec_decode_num_accepted_tokens_total 96",
"llamacpp:spec_decode_num_drafts_total 16",
].join("\n");
const sampleSlotsResponse = [
{
id: 0,
n_ctx: 131072,
n_prompt_tokens: 101076,
is_processing: true,
speculative: false,
id_task: 7,
next_token: [{ n_decoded: 42, n_remain: 100, has_next_token: true, has_new_line: false }],
},
];
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const textResponse = (body: string, status = 200) =>
new Response(body, {
status,
headers: { "content-type": "text/plain" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new LlamacppIntegration({
id: "test-llamacpp",
name: "Test llama.cpp",
url: TEST_URL,
externalUrl: null,
decryptedSecrets,
});
const mockAllEndpoints = () => {
mockFetch.mockReset();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/v1/models")) {
return jsonResponse(sampleModelsResponse);
}
if (url.includes("/metrics")) {
return textResponse(sampleMetricsText);
}
if (url.includes("/slots")) {
return jsonResponse(sampleSlotsResponse);
}
return textResponse("not found", 404);
}) as typeof fetchWithTrustedCertificatesAsync);
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("parsePrometheusMetrics", () => {
test("parses metric lines and skips comments and HELP/TYPE lines", () => {
const metrics = parsePrometheusMetrics(sampleMetricsText);
expect(metrics).toStrictEqual([
{ name: "llamacpp:prompt_tokens_total", value: 69834 },
{ name: "llamacpp:prompt_seconds_total", value: 23278 },
{ name: "llamacpp:prompt_tokens_cached_total", value: 12000 },
{ name: "llamacpp:tokens_predicted_total", value: 12161 },
{ name: "llamacpp:tokens_predicted_seconds_total", value: 380.03125 },
{ name: "llamacpp:requests_processing", value: 1 },
{ name: "llamacpp:requests_deferred", value: 0 },
{ name: "llamacpp:predicted_tokens_seconds", value: 32.8472 },
{ name: "llamacpp:prompt_tokens_seconds", value: 1500.5 },
{ name: "llamacpp:spec_decode_num_draft_tokens_total", value: 128 },
{ name: "llamacpp:spec_decode_num_accepted_tokens_total", value: 96 },
{ name: "llamacpp:spec_decode_num_drafts_total", value: 16 },
]);
});
test("parses metric lines with labels and skips non-numeric values", () => {
const metrics = parsePrometheusMetrics(
[
'llamacpp:requests_by_status{status="ok"} 5',
"llamacpp:uptime_seconds +Inf",
"llamacpp:nan_metric NaN",
"",
].join("\n"),
);
expect(metrics).toStrictEqual([{ name: "llamacpp:requests_by_status", value: 5 }]);
});
test("returns an empty array for empty input", () => {
expect(parsePrometheusMetrics("")).toStrictEqual([]);
});
});
describe("mapLlamacppModel", () => {
test("extracts a short display name and metadata fields", () => {
const model = mapLlamacppModel(sampleModel);
expect(model).toStrictEqual({
id: "/root/models/unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
name: "Qwen3.8-27B-UD-Q4_K_XL",
contextSize: 131072,
parameterCount: 27320697856,
fileSizeBytes: 17912397824,
quantization: "Q4_K - Small",
});
});
test("returns null metadata when meta is missing", () => {
const model = mapLlamacppModel({ id: "models/foo.gguf" });
expect(model).toStrictEqual({
id: "models/foo.gguf",
name: "foo",
contextSize: null,
parameterCount: null,
fileSizeBytes: null,
quantization: null,
});
});
});
describe("mapContextUsage", () => {
test("computes percent from a slot with valid n_ctx and n_prompt_tokens", () => {
expect(mapContextUsage(sampleSlotsResponse)).toStrictEqual({
usedTokens: 101076,
contextSize: 131072,
percent: 77.1,
});
});
test("returns the slot with the greatest used tokens, not the first valid slot", () => {
expect(
mapContextUsage([
{ n_ctx: 1000, n_prompt_tokens: 100 },
{ n_ctx: 8192, n_prompt_tokens: 6000 },
{ n_ctx: 4096, n_prompt_tokens: 3000 },
]),
).toStrictEqual({
usedTokens: 6000,
contextSize: 8192,
percent: 73.2,
});
});
test("returns null for missing, non-array, or malformed slots", () => {
expect(mapContextUsage(null)).toBeNull();
expect(mapContextUsage([{ n_ctx: 0, n_prompt_tokens: 10 }])).toBeNull();
expect(mapContextUsage([{ n_prompt_tokens: 10 }])).toBeNull();
expect(mapContextUsage(["not-an-object", null])).toBeNull();
});
test("clamps the percentage to 100", () => {
expect(mapContextUsage([{ n_ctx: 10, n_prompt_tokens: 50 }])).toStrictEqual({
usedTokens: 50,
contextSize: 10,
percent: 100,
});
});
});
describe("avgTokensPerSecond", () => {
test("divides cumulative counters", () => {
expect(avgTokensPerSecond(12161, 380.03125)).toBe(32);
});
test("returns null when either counter is missing or seconds are zero", () => {
expect(avgTokensPerSecond(null, 5)).toBeNull();
expect(avgTokensPerSecond(10, null)).toBeNull();
expect(avgTokensPerSecond(10, 0)).toBeNull();
});
});
describe("mapLlamacppPerRequest", () => {
test("returns the active request id and decoded tokens from the processing slot", () => {
expect(mapLlamacppPerRequest(sampleSlotsResponse)).toStrictEqual({ taskId: 7, decodedTokens: 42 });
});
test("skips idle slots and returns nulls", () => {
expect(
mapLlamacppPerRequest([{ is_processing: false, id_task: 3, next_token: [{ n_decoded: 10 }] }]),
).toStrictEqual({ taskId: null, decodedTokens: null });
});
test("returns nulls for missing, empty, or malformed slots", () => {
expect(mapLlamacppPerRequest(null)).toStrictEqual({ taskId: null, decodedTokens: null });
expect(mapLlamacppPerRequest([])).toStrictEqual({ taskId: null, decodedTokens: null });
expect(mapLlamacppPerRequest(["nope", null])).toStrictEqual({ taskId: null, decodedTokens: null });
});
test("handles a processing slot missing the per-request fields", () => {
expect(mapLlamacppPerRequest([{ is_processing: true }])).toStrictEqual({
taskId: null,
decodedTokens: null,
});
});
});
describe("mapSlotsSummary", () => {
test("counts slots, processing slots, decoding slots and speculative flags", () => {
expect(
mapSlotsSummary([
{ is_processing: true, next_token: [{ n_decoded: 4 }], speculative: false },
{ is_processing: false, speculative: true },
{ is_processing: true, next_token: [], speculative: false },
]),
).toStrictEqual({ total: 3, processing: 2, decoding: 1, speculative: true });
});
test("treats an empty next_token list as not decoding", () => {
expect(mapSlotsSummary([{ is_processing: true, next_token: [] }, { is_processing: false }])).toStrictEqual({
total: 2,
processing: 1,
decoding: 0,
speculative: null,
});
});
test("returns null for missing, empty, or malformed slot lists", () => {
expect(mapSlotsSummary(null)).toBeNull();
expect(mapSlotsSummary([])).toBeNull();
expect(mapSlotsSummary(["not-an-object", null])).toBeNull();
});
});
describe("requestSpeedTps", () => {
test("divides token delta by elapsed seconds", () => {
expect(requestSpeedTps(128, 4)).toBe(32);
});
test("returns null for missing values, non-positive, or negative elapsed", () => {
expect(requestSpeedTps(null, 4)).toBeNull();
expect(requestSpeedTps(10, null)).toBeNull();
expect(requestSpeedTps(10, 0)).toBeNull();
expect(requestSpeedTps(10, -2)).toBeNull();
});
});
describe("LlamacppIntegration getStatsAsync", () => {
test("fetches health, models, metrics and slots and maps them into stats", async () => {
mockAllEndpoints();
const stats = await createIntegration().getStatsAsync();
expect(stats).toStrictEqual({
health: "ok",
model: {
id: "/root/models/unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
name: "Qwen3.8-27B-UD-Q4_K_XL",
contextSize: 131072,
parameterCount: 27320697856,
fileSizeBytes: 17912397824,
quantization: "Q4_K - Small",
},
contextUsage: {
usedTokens: 101076,
contextSize: 131072,
percent: 77.1,
},
slots: { total: 1, processing: 1, decoding: 1, speculative: false },
metrics: {
generationSpeedTps: 32.8472,
promptSpeedTps: 1500.5,
avgGenerationSpeedTps: 32,
avgPromptSpeedTps: 3,
requestsProcessing: 1,
requestsDeferred: 0,
tokensProcessed: 69834,
tokensGenerated: 12161,
promptCacheHitRate: 15,
requestDecodedTokens: 42,
taskId: 7,
specDraftTokens: 128,
specAcceptedTokens: 96,
specDrafts: 16,
},
});
const requestedUrls = mockFetch.mock.calls.map((call) => String(call[0])).toSorted();
expect(requestedUrls).toStrictEqual(
[`${TEST_URL}/health`, `${TEST_URL}/metrics`, `${TEST_URL}/slots`, `${TEST_URL}/v1/models`].toSorted(),
);
});
test("returns null model when the server has no models loaded", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/v1/models")) {
return jsonResponse({ data: [] });
}
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/slots")) {
return jsonResponse([]);
}
return textResponse(sampleMetricsText);
}) as typeof fetchWithTrustedCertificatesAsync);
const stats = await createIntegration().getStatsAsync();
expect(stats.model).toBeNull();
expect(stats.metrics.generationSpeedTps).toBe(32.8472);
});
test("returns null for metrics that are absent from /metrics", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/v1/models")) {
return jsonResponse({ data: [] });
}
if (url.includes("/slots")) {
return jsonResponse([]);
}
return textResponse("");
}) as typeof fetchWithTrustedCertificatesAsync);
const stats = await createIntegration().getStatsAsync();
expect(stats.metrics).toStrictEqual({
generationSpeedTps: null,
promptSpeedTps: null,
avgGenerationSpeedTps: null,
avgPromptSpeedTps: null,
requestsProcessing: null,
requestsDeferred: null,
tokensProcessed: null,
tokensGenerated: null,
promptCacheHitRate: null,
requestDecodedTokens: null,
taskId: null,
specDraftTokens: null,
specAcceptedTokens: null,
specDrafts: null,
});
});
test("throws when the /health endpoint returns an error status", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/health")) {
return textResponse("Service Unavailable", 503);
}
return jsonResponse({ status: "ok" });
}) as typeof fetchWithTrustedCertificatesAsync);
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
});
test("degrades to null metrics when /metrics is not available (501) but keeps health and model data", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/metrics")) {
return textResponse("Not Implemented", 501);
}
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/v1/models")) {
return jsonResponse(sampleModelsResponse);
}
if (url.includes("/slots")) {
return jsonResponse(sampleSlotsResponse);
}
return textResponse("not found", 404);
}) as typeof fetchWithTrustedCertificatesAsync);
const stats = await createIntegration().getStatsAsync();
expect(stats.health).toBe("ok");
expect(stats.model?.name).toBe("Qwen3.8-27B-UD-Q4_K_XL");
expect(stats.contextUsage).toStrictEqual({
usedTokens: 101076,
contextSize: 131072,
percent: 77.1,
});
expect(stats.slots).toStrictEqual({ total: 1, processing: 1, decoding: 1, speculative: false });
expect(stats.metrics).toStrictEqual({
generationSpeedTps: null,
promptSpeedTps: null,
avgGenerationSpeedTps: null,
avgPromptSpeedTps: null,
requestsProcessing: null,
requestsDeferred: null,
tokensProcessed: null,
tokensGenerated: null,
promptCacheHitRate: null,
requestDecodedTokens: 42,
taskId: 7,
specDraftTokens: null,
specAcceptedTokens: null,
specDrafts: null,
});
});
test("throws a parse error when /v1/models is not valid JSON", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/v1/models")) {
return textResponse("not-json");
}
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/slots")) {
return jsonResponse([]);
}
return textResponse(sampleMetricsText);
}) as typeof fetchWithTrustedCertificatesAsync);
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
if (!(error instanceof IntegrationParseError)) return false;
const cause = error.cause;
return cause instanceof ParseError && cause.message.includes("Invalid llama.cpp /v1/models response");
});
});
});
@@ -11,6 +11,7 @@ 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 { LlamacppStats } from "../llama-cpp/llamacpp-types";
import type { WudStats } from "../wud/wud-types";
import type {
FirewallCpuSummary,
@@ -352,4 +353,33 @@ export const mockWidgetData = {
{ id: "redis", name: "Redis", currentVersion: "8.0.2", newVersion: "8.0.3", link: null },
],
} satisfies WudStats,
llamacpp: {
health: "ok",
model: {
id: "unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
name: "Qwen3.8-27B-UD-Q4_K_XL",
contextSize: 32768,
parameterCount: 27_347_098_624,
fileSizeBytes: 16_842_776_576,
quantization: "Q4_K_XL",
},
contextUsage: { usedTokens: 8192, contextSize: 32768, percent: 25 },
slots: { total: 2, processing: 1, decoding: 1, speculative: false },
metrics: {
generationSpeedTps: 42.5,
promptSpeedTps: 1840.2,
avgGenerationSpeedTps: 39.8,
avgPromptSpeedTps: 2104.6,
requestsProcessing: 1,
requestsDeferred: 0,
tokensProcessed: 12_483,
tokensGenerated: 1_024,
promptCacheHitRate: 62,
requestDecodedTokens: 96,
taskId: 1042,
specDraftTokens: 48,
specAcceptedTokens: 12,
specDrafts: 8,
},
} satisfies LlamacppStats,
} as const;
+16
View File
@@ -0,0 +1,16 @@
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { LlamacppStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const llamacppStatsRequestHandler = createIntegrationRequestHandler<
LlamacppStats,
"llamacpp",
Record<string, never>
>({
cacheNamespace: "llamacpp:stats",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
},
});
+81 -1
View File
@@ -1027,7 +1027,8 @@
"analytics": "分析",
"vpn": "VPN",
"mediaLibrary": "媒体库",
"uptimeMonitoring": "运行时间监控"
"uptimeMonitoring": "运行时间监控",
"systemMonitoring": "系统监控"
},
"testConnection": {
"action": {
@@ -1355,6 +1356,10 @@
"bazarr": {
"name": "Bazarr",
"description": "Sonarr 和 Radarr 的配套应用,用于管理和下载字幕"
},
"llamacpp": {
"name": "llama.cpp",
"description": "监控本地 llama.cpp llama-server:健康状态、模型与生成指标"
}
},
"media": {
@@ -4171,6 +4176,81 @@
"subtitle": "Docker 容器的网络流量"
}
}
},
"llamacpp": {
"name": "llama.cpp",
"description": "显示本地 llama.cpp 服务器的健康状态、加载模型和生成速度。",
"title": "llama.cpp",
"status": {
"unhealthy": "异常 ({status})"
},
"idle": "空闲",
"speedTooltip": {
"current": "当前生成速度",
"average": "启动以来的平均生成速度",
"request": "本次请求开始以来的平均速度"
},
"busy": "{count, plural, other {# 个请求处理中}}",
"modelInfo": {
"context": "上下文",
"size": "文件大小",
"parameters": "参数量",
"model": "模型",
"quantization": "量化"
},
"stats": {
"tokensProcessed": "已处理 Token",
"contextUsage": "上下文 / KV 占用",
"cacheHitRate": "提示缓存命中率",
"tokensGenerated": "已生成 Token"
},
"error": {
"unreachable": "无法连接 llama-server"
},
"unit": {
"speed": "{value} t/s",
"parameters": "{value}B",
"none": "—",
"speedShort": "t/s"
},
"option": {
"showTitle": {
"label": "显示标题"
},
"showModelInfo": {
"label": "显示模型信息"
},
"showContextUsage": {
"label": "显示上下文占用"
},
"showCacheHitRate": {
"label": "显示缓存命中率"
},
"showSpeedStats": {
"label": "显示速度统计"
},
"showRequests": {
"label": "显示请求负载"
},
"showTokenStats": {
"label": "显示 Token 总量"
},
"showSpeculative": {
"label": "显示投机解码"
}
},
"advanced": {
"promptSpeed": "Prompt 速度",
"avgSpeed": "平均生成速度",
"requests": "请求数",
"deferred": "排队中",
"slots": "Slot 占用 {processing}/{total}",
"speculative": "投机解码",
"avgPrompt": "平均 Prompt 速度",
"specDrafts": "投机草稿数",
"specAccepted": "投机接受 Token",
"specDraftTokens": "投机草稿 Token"
}
}
},
"widgetPreview": {
+84 -1
View File
@@ -1424,7 +1424,8 @@
"vpn": "VPN",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring",
"beszel": "Beszel"
"beszel": "Beszel",
"systemMonitoring": "System Monitoring"
},
"testConnection": {
"action": {
@@ -1716,6 +1717,10 @@
"use": "Select integrations in items",
"interact": "Interact with integrations",
"full": "Full integration access"
},
"llamacpp": {
"name": "llama.cpp",
"description": "Monitor a local llama.cpp llama-server: health, model and generation metrics"
}
},
"media": {
@@ -5205,6 +5210,81 @@
"uptime": "Uptime",
"agent": "Agent"
}
},
"llamacpp": {
"name": "llama.cpp",
"description": "Show the health, loaded model and generation speed of your local llama.cpp server.",
"title": "llama.cpp",
"status": {
"unhealthy": "Unhealthy ({status})"
},
"idle": "Idle",
"speedTooltip": {
"current": "Current generation speed",
"average": "Average generation speed since startup",
"request": "Average speed since this request started"
},
"busy": "{count, plural, one {# request running} other {# requests running}}",
"modelInfo": {
"context": "Context",
"size": "File size",
"parameters": "Parameters",
"model": "Model",
"quantization": "Quantization"
},
"stats": {
"tokensProcessed": "Tokens processed",
"contextUsage": "Context / KV usage",
"cacheHitRate": "Prompt cache hit rate",
"tokensGenerated": "Tokens generated"
},
"error": {
"unreachable": "llama-server is not reachable"
},
"unit": {
"speed": "{value} t/s",
"parameters": "{value}B",
"none": "—",
"speedShort": "t/s"
},
"option": {
"showTitle": {
"label": "Show title"
},
"showModelInfo": {
"label": "Show model info"
},
"showContextUsage": {
"label": "Show context usage"
},
"showCacheHitRate": {
"label": "Show cache hit rate"
},
"showSpeedStats": {
"label": "Show speed stats"
},
"showRequests": {
"label": "Show request load"
},
"showTokenStats": {
"label": "Show token totals"
},
"showSpeculative": {
"label": "Show speculative decoding"
}
},
"advanced": {
"promptSpeed": "Prompt speed",
"avgSpeed": "Avg generation",
"requests": "Requests",
"deferred": "Deferred",
"slots": "{processing}/{total} slots busy",
"speculative": "Speculative decoding",
"avgPrompt": "Avg prompt",
"specDrafts": "Spec. drafts",
"specAccepted": "Spec. accepted tokens",
"specDraftTokens": "Spec. draft tokens"
}
}
},
"widgetPreview": {
@@ -6157,6 +6237,9 @@
"lm-studio": {
"description": "An LM Studio local server with OpenAI compatibility enabled."
},
"llama-cpp": {
"description": "A local or network llama.cpp server using its OpenAI-compatible API."
},
"custom": {
"label": "Custom endpoint",
"description": "Any other endpoint compatible with OpenAI Chat Completions."
+81 -1
View File
@@ -822,7 +822,8 @@
"analytics": "分析",
"vpn": "VPN",
"mediaLibrary": "媒體庫",
"uptimeMonitoring": "運作時間監控"
"uptimeMonitoring": "運作時間監控",
"systemMonitoring": "系統監控"
},
"testConnection": {
"action": {
@@ -1150,6 +1151,10 @@
"bazarr": {
"name": "Bazarr",
"description": "Sonarr 和 Radarr 的配套應用程式,管理和下載字幕"
},
"llamacpp": {
"name": "llama.cpp",
"description": "監控本地 llama.cpp llama-server:健康狀態、模型與生成指標"
}
},
"media": {
@@ -3946,6 +3951,81 @@
"error": {
"internalServerError": "無法擷取 Traefik 資料"
}
},
"llamacpp": {
"name": "llama.cpp",
"description": "顯示本地 llama.cpp 伺服器的健康狀態、載入模型與生成速度。",
"title": "llama.cpp",
"status": {
"unhealthy": "異常 ({status})"
},
"idle": "閒置",
"speedTooltip": {
"current": "目前生成速度",
"average": "啟動以來的平均生成速度",
"request": "本次請求開始以來的平均速度"
},
"busy": "{count, plural, other {# 個請求處理中}}",
"modelInfo": {
"context": "上下文",
"size": "檔案大小",
"parameters": "參數量",
"model": "模型",
"quantization": "量化"
},
"stats": {
"tokensProcessed": "已處理 Token",
"contextUsage": "上下文 / KV 佔用",
"cacheHitRate": "提示快取命中率",
"tokensGenerated": "已生成 Token"
},
"error": {
"unreachable": "無法連接 llama-server"
},
"unit": {
"speed": "{value} t/s",
"parameters": "{value}B",
"none": "—",
"speedShort": "t/s"
},
"option": {
"showTitle": {
"label": "顯示標題"
},
"showModelInfo": {
"label": "顯示模型資訊"
},
"showContextUsage": {
"label": "顯示上下文佔用"
},
"showCacheHitRate": {
"label": "顯示快取命中率"
},
"showSpeedStats": {
"label": "顯示速度統計"
},
"showRequests": {
"label": "顯示請求負載"
},
"showTokenStats": {
"label": "顯示 Token 總量"
},
"showSpeculative": {
"label": "顯示投機解碼"
}
},
"advanced": {
"promptSpeed": "Prompt 速度",
"avgSpeed": "平均生成速度",
"requests": "請求數",
"deferred": "排隊中",
"slots": "Slot 佔用 {processing}/{total}",
"speculative": "投機解碼",
"avgPrompt": "平均 Prompt 速度",
"specDrafts": "投機草稿數",
"specAccepted": "投機接受 Token",
"specDraftTokens": "投機草稿 Token"
}
}
},
"widgetPreview": {
+2
View File
@@ -20,6 +20,7 @@ import {
IconClock,
IconClockPlay,
IconCloud,
IconCpu,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
@@ -116,4 +117,5 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
traefik: IconRoute,
customApi: IconApi,
wud: IconBrandDocker,
llamacpp: IconCpu,
};
+14 -1
View File
@@ -8,6 +8,7 @@ import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -27,7 +28,19 @@ import {
getUvCategory,
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
export default function AirQualityWidget({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"airQuality">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
+11 -2
View File
@@ -11,6 +11,7 @@ import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -37,10 +38,18 @@ const isForbiddenError = (error: unknown): boolean => {
export default function AnchorNoteWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"anchorNote">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
+9 -1
View File
@@ -1,8 +1,16 @@
.appText {
min-width: 0;
min-height: 0;
flex-shrink: 1;
overflow-wrap: anywhere;
overflow: hidden;
}
.appIcon {
max-height: 100%;
max-width: 100%;
overflow: auto;
flex: 1 !important;
flex: 1;
object-fit: contain;
scale: 0.8;
transition: scale 0.2s ease-in-out;
+44 -11
View File
@@ -1,8 +1,8 @@
"use client";
import type { PropsWithChildren } from "react";
import type { CSSProperties, PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -12,6 +12,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { getWidgetDisplayScale } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -21,7 +22,14 @@ import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
export default function AppWidget({
options,
isEditMode,
height,
width,
displayScale = 1,
displayMode,
}: WidgetComponentProps<"app">) {
const tCommon = useI18n("common");
const settings = useSettings();
const board = useRequiredBoard();
@@ -33,8 +41,31 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
// Readable board tokens stay fixed on screen, but must yield space to the icon
// when the tile itself becomes small. Below 100px, scale the whole composition.
const scale = getWidgetDisplayScale({ displayScale, displayMode });
const maximumContentScale = Math.min(width, height) / 100;
let uiScale = "var(--board-canvas-ui-scale, 1)";
if (displayMode === "advanced") uiScale = "1";
const textSize = `min(calc(14px * ${uiScale}), ${14 * maximumContentScale}px)`;
const spacing = `min(calc(12px * ${uiScale}), ${12 * maximumContentScale}px)`;
const rowGap = `min(calc(6px * ${uiScale}), ${6 * maximumContentScale}px)`;
const isColumnLayout = options.layout.startsWith("column");
const isTiny = Math.min(width, height) * scale < 100;
const textStyle: CSSProperties = {};
let titleLineClamp: number | undefined;
let descriptionLineClamp = 4;
if (isTiny) {
titleLineClamp = 2;
descriptionLineClamp = 2;
textStyle.flexShrink = 1;
textStyle.maxWidth = "50%";
textStyle.maxHeight = "100%";
if (isColumnLayout) {
textStyle.maxWidth = "100%";
textStyle.maxHeight = "50%";
}
}
return (
<Box h="100%" w="100%" pos="relative">
@@ -44,22 +75,22 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
enabled={options.descriptionDisplayMode === "tooltip" && Boolean(app.description) && !isEditMode}
>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
style={{ padding: spacing, gap: isColumnLayout ? 0 : rowGap }}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
<Stack gap={0} className={classes.appText} style={textStyle}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? rem(8) : "sm"}
lineClamp={titleLineClamp}
style={{ fontSize: textSize }}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
@@ -68,10 +99,10 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
style={{ fontSize: textSize }}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={4}
lineClamp={descriptionLineClamp}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
@@ -88,9 +119,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
flex: isColumnLayout ? "1 0 25%" : "0 0 50%",
height: "100%",
width: "100%",
minWidth: "20%",
minWidth: 0,
minHeight: 0,
maxWidth: isColumnLayout ? undefined : "50%",
}}
/>
@@ -7,15 +7,24 @@ import { getIconUrl } from "@homarr/definitions";
import { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0];
if (!integrationId) {
@@ -8,6 +8,7 @@ import { formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { useByteFormatter } from "@homarr/settings";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -42,12 +43,12 @@ export default function AudioStatsWidget({
const currentStreams = streamResults ?? [];
if (!response) return <WidgetEmptyState />;
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const summary = (
<AudioStatsContent
+11 -2
View File
@@ -9,6 +9,7 @@ import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
@@ -58,10 +59,18 @@ const iconSizeByWidth = [
export default function BazarrWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"bazarr">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.bazarr");
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
@@ -37,6 +37,7 @@ import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../common/widget-layout-size";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
@@ -456,10 +457,17 @@ export default function BeszelSystemGridWidget({
options,
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"beszelSystemGrid">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.beszel");
const tCommon = useI18n("common");
const board = useRequiredBoard();
@@ -520,7 +528,9 @@ export default function BeszelSystemGridWidget({
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
gridTemplateRows: scrollEnabled
? `repeat(${rows}, ${minimumCellHeight / getWidgetDisplayScale({ displayScale, displayMode })}px)`
: `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
@@ -13,6 +13,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
@@ -33,6 +34,7 @@ export default function BeszelSystemStatsWidget({
width,
height,
displayScale = 1,
displayMode,
boardId,
itemId,
setOptions,
@@ -71,12 +73,12 @@ export default function BeszelSystemStatsWidget({
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
];
let responsiveWidth = width;
let responsiveHeight = height;
if (Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
useWidgetRuntimeQueries(
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
@@ -27,6 +27,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -112,12 +113,16 @@ export default function BeszelSystemTableWidget({
options,
integrationIds,
isEditMode,
width,
width: logicalWidth,
boardId,
itemId,
setOptions,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"beszelSystemTable">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.beszelSystemTable");
const tBeszel = useI18n("widget.beszel");
const tCommon = useI18n("common");
@@ -25,6 +25,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -68,12 +69,12 @@ export default function BookmarksWidget({
});
const data = [...configuredItems, ...legacyItems];
let responsiveWidth = width;
let responsiveHeight = height;
if (!advanced && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const contentHeight = Math.max(0, responsiveHeight - (options.title.length > 0 ? 36 : 0));
let layoutWidth = responsiveWidth;
if (options.layout === "gridHorizontal") layoutWidth = width;
+20 -2
View File
@@ -13,6 +13,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -68,7 +69,15 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
displayMode,
displayScale,
}: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth() + 1,
@@ -111,11 +120,13 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
setMonth={setMonth}
options={options}
displayMode={displayMode}
displayScale={displayScale}
/>
);
};
interface CalendarBaseProps {
displayScale?: number;
isEditMode: boolean;
events: CalendarEventWithSource[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
@@ -135,13 +146,20 @@ const CalendarBase = ({
setMonth,
options,
displayMode,
displayScale,
}: CalendarBaseProps) => {
const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const { ref, width, height } = useElementSize();
const { ref, width: logicalWidth, height: logicalHeight } = useElementSize();
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isSmall = width < 256;
const normalizedEvents = useMemo(
+14 -1
View File
@@ -4,6 +4,7 @@ import { Box, Center, Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { AdvancedClockView } from "./advanced-view";
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
@@ -13,7 +14,19 @@ import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
dayjs.extend(advancedFormat);
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
export default function ClockWidget({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"clock">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isAdvanced = displayMode === "advanced";
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
const time = useCurrentTime({ showSeconds });
@@ -0,0 +1,22 @@
interface WidgetDisplay {
displayScale?: number;
displayMode?: "compact" | "advanced";
}
export const getWidgetDisplayScale = ({ displayScale = 1, displayMode }: WidgetDisplay): number => {
// Advanced surfaces are outside the scaled board, even when their source tile is not.
if (displayMode === "advanced" || !Number.isFinite(displayScale) || displayScale <= 0) return 1;
return displayScale;
};
/** Visible dimensions for density, breakpoints, and content budgets.
* Keep the original logical dimensions for canvas/SVG geometry and CSS pixel sizes.
*/
export const getWidgetLayoutSize = ({
width,
height,
...display
}: WidgetDisplay & { width: number; height: number }) => {
const scale = getWidgetDisplayScale(display);
return { width: width * scale, height: height * scale };
};
+6 -1
View File
@@ -5,6 +5,7 @@ import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
@@ -16,9 +17,13 @@ import type { InstanceData } from "./types";
export default function CoolifyWidget({
options,
integrationIds,
width,
width: logicalWidth,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"coolify">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.coolify");
if (integrationIds.length === 0) {
+2
View File
@@ -175,8 +175,10 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
isEditMode: boolean;
displayMode?: "compact" | "advanced";
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
/** Logical pixels for rendering geometry. Use getWidgetLayoutSize for responsive decisions. */
width: number;
height: number;
/** Board scale; advanced surfaces render outside the board at scale 1. */
displayScale?: number;
widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
@@ -38,6 +38,7 @@ import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerPopover from "./timer-popover";
@@ -58,9 +59,12 @@ export default function DnsHoleControlsWidget({
options,
integrationIds,
isEditMode,
width,
width: logicalWidth,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<typeof widgetKind>) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
@@ -29,6 +29,7 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
@@ -38,9 +39,18 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
export default function DnsHoleSummaryWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const scale = getWidgetDisplayScale({ displayScale, displayMode });
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
@@ -67,7 +77,14 @@ export default function DnsHoleSummaryWidget({
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
<StatCard
displayScale={scale}
key={item.color}
item={item}
usePiHoleColors={options.usePiHoleColors}
data={data}
t={t}
/>
))
) : (
<Stack
@@ -171,13 +188,15 @@ interface StatItem {
}
interface StatCardProps {
displayScale: number;
item: StatItem;
data: DnsHoleSummary[];
usePiHoleColors: boolean;
t: TranslationFunction;
}
const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
const { ref, height, width } = useElementSize();
const StatCard = ({ item, data, usePiHoleColors, t, displayScale }: StatCardProps) => {
const { ref, height: logicalHeight, width: logicalWidth } = useElementSize();
const { width, height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale });
const isLong = width > height + 20;
const canStackText = height > 32;
const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92);
+6 -1
View File
@@ -46,6 +46,7 @@ import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
@@ -241,13 +242,17 @@ const useContainerAction = (action: ContainerAction) => {
export default function DockerWidget({
options,
width,
width: logicalWidth,
isEditMode,
displayMode,
boardId,
itemId,
setOptions,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"dockerContainers">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("docker");
const tCommon = useI18n("common");
const tWidget = useI18n("widget.dockerContainers");
+11 -2
View File
@@ -53,6 +53,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -302,10 +303,18 @@ export default function DownloadClientsWidget({
setOptions,
boardId,
itemId,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"downloads">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const { formatByteRate, formatBytes, formatBytesPair } = useByteFormatter();
const board = useOptionalBoard();
const { data: session } = useSession();
+11 -2
View File
@@ -25,6 +25,7 @@ import type { FirewallInterfacesSummary } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { calculateBandwidth } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu";
@@ -60,11 +61,19 @@ export const hasFirewallPartialFailure = (firewallId: string, queries: readonly
export default function FirewallWidget({
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
itemId,
displayMode,
displayScale,
}: WidgetComponentProps<"firewall">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const [selectedFirewall, setSelectedFirewall] = useState("");
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && (width < 256 || height < 180);
@@ -8,6 +8,7 @@ import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state";
import { getUsableWidgetQueryData } from "../../common/query-state";
import type { WidgetComponentProps } from "../../definition";
@@ -38,6 +39,8 @@ export const ClusterHealthMonitoring = ({
integrationId,
options,
width,
height,
displayScale,
displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n("widget.healthMonitoring");
@@ -68,7 +71,8 @@ export const ClusterHealthMonitoring = ({
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
const isAdvanced = displayMode === "advanced";
const isTiny = displayMode !== "advanced" && width < 256;
const { width: responsiveWidth } = getWidgetLayoutSize({ width, height, displayScale, displayMode });
const isTiny = displayMode !== "advanced" && responsiveWidth < 256;
return (
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
{(isAdvanced || options.showUptime) && !isTiny && (
@@ -5,6 +5,7 @@ import { IconCpu } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
export const CpuRing = ({
cpuUtilization,
@@ -17,6 +18,7 @@ export const CpuRing = ({
}) => {
return (
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-cpu"
aria-label={ariaLabel}
roundCaps
@@ -5,6 +5,7 @@ import { IconCpu } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
const CPU_TEMPERATURE_MIN_CELSIUS = 0;
const CPU_TEMPERATURE_MAX_CELSIUS = 100;
@@ -31,6 +32,7 @@ export const CpuTempRing = ({
return (
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-cpu-temperature"
aria-label={ariaLabel}
roundCaps
@@ -5,6 +5,7 @@ import { IconDeviceDesktop } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
interface GpuRingProps {
gpu: {
@@ -43,6 +44,7 @@ export const GpuRing = ({ gpu, isTiny, fahrenheit, ariaLabel }: GpuRingProps) =>
multiline
>
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className={`health-monitoring-gpu health-monitoring-gpu-${gpu.gpuId}`}
aria-label={ariaLabel}
roundCaps
@@ -6,6 +6,7 @@ import { useByteFormatter } from "@homarr/settings";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
export const MemoryRing = ({
available,
@@ -24,6 +25,7 @@ export const MemoryRing = ({
return (
<Tooltip label={`${memoryUsage.memUsed.percent}%`}>
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-memory"
aria-label={ariaLabel}
roundCaps
@@ -33,3 +33,12 @@
transition: none;
}
}
/* Gauge labels live inside an SVG foreignObject. The SVG already scales its
* coordinate system to the compensated gauge size; compensating again makes
* the label grow as the board shrinks. */
.gaugeLabel {
--mantine-scale: 1;
--board-canvas-ui-scale: 1;
--mantine-font-size-xs: 0.75rem;
}
@@ -42,6 +42,7 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -61,6 +62,8 @@ export const SystemHealthMonitoring = ({
options,
integrationIds,
width,
height,
displayScale = 1,
displayMode,
withScrollArea = true,
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
@@ -75,7 +78,13 @@ export const SystemHealthMonitoring = ({
const { formatBytes, formatBytesPair } = useByteFormatter();
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && width < 256;
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const isTiny = !isAdvanced && (responsiveWidth < 256 || responsiveHeight < 160);
const showCpu = isAdvanced || options.cpu;
const showMemory = isAdvanced || options.memory;
const showGpu = isAdvanced || options.gpu;
@@ -27,6 +27,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -45,12 +46,20 @@ const statusPresentation = {
export default function IndexerManagerWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
isEditMode,
widgetRuntimeRef,
displayMode,
displayScale,
}: WidgetComponentProps<"indexerManager">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.indexerManager");
const tCommon = useI18n("common");
const indexersQuery = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
@@ -0,0 +1,573 @@
"use client";
import { useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { Box, Center, Group, Progress, RingProgress, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import {
IconBrain,
IconBolt,
IconCircleX,
IconDatabase,
IconFileText,
IconGauge,
IconLayersIntersect,
IconServer,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { formatBytes, formatNumber } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
// Visible width drives how much info fits side-by-side; height only matters for the
// single-row (short) case where the widget must drop down to essentials.
const isNarrowWidth = (width: number) => width < 300; // 1 cell (200px)
const isWideWidth = (width: number) => width >= 550; // 3+ cells (624px+)
const isShortHeight = (height: number) => height < 300; // 1 row (200px)
const ringSizeByScale = [
{ minWidth: 320, size: 108 },
{ minWidth: 220, size: 92 },
{ minWidth: 0, size: 68 },
] as const;
const getContextColor = (percent: number) => (percent > 90 ? "red" : percent > 75 ? "yellow" : "blue");
export default function LlamacppWidget({
integrationIds,
options,
width,
height,
displayScale,
displayMode = "compact",
}: WidgetComponentProps<"llamacpp">) {
const integrationId = integrationIds[0];
const layoutSize = getWidgetLayoutSize({ width, height, displayScale, displayMode });
if (!integrationId) {
throw new NoIntegrationSelectedError();
}
return (
<LlamacppContent
integrationId={integrationId}
options={options}
width={layoutSize.width}
height={layoutSize.height}
displayMode={displayMode}
/>
);
}
interface LlamacppContentProps {
integrationId: string;
options: WidgetComponentProps<"llamacpp">["options"];
width: number;
height: number;
displayMode: "compact" | "advanced";
}
function LlamacppContent({ integrationId, options, width, height, displayMode }: LlamacppContentProps) {
const t = useI18n("widget.llamacpp");
const { data, isError } = clientApi.widget.llamacpp.getStats.useQuery({ integrationId });
// Tracks the in-flight request across polls so the widget can show the average
// speed since that request started: /slots reports the request id (taskId) and how
// many tokens it has generated so far (requestDecodedTokens). The server resets that
// counter to 0 when a new request starts, so a fresh taskId means a fresh request.
const requestRef = useRef<{ taskId: number; baseTokens: number; baseTimeMs: number } | null>(null);
const perRequestSpeedTps = useMemo(() => {
const taskId = data?.stats.metrics.taskId ?? null;
const decoded = data?.stats.metrics.requestDecodedTokens ?? null;
if (taskId === null || decoded === null) {
requestRef.current = null;
return null;
}
const nowMs = Date.now();
const current = requestRef.current;
if (current === null || current.taskId !== taskId) {
requestRef.current = { taskId, baseTokens: decoded, baseTimeMs: nowMs };
return null;
}
const elapsedSeconds = (nowMs - current.baseTimeMs) / 1000;
const deltaTokens = decoded - current.baseTokens;
if (elapsedSeconds <= 0 || deltaTokens < 0) {
return null;
}
const speed = deltaTokens / elapsedSeconds;
return Number.isFinite(speed) && speed > 0 ? speed : null;
}, [data]);
if (isError) {
return (
<Center h="100%" w="100%" p="sm">
<Group gap="xs" wrap="nowrap" miw={0}>
<IconCircleX size={20} color="red" />
<Text size="sm" c="dimmed" lineClamp={1}>
{t("error.unreachable")}
</Text>
</Group>
</Center>
);
}
if (!data) {
return <WidgetEmptyState />;
}
const stats = data.stats;
const isHealthy = stats.health === "ok";
const model = stats.model;
const contextUsage = stats.contextUsage;
const slots = stats.slots;
const requestsProcessing = stats.metrics.requestsProcessing;
const isBusy = requestsProcessing !== null && requestsProcessing > 0;
let displayedSpeed: number | null;
let speedMode: "request" | "live" | "average";
if (isBusy && perRequestSpeedTps !== null) {
displayedSpeed = perRequestSpeedTps;
speedMode = "request";
} else if (isBusy && (stats.metrics.generationSpeedTps ?? 0) > 0) {
displayedSpeed = stats.metrics.generationSpeedTps;
speedMode = "live";
} else {
displayedSpeed = stats.metrics.avgGenerationSpeedTps;
speedMode = "average";
}
const speedTooltip =
speedMode === "request"
? t("speedTooltip.request")
: speedMode === "live"
? t("speedTooltip.current")
: t("speedTooltip.average");
const speedValue = displayedSpeed !== null && displayedSpeed > 0 ? formatNumber(displayedSpeed, 1) : t("unit.none");
const statusColor = isHealthy ? "green" : "red";
const statusLabel = isHealthy ? undefined : t("status.unhealthy", { status: stats.health });
const contextPercent = options.showContextUsage && contextUsage ? contextUsage.percent : null;
const contextLabel = contextUsage
? `${formatNumber(contextUsage.usedTokens, 0)} / ${formatNumber(contextUsage.contextSize, 0)} (${contextUsage.percent}%)`
: null;
const ringSize = ringSizeByScale.find(({ minWidth }) => Math.min(width, height) >= minWidth)?.size ?? 68;
const ring = (
<Tooltip label={speedTooltip} withArrow position="bottom">
<RingProgress
size={ringSize}
thickness={Math.max(6, Math.round(ringSize / 10))}
roundCaps
sections={[
{ value: contextPercent ?? 0, color: contextPercent !== null ? getContextColor(contextPercent) : "gray" },
]}
label={
<Center>
<Stack gap={0} align="center" style={{ lineHeight: 1.15 }}>
<Text size={ringSize >= 92 ? "lg" : "sm"} fw={800} lineClamp={1}>
{speedValue}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{t("unit.speedShort")}
</Text>
</Stack>
</Center>
}
/>
</Tooltip>
);
const modelLine = model && (
<Group gap={6} wrap="nowrap" miw={0}>
<IconBrain size={14} color="dimmed" style={{ flexShrink: 0 }} />
<Tooltip label={model.id} withArrow>
<Text size="xs" lineClamp={1} style={{ maxWidth: "100%" }}>
{model.name}
</Text>
</Tooltip>
{model.quantization && (
<Text size="xs" c="dimmed" fw={600} lineClamp={1} style={{ flexShrink: 0 }}>
{model.quantization}
</Text>
)}
</Group>
);
const busyLabel =
requestsProcessing !== null ? (isBusy ? t("busy", { count: requestsProcessing }) : t("idle")) : null;
const promptSpeed = stats.metrics.promptSpeedTps;
const avgPromptSpeed = stats.metrics.avgPromptSpeedTps;
const cacheHitRate = stats.metrics.promptCacheHitRate;
const tokensProcessed = stats.metrics.tokensProcessed;
const tokensGenerated = stats.metrics.tokensGenerated;
const requestsDeferred = stats.metrics.requestsDeferred;
const specDraftTokens = stats.metrics.specDraftTokens;
const specAcceptedTokens = stats.metrics.specAcceptedTokens;
const specDrafts = stats.metrics.specDrafts;
const hasSpecStats = specDraftTokens !== null || specAcceptedTokens !== null || specDrafts !== null;
if (displayMode === "advanced") {
return (
<Box h="100%" w="100%">
<ScrollArea h="100%">
<Stack gap="md" p="md">
<Group gap="xs" wrap="nowrap" miw={0}>
<StatusDot color={statusColor} label={statusLabel} />
<Text size="md" fw={700} lineClamp={1}>
{t("title")}
</Text>
{options.showRequests && busyLabel && (
<Text size="xs" c="dimmed" lineClamp={1}>
{busyLabel}
</Text>
)}
</Group>
<Group gap="lg" wrap="nowrap" miw={0}>
{ring}
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
{options.showSpeedStats && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("advanced.promptSpeed")}
value={
promptSpeed !== null && promptSpeed > 0
? t("unit.speed", { value: formatNumber(promptSpeed, 1) })
: t("unit.none")
}
/>
)}
{options.showSpeedStats && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("advanced.avgPrompt")}
value={
avgPromptSpeed !== null && avgPromptSpeed > 0
? t("unit.speed", { value: formatNumber(avgPromptSpeed, 1) })
: t("unit.none")
}
/>
)}
{options.showSpeedStats && stats.metrics.avgGenerationSpeedTps !== null && (
<AdvancedRow
icon={<IconGauge size={14} color="dimmed" />}
label={t("advanced.avgSpeed")}
value={
(stats.metrics.avgGenerationSpeedTps ?? 0) > 0
? t("unit.speed", { value: formatNumber(stats.metrics.avgGenerationSpeedTps, 1) })
: t("unit.none")
}
/>
)}
{options.showContextUsage && contextUsage && (
<AdvancedRow
icon={<IconDatabase size={14} color="dimmed" />}
label={t("stats.contextUsage")}
value={contextLabel ?? "—"}
/>
)}
{options.showRequests && requestsProcessing !== null && (
<AdvancedRow
icon={<IconServer size={14} color="dimmed" />}
label={t("advanced.requests")}
value={formatNumber(requestsProcessing, 0)}
/>
)}
{options.showRequests && requestsDeferred !== null && (
<AdvancedRow
icon={<IconLayersIntersect size={14} color="dimmed" />}
label={t("advanced.deferred")}
value={formatNumber(requestsDeferred, 0)}
/>
)}
</Stack>
</Group>
{(options.showRequests || (options.showSpeculative && slots?.speculative)) && slots && (
<Group gap="sm" wrap="nowrap" miw={0}>
{options.showRequests && (
<Text size="xs" c="dimmed" lineClamp={1}>
{t("advanced.slots", { processing: slots.processing, total: slots.total })}
</Text>
)}
{options.showSpeculative && slots.speculative && (
<Text size="xs" c="dimmed" lineClamp={1} fw={600}>
{t("advanced.speculative")}
</Text>
)}
</Group>
)}
{options.showModelInfo && model && (
<Stack gap="xs">
<AdvancedRow
icon={<IconBrain size={14} color="dimmed" />}
label={t("modelInfo.model")}
value={model.name}
valueTooltip={model.id}
/>
{model.quantization && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("modelInfo.quantization")}
value={model.quantization}
/>
)}
{model.contextSize !== null && (
<AdvancedRow
icon={<IconDatabase size={14} color="dimmed" />}
label={t("modelInfo.context")}
value={formatNumber(model.contextSize, 0)}
/>
)}
{model.fileSizeBytes !== null && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("modelInfo.size")}
value={formatBytes(model.fileSizeBytes)}
/>
)}
{model.parameterCount !== null && (
<AdvancedRow
icon={<IconBrain size={14} color="dimmed" />}
label={t("modelInfo.parameters")}
value={t("unit.parameters", { value: formatNumber(model.parameterCount / 1e9, 2) })}
/>
)}
</Stack>
)}
{options.showTokenStats && (tokensProcessed !== null || tokensGenerated !== null) && (
<Group gap="md" wrap="nowrap" miw={0}>
{tokensProcessed !== null && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("stats.tokensProcessed")}
value={formatNumber(tokensProcessed, 0)}
/>
)}
{tokensGenerated !== null && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("stats.tokensGenerated")}
value={formatNumber(tokensGenerated, 0)}
/>
)}
</Group>
)}
{options.showSpeculative && hasSpecStats && (
<Stack gap="xs">
{specDrafts !== null && (
<AdvancedRow
icon={<IconLayersIntersect size={14} color="dimmed" />}
label={t("advanced.specDrafts")}
value={formatNumber(specDrafts, 0)}
/>
)}
{specDraftTokens !== null && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("advanced.specDraftTokens")}
value={formatNumber(specDraftTokens, 0)}
/>
)}
{specAcceptedTokens !== null && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("advanced.specAccepted")}
value={formatNumber(specAcceptedTokens, 0)}
/>
)}
</Stack>
)}
{options.showCacheHitRate && cacheHitRate !== null && (
<AdvancedRow
icon={<IconDatabase size={14} color="dimmed" />}
label={t("stats.cacheHitRate")}
value={`${cacheHitRate}%`}
progress={{ value: cacheHitRate, color: cacheHitRate > 75 ? "green" : "blue" }}
/>
)}
</Stack>
</ScrollArea>
</Box>
);
}
// Compact (default) mode — density adapts to the widget's grid footprint.
if (isNarrowWidth(width)) {
return (
<Center h="100%" w="100%">
<Stack gap={isShortHeight(height) ? 0 : 4} align="center" p="xs" style={{ position: "relative" }}>
<Center pos="relative">
{ring}
<Box pos="absolute" top={0} right={0} style={{ zIndex: 1 }}>
<StatusDot color={statusColor} label={statusLabel} />
</Box>
</Center>
{!isShortHeight(height) && options.showModelInfo && modelLine}
</Stack>
</Center>
);
}
const rightColumn = (
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
{options.showModelInfo && modelLine}
{options.showRequests && busyLabel && (
<Text size="xs" c="dimmed" lineClamp={1}>
{busyLabel}
</Text>
)}
{options.showContextUsage && contextUsage && (
<Group gap={6} wrap="nowrap" miw={0} w="100%" align="center">
<Progress
value={contextUsage.percent}
size="xs"
radius="xs"
color={getContextColor(contextUsage.percent)}
style={{ flex: 1, minWidth: 0 }}
/>
<Text size="xs" c="dimmed" lineClamp={1} style={{ flexShrink: 0 }}>
{contextUsage.percent}%
</Text>
</Group>
)}
{isWideWidth(width) && (
<Stack gap="xs">
{options.showSpeedStats && promptSpeed !== null && promptSpeed > 0 && (
<InfoRow
label={t("advanced.promptSpeed")}
value={t("unit.speed", { value: formatNumber(promptSpeed, 1) })}
/>
)}
{options.showCacheHitRate && cacheHitRate !== null && (
<InfoRow label={t("stats.cacheHitRate")} value={`${cacheHitRate}%`} />
)}
{options.showTokenStats && (tokensProcessed !== null || tokensGenerated !== null) && (
<InfoRow
label={t("stats.tokensProcessed")}
value={
tokensProcessed !== null && tokensGenerated !== null
? formatNumber(tokensProcessed + tokensGenerated, 0)
: tokensProcessed !== null
? formatNumber(tokensProcessed, 0)
: "—"
}
/>
)}
</Stack>
)}
</Stack>
);
if (isShortHeight(height)) {
return (
<Center h="100%" w="100%">
<Group gap="md" w="100%" wrap="nowrap" miw={0} justify="center" p="xs">
{ring}
{!isNarrowWidth(width) && (
<Stack gap={2} style={{ minWidth: 0 }}>
{options.showModelInfo && modelLine}
{options.showRequests && busyLabel && (
<Text size="xs" c="dimmed" lineClamp={1}>
{busyLabel}
</Text>
)}
</Stack>
)}
</Group>
</Center>
);
}
return (
<Box h="100%" p="xs" pos="relative">
<Group pos="absolute" top={6} right={10} gap={0} style={{ zIndex: 2 }}>
<StatusDot color={statusColor} label={statusLabel} />
</Group>
<Center h="100%">
<Group gap="md" w="100%" wrap="nowrap" miw={0}>
{ring}
{rightColumn}
</Group>
</Center>
</Box>
);
}
const StatusDot = ({ color, label }: { color: string; label?: string }) => {
const dot = (
<Box
aria-label={label}
style={{
width: 9,
height: 9,
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${color}-6)`,
flexShrink: 0,
cursor: "default",
}}
/>
);
if (!label) return dot;
return (
<Tooltip label={label} withArrow position="left">
{dot}
</Tooltip>
);
};
interface AdvancedRowProps {
icon: ReactNode;
label: string;
value: string;
valueTooltip?: string;
progress?: { value: number; color: string };
}
const AdvancedRow = ({ icon, label, value, valueTooltip, progress }: AdvancedRowProps) => (
<Stack gap={3} style={{ minWidth: 150 }}>
<Group gap={6} wrap="nowrap" miw={0} justify="space-between" w="100%">
<Group gap={6} wrap="nowrap" miw={0}>
{icon}
<Text size="xs" c="dimmed" lineClamp={1}>
{label}
</Text>
</Group>
{valueTooltip ? (
<Tooltip label={valueTooltip} withArrow>
<Text size="xs" fw={600} lineClamp={1} style={{ maxWidth: 240 }}>
{value}
</Text>
</Tooltip>
) : (
<Text size="xs" fw={600} lineClamp={1} style={{ maxWidth: 240 }}>
{value}
</Text>
)}
</Group>
{progress && <Progress value={progress.value} size="xs" radius="xs" color={progress.color} />}
</Stack>
);
const InfoRow = ({ label, value }: { label: string; value: string }) => (
<Group justify="space-between" wrap="nowrap" miw={0}>
<Text size="xs" c="dimmed" lineClamp={1}>
{label}
</Text>
<Text size="xs" fw={600} lineClamp={1}>
{value}
</Text>
</Group>
);
+24
View File
@@ -0,0 +1,24 @@
import { IconCpu } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("llamacpp", {
icon: IconCpu,
refetchInterval: 5,
supportsAdvancedFocus: true,
...getWidgetIntegrationConfig("llamacpp"),
createOptions() {
return optionsBuilder.from((factory) => ({
showModelInfo: factory.switch({ defaultValue: true }),
showContextUsage: factory.switch({ defaultValue: true }),
showCacheHitRate: factory.switch({ defaultValue: true }),
showSpeedStats: factory.switch({ defaultValue: true }),
showRequests: factory.switch({ defaultValue: true }),
showTokenStats: factory.switch({ defaultValue: true }),
showSpeculative: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -26,6 +26,7 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -66,10 +67,13 @@ const getMediaMissingDensity = (contentWidth: number, columns: number, isThin: b
export default function MediaMissingWidget({
integrationIds,
options,
width,
width: logicalWidth,
displayMode,
widgetRuntimeRef,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"mediaMissing">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.mediaMissing");
const isAdvanced = displayMode === "advanced";
const showMissing = isAdvanced || options.showMissing;
@@ -13,6 +13,7 @@ import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
@@ -28,8 +29,13 @@ export default function MediaServerWidget({
integrationIds,
isEditMode,
options,
width,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.mediaRequests-requestList");
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
@@ -21,6 +21,7 @@ import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
@@ -38,9 +39,18 @@ const JELLYSEERR_COLOR = "#6677CC";
export default function MediaServerWidget({
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.mediaRequests-requestStats");
const tCommon = useI18n("common");
const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds });
@@ -38,6 +38,7 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -169,10 +170,13 @@ function StreamTableHeader({
export default function MediaServerWidget({
options,
integrationIds,
width,
width: logicalWidth,
isEditMode,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"mediaServer">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
@@ -10,6 +10,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -51,11 +52,19 @@ const getTranscodingWidthLayout = (width: number) =>
export default function MediaTranscodingWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
widgetRuntimeRef,
displayScale,
}: WidgetComponentProps<"mediaTranscoding">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced);
const [queuePagination, setQueuePagination] = useState<QueuePaginationState>(() => ({
@@ -9,14 +9,22 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { WidgetEmptyState } from "../../common/empty-state";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"minecraftServerStatus">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n("widget.minecraftServerStatus");
const tCommon = useI18n("common");
@@ -5,6 +5,7 @@ import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text }
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
@@ -41,12 +42,12 @@ export default function NetworkControllerNetworkStatusWidget({
const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0);
const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0);
const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0);
let responsiveWidth = width;
let responsiveHeight = height;
if (!isAdvanced && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const layout = getNetworkControllerStatusLayout({
width: responsiveWidth,
height: responsiveHeight,
+6 -1
View File
@@ -58,6 +58,7 @@ import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
@@ -88,9 +89,13 @@ export function Notebook({
isEditMode,
boardId,
itemId,
height,
height: logicalHeight,
displayMode = "compact",
width: logicalWidth,
displayScale,
}: WidgetComponentProps<"notebook">) {
const { height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const [content, setContent] = useState(options.content);
const previousContentRef = useRef(options.content);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getCompactStatLayout } from "../common/compact-stat-layout";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
@@ -149,12 +150,12 @@ export default function PaperlessNgxWidget({
.map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const advanced = displayMode === "advanced";
const layout = getCompactStatLayout({
width: responsiveWidth,
+11 -2
View File
@@ -20,6 +20,7 @@ import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -70,10 +71,18 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
export default function PatchMonWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"patchmon">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? "";
const statsQuery = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
@@ -14,6 +14,7 @@ export const widgetQueryRefetchIntervals = [
},
{ queryKey: [["widget", "firewall"]], intervalSeconds: 10 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
{ queryKey: [["widget", "llamacpp"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 10 },
{
+4
View File
@@ -59,6 +59,7 @@ import type * as video from "./video";
import type * as vpn from "./vpn";
import type * as weather from "./weather";
import type * as wud from "./wud";
import type * as llamacpp from "./llama-cpp";
// Keep these imports explicit so Next.js and Turbopack can discover every widget
// module without loading any widget definition or component eagerly.
@@ -122,6 +123,7 @@ export const widgetModuleLoaders = {
customApi: () => import("./custom-api"),
weather: () => import("./weather"),
wud: () => import("./wud"),
llamacpp: () => import("./llama-cpp"),
} satisfies { [TKind in WidgetKind]: () => Promise<WidgetImports[TKind]> };
export type WidgetImports = {
@@ -184,6 +186,7 @@ export type WidgetImports = {
customApi: typeof customApi;
weather: typeof weather;
wud: typeof wud;
llamacpp: typeof llamacpp;
};
type WidgetComponentLoaders = {
@@ -253,6 +256,7 @@ export const widgetComponentLoaders = {
customApi: () => import("./custom-api/component"),
weather: () => import("./weather/component"),
wud: () => import("./wud/component"),
llamacpp: () => import("./llama-cpp/component"),
} satisfies WidgetComponentLoaders;
export type WidgetImportKey = keyof WidgetImports;
+14 -1
View File
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -43,7 +44,19 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
};
};
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) {
export default function RssFeed({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"rssFeed">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const feed = useLiveFeed({
urls: options.feedUrls,
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
@@ -10,6 +10,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout";
import { getEntityStateLabel } from "./state";
@@ -18,10 +19,18 @@ export default function SmartHomeEntityStateWidget({
options,
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"smartHome-entityState">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.smartHome-entityState");
const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("common");
@@ -21,6 +21,7 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout";
@@ -28,10 +29,17 @@ export default function SmartHomeTriggerAutomationWidget({
options,
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"smartHome-executeAutomation">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0];
const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
const [lastExecutedAt, setLastExecutedAt] = React.useState<Date | null>(null);
@@ -14,10 +14,12 @@ export function AveragesSection({
stats,
width = 500,
compactSurface = false,
displayScale,
}: {
stats: SpeedtestTrackerStats;
width?: number;
compactSurface?: boolean;
displayScale?: number;
}) {
const t = useI18n("widget.speedtestTracker");
@@ -28,6 +30,7 @@ export function AveragesSection({
</SectionLabel>
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowDown}
color="blue"
value={formatStatsSpeed(stats.download)}
@@ -36,6 +39,7 @@ export function AveragesSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowUp}
color="teal"
value={formatStatsSpeed(stats.upload)}
@@ -44,6 +48,7 @@ export function AveragesSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconWaveSine}
color="orange"
value={`${stats.ping.avg.toFixed(1)} ms`}
@@ -6,6 +6,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -20,8 +21,13 @@ const emptyDashboardData = [] as const;
export default function SpeedtestTrackerWidget({
options,
integrationIds,
width,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"speedtestTracker">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.speedtestTracker");
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
@@ -44,10 +50,20 @@ export default function SpeedtestTrackerWidget({
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const latest = showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={width} compactSurface />
<LatestResultSection
result={combined.latestResult}
width={width}
displayScale={getWidgetDisplayScale({ displayScale, displayMode })}
compactSurface
/>
);
const averages = showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={width} compactSurface />
<AveragesSection
stats={combined.stats}
width={width}
displayScale={getWidgetDisplayScale({ displayScale, displayMode })}
compactSurface
/>
);
return (
@@ -14,10 +14,12 @@ export function LatestResultSection({
result,
width = 500,
compactSurface = false,
displayScale,
}: {
result: SpeedtestTrackerResult;
width?: number;
compactSurface?: boolean;
displayScale?: number;
}) {
const t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale();
@@ -40,6 +42,7 @@ export function LatestResultSection({
</Group>
<SimpleGrid cols={cols} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowDown}
color="blue"
value={formatResultSpeed(result, "download")}
@@ -47,6 +50,7 @@ export function LatestResultSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowUp}
color="teal"
value={formatResultSpeed(result, "upload")}
@@ -54,6 +58,7 @@ export function LatestResultSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconWaveSine}
color="orange"
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
@@ -62,6 +67,7 @@ export function LatestResultSection({
/>
{result.healthy !== null && (
<SpeedStatCard
displayScale={displayScale}
icon={result.healthy ? IconCircleCheck : IconCircleX}
color={result.healthy ? "green" : "red"}
value={result.healthy ? t("healthy") : t("unhealthy")}
@@ -1,5 +1,6 @@
"use client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { Card, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -14,6 +15,7 @@ export interface SpeedStatCardProps {
label: string;
compact?: boolean;
legacySurface?: boolean;
displayScale?: number;
}
export function SpeedStatCard({
@@ -23,8 +25,10 @@ export function SpeedStatCard({
label,
compact = false,
legacySurface = false,
displayScale,
}: SpeedStatCardProps) {
const { ref, height, width } = useElementSize<HTMLDivElement>();
const { ref, height: logicalHeight, width: logicalWidth } = useElementSize<HTMLDivElement>();
const { width, height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale });
const board = useRequiredBoard();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
+10 -2
View File
@@ -7,6 +7,7 @@ import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -71,10 +72,17 @@ export function getStockSummary(priceHistory: number[], previousClose: number):
export default function StockPriceWidget({
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode = "compact",
displayScale,
}: WidgetComponentProps<"stockPrice">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.stockPrice");
const tCommon = useI18n("common");
const locale = useCurrentIntlLocale();
@@ -12,6 +12,7 @@ export const CombinedNetworkTrafficChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
usageOverTime: {
up: number;
@@ -20,6 +21,7 @@ export const CombinedNetworkTrafficChart = ({
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = usageOverTime.map((usage, index) => ({
index,
@@ -51,6 +53,7 @@ export const CombinedNetworkTrafficChart = ({
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[
@@ -1,22 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
CSSProperties,
PointerEvent as ReactPointerEvent,
ReactNode,
} from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { useState } from "react";
import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import {
Card,
Center,
Group,
Stack,
Text,
Tooltip,
useComputedColorScheme,
useMantineTheme,
} from "@mantine/core";
import { Card, Center, Group, Stack, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { YAxisProps } from "recharts";
@@ -24,6 +11,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetDisplayScale } from "../../common/widget-layout-size";
import type { LabelDisplayModeOption } from "..";
export const CommonChart = ({
@@ -38,6 +26,7 @@ export const CommonChart = ({
lastValue,
chartType = "line",
advanced = false,
displayScale,
}: {
data: Record<string, any>[];
dataKey: string;
@@ -53,8 +42,11 @@ export const CommonChart = ({
lastValue?: string;
chartType?: "line" | "area";
advanced?: boolean;
displayScale?: number;
}) => {
const { ref: elementSizeRef, height } = useElementSize();
const { ref: elementSizeRef, height: logicalHeight } = useElementSize();
const height =
logicalHeight * getWidgetDisplayScale({ displayScale, displayMode: advanced ? "advanced" : "compact" });
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard();
@@ -62,28 +54,22 @@ export const CommonChart = ({
const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100;
let backgroundColor =
colorScheme === "dark"
? `rgba(57, 57, 57, ${opacity})`
: `rgba(246, 247, 248, ${opacity})`;
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const cardStyle: CSSProperties = { overflow: "hidden" };
const chartRootStyle: CSSProperties = {
padding: 5,
borderRadius: theme.radius[board.itemRadius],
};
if (advanced) {
backgroundColor =
"rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
cardStyle.border =
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
chartRootStyle.backgroundColor = backgroundColor;
}
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon =
labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText =
labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
// Track which data point the cursor is over so the tooltip can reflect that point
// instead of always showing the latest value while scrubbing across the chart.
@@ -100,20 +86,12 @@ export const CommonChart = ({
// Clamp on read rather than syncing via an effect - handles data growing/shrinking
// between renders (e.g. history resetting on integration change) without ever
// handing a stale or out-of-range index to tooltipLabel.
const clampedHoveredIndex = Math.max(
0,
Math.min(hoveredIndex, data.length - 1),
);
const clampedHoveredIndex = Math.max(0, Math.min(hoveredIndex, data.length - 1));
const resolvedTooltipLabel =
typeof tooltipLabel === "function"
? tooltipLabel(clampedHoveredIndex)
: (tooltipLabel ?? lastValue);
typeof tooltipLabel === "function" ? tooltipLabel(clampedHoveredIndex) : (tooltipLabel ?? lastValue);
return (
<Tooltip.Floating
label={resolvedTooltipLabel}
disabled={data.length <= 1 || !resolvedTooltipLabel}
>
<Tooltip.Floating label={resolvedTooltipLabel} disabled={data.length <= 1 || !resolvedTooltipLabel}>
<Card
ref={ref}
h={"100%"}
@@ -149,33 +127,19 @@ export const CommonChart = ({
</Text>
)}
{lastValue && (
<Text
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
lineClamp={1}
>
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
{lastValue}
</Text>
)}
</Group>
)}
{advanced &&
data.length > 1 &&
height > 0 &&
height <= 40 &&
lastValue &&
!hovered && (
<Center
pos="absolute"
w="100%"
h="100%"
style={{ zIndex: 2, pointerEvents: "none" }}
>
<Text size="xs" fw={600}>
{lastValue}
</Text>
</Center>
)}
{advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
<Text size="xs" fw={600}>
{lastValue}
</Text>
</Center>
)}
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}>
@@ -187,12 +151,7 @@ export const CommonChart = ({
/>
)}
{showText && (
<Text
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
fw={"bold"}
ta="center"
>
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
{title}
</Text>
)}
@@ -10,16 +10,19 @@ export const SystemResourceCPUChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
cpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "blue.5" }]}
@@ -10,16 +10,19 @@ export const SystemResourceGPUChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
gpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage }));
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "grape.5" }]}
@@ -12,12 +12,14 @@ export const SystemResourceMemoryChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
memoryUsageOverTime: number[];
totalCapacityInBytes: number;
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({
index,
@@ -45,6 +47,7 @@ export const SystemResourceMemoryChart = ({
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "red.6" }]}
@@ -12,12 +12,14 @@ export const NetworkTrafficChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
usageOverTime: number[];
isUp: boolean;
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = usageOverTime.map((usage, index) => ({ index, usage }));
const t = useI18n("widget.systemResources.card");
@@ -29,6 +31,7 @@ export const NetworkTrafficChart = ({
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "yellow.5" }]}
@@ -5,6 +5,7 @@ import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from
import { clientApi } from "@homarr/api/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -89,6 +90,7 @@ export default function SystemResources({
options,
width,
height,
displayScale,
displayMode,
}: WidgetComponentProps<"systemResources">) {
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
@@ -150,6 +152,7 @@ export default function SystemResources({
width={availableWidth}
height={availableHeight}
isAdvanced={isAdvanced}
displayScale={displayScale}
showTitle={isAdvanced || data.length > 1}
/>
);
@@ -211,6 +214,7 @@ interface SystemChartsProps {
height: number;
isAdvanced: boolean;
showTitle: boolean;
displayScale?: number;
}
const SystemCharts = ({
@@ -223,13 +227,20 @@ const SystemCharts = ({
height,
isAdvanced,
showTitle,
displayScale,
}: SystemChartsProps) => {
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode: isAdvanced ? "advanced" : "compact",
});
const networkItems = getNetworkHistory(items);
const visibleCharts = getVisibleSystemCharts({
configuredCharts: options.visibleCharts,
hasGpu,
hasNetwork: networkItems.length > 0,
height,
height: responsiveHeight,
isAdvanced,
});
const showNetwork = visibleCharts.includes("network");
@@ -270,6 +281,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
)}
@@ -281,6 +293,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
)}
@@ -291,11 +304,12 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
)}
{showNetwork &&
(width >= 300 ? (
(responsiveWidth >= 300 ? (
<Group h="100%" gap="xs" grow wrap="nowrap">
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.down)}
@@ -303,6 +317,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.up)}
@@ -310,6 +325,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Group>
) : (
@@ -319,6 +335,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
))}
+11 -2
View File
@@ -29,6 +29,7 @@ import {
requestBrowserNotificationPermission,
showBrowserNotification,
} from "../common/browser-alert";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { useLocalWidgetState } from "../common/use-local-widget-state";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
@@ -52,12 +53,20 @@ const completionSoundIntervalMs = 300;
export default function TimerWidget({
boardId,
displayMode,
height,
height: logicalHeight,
isEditMode,
itemId,
options,
width,
width: logicalWidth,
displayScale,
}: WidgetComponentProps<"timer">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const now = useWidgetNow("second");
const t = useI18n("widget.timer");
const reducedMotion = useReducedMotion();
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more