Compare commits

..
Author SHA1 Message Date
Thomas 3b1b4c76a4 Merge branch 'dev' into feat/recover-beszel 2026-06-10 18:26:53 +02:00
Thomas Camlong 78f2a25f2b fix(widgets): restore lucide-react and mantine-datatable dependencies
Beszel widgets import these packages directly but they were missing
from package.json while still listed in pnpm-lock.yaml, causing
frozen-lockfile Docker builds to fail.
2026-06-10 18:21:16 +02:00
Thomas Camlong 13c9f768fd chore: update pnpm-lock.yaml 2026-06-10 18:13:58 +02:00
Thomas Camlong 8920be7fd3 fix(i18n): disable createMessagesDeclaration to fix typecheck
The auto-generated en.d.json.ts was stale (missing beszel keys),
causing 67 TS2345 errors. Additionally, with ~2,367 leaf keys at
depth 10, the generated declaration triggers TS type complexity
limits (known next-intl issue #2296).

Disable the feature in next.config.ts, delete the stale file,
and remove its .gitignore entry. Key/namespace validation is
preserved via raw JSON import types.
2026-06-10 18:13:51 +02:00
Thomas Camlong 4b0c69e6a3 fix(beszel): resolve lint errors in widget and API files
Remove unused BeszelStatsData import from beszel router.
Replace non-null assertions with safe fallbacks across beszel
widget files (format.ts, chart.tsx, system-grid, system-stats).
2026-06-10 18:13:38 +02:00
Thomas Camlong 79b3e5cc18 fix: narrow possibly undefined system in beszel test
TypeScript reports 'system' is possibly undefined since it comes from
array indexing. Replace expect().toBeDefined() with an explicit throw
guard so the type narrows for subsequent property accesses.
2026-06-08 23:57:44 +02:00
Thomas Camlong 27521a6cfd fix(beszel): resolve lint errors in integration test
Remove unused BeszelSystem import and replace non-null assertions
with optional chaining in test expectations.
2026-06-08 22:42:12 +02:00
Thomas Camlong 5e4867edaf refactor(beszel): replace hand-rolled formatUptime with dayjs humanize 2026-06-08 22:40:04 +02:00
Thomas Camlong 540ad5a2c1 refactor(beszel): remove dead exports and deduplicate formatters
- Remove unused getBandwidthRates, getContainerNetworkRate, formatMB
- Remove unused alertColorMap from colors.ts
- Remove duplicate chartAxisFormatters.percentPrecise (identical to .percent)
- Make chartAxisFormatters.percent reference formatPercent directly
2026-06-08 22:34:21 +02:00
Thomas Camlong 5588761db7 fix(beszel): remove non-null assertion in stats request handler 2026-06-08 16:29:34 +02:00
ajnart 4d886bf980 style: auto-fix lint and format 2026-06-08 14:20:38 +00:00
Thomas Camlong dfec430b86 fix(beszel): escape backslashes before quotes in filter values
Fixes CodeQL warning: incomplete string escaping. Backslashes must be
escaped first to prevent `\'` injection via `\` in user input.
2026-06-08 16:19:11 +02:00
Thomas Camlong 466c439ba2 chore: remove beszel dev environment files from PR 2026-06-08 16:17:10 +02:00
Thomas Camlong 4c36c9cff1 fix(beszel): prevent recharts negative dimension warnings
Add minWidth: 0 and overflow: hidden to ChartPanel (CSS Grid item)
and minWidth: 0 to BeszelAreaChart. Prevents ResponsiveContainer from
computing negative width/height in grid/flex layouts before first paint.
2026-06-08 16:16:18 +02:00
Thomas Camlong cfa510dbf9 fix(beszel-mock): improve mock data fidelity and demo coverage
- Use correct time intervals per period (not always 1min)
- Return newest-first sort order matching real Beszel API
- Pass systemId into time series records
- Stable container sets per system (deterministic from system def)
- Fix pi-cluster arch to aarch64
- Clamp cpu/mem percentages to [0, 100]
- Add beszelSystemStats widget to demo seed
2026-06-08 16:14:22 +02:00
ajnart 52d5c050b6 style: auto-fix lint and format 2026-06-08 14:09:13 +00:00
Thomas Camlong 43f1613215 feat(beszel): add mock integration support for demo mode
Wire Beszel widgets into the mock integration pipeline so they work
with UNSAFE_ENABLE_MOCK_INTEGRATION and DEMO_MODE for demos.
2026-06-08 16:06:49 +02:00
Thomas Camlong bb96ca354f docs(beszel): document Beszel API types and field semantics
Add JSDoc comments to all Beszel PocketBase API types explaining the
cryptic short field names (cpu, mu, du, dr, ns, b, etc.), their units
(bytes, bytes/s, percentages), and the distinction between ns/nr
(all-interface traffic) vs b (public interface bandwidth).

Also documents the mapToSystemRow mapping and chart data extractors
so future readers understand the data flow without needing Beszel
source code.
2026-06-08 16:00:15 +02:00
ajnart c1bd6cccaa style: auto-fix lint and format 2026-06-08 13:58:40 +00:00
Thomas Camlong 2bf014ba43 fix(beszel): use bandwidth field (b) for public interface traffic
In Beszel, ns/nr are all-interface traffic (loopback, docker bridges)
while b is public interface bandwidth. Use b with ns/nr fallback for
both system stats and container network charts to match what the
grid/table display via netBytes.
2026-06-08 15:57:18 +02:00
ajnart 515b285ef7 style: auto-fix lint and format 2026-06-08 13:54:14 +00:00
Thomas Camlong 5eebfb7719 chore(dev): update beszel dev environment config
Update docker-compose and gitignore for beszel development setup.
2026-06-08 15:51:49 +02:00
Thomas Camlong 7f1638fec6 chore(beszel): add translations and dependency updates
Add i18n keys for beszel system stats chart labels (memory cache,
disk IO series) and update workspace dependencies.
2026-06-08 15:51:39 +02:00
Thomas Camlong 6b1cc0fd6b refactor(beszel): migrate widgets to shared utilities and fix perf
Update all four Beszel widgets to use extracted shared hooks, chart
helpers, colors, and formatters. Fix chart data regressions: restore
byte-to-GB conversion for memory/disk, use raw byte values for diskIO
instead of incorrect multiplication, and use ns/nr fields for network
instead of the optional b field.

Add drag-and-drop performance fixes: disable pointer events and
WebSocket subscriptions during edit mode to prevent re-renders while
dragging widgets on the board.
2026-06-08 15:51:30 +02:00
Thomas Camlong 3131687229 refactor(beszel): extract shared hooks, chart helpers, and tooltip
Create useBeszelSystemsSubscription and useBeszelFilteredSystems hooks
to eliminate duplicated subscription + cache-update + filtering logic
across grid/table/alerts. Add BeszelAreaChart wrapper and chart data
hooks (useSystemChartData, useDockerChartData) to reduce ~90 lines of
repetitive useMemo transforms in system-stats. Add portal tooltip with
mousemove listener gated on active state to prevent unnecessary
re-renders during drag-and-drop.
2026-06-08 15:51:17 +02:00
Thomas Camlong 59d44933cf refactor(beszel): centralize shared constants and formatters
Move alertColorMap and containerColors to _shared/colors.ts, extract
statusOptions to _shared/options.ts, and rename formatBytes to
formatByteRate for clarity since it formats byte rates not raw bytes.
2026-06-08 15:51:05 +02:00
Thomas Camlong 5cf9b1b752 refactor(beszel): deduplicate BeszelSystemRow type definition
Consolidate BeszelSystemRow into @homarr/integrations as the single
source of truth. Both request-handler and widgets now re-export from
there instead of maintaining separate identical copies that could
silently diverge.
2026-06-08 15:50:55 +02:00
Thomas Camlong 48dcf65c4e chore(dev): colocate beszel agent data with docker-compose
Move beszel_agent_data, beszel_data, and beszel_socket directories
into development/ alongside beszel.docker-compose.yml. Add .gitignore
for volatile data (dbs, sockets) while keeping the fingerprint.
2026-06-08 12:56:16 +02:00
Thomas Camlong 6757fbe956 feat(integrations): recover beszel integration from worktree
Recovered all beszel integration files from the local worktree
at /Users/ajnart/.cursor/worktrees/homarr/pwxp that were built
and tested with the homarr:beszel docker image.
2026-06-08 12:54:30 +02:00
158 changed files with 12822 additions and 9923 deletions

No files matched your search

@@ -1,197 +0,0 @@
---
name: Revert Demo MCP
overview: Revert the demo-specific MCP accommodations (API key bypass, read-only filtering, demo UI) since demo MCP is an edge case with poor ROI. Replace with a clear "MCP not available in demo mode" message. Keep the core MCP feature intact for real instances.
todos:
- id: revert-backend
content: Revert demoSafe procedure, apiKeys demo bypass, isDemoMode export from mcp.ts
status: pending
- id: revert-mcp-route
content: Remove isDemoMode filtering and demo SERVER_INSTRUCTIONS from MCP route
status: pending
- id: revert-ui
content: Remove isDemoMode prop plumbing; add demo-not-supported Alert via info.isDemoMode
status: pending
- id: verify
content: Run lint + typecheck; smoke-test demo vs non-demo MCP tab
status: pending
isProject: false
---
# Revert Demo MCP Accommodations
## Why Docker works but integrations do not
Demo mode has **two different mock mechanisms**:
```mermaid
flowchart TB
subgraph dockerPath [Docker - works in demo]
MCP1[MCP docker_getContainers]
Handler[docker.ts request handler]
MockContainers[Hardcoded mockContainers]
MCP1 --> Handler
Handler -->|"if DEMO_MODE"| MockContainers
end
subgraph integrationPath [Integrations - confusing in demo]
MCP2[MCP widget tools e.g. calendar_findAllEvents]
TRPC[tRPC procedure]
RH[cached-integration-request-handler]
Kind{integration.kind}
Mock[MockIntegration fake data]
Real[Sonarr/Radarr HTTP - fails]
MCP2 --> TRPC --> RH --> Kind
Kind -->|mock| Mock
Kind -->|sonarr/radarr| Real
end
```
- **Docker**:
[`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
checks `DEMO_MODE` directly and returns inline fake containers. No integration
record needed.
- **Everything else**: Demo seed creates **one** integration with `kind: "mock"`
([`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts)), not
Sonarr/Radarr/Overseerr.
[`MockIntegration`](packages/integrations/src/mock/mock-integration.ts) can
serve calendar, downloads, health, DNS, media server, etc. **if** callers pass
that integration ID.
- **Why it felt broken**: MCP tool descriptions tell AI clients to look for
"Overseerr/Sonarr IDs". Demo has none. Tools like
`integration_searchMediaRequests` hard-filter to `mediaSearch` kinds
(`overseerr`, `jellyseerr`, `seerr`) — `mock` is excluded from that category
in
[`packages/definitions/src/integration.ts`](packages/definitions/src/integration.ts).
Fixing this properly means extending the mock integration system (new
`searchAsync`, category changes, middleware relaxations, instruction rewrites)
for a feature almost nobody will use on the public demo.
## Option comparison
| | **Revert (chosen)** | **Minimal fix** | **Full fix** |
| -------------------- | --------------------------- | ---------------------------------------------- | --------------------------------------------------- |
| **Effort** | ~1 hour, ~100 lines removed | ~2 hours | 1-2 days |
| **Files touched** | 8 MCP/demo-specific files | 8 + route instructions | 15+ across integrations, definitions, seed, routers |
| **Risk** | Low | Low-medium | Medium (mock/search semantics) |
| **Demo MCP outcome** | Clearly unsupported | Partial (widget reads only, no search/request) | Near-full read coverage |
| **Maintenance** | None ongoing | Instructions drift with tool changes | Mock parity with real integrations |
**Recommendation: Revert.** The demo accommodation code added special-case
plumbing (`demoSafePermissionRequiredProcedure`, dual mutation guards, hardcoded
UI strings) for a workflow that still cannot deliver a convincing "homelab AI"
experience without substantial integration-layer work.
## What to revert (demo MCP accommodations only)
These changes were added specifically for demo + MCP. **Do not revert** the core
MCP server, OAuth, Scalar docs, tool descriptions, or `normalizeSchema`.
### Backend (3 files)
1. [`packages/api/src/trpc.ts`](packages/api/src/trpc.ts)
- Remove `demoSafePermissionRequiredProcedure` export (~12 lines)
- Keep existing `enforceDemoModeReadOnly` (pre-dates MCP; used by whole app)
2. [`packages/api/src/router/apiKeys.ts`](packages/api/src/router/apiKeys.ts)
- Change `create` back to `permissionRequiredProcedure` (mutations blocked in
demo again)
- Remove `isDemoMode` guard on `delete` (global demo middleware already
blocks delete mutations)
- Remove `TRPCError` / `isDemoMode` imports if unused
3. [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts)
- Stop exporting `isDemoMode`; keep only `createTRPCContext` and `mcpRouter`
### MCP route (1 file)
4. [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
- Remove `isDemoMode` import and all demo branches:
- `ListToolsRequestSchema` mutation filter
- `CallToolRequestSchema` mutation rejection
- `SERVER_INSTRUCTIONS` demo section
- Keep `getToolType` / `buildToolTypeMap` only if still needed elsewhere;
otherwise remove dead code from demo-only filtering
### UI (4 files)
5. [`apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx)
- Remove `isDemoMode` import from `@homarr/api/mcp`
- Stop passing `isDemoMode` to child components
6. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx)
- Remove `isDemoMode` prop from interface and `McpInstructions` call
7. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
- Remove `isDemoMode` prop
- Replace current demo banner with a single **Alert**: MCP requires a
self-hosted instance; demo mode does not support MCP (API key creation and
write tools are disabled)
- Use `clientApi.info.isDemoMode.useQuery()` or pass `isDemoMode` from server
via existing `api.info.isDemoMode()` tRPC query (already exists in
[`packages/api/src/router/info.ts`](packages/api/src/router/info.ts)) —
avoids re-exporting from `@homarr/api/mcp`
8. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx)
- Remove `isDemoMode` prop and conditional delete-column spread
- Delete button hidden automatically in demo because `apiKeys.delete`
mutation is blocked by global `enforceDemoModeReadOnly`
## What to add instead
- **MCP tab**: When `info.isDemoMode` is true, show a prominent notice at the
top of
[`mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
explaining MCP is for self-hosted instances only.
- **Optional**: Disable/hide the "Create API Key" button on the MCP tab in demo
(mutations will fail anyway).
- **No changes** to integrations, definitions, seed, or mock services.
## Files NOT impacted by revert
Core MCP feature stays as-is:
- [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
— auth, rate limiting, `normalizeSchema`, `SERVER_INSTRUCTIONS` (minus demo
section)
- OAuth routes under `apps/nextjs/src/app/api/mcp/oauth/`
- All MCP-enabled routers in `packages/api/src/router/`
- [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts) — eager router (minus
`isDemoMode` export)
- Scalar UI, dynamic tools table,
[`.cursor/rules/mcp-integration.mdc`](.cursor/rules/mcp-integration.mdc)
Pre-existing demo infrastructure (unchanged):
- [`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
— Docker mock containers
- [`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts) — mock
integration + demo board
- [`packages/integrations/src/mock/`](packages/integrations/src/mock/) — mock
services for widgets on demo board UI
## Verification
1. `pnpm lint` and `pnpm typecheck` pass
2. Demo mode (`DEMO_MODE=true`):
- MCP tab shows "not supported" notice
- API key create fails with existing "Mutations are disabled in demo mode"
- `docker_getContainers` still works on demo board UI (unchanged; separate
code path)
3. Non-demo mode: MCP unchanged (all tools listed, mutations work with
permissions)
## Commit strategy
Single focused commit on `feat/add-mcp-server`:
```
revert(mcp): drop demo-mode accommodations for MCP
MCP is intended for self-hosted instances. Remove demo-specific API key
bypass, read-only tool filtering, and demo UI plumbing. Show a clear
notice on the MCP tab when demo mode is active.
```
Then push to the existing PR (no force-push needed unless you want another
squash).
-103
View File
@@ -1,103 +0,0 @@
---
description: How to expose tRPC procedures as MCP tools for AI clients
globs: packages/api/src/router/**/*.ts
alwaysApply: false
---
# MCP Integration Guide
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
## Adding MCP to a procedure
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
```typescript
// Read-only tool (query)
myProcedure: protectedProcedure
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
.input(z.object({ limit: z.number().default(50) }))
.query(async ({ ctx, input }) => { ... })
// Write tool (mutation)
myAction: protectedProcedure
.meta({ mcp: { enabled: true, description: "Delete an item by ID. Requires interact permission on the integration" } })
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { ... })
// No-input procedure (z.void) — works fine, no .input() needed
getAll: protectedProcedure
.meta({ mcp: { enabled: true, description: "List all resources" } })
.query(async ({ ctx }) => { ... })
```
The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` in the chain.
## Writing good descriptions
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
**Must include:**
- What the tool returns or does
- Which integrations/services it works with (if applicable)
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
- Permission requirements (if non-obvious)
**Good:**
```
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
```
**Bad:**
```
"Get calendar events"
```
**For tools that return permission fields**, explain what they mean:
```
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
```
## Registering in the MCP router
After adding `.meta()` to procedures, you must register the router in `packages/api/src/mcp.ts`. This file uses **eager imports** (no `lazy()`) because MCP tool extraction requires synchronous access to all procedure definitions.
```typescript
// packages/api/src/mcp.ts
import { myNewRouter } from "./router/my-new-feature";
export const mcpRouter = createTRPCRouter({
// ... existing routers
myFeature: myNewRouter,
});
```
**If your router is nested** (e.g., widget sub-routers), import and register the sub-router directly — do not import a parent that uses `lazy()`.
## What to expose vs. what to skip
**Expose:**
- Data queries (list, search, get by ID)
- Actions users would ask an AI to do (toggle, create, delete, approve)
- Status checks (health, stats, summaries)
**Skip:**
- Subscriptions (WebSocket-only, not supported by MCP)
- Internal procedures (session management, onboarding steps)
- File upload/download procedures
- Procedures that return non-serializable data (streams, blobs)
## Input schema rules
- Use `z.object({...})` for inputs — this maps cleanly to MCP tool parameters
- `z.void()` (no `.input()`) works — the MCP handler patches these to accept empty objects
- Avoid `z.union()` or `z.discriminatedUnion()` at the top level — AI clients struggle with these
- Use `.default()` on optional fields so the AI doesn't need to guess values
## Checklist for new features
1. Add `.meta({ mcp: { enabled: true, description: "..." } })` to relevant procedures
2. Import and register the router in `packages/api/src/mcp.ts` (eager, no lazy)
3. Write descriptions that explain what the tool does, what it needs, and what it returns
4. If the tool requires an ID from another tool, say which one in the description
5. Test with `curl` against `/api/mcp/mcp` to verify the tool appears in `tools/list`
-3
View File
@@ -1,3 +0,0 @@
[tools]
node = "24.16.0"
pnpm = "10.33.4"
+2 -5
View File
@@ -56,10 +56,7 @@
"@mantine/hooks": "catalog:",
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-persist-client-core": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
@@ -86,7 +83,6 @@
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"mcp-handler": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
"prismjs": "catalog:",
@@ -97,7 +93,7 @@
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"swagger-ui-react": "catalog:",
"use-deep-compare-effect": "catalog:",
"yaml": "catalog:",
"zod": "catalog:"
@@ -111,6 +107,7 @@
"@types/prismjs": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/swagger-ui-react": "catalog:",
"concurrently": "catalog:",
"node-loader": "catalog:",
"typescript": "catalog:"
@@ -1,32 +0,0 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:tools"],
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -1,25 +0,0 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
resource: baseUrl,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -1,49 +0,0 @@
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
import { parse, stringify } from "superjson";
import { fetchApi } from "@homarr/api/client";
import {
getActiveQueryCacheBoardId,
isPersistableWidgetQueryKey,
queryCacheStoragePrefix,
} from "@homarr/api/query-cache";
const queryCacheStorage = {
async getItem(_key) {
return null;
},
async setItem(key, value) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
} catch {
// Query persistence is best-effort; failed writes should not affect widget rendering.
}
},
async removeItem(key) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
} catch {
// Query persistence is best-effort; failed removals should not affect widget rendering.
}
},
} satisfies AsyncStorage<string>;
export const createWidgetQueryPersister = () =>
experimental_createQueryPersister<string>({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
maxAge: Number.POSITIVE_INFINITY,
prefix: queryCacheStoragePrefix,
refetchOnRestore: true,
serialize: (query) => stringify(query),
deserialize: (value) => parse<PersistedQuery>(value),
filters: {
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
},
});
@@ -21,13 +21,10 @@ import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => {
// window is not defined on server side
if (typeof window === "undefined") {
@@ -55,14 +52,12 @@ const wsClient = createWSClient({
});
export function TRPCReactProvider(props: PropsWithChildren) {
const [queryPersister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
persister: queryPersister.persisterFn,
staleTime: queryCacheDefaultStaleTimeMs,
staleTime: 5 * 1000,
},
mutations: {
onError(error) {
@@ -1,13 +1,10 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import type { DehydratedState } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { parse } from "superjson";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client";
@@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { getAllQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -76,14 +72,9 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
),
]);
const userId = session?.user.id ?? "anonymous";
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
@@ -117,37 +108,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
};
interface PersistedEntry {
state: { data: unknown; dataUpdatedAt: number; status: string };
queryKey: unknown[];
queryHash: string;
}
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
const entries = await getAllQueryCacheAsync(userId, boardId);
const queries: DehydratedState["queries"] = [];
for (const serialized of Object.values(entries)) {
try {
const persisted = parse<PersistedEntry>(serialized);
if (persisted.state.status !== "success") continue;
queries.push({
state: persisted.state as DehydratedState["queries"][number]["state"],
queryKey: persisted.queryKey,
queryHash: persisted.queryHash,
});
} catch {
// skip malformed entries
}
}
if (queries.length === 0) return null;
return <HydrationBoundary state={{ mutations: [], queries }} />;
} catch {
return null;
}
}
@@ -8,7 +8,6 @@ import {
IconPasswordUser,
IconPlug,
IconServer,
IconTag,
IconUser,
} from "@tabler/icons-react";
@@ -29,5 +28,4 @@ export const integrationSecretIcons = {
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
slug: IconTag,
} satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -1,58 +0,0 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
import { McpInstructions } from "./mcp-instructions";
export interface McpToolGroup {
namespace: string;
tools: { name: string; description: string; type: "query" | "mutation" }[];
}
interface ApiPageTabsProps {
documentationLabel: string;
apiKeyLabel: string;
mcpLabel: string;
documentationPanel: ReactNode;
authenticationPanel: ReactNode;
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
}
export function ApiPageTabs({
documentationLabel,
apiKeyLabel,
mcpLabel,
documentationPanel,
authenticationPanel,
baseUrl,
hasApiKeys,
toolGroups,
}: ApiPageTabsProps) {
const [activeTab, setActiveTab] = useState<string | null>("documentation");
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<TabsList>
<TabsTab value="documentation" leftSection={<IconCode size={16} />}>
{documentationLabel}
</TabsTab>
<TabsTab value="authentication" leftSection={<IconKey size={16} />}>
{apiKeyLabel}
</TabsTab>
<TabsTab value="mcp" leftSection={<IconBrain size={16} />}>
{mcpLabel}
</TabsTab>
</TabsList>
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
<TabsPanel value="mcp">
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
</TabsPanel>
</Tabs>
);
}
@@ -1,343 +0,0 @@
"use client";
import {
Accordion,
AccordionControl,
AccordionItem,
AccordionPanel,
ActionIcon,
Alert,
Badge,
Button,
Code,
CopyButton,
Group,
List,
ListItem,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import {
IconAlertTriangle,
IconBrain,
IconCheck,
IconCopy,
IconKey,
IconLock,
IconPlus,
IconPlugConnected,
IconSparkles,
IconTerminal,
IconTools,
IconWorld,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { CopyApiKeyModal } from "./copy-api-key-modal";
import type { McpToolGroup } from "./api-page-tabs";
import classes from "./mcp-tools-accordion.module.css";
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
query: { color: "blue", method: "GET" },
mutation: { color: "orange", method: "POST" },
};
interface McpInstructionsProps {
baseUrl: string;
hasApiKeys: boolean;
toolGroups: McpToolGroup[];
}
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
const t = useScopedI18n("management.page.tool.api.tab.mcp");
const { openModal } = useModalAction(CopyApiKeyModal);
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({ apiKey: data.apiKey });
await revalidatePathActionAsync("/manage/tools/api");
},
});
const mcpUrl = `${baseUrl}/api/mcp/mcp`;
const streamableHttpConfig = JSON.stringify(
{
mcpServers: {
homarr: {
url: mcpUrl,
headers: {
ApiKey: "<your-api-key>",
},
},
},
},
null,
2,
);
const stdioConfig = JSON.stringify(
{
mcpServers: {
homarr: {
command: "npx",
args: ["-y", "mcp-remote", mcpUrl, "--header", "ApiKey:<your-api-key>"],
},
},
},
null,
2,
);
const testCommand = `curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "ApiKey: <your-api-key>" -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' ${mcpUrl}`;
return (
<Stack gap="xl" p="md">
<Group gap="sm" align="center">
<ThemeIcon variant="light" size="xl" radius="md">
<IconBrain size={24} />
</ThemeIcon>
<div>
<Title order={3}>{t("title")}</Title>
<Text size="sm" c="dimmed">
{t("subtitle")}
</Text>
</div>
</Group>
<Alert variant="light" icon={<IconSparkles size={18} />}>
<Text size="sm">{t("description")}</Text>
</Alert>
{!hasApiKeys && (
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
<Group justify="space-between" align="center">
<Text size="sm">{t("noApiKey")}</Text>
<Button
size="compact-sm"
variant="filled"
leftSection={<IconPlus size={14} />}
onClick={() => createApiKey()}
loading={isPending}
>
{t("createApiKeyButton")}
</Button>
</Group>
</Alert>
)}
<div>
<Title order={5} mb="xs">
<Group gap="xs">
<IconPlugConnected size={16} />
{t("endpoint.title")}
</Group>
</Title>
<CopyableCode value={mcpUrl} copyLabel={t("copy")} />
</div>
<div>
<Title order={5} mb="xs">
<Group gap="xs">
<IconLock size={16} />
{t("authentication.title")}
</Group>
</Title>
<Text size="sm" mb="sm">
{t("authentication.intro")}
</Text>
<Accordion variant="separated" defaultValue="apikey">
<AccordionItem value="apikey">
<AccordionControl>
<Group gap="xs">
<IconKey size={16} />
{t("authentication.apiKey.title")}
</Group>
</AccordionControl>
<AccordionPanel>
<Stack gap="xs">
<Text size="sm">{t("authentication.apiKey.description")}</Text>
<Text size="xs" c="dimmed">
{t("authentication.permissions")}
</Text>
</Stack>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="oauth">
<AccordionControl>
<Group gap="xs">
<IconWorld size={16} />
{t("authentication.oauth.title")}
</Group>
</AccordionControl>
<AccordionPanel>
<Stack gap="xs">
<Text size="sm">{t("authentication.oauth.description")}</Text>
<Text size="xs" c="dimmed">
{t("authentication.oauth.hint")}
</Text>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
<div>
<Title order={5} mb="sm">
{t("clientConfig.title")}
</Title>
<Accordion variant="separated" defaultValue="streamable-http">
<AccordionItem value="streamable-http">
<AccordionControl>
<Group gap="xs">
<Badge size="xs" variant="filled">
{t("clientConfig.streamableHttp.recommended")}
</Badge>
{t("clientConfig.streamableHttp.title")}
</Group>
</AccordionControl>
<AccordionPanel>
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("clientConfig.streamableHttp.worksWith")}
</Text>
<Text size="sm" c="dimmed">
{t("clientConfig.streamableHttp.addToConfig")}
</Text>
<List size="xs" spacing={4}>
<ListItem>
{t("clientConfig.streamableHttp.cursor", {
name: "Cursor",
file: ".cursor/mcp.json",
})}
</ListItem>
<ListItem>
{t("clientConfig.streamableHttp.claudeDesktop", {
name: "Claude Desktop",
file: "claude_desktop_config.json",
})}
</ListItem>
<ListItem>
{t("clientConfig.streamableHttp.claudeCode", {
name: "Claude Code",
file: ".claude/mcp.json",
command: "claude mcp add homarr",
})}
</ListItem>
<ListItem>
{t("clientConfig.streamableHttp.vsCodeCopilot", {
name: "VS Code Copilot",
file: ".vscode/mcp.json",
})}
</ListItem>
</List>
<CopyableCode value={streamableHttpConfig} copyLabel={t("copy")} />
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{t("clientConfig.streamableHttp.replaceHint")}</Text>
</Alert>
</Stack>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="stdio">
<AccordionControl>{t("clientConfig.stdio.title")}</AccordionControl>
<AccordionPanel>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("clientConfig.stdio.description", { dependency: "npx" })}
</Text>
<CopyableCode value={stdioConfig} copyLabel={t("copy")} />
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
<div>
<Title order={5} mb="xs">
<Group gap="xs">
<IconTerminal size={16} />
{t("testConnection.title")}
</Group>
</Title>
<Text size="sm" c="dimmed" mb="xs">
{t("testConnection.description")}
</Text>
<CopyableCode value={testCommand} copyLabel={t("copy")} />
<Text size="xs" c="dimmed" mt="xs">
{t("testConnection.hint")}
</Text>
</div>
<div>
<Title order={5} mb="xs">
<Group gap="xs">
<IconTools size={16} />
{t("availableTools.title")}
</Group>
</Title>
<Text size="sm" c="dimmed" mb="sm">
{t("availableTools.description", {
count: String(toolGroups.reduce((sum, g) => sum + g.tools.length, 0)),
})}
</Text>
<Accordion variant="separated" multiple>
{toolGroups.map((group) => (
<AccordionItem key={group.namespace} value={group.namespace}>
<AccordionControl>
<Group gap="xs">
<Text size="sm" fw={600}>
{group.namespace}
</Text>
<Badge size="xs" variant="light" circle>
{group.tools.length}
</Badge>
</Group>
</AccordionControl>
<AccordionPanel>
<Stack gap={6}>
{group.tools.map((tool) => (
<div key={tool.name} className={classes.toolRow}>
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
{toolTypeDisplay[tool.type].method}
</Badge>
<div>
<Code fz="xs" fw={600}>
{tool.name}
</Code>
<Text size="xs" c="dimmed" lh={1.4}>
{tool.description}
</Text>
</div>
</div>
))}
</Stack>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</div>
</Stack>
);
}
function CopyableCode({ value, copyLabel }: { value: string; copyLabel: string }) {
return (
<Code block pos="relative" style={{ whiteSpace: "pre-wrap" }}>
{value}
<CopyButton value={value}>
{({ copied, copy }) => (
<ActionIcon variant="subtle" size="sm" onClick={copy} pos="absolute" top={8} right={8} title={copyLabel}>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
)}
</CopyButton>
</Code>
);
}
@@ -1,6 +0,0 @@
.toolRow {
display: grid;
grid-template-columns: 42px 1fr;
align-items: first baseline;
gap: var(--mantine-spacing-xs);
}
@@ -1,35 +0,0 @@
"use client";
import { useComputedColorScheme } from "@mantine/core";
import { ApiReferenceReact } from "@scalar/api-reference-react";
import "@scalar/api-reference-react/style.css";
import "./scalar-theme.css";
interface ScalarApiReferenceProps {
document: object;
}
export function ScalarApiReference({ document }: ScalarApiReferenceProps) {
const colorScheme = useComputedColorScheme("light");
return (
<ApiReferenceReact
configuration={{
content: document,
layout: "classic",
theme: "alternate",
showSidebar: false,
hideDarkModeToggle: true,
hideSearch: true,
hiddenClients: true,
showDeveloperTools: "never",
defaultOpenAllTags: true,
forceDarkModeState: colorScheme,
authentication: {
preferredSecurityScheme: "apikey",
},
}}
/>
);
}
@@ -1,51 +0,0 @@
.light-mode {
--scalar-background-1: var(--mantine-color-body);
--scalar-background-2: var(--mantine-color-gray-0);
--scalar-background-3: var(--mantine-color-gray-1);
--scalar-background-card: var(--mantine-color-white);
--scalar-background-accent: rgba(0, 0, 0, 0.04);
--scalar-color-1: var(--mantine-color-text);
--scalar-color-2: var(--mantine-color-dimmed);
--scalar-color-3: var(--mantine-color-dimmed);
--scalar-color-accent: var(--mantine-color-red-6);
--scalar-border-color: var(--mantine-color-gray-3);
--scalar-button-1: var(--mantine-color-dark-7);
--scalar-button-1-hover: var(--mantine-color-dark-5);
--scalar-button-1-color: var(--mantine-color-white);
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
--scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36);
}
.dark-mode {
--scalar-background-1: var(--mantine-color-body);
--scalar-background-2: var(--mantine-color-dark-6);
--scalar-background-3: var(--mantine-color-dark-5);
--scalar-background-card: var(--mantine-color-dark-6);
--scalar-background-accent: rgba(255, 255, 255, 0.06);
--scalar-color-1: var(--mantine-color-text);
--scalar-color-2: var(--mantine-color-dimmed);
--scalar-color-3: var(--mantine-color-dimmed);
--scalar-color-accent: var(--mantine-color-red-6);
--scalar-border-color: var(--mantine-color-dark-4);
--scalar-button-1: rgba(255, 255, 255, 0.9);
--scalar-button-1-hover: rgba(255, 255, 255, 0.8);
--scalar-button-1-color: var(--mantine-color-dark-7);
--scalar-scrollbar-color: rgba(255, 255, 255, 0.18);
--scalar-scrollbar-color-active: rgba(255, 255, 255, 0.36);
}
.scalar-card,
.scalar-api-client__item,
.dark-mode .dark-mode.scalar-card {
--scalar-background-1: var(--scalar-background-card);
--scalar-background-2: var(--scalar-background-card);
--scalar-background-3: var(--scalar-background-card);
}
@@ -0,0 +1,23 @@
"use client";
import type { OpenAPIObject } from "openapi3-ts/oas31";
import SwaggerUI from "swagger-ui-react";
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
import "../swagger-ui-dark.css";
import "../swagger-ui-overrides.css";
import "../swagger-ui.css";
interface SwaggerUIClientProps {
document: OpenAPIObject;
}
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const requestInterceptor = (req: Record<string, any>) => {
req.credentials = "omit";
return req;
};
return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
};
@@ -1,57 +1,16 @@
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { Stack } from "@mantine/core";
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { mcpRouter } from "@homarr/api/mcp";
import { openApiDocument } from "@homarr/api/open-api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
import { createMetaTitle } from "~/metadata";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
import { ScalarApiReference } from "./components/scalar-api-reference";
import type { McpToolGroup } from "./components/api-page-tabs";
let cachedToolGroups: McpToolGroup[] | null = null;
function getMcpToolGroups(): McpToolGroup[] {
if (cachedToolGroups) return cachedToolGroups;
const tools = extractMcpTools();
const procedures = (mcpRouter as any)._def.procedures as Record<string, { _def?: { type?: string } }>;
const procedureTypeMap = new Map<string, "query" | "mutation">();
for (const [key, proc] of Object.entries(procedures)) {
const type = proc?._def?.type;
if (type === "query" || type === "mutation") {
procedureTypeMap.set(key, type);
}
}
const groups = new Map<string, McpToolGroup["tools"]>();
for (const tool of tools) {
const namespace = tool.pathInRouter[0] ?? "other";
if (!groups.has(namespace)) {
groups.set(namespace, []);
}
const procedureKey = tool.pathInRouter.join(".");
groups.get(namespace)?.push({
name: tool.name,
description: tool.description,
type: procedureTypeMap.get(procedureKey) ?? "query",
});
}
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({
namespace,
tools: items,
}));
return cachedToolGroups;
}
export async function generateMetadata() {
const session = await auth();
@@ -71,25 +30,24 @@ export default async function ApiPage() {
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const requestHeaders = await headers();
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
const document = openApiDocument(baseUrl);
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
const apiKeys = await api.apiKeys.getAll();
const t = await getScopedI18n("management.page.tool.api.tab");
const toolGroups = getMcpToolGroups();
return (
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
<Tabs defaultValue={"documentation"}>
<TabsList>
<TabsTab value={"documentation"}>{t("documentation.label")}</TabsTab>
<TabsTab value={"authentication"}>{t("apiKey.label")}</TabsTab>
</TabsList>
<TabsPanel value={"authentication"}>
<ApiKeysManagement apiKeys={apiKeys} />
</TabsPanel>
<TabsPanel value={"documentation"}>
<SwaggerUIClient document={document} />
</TabsPanel>
</Tabs>
</Stack>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,4 @@
.swagger-ui .info {
margin: 0 !important;
margin-bottom: 20px !important;
}
File diff suppressed because it is too large. Load diff
@@ -1,300 +0,0 @@
import { AsyncLocalStorage } from "async_hooks";
import type { NextRequest } from "next/server";
import { userAgent } from "next/server";
import { createMcpHandler } from "mcp-handler";
import type { McpTool } from "trpc-to-mcp";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { extractMcpTools } from "../_extract-tools";
const logger = createLogger({ module: "mcpRoute" });
const AUTH_RATE_WINDOW_MS = 60_000;
const AUTH_MAX_FAILURES = 10;
interface RateLimitStore {
authFailures: Map<string, { count: number; resetAt: number }>;
cleanupTimer: ReturnType<typeof setInterval> | null;
}
declare global {
var mcpRateLimit: RateLimitStore | undefined;
}
const rateLimitStore =
globalThis.mcpRateLimit ??
(globalThis.mcpRateLimit = {
authFailures: new Map(),
cleanupTimer: null,
});
const authFailures = rateLimitStore.authFailures;
if (!rateLimitStore.cleanupTimer) {
rateLimitStore.cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of authFailures) {
if (entry.resetAt < now) authFailures.delete(key);
}
}, AUTH_RATE_WINDOW_MS);
rateLimitStore.cleanupTimer.unref();
}
function checkAuthRateLimit(ip: string | null): boolean {
const key = ip ?? "unknown";
const now = Date.now();
const entry = authFailures.get(key);
if (!entry || entry.resetAt < now) return true;
return entry.count < AUTH_MAX_FAILURES;
}
function recordAuthFailure(ip: string | null) {
const key = ip ?? "unknown";
const now = Date.now();
const entry = authFailures.get(key);
if (!entry || entry.resetAt < now) {
authFailures.set(key, { count: 1, resetAt: now + AUTH_RATE_WINDOW_MS });
return;
}
entry.count++;
}
function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown");
}
const callerStorage = new AsyncLocalStorage<ReturnType<typeof mcpRouter.createCaller>>();
const emptyObjectSchema = {
type: "object" as const,
properties: {},
required: [] as string[],
};
let toolsCache: McpTool[] | null = null;
function normalizeSchema(schema: McpTool["inputSchema"]) {
if (!schema) return emptyObjectSchema;
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = ((schema.required as string[]) ?? []).filter((key) => !("default" in (properties[key] ?? {})));
return { type: schema.type ?? ("object" as const), properties, required };
}
function getTools() {
if (!toolsCache) {
toolsCache = extractMcpTools().map((tool) => ({
...tool,
inputSchema: normalizeSchema(tool.inputSchema),
}));
logger.info(`Extracted ${toolsCache.length} MCP tools`);
}
return toolsCache;
}
const SAFE_ERROR_MESSAGES: Record<string, string> = {
UNAUTHORIZED: "You do not have permission to perform this action",
FORBIDDEN: "Access denied",
NOT_FOUND: "The requested resource was not found",
BAD_REQUEST: "Invalid request parameters",
INTERNAL_SERVER_ERROR: "An internal error occurred",
TIMEOUT: "The operation timed out",
TOO_MANY_REQUESTS: "Rate limit exceeded, try again later",
CONFLICT: "A conflict occurred with the current state",
PRECONDITION_FAILED: "A precondition for this operation was not met",
};
function sanitizeErrorMessage(error: unknown): string {
if (error instanceof Error && "code" in error) {
const trpcCode = (error as Error & { code: string }).code;
const safeMessage = SAFE_ERROR_MESSAGES[trpcCode];
if (safeMessage) return safeMessage;
}
if (error instanceof Error && error.message.length < 200 && !error.message.includes("/")) {
return error.message;
}
return "Tool execution failed";
}
const SERVER_INSTRUCTIONS = `You are connected to Homarr, a self-hosted homelab dashboard.
## Key Concepts
**Integrations** are connections to external self-hosted services (Sonarr, Radarr, Plex, Overseerr, Pi-hole, Home Assistant, etc.). Each integration has an \`id\` (integrationId) and a \`kind\` (e.g. "sonarr", "radarr", "overseerr"). Use \`integration_all\` to list them. Many tools require an \`integrationId\` — always fetch it from \`integration_all\` or \`integration_search\` first.
**Boards** are customizable dashboards containing widgets that display data from integrations. Users can have multiple boards.
**Apps** are links/bookmarks to self-hosted services displayed on boards. They are separate from integrations — an app is a visual shortcut, an integration is a data connection.
**Permissions**: Each integration result includes a \`permissions\` object:
- \`hasUseAccess: true\` means you can read data from this integration
- \`hasInteractAccess: true\` means you can perform actions (start/stop, request media, etc.)
- \`hasFullAccess: true\` means you can modify/delete the integration config
If \`hasUseAccess\` is false, the API key owner lacks permission for that integration — this is normal, not an error. Simply skip integrations where you lack the required permission level.
## Common Workflows
**"What's coming this week?"** → Use \`calendar_findAllEvents\` to get upcoming media releases from Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books).
**"Search and request a movie/show"** → Use \`integration_searchMediaRequests\` to search across Overseerr/Jellyseerr, then \`integration_requestMedia\` to submit a request. Use \`integration_getMediaRequestOptions\` first for TV shows to pick seasons.
**"What's streaming right now?"** → Use \`mediaServer_getCurrentStreams\` to see active Plex/Jellyfin/Emby streams.
**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters.
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
**"Block/unblock ads"** → Use \`dnsHole_summary\` for Pi-hole/AdGuard stats, \`dnsHole_disable\` to temporarily disable blocking, \`dnsHole_enable\` to re-enable.
**"Check downloads"** → Use \`downloads_getJobsAndStatuses\` for queue status, \`downloads_pause\`/\`downloads_resume\` to control queues.
**"Pending media requests"** → Use \`mediaRequests_getLatestRequests\` to see pending/approved/declined requests, \`mediaRequests_answerRequest\` to approve or decline.
## Tips
- Always call \`integration_all\` first to discover available integrations and their IDs before using integration-dependent tools.
- The \`integrationId\` parameter in widget tools accepts the ID from \`integration_all\`, not the integration name or kind.
- If a tool returns empty results, the user may not have the required integration configured.
- Docker and Kubernetes tools work only if the Homarr instance has access to the Docker socket or Kubernetes cluster.`;
const mcpHandler = createMcpHandler(
(server) => {
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: getTools(),
}));
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const tools = getTools();
const tool = tools.find((t) => t.name === name);
if (!tool) {
return {
content: [{ type: "text" as const, text: `Tool "${name}" not found` }],
isError: true,
};
}
const caller = callerStorage.getStore();
if (!caller) {
return {
content: [{ type: "text" as const, text: "Authentication context not available" }],
isError: true,
};
}
try {
const procedure = tool.pathInRouter.reduce<any>((acc, part) => acc?.[part], caller);
if (typeof procedure !== "function") {
return {
content: [{ type: "text" as const, text: `Tool "${name}" is not callable` }],
isError: true,
};
}
const input = args && Object.keys(args).length > 0 ? args : undefined;
const result = await procedure(input);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
} catch (error) {
const message = sanitizeErrorMessage(error);
logger.warn("MCP tool execution failed", {
tool: name,
error: error instanceof Error ? error.message : String(error),
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
isError: true,
};
}
});
},
{
capabilities: { tools: {} },
instructions: SERVER_INSTRUCTIONS,
serverInfo: {
name: "homarr",
version: getPackageVersion(),
},
},
{
basePath: "/api/mcp",
maxDuration: 60,
},
);
function extractApiKeyValue(req: NextRequest): string | null {
const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME);
if (apiKeyHeader) return apiKeyHeader;
const authHeader = req.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
return null;
}
function jsonErrorResponse(status: number, body: Record<string, string>, extraHeaders: Record<string, string> = {}) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...extraHeaders },
});
}
const handler = async (req: NextRequest) => {
const apiKeyValue = extractApiKeyValue(req);
const ipAddress = ipAddressFromHeaders(req.headers);
const { ua } = userAgent(req);
if (!checkAuthRateLimit(ipAddress)) {
return jsonErrorResponse(
429,
{ error: "rate_limited", hint: "Too many failed authentication attempts. Try again later." },
{ "Retry-After": "60" },
);
}
if (!apiKeyValue) {
recordAuthFailure(ipAddress);
const baseUrl = extractBaseUrlFromHeaders(req.headers);
return jsonErrorResponse(
401,
{
error: "unauthorized",
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server",
},
{ "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` },
);
}
if (!apiKeyValue.includes(".")) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." });
}
const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua);
if (!session) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(401, {
error: "invalid_token",
hint: "The API key was not found or the token is incorrect.",
});
}
clearAuthFailures(ipAddress);
logger.info("MCP request authenticated", { userId: session.user.id });
const ctx = createTRPCContext({ session, headers: req.headers });
const caller = mcpRouter.createCaller(ctx);
return callerStorage.run(caller, () => mcpHandler(req as unknown as Request));
};
export { handler as GET, handler as POST, handler as DELETE };
@@ -1,23 +0,0 @@
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { mcpRouter } from "@homarr/api/mcp";
let cache: ReturnType<typeof extractToolsFromProcedures> | null = null;
export function extractMcpTools() {
if (cache) return cache;
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
if (typeof args[0] === "string" && args[0].includes("[TRPC-TO-MCP]")) return;
originalWarn.apply(console, args);
};
try {
cache = extractToolsFromProcedures(mcpRouter);
} finally {
console.warn = originalWarn;
}
return cache;
}
-157
View File
@@ -1,157 +0,0 @@
import { createHash, randomBytes, timingSafeEqual } from "crypto";
interface OAuthClient {
clientId: string;
clientSecret: string;
redirectUris: string[];
clientName: string;
createdAt: number;
}
interface AuthCode {
code: string;
clientId: string;
userId: string;
codeChallenge: string;
codeChallengeMethod: string;
redirectUri: string;
expiresAt: number;
}
interface PendingAuth {
clientId: string;
redirectUri: string;
codeChallenge: string;
codeChallengeMethod: string;
state: string | null;
expiresAt: number;
}
interface OAuthStore {
clients: Map<string, OAuthClient>;
authCodes: Map<string, AuthCode>;
pendingAuths: Map<string, PendingAuth>;
cleanupTimer: ReturnType<typeof setInterval> | null;
}
declare global {
var mcpOAuthStore: OAuthStore | undefined;
}
const store =
globalThis.mcpOAuthStore ??
(globalThis.mcpOAuthStore = {
clients: new Map(),
authCodes: new Map(),
pendingAuths: new Map(),
cleanupTimer: null,
});
const clients = store.clients;
const authCodes = store.authCodes;
const pendingAuths = store.pendingAuths;
const CLEANUP_INTERVAL_MS = 60_000;
const AUTH_CODE_TTL_MS = 300_000;
const PENDING_AUTH_TTL_MS = 600_000;
const CLIENT_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_CLIENTS = 1000;
const MAX_AUTH_CODES = 5000;
const MAX_PENDING_AUTHS = 5000;
if (!store.cleanupTimer) {
store.cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, code] of authCodes) {
if (code.expiresAt < now) authCodes.delete(key);
}
for (const [key, pending] of pendingAuths) {
if (pending.expiresAt < now) pendingAuths.delete(key);
}
for (const [key, client] of clients) {
if (client.createdAt + CLIENT_TTL_MS < now) clients.delete(key);
}
}, CLEANUP_INTERVAL_MS);
}
export function registerClient(redirectUris: string[], clientName: string) {
if (clients.size >= MAX_CLIENTS) {
return null;
}
const clientId = randomBytes(16).toString("hex");
const clientSecret = randomBytes(32).toString("hex");
const client: OAuthClient = {
clientId,
clientSecret,
redirectUris,
clientName,
createdAt: Date.now(),
};
clients.set(clientId, client);
return { clientId, clientSecret };
}
export function getClient(clientId: string) {
return clients.get(clientId) ?? null;
}
export function createAuthCode(
clientId: string,
userId: string,
codeChallenge: string,
codeChallengeMethod: string,
redirectUri: string,
) {
if (authCodes.size >= MAX_AUTH_CODES) {
return null;
}
const code = randomBytes(32).toString("hex");
authCodes.set(code, {
code,
clientId,
userId,
codeChallenge,
codeChallengeMethod,
redirectUri,
expiresAt: Date.now() + AUTH_CODE_TTL_MS,
});
return code;
}
export function consumeAuthCode(code: string) {
const authCode = authCodes.get(code);
if (!authCode) return null;
authCodes.delete(code);
if (authCode.expiresAt < Date.now()) return null;
return authCode;
}
export function verifyPkce(codeVerifier: string, codeChallenge: string, method: string) {
if (method !== "S256") return false;
const computed = createHash("sha256").update(codeVerifier).digest("base64url");
if (computed.length !== codeChallenge.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(codeChallenge));
}
export function storePendingAuth(params: Omit<PendingAuth, "expiresAt">) {
if (pendingAuths.size >= MAX_PENDING_AUTHS) {
return null;
}
const id = randomBytes(16).toString("hex");
pendingAuths.set(id, {
...params,
expiresAt: Date.now() + PENDING_AUTH_TTL_MS,
});
return id;
}
export function consumePendingAuth(id: string) {
const pending = pendingAuths.get(id);
if (!pending) return null;
pendingAuths.delete(id);
if (pending.expiresAt < Date.now()) return null;
return pending;
}
@@ -1,143 +0,0 @@
import type { NextRequest } from "next/server";
import { auth } from "@homarr/auth/next";
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const session = await auth();
const pendingId = url.searchParams.get("pending");
if (pendingId) {
if (!session?.user) {
return Response.json(
{
error: "access_denied",
error_description: "Login was not completed",
},
{ status: 403 },
);
}
const pending = consumePendingAuth(pendingId);
if (!pending) {
return Response.json(
{
error: "invalid_request",
error_description: "Authorization request expired, please try again",
},
{ status: 400 },
);
}
const code = createAuthCode(
pending.clientId,
session.user.id,
pending.codeChallenge,
pending.codeChallengeMethod,
pending.redirectUri,
);
if (!code) {
return Response.json(
{
error: "server_error",
error_description: "Server is busy, please try again later",
},
{ status: 503 },
);
}
const redirect = new URL(pending.redirectUri);
redirect.searchParams.set("code", code);
if (pending.state) redirect.searchParams.set("state", pending.state);
return Response.redirect(redirect.toString());
}
const clientId = url.searchParams.get("client_id");
const redirectUri = url.searchParams.get("redirect_uri");
const codeChallenge = url.searchParams.get("code_challenge");
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256";
const state = url.searchParams.get("state");
if (!clientId || !redirectUri || !codeChallenge) {
return Response.json(
{
error: "invalid_request",
error_description: "Missing required parameters",
},
{ status: 400 },
);
}
if (codeChallengeMethod !== "S256") {
return Response.json(
{
error: "invalid_request",
error_description: "Only S256 code_challenge_method is supported",
},
{ status: 400 },
);
}
const client = getClient(clientId);
if (!client) {
return Response.json(
{
error: "invalid_client",
error_description:
"Unknown client_id — the server was restarted and lost the registration. Please re-run the auth command to re-register.",
},
{ status: 400 },
);
}
if (!client.redirectUris.includes(redirectUri)) {
return Response.json(
{
error: "invalid_request",
error_description: "redirect_uri not registered",
},
{ status: 400 },
);
}
if (session?.user) {
const code = createAuthCode(clientId, session.user.id, codeChallenge, codeChallengeMethod, redirectUri);
if (!code) {
return Response.json(
{
error: "server_error",
error_description: "Server is busy, please try again later",
},
{ status: 503 },
);
}
const redirect = new URL(redirectUri);
redirect.searchParams.set("code", code);
if (state) redirect.searchParams.set("state", state);
return Response.redirect(redirect.toString());
}
const id = storePendingAuth({
clientId,
redirectUri,
codeChallenge,
codeChallengeMethod,
state,
});
if (!id) {
return Response.json(
{
error: "server_error",
error_description: "Server is busy, please try again later",
},
{ status: 503 },
);
}
const loginUrl = new URL("/auth/login", url.origin);
loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
return Response.redirect(loginUrl.toString());
}
@@ -1,107 +0,0 @@
import { registerClient } from "../_store";
interface RateLimitStore {
attempts: Map<string, { count: number; resetAt: number }>;
timer: ReturnType<typeof setInterval> | null;
}
const REGISTER_RATE_WINDOW_MS = 60_000;
const REGISTER_MAX_ATTEMPTS = 5;
declare global {
var mcpRegisterRateLimit: RateLimitStore | undefined;
}
const store =
globalThis.mcpRegisterRateLimit ??
(globalThis.mcpRegisterRateLimit = {
attempts: new Map(),
timer: null,
});
const registerAttempts = store.attempts;
if (!store.timer) {
store.timer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of registerAttempts) {
if (entry.resetAt < now) registerAttempts.delete(key);
}
}, REGISTER_RATE_WINDOW_MS);
store.timer.unref();
}
function getClientIp(req: Request): string {
const forwarded = req.headers.get("x-forwarded-for");
return forwarded?.split(",")[0]?.trim() ?? "unknown";
}
function isValidRedirectUri(uri: string): boolean {
try {
const parsed = new URL(uri);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
export async function POST(req: Request) {
const ip = getClientIp(req);
const now = Date.now();
const entry = registerAttempts.get(ip);
if (entry && entry.resetAt >= now && entry.count >= REGISTER_MAX_ATTEMPTS) {
return Response.json(
{ error: "rate_limited", error_description: "Too many registration attempts, try again later" },
{ status: 429, headers: { "Retry-After": "60" } },
);
}
const existing = registerAttempts.get(ip);
if (!existing || existing.resetAt < now) {
registerAttempts.set(ip, { count: 1, resetAt: now + REGISTER_RATE_WINDOW_MS });
} else {
existing.count++;
}
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) {
return Response.json({ error: "invalid_request", error_description: "Invalid JSON body" }, { status: 400 });
}
const redirectUris = body.redirect_uris;
const clientName = typeof body.client_name === "string" ? body.client_name.slice(0, 256) : "MCP Client";
if (!Array.isArray(redirectUris) || redirectUris.length === 0 || redirectUris.length > 10) {
return Response.json(
{ error: "invalid_client_metadata", error_description: "redirect_uris must be an array of 1-10 URIs" },
{ status: 400 },
);
}
const validUris = redirectUris.filter((uri): uri is string => typeof uri === "string" && isValidRedirectUri(uri));
if (validUris.length !== redirectUris.length) {
return Response.json(
{ error: "invalid_client_metadata", error_description: "All redirect_uris must be valid http or https URLs" },
{ status: 400 },
);
}
const result = registerClient(validUris, clientName);
if (!result) {
return Response.json(
{ error: "server_error", error_description: "Too many registered clients, please try again later" },
{ status: 503 },
);
}
return Response.json(
{
client_id: result.clientId,
client_secret: result.clientSecret,
redirect_uris: validUris,
client_name: clientName,
token_endpoint_auth_method: "none",
},
{ status: 201 },
);
}
@@ -1,143 +0,0 @@
import { randomBytes } from "crypto";
import { hashPasswordAsync } from "@homarr/auth";
import { generateSecureRandomToken } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
import { consumeAuthCode, verifyPkce } from "../_store";
const logger = createLogger({ module: "mcpOAuthToken" });
interface RateLimitStore {
failures: Map<string, { count: number; resetAt: number }>;
timer: ReturnType<typeof setInterval> | null;
}
const TOKEN_RATE_WINDOW_MS = 60_000;
const TOKEN_MAX_FAILURES = 10;
declare global {
var mcpTokenRateLimit: RateLimitStore | undefined;
}
const store =
globalThis.mcpTokenRateLimit ??
(globalThis.mcpTokenRateLimit = {
failures: new Map(),
timer: null,
});
const tokenFailures = store.failures;
if (!store.timer) {
store.timer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of tokenFailures) {
if (entry.resetAt < now) tokenFailures.delete(key);
}
}, TOKEN_RATE_WINDOW_MS);
store.timer.unref();
}
function getClientIp(req: Request): string {
const forwarded = req.headers.get("x-forwarded-for");
return forwarded?.split(",")[0]?.trim() ?? "unknown";
}
function recordTokenFailure(ip: string) {
const now = Date.now();
const existing = tokenFailures.get(ip);
if (!existing || existing.resetAt < now) {
tokenFailures.set(ip, { count: 1, resetAt: now + TOKEN_RATE_WINDOW_MS });
return;
}
existing.count++;
}
export async function POST(req: Request) {
const ip = getClientIp(req);
const now = Date.now();
const entry = tokenFailures.get(ip);
if (entry && entry.resetAt >= now && entry.count >= TOKEN_MAX_FAILURES) {
return Response.json(
{ error: "rate_limited", error_description: "Too many requests, try again later" },
{ status: 429, headers: { "Retry-After": "60" } },
);
}
let params: Record<string, string>;
try {
const body = await req.formData().catch(() => null);
params = body
? (Object.fromEntries(body.entries()) as Record<string, string>)
: ((await req.json()) as Record<string, string>);
} catch {
return Response.json(
{ error: "invalid_request", error_description: "Could not parse request body" },
{ status: 400 },
);
}
const { grant_type, code, code_verifier, redirect_uri } = params;
if (grant_type !== "authorization_code") {
return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
}
if (!code || !code_verifier) {
return Response.json(
{ error: "invalid_request", error_description: "Missing code or code_verifier" },
{ status: 400 },
);
}
if (code.length > 256 || code_verifier.length > 256) {
return Response.json({ error: "invalid_request", error_description: "Parameter too long" }, { status: 400 });
}
const authCode = consumeAuthCode(code);
if (!authCode) {
recordTokenFailure(ip);
return Response.json(
{ error: "invalid_grant", error_description: "Invalid or expired authorization code" },
{ status: 400 },
);
}
if (redirect_uri && redirect_uri !== authCode.redirectUri) {
recordTokenFailure(ip);
return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 });
}
if (!verifyPkce(code_verifier, authCode.codeChallenge, authCode.codeChallengeMethod)) {
recordTokenFailure(ip);
return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 });
}
try {
const id = randomBytes(4).toString("hex");
const randomToken = generateSecureRandomToken(24);
const hashedToken = await hashPasswordAsync(randomToken);
await db.insert(apiKeys).values({
id,
apiKey: hashedToken,
userId: authCode.userId,
});
const accessToken = `${id}.${randomToken}`;
return Response.json({
access_token: accessToken,
token_type: "Bearer",
scope: "mcp:tools",
});
} catch (error) {
logger.error("Failed to create API key during OAuth token exchange", {
error: error instanceof Error ? error.message : String(error),
});
return Response.json({ error: "server_error", error_description: "Failed to issue access token" }, { status: 500 });
}
}
+1 -2
View File
@@ -44,8 +44,7 @@ const handler = auth(async (req) => {
const response = await fetchRequestHandler({
endpoint: trpcPath,
router: appRouter,
// mcp-handler globally augments Request with `auth?: AuthInfo`, conflicting with NextAuth's `auth: Session | null`
req: req as unknown as Request,
req,
createContext: () => createTRPCContext({ session, headers: req.headers }),
onError({ error, path, type }) {
logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error }));
@@ -2,7 +2,6 @@ import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
@@ -20,7 +19,7 @@ export const createItemCallback =
(previous: Board): Board => {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
@@ -29,7 +28,7 @@ export const createItemCallback =
id: id ?? createId(),
kind,
options,
layouts: createItemLayouts(previous, firstSection, kind),
layouts: createItemLayouts(previous, firstSection),
integrationIds,
advancedOptions: {
title: null,
@@ -49,20 +48,15 @@ export const createItemCallback =
};
};
const defaultItemSize = { width: 1, height: 1 };
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => {
const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayout[] => {
const layouts = getBoardLayouts(board);
const itemSize = getWidgetItemSize(kind);
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
? getFirstEmptyPosition(elements, boardLayout.columnCount)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -70,8 +64,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
}
return {
width: itemSize.width,
height: itemSize.height,
width: 1,
height: 1,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
@@ -1,155 +0,0 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -1,116 +0,0 @@
.wrapper {
position: absolute;
left: 10px;
right: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
z-index: 200;
pointer-events: auto;
opacity: 0;
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
.panel {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
}
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
backdrop-filter: blur(8px);
overflow: hidden;
}
.nameSection {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 0;
}
.widgetIcon {
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.integrations {
flex-shrink: 0;
}
.integrationAvatar {
border: none;
}
.metadata {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-left: auto;
}
.metadataItem {
@mixin dark {
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
}
@mixin light {
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
}
display: flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.metadataLabel {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.metadataValue {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.wrapper {
display: none;
}
}
@@ -1,86 +0,0 @@
"use client";
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Translator } from "./widget-hover-metadata";
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
import classes from "./widget-hover-overlay.module.css";
interface WidgetHoverOverlayProps {
item: SectionItem;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode();
const settings = useSettings();
const t = useI18n();
const { definition } = widgetImports[item.kind];
const WidgetIcon = definition.icon;
const translate = ((key: string) => t(key as never)) as Translator;
const displayName = resolveWidgetDisplayName(item, settings, translate);
const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id),
);
const metadataItems = collectWidgetHoverMetadata(
item,
settings,
connectedIntegrations.map((integration) => integration.kind),
translate,
);
const hasIntegrations = connectedIntegrations.length > 0;
const hasMetadata = metadataItems.length > 0;
if (isEditMode) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
<Text className={classes.name} title={displayName}>
{displayName}
</Text>
</div>
{hasIntegrations && (
<Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar
src={getIconUrl(integration.kind)}
size={20}
radius="sm"
className={classes.integrationAvatar}
/>
</Tooltip>
))}
</Avatar.Group>
)}
{hasMetadata && (
<Group className={classes.metadata} gap={4}>
{metadataItems.map((metadataItem) => (
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
<span className={classes.metadataLabel}>{metadataItem.label}</span>
<span className={classes.metadataValue}>{metadataItem.value}</span>
</div>
))}
</Group>
)}
</div>
</div>
);
};
@@ -2,12 +2,8 @@ import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
@@ -15,7 +11,6 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -23,7 +18,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
return [...items, ...innerSections].sort((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset;
}
@@ -35,7 +30,7 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
))}
</>
);
@@ -44,12 +39,12 @@ export const SectionContent = () => {
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
const Item = ({ item, innerRef, integrations }: ItemProps) => {
const Item = ({ item, innerRef }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<GridStackItem
key={item.id}
@@ -64,7 +59,6 @@ const Item = ({ item, innerRef, integrations }: ItemProps) => {
minWidth={minWidth}
minHeight={minHeight}
>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
@@ -57,9 +57,6 @@ export const categoryTranslationKeys: Record<string, string> = {
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
};
export interface IntegrationGridItem {
@@ -1,7 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core";
@@ -16,21 +15,8 @@ const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
const useClientPathname = () => {
const pathname = usePathname();
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return { pathname, isClient };
};
const NavLinkHref = (props: NavigationLinkHref) => {
const { pathname, isClient } = useClientPathname();
const pathname = usePathname();
const tourId = props["data-onboarding-tour-id"];
const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
@@ -40,15 +26,15 @@ const NavLinkHref = (props: NavigationLinkHref) => {
label={props.label}
leftSection={props.icon}
href={props.href}
active={isClient && pathMatches(pathname, props.href)}
active={pathname === props.href || pathname.startsWith(`${props.href}/`)}
/>
);
return <TourTarget id={tourId}>{link}</TourTarget>;
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const { pathname, isClient } = useClientPathname();
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
const pathname = usePathname();
const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
const nav = (
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
{props.items.map((item) => (
-4
View File
@@ -102,10 +102,6 @@
overflow-y: auto;
}
.grid-stack > .grid-stack-item[data-type="item"] {
overflow: visible;
}
.grid-stack.grid-stack-animate {
transition: none;
}
+1 -4
View File
@@ -9,12 +9,10 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./query-cache": "./src/query-cache.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
"./mcp": "./src/mcp.ts"
"./open-api": "./src/open-api.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -52,7 +50,6 @@
"react": "catalog:",
"react-dom": "catalog:",
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"zod": "catalog:"
},
-36
View File
@@ -1,36 +0,0 @@
export { createTRPCContext } from "./trpc";
import { createTRPCRouter } from "./trpc";
import { appRouter as appRouterForApps } from "./router/app";
import { apiKeysRouter } from "./router/apiKeys";
import { boardRouter } from "./router/board";
import { dockerRouter } from "./router/docker/docker-router";
import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { smartHomeRouter } from "./router/widgets/smart-home";
export const mcpRouter = createTRPCRouter({
app: appRouterForApps,
apiKeys: apiKeysRouter,
board: boardRouter,
docker: dockerRouter,
icon: iconsRouter,
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
calendar: calendarRouter,
dnsHole: dnsHoleRouter,
downloads: downloadsRouter,
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
smartHome: smartHomeRouter,
});
-2
View File
@@ -3,7 +3,6 @@ import { generateOpenApiDocument } from "trpc-to-openapi";
import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
import { appRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { infoRouter } from "./router/info";
import { inviteRouter } from "./router/invite";
import { userRouter } from "./router/user";
@@ -11,7 +10,6 @@ import { createTRPCRouter } from "./trpc";
export const openApiRouter = createTRPCRouter({
appRouter,
boardRouter,
infoRouter,
inviteRouter,
userRouter,
-34
View File
@@ -1,34 +0,0 @@
import type { QueryKey } from "@tanstack/react-query";
export const queryCacheRetentionMs = 1000 * 60 * 60 * 24;
export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5;
export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
let activeQueryCacheBoardId: string | null = null;
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
activeQueryCacheBoardId = boardId;
};
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
if (!Array.isArray(first)) return null;
const parts = first.filter((part): part is string => typeof part === "string");
return parts.length > 0 ? parts : null;
};
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
return persistableQueryPaths.has(path.join("."));
};
-1
View File
@@ -24,7 +24,6 @@ export const appRouter = createTRPCRouter({
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
+29 -49
View File
@@ -1,8 +1,7 @@
import { z } from "zod/v4";
import { randomBytes } from "crypto";
import { hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { generateSecureRandomToken } from "@homarr/common/server";
import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
@@ -10,58 +9,39 @@ import { apiKeys } from "@homarr/db/schema";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const apiKeysRouter = createTRPCRouter({
getAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: { enabled: true, description: "List all API keys (admin only)" },
})
.query(() => {
return db.query.apiKeys.findMany({
columns: {
id: true,
apiKey: false,
},
with: {
user: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
getAll: permissionRequiredProcedure.requiresPermission("admin").query(() => {
return db.query.apiKeys.findMany({
columns: {
id: true,
apiKey: false,
},
with: {
user: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
},
});
}),
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Create a new API key for the current user (admin only)",
},
})
.mutation(async ({ ctx }) => {
const id = randomBytes(4).toString("hex");
const token = generateSecureRandomToken(24);
const hashedToken = await hashPasswordAsync(token);
await db.insert(apiKeys).values({
id,
apiKey: hashedToken,
userId: ctx.session.user.id,
});
return {
apiKey: `${id}.${token}`,
};
}),
});
}),
create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
const randomToken = generateSecureRandomToken(64);
const hashedRandomToken = await hashPasswordAsync(randomToken);
const id = createId();
await db.insert(apiKeys).values({
id,
apiKey: hashedRandomToken,
userId: ctx.session.user.id,
});
return {
apiKey: `${id}.${randomToken}`,
};
}),
delete: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Delete an API key by ID (admin only). REQUIRED: apiKeyId (string)",
},
})
.input(z.object({ apiKeyId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apiKeys).where(eq(apiKeys.id, input.apiKeyId)).limit(1);
+9 -92
View File
@@ -21,19 +21,7 @@ export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({
openapi: {
method: "GET",
path: "/api/apps/paginated",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description:
"List apps with pagination. OPTIONAL: search (string to filter by name), pageSize (number, default 10), page (number, default 1). All fields are optional — call with no arguments to get the first page",
},
})
.meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
const totalCount = await ctx.db.$count(apps, whereQuery);
@@ -53,40 +41,16 @@ export const appRouter = createTRPCRouter({
all: protectedProcedure
.input(z.void())
.output(z.array(selectAppSchema))
.meta({
openapi: {
method: "GET",
path: "/api/apps",
tags: ["apps"],
protect: true,
},
mcp: { enabled: true, description: "List all apps" },
})
.meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
orderBy: asc(apps.name),
});
}),
search: protectedProcedure
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.output(z.array(selectAppSchema))
.meta({
openapi: {
method: "GET",
path: "/api/apps/search",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description: "Search apps by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10)",
},
})
.meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } })
.query(({ ctx, input }) => {
return ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
@@ -98,14 +62,7 @@ export const appRouter = createTRPCRouter({
.input(z.void())
.output(
z.array(
selectAppSchema.pick({
id: true,
name: true,
iconUrl: true,
href: true,
pingUrl: true,
description: true,
}),
selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }),
),
)
.meta({
@@ -132,15 +89,7 @@ export const appRouter = createTRPCRouter({
byId: publicProcedure
.input(byIdSchema)
.output(selectAppSchema)
.meta({
openapi: {
method: "GET",
path: "/api/apps/{id}",
tags: ["apps"],
protect: true,
},
mcp: { enabled: true, description: "Get a single app by its ID. REQUIRED: id (app ID string)" },
})
.meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.query(async ({ ctx, input }) => {
const repository = new AppRepository(ctx.db, ctx.session?.user ?? null);
const app = await repository.getByIdAsync(input.id);
@@ -162,19 +111,7 @@ export const appRouter = createTRPCRouter({
.requiresPermission("app-create")
.input(appManageSchema)
.output(z.object({ appId: z.string() }).and(selectAppSchema))
.meta({
openapi: {
method: "POST",
path: "/api/apps",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description:
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
},
})
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
.mutation(async ({ ctx, input }) => {
const id = createId();
const insertValues = {
@@ -209,19 +146,7 @@ export const appRouter = createTRPCRouter({
.requiresPermission("app-modify-all")
.input(convertIntersectionToZodObject(appEditSchema))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/apps/{id}",
tags: ["apps"],
protect: true,
},
mcp: {
enabled: true,
description:
"Update an existing app. REQUIRED: id (app ID), name, iconUrl, href. OPTIONAL: description (string or null), pingUrl (URL or empty string)",
},
})
.meta({ openapi: { method: "PATCH", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
@@ -248,15 +173,7 @@ export const appRouter = createTRPCRouter({
delete: permissionRequiredProcedure
.requiresPermission("app-full-all")
.output(z.void())
.meta({
openapi: {
method: "DELETE",
path: "/api/apps/{id}",
tags: ["apps"],
protect: true,
},
mcp: { enabled: true, description: "Delete an app by ID. REQUIRED: id (app ID string)" },
})
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
+70 -217
View File
@@ -19,7 +19,6 @@ import {
integrationGroupPermissions,
integrationItems,
integrationUserPermissions,
integrations,
itemLayouts,
items,
layouts,
@@ -40,7 +39,6 @@ import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
import {
addItemToBoardSchema,
boardByNameSchema,
boardChangeVisibilitySchema,
boardCreateSchema,
@@ -50,7 +48,6 @@ import {
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
boardSaveSchema,
boardSummarySchema,
} from "@homarr/validation/board";
import { byIdSchema } from "@homarr/validation/common";
import { zodUnionFromArray } from "@homarr/validation/enums";
@@ -117,89 +114,78 @@ export const boardRouter = createTRPCRouter({
where: boardWhere,
});
}),
getAllBoards: publicProcedure
.input(z.void())
.output(z.array(boardSummarySchema))
.meta({
openapi: { method: "GET", path: "/api/boards", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"List all boards the current user can access. Returns id, name, logoImageUrl, isPublic, creator, isHome and isMobileHome flags",
getAllBoards: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
})
.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
id: true,
name: true,
logoImageUrl: true,
isPublic: true,
},
with: {
creator: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
id: true,
name: true,
logoImageUrl: true,
isPublic: true,
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
with: {
creator: {
columns: {
id: true,
name: true,
image: true,
email: true,
},
},
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
// Allow viewing all boards if the user has the permission
where: ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
});
return dbBoards.map((board) => ({
...board,
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
},
// Allow viewing all boards if the user has the permission
where: ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
),
});
return dbBoards.map((board) => ({
...board,
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
@@ -276,16 +262,7 @@ export const boardRouter = createTRPCRouter({
}),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.meta({
openapi: { method: "POST", path: "/api/boards", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId }. Requires board-create permission",
},
})
.input(boardCreateSchema)
.output(z.object({ boardId: z.string() }))
.mutation(async ({ ctx, input }) => {
const boardId = createId();
@@ -570,22 +547,11 @@ export const boardRouter = createTRPCRouter({
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
deleteBoard: protectedProcedure
.meta({
openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Delete a board by its ID. Requires full permission on the board. Use board_getAllBoards to find the board ID",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
@@ -1361,119 +1327,6 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
addItem: protectedProcedure
.meta({
openapi: { method: "POST", path: "/api/boards/items", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Add a widget/app item to a board. Automatically places it in the first empty section at the next free grid position. Provide boardId (from board_getAllBoards), kind (widget type like 'app', 'weather', etc.), optional options map, and optional integrationIds array. Returns { itemId }",
},
})
.input(addItemToBoardSchema)
.output(z.object({ itemId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
columns: { id: true },
where: inArray(integrations.id, input.integrationIds),
});
const validIds = new Set(existing.map((row) => row.id));
const invalid = input.integrationIds.filter((id) => !validIds.has(id));
if (invalid.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
}
}
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
with: {
sections: true,
layouts: true,
items: { with: { layouts: true } },
},
});
if (!board) {
throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
}
const emptySection = board.sections
.filter((s) => s.kind === "empty")
.toSorted((a, b) => (a.yOffset ?? 0) - (b.yOffset ?? 0))[0];
if (!emptySection) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no empty section to place items in" });
}
const itemId = createId();
await ctx.db.insert(items).values({
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
});
const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
for (const layout of board.layouts) {
const existingInSection = board.items
.flatMap((item) => item.layouts)
.filter((il) => il.sectionId === emptySection.id && il.layoutId === layout.id);
const occupied: boolean[][] = [];
for (const il of existingInSection) {
for (let y = il.yOffset; y < il.yOffset + il.height; y++) {
while (occupied.length <= y) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
for (let x = il.xOffset; x < il.xOffset + il.width; x++) {
occupied[y]![x] = true;
}
}
}
let placed = false;
for (let y = 0; y < 9999 && !placed; y++) {
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
for (let x = 0; x < layout.columnCount && !placed; x++) {
if (!occupied[y]![x]) {
layoutRows.push({
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: 1,
height: 1,
});
placed = true;
}
}
}
if (!placed) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Board section is full, no free grid position available",
});
}
}
if (layoutRows.length > 0) {
await ctx.db.insert(itemLayouts).values(layoutRows);
}
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
return { itemId };
}),
});
/**
@@ -16,18 +16,10 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
export const dockerRouter = createTRPCRouter({
getContainers: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "List all Docker containers with their state, image, CPU/memory usage, and ports",
},
})
.concat(dockerMiddleware())
.query(async () => {
const innerHandler = dockerContainersRequestHandler.handler({});
const result = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const result = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data, timestamp } = result;
@@ -58,12 +50,6 @@ export const dockerRouter = createTRPCRouter({
}),
startAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Start Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
},
})
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -79,12 +65,6 @@ export const dockerRouter = createTRPCRouter({
}),
stopAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "Stop Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
},
})
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -100,13 +80,6 @@ export const dockerRouter = createTRPCRouter({
}),
restartAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Restart Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
},
})
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -122,13 +95,6 @@ export const dockerRouter = createTRPCRouter({
}),
removeAll: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Remove/delete Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
},
})
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -144,13 +110,6 @@ export const dockerRouter = createTRPCRouter({
}),
logs: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Fetch logs from a Docker container. REQUIRED: id (container ID from docker_getContainers). OPTIONAL: tail (number 1-1000, default 200)",
},
})
.concat(dockerMiddleware())
.input(
z.object({
+20 -29
View File
@@ -5,35 +5,26 @@ import { iconsFindSchema } from "@homarr/validation/icons";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const iconsRouter = createTRPCRouter({
findIcons: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Search for icons by name across all icon repositories. OPTIONAL: searchText (string to filter), limitPerGroup (number 1-500, default 12). Call with no arguments to browse all icons",
},
})
.input(iconsFindSchema)
.query(async ({ ctx, input }) => {
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
},
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup,
findIcons: publicProcedure.input(iconsFindSchema).query(async ({ ctx, input }) => {
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
},
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup,
},
}),
countIcons: await ctx.db.$count(icons),
};
}),
},
}),
countIcons: await ctx.db.$count(icons),
};
}),
});
+1 -7
View File
@@ -7,13 +7,7 @@ export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
.input(z.void())
.output(z.object({ version: z.string() }))
.meta({
openapi: { method: "GET", path: "/api/info", tags: ["info"] },
mcp: {
enabled: true,
description: "Get Homarr server version information",
},
})
.meta({ openapi: { method: "GET", path: "/api/info", tags: ["info"] } })
.query(() => {
return {
version: packageJson.version,
@@ -45,72 +45,48 @@ const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({
getKinds: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"List all supported integration kinds (e.g. sonarr, radarr, overseerr, pihole, homeAssistant) with the secret fields each kind requires. Use this before creating an integration to know which 'kind' values are valid and what secrets to provide.",
},
})
.query(() => {
return objectEntries(integrationDefs).map(([kind, def]) => ({
kind,
name: def.name,
category: def.category,
requiredSecrets: def.secretKinds,
}));
}),
all: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error",
},
})
.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
all: protectedProcedure.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
});
const integrations = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
const integrations = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
});
return integrations
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
return integrations
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
@@ -211,19 +187,7 @@ export const integrationRouter = createTRPCRouter({
);
}),
search: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Search integrations by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10). Returns matching integrations with id (use as integrationId), name, kind, url, and permissions",
},
})
.input(
z.object({
query: z.string(),
limit: z.number().min(1).max(100).default(10),
}),
)
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({
where: like(integrations.name, `%${input.query}%`),
@@ -241,68 +205,52 @@ export const integrationRouter = createTRPCRouter({
},
});
}),
byId: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Get integration details by ID, including secrets metadata and linked app. REQUIRED: id (integration ID string)",
},
})
.input(byIdSchema)
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: {
columns: {
kind: true,
value: true,
updatedAt: true,
},
},
app: {
columns: {
id: true,
name: true,
iconUrl: true,
href: true,
},
byId: protectedProcedure.input(byIdSchema).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
secrets: {
columns: {
kind: true,
value: true,
updatedAt: true,
},
},
app: {
columns: {
id: true,
name: true,
iconUrl: true,
href: true,
},
},
},
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
secrets: integration.secrets.map((secret) => ({
kind: secret.kind,
// Only return the value if the secret is public, so for example the username
value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
updatedAt: secret.updatedAt,
})),
app: integration.app,
};
}),
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
secrets: integration.secrets.map((secret) => ({
kind: secret.kind,
// Only return the value if the secret is public, so for example the username
value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
updatedAt: secret.updatedAt,
})),
app: integration.app,
};
}),
create: permissionRequiredProcedure
.requiresPermission("integration-create")
.meta({
mcp: {
enabled: true,
description:
"Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it.",
},
})
.input(integrationCreateSchema)
.mutation(async ({ ctx, input }) => {
logger.info("Creating integration", {
@@ -367,6 +315,8 @@ export const integrationRouter = createTRPCRouter({
kind: input.kind,
url: input.url,
});
// Media request search is a first-class Spotlight action. Do not create a search engine for it.
}),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -470,27 +420,22 @@ export const integrationRouter = createTRPCRouter({
url: input.url,
});
}),
delete: protectedProcedure
.meta({
mcp: { enabled: true, description: "Delete an integration by ID. REQUIRED: id (integration ID string)" },
})
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -656,51 +601,30 @@ export const integrationRouter = createTRPCRouter({
});
}),
searchInIntegration: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Search within a specific integration (e.g. Overseerr, Jellyseerr). REQUIRED: integrationId (from integration_all), query (search string)",
},
})
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search")))
.input(z.object({ integrationId: z.string(), query: z.string() }))
.query(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query));
}),
mediaRequestSearchTargets: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List integrations that support media request search (Overseerr, Jellyseerr, Seerr)",
},
})
.query(async ({ ctx }) => {
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => {
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
return integrationsWithAccess.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return integrationsWithAccess.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
};
});
}),
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
};
});
}),
searchMediaRequests: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Search for movies and TV shows across Overseerr/Jellyseerr/Seerr. REQUIRED: query (search string). OPTIONAL: integrationIds (array of integration IDs — omit to search all accessible Overseerr/Jellyseerr/Seerr integrations). Returns results with integrationId, mediaId, and mediaType for use with getMediaRequestOptions and requestMedia",
},
})
.input(
z.object({
query: z.string(),
@@ -748,13 +672,6 @@ export const integrationRouter = createTRPCRouter({
return results.flat();
}),
getMediaRequestOptions: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Get available options (seasons/episodes for TV) before requesting media. REQUIRED: integrationId (Overseerr/Jellyseerr ID), mediaId (number from searchMediaRequests), mediaType ('tv' or 'movie'). Required step for TV shows — for movies you can skip this and go straight to requestMedia",
},
})
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
@@ -762,13 +679,6 @@ export const integrationRouter = createTRPCRouter({
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Submit a media request to Overseerr/Jellyseerr/Seerr. REQUIRED: integrationId (Overseerr/Jellyseerr ID), mediaId (number), mediaType ('tv' or 'movie'). OPTIONAL: seasons (array of season numbers — for TV only, omit to request all). Workflow: searchMediaRequests → (getMediaRequestOptions for TV) → requestMedia",
},
})
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
+4 -34
View File
@@ -20,21 +20,11 @@ export const inviteRouter = createTRPCRouter({
id: true,
expirationDate: true,
})
.extend({
creator: z.object({ name: z.string().nullable(), id: z.string() }),
}),
.extend({ creator: z.object({ name: z.string().nullable(), id: z.string() }) }),
),
)
.input(z.undefined())
.meta({
openapi: {
method: "GET",
path: "/api/invites",
tags: ["invites"],
protect: true,
},
mcp: { enabled: true, description: "List all user invites" },
})
.meta({ openapi: { method: "GET", path: "/api/invites", tags: ["invites"], protect: true } })
.query(async ({ ctx }) => {
throwIfCredentialsDisabled();
return await ctx.db.query.invites.findMany({
@@ -60,19 +50,7 @@ export const inviteRouter = createTRPCRouter({
}),
)
.output(z.object({ id: z.string(), token: z.string() }))
.meta({
openapi: {
method: "POST",
path: "/api/invites",
tags: ["invites"],
protect: true,
},
mcp: {
enabled: true,
description:
"Create a new user invite link. REQUIRED: expirationDate (ISO date string for when the invite expires). Returns the invite ID and token",
},
})
.meta({ openapi: { method: "POST", path: "/api/invites", tags: ["invites"], protect: true } })
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const id = createId();
@@ -98,15 +76,7 @@ export const inviteRouter = createTRPCRouter({
}),
)
.output(z.undefined())
.meta({
openapi: {
method: "DELETE",
path: "/api/invites/{id}",
tags: ["invites"],
protect: true,
},
mcp: { enabled: true, description: "Delete an invite by ID. REQUIRED: id (invite ID string)" },
})
.meta({ openapi: { method: "DELETE", path: "/api/invites/{id}", tags: ["invites"], protect: true } })
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const dbInvite = await ctx.db.query.invites.findFirst({
-54
View File
@@ -1,54 +0,0 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { createQueryCacheChannel } from "@homarr/redis";
import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheKeyInput = z.object({
boardId: z.string().min(1),
key: z.string().min(1).max(4096),
});
const queryCacheSetInput = queryCacheKeyInput.extend({
value: z.string().max(queryCacheMaxValueBytes * 2),
});
const createChannel = (input: z.infer<typeof queryCacheKeyInput>, userId: string) =>
createQueryCacheChannel({
userId,
boardId: input.boardId,
key: input.key,
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
};
export const queryCacheRouter = createTRPCRouter({
getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return await channel.getAsync();
}),
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return {
stored: await channel.setAsync(input.value),
};
}),
removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
await channel.removeAsync();
}),
});
+16 -143
View File
@@ -144,19 +144,7 @@ export const userRouter = createTRPCRouter({
}),
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: {
method: "POST",
path: "/api/users",
tags: ["users"],
protect: true,
},
mcp: {
enabled: true,
description:
"Create a new user account (admin only). REQUIRED: username (string), password (string), confirmPassword (must match password). OPTIONAL: email (string or null)",
},
})
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
.input(convertIntersectionToZodObject(userCreateSchema))
.output(z.void())
.mutation(async ({ ctx, input }) => {
@@ -171,14 +159,7 @@ export const userRouter = createTRPCRouter({
}),
setProfileImage: protectedProcedure
.output(z.void())
.meta({
openapi: {
method: "PUT",
path: "/api/users/profileImage",
tags: ["users"],
protect: true,
},
})
.meta({ openapi: { method: "PUT", path: "/api/users/profileImage", tags: ["users"], protect: true } })
.input(
z.object({
userId: z.string(),
@@ -224,26 +205,8 @@ export const userRouter = createTRPCRouter({
getAll: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.void())
.output(
z.array(
selectUserSchema.pick({
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
}),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/users",
tags: ["users"],
protect: true,
},
mcp: { enabled: true, description: "List all users (admin only)" },
})
.output(z.array(selectUserSchema.pick({ id: true, name: true, email: true, emailVerified: true, image: true })))
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
.query(({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {
@@ -258,24 +221,8 @@ export const userRouter = createTRPCRouter({
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
.output(
z.array(
selectUserSchema.pick({
id: true,
name: true,
image: true,
email: true,
}),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/users/selectable",
tags: ["users"],
protect: true,
},
})
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
.meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
.query(({ ctx, input }) => {
return ctx.db.query.users.findMany({
columns: {
@@ -295,24 +242,8 @@ export const userRouter = createTRPCRouter({
limit: z.number().min(1).max(100).default(10),
}),
)
.output(
z.array(
selectUserSchema.pick({
id: true,
name: true,
image: true,
email: true,
}),
),
)
.meta({
openapi: {
method: "POST",
path: "/api/users/search",
tags: ["users"],
protect: true,
},
})
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
.meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
.query(async ({ input, ctx }) => {
const dbUsers = await ctx.db.query.users.findMany({
columns: {
@@ -352,15 +283,7 @@ export const userRouter = createTRPCRouter({
completedBoardTour: true,
}),
)
.meta({
openapi: {
method: "GET",
path: "/api/users/{userId}",
tags: ["users"],
protect: true,
},
mcp: { enabled: true, description: "Get user details by user ID. REQUIRED: userId (string)" },
})
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
.query(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
@@ -402,14 +325,7 @@ export const userRouter = createTRPCRouter({
editProfile: protectedProcedure
.input(userEditProfileSchema)
.output(z.void())
.meta({
openapi: {
method: "PUT",
path: "/api/users/profile",
tags: ["users"],
protect: true,
},
})
.meta({ openapi: { method: "PUT", path: "/api/users/profile", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
@@ -453,15 +369,7 @@ export const userRouter = createTRPCRouter({
delete: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(z.void())
.meta({
openapi: {
method: "DELETE",
path: "/api/users/{userId}",
tags: ["users"],
protect: true,
},
mcp: { enabled: true, description: "Delete a user by ID. REQUIRED: userId (string)" },
})
.meta({ openapi: { method: "DELETE", path: "/api/users/{userId}", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
@@ -476,14 +384,7 @@ export const userRouter = createTRPCRouter({
changePassword: protectedProcedure
.input(convertIntersectionToZodObject(userChangePasswordApiSchema))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/{userId}/changePassword",
tags: ["users"],
protect: true,
},
})
.meta({ openapi: { method: "PATCH", path: "/api/users/{userId}/changePassword", tags: ["users"], protect: true } })
.mutation(async ({ ctx, input }) => {
const user = ctx.session.user;
// Only admins can change other users' passwords
@@ -548,14 +449,7 @@ export const userRouter = createTRPCRouter({
changeHomeBoards: protectedProcedure
.input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() }))))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/changeHome",
tags: ["users"],
protect: true,
},
})
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users passwords
@@ -621,28 +515,14 @@ export const userRouter = createTRPCRouter({
changeSearchPreferences: protectedProcedure
.input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/search-preferences",
tags: ["users"],
protect: true,
},
})
.meta({ openapi: { method: "PATCH", path: "/api/users/search-preferences", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
await changeSearchPreferencesAsync(ctx.db, ctx.session, input);
}),
changeColorScheme: protectedProcedure
.input(userChangeColorSchemeSchema)
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/changeScheme",
tags: ["users"],
protect: true,
},
})
.meta({ openapi: { method: "PATCH", path: "/api/users/changeScheme", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
await ctx.db
.update(users)
@@ -699,14 +579,7 @@ export const userRouter = createTRPCRouter({
changeFirstDayOfWeek: protectedProcedure
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/firstDayOfWeek",
tags: ["users"],
protect: true,
},
})
.meta({ openapi: { method: "PATCH", path: "/api/users/firstDayOfWeek", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
// Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
@@ -1,29 +0,0 @@
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const audioStatsRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf"))
.query(async ({ ctx }) => {
const { kind } = ctx.integration;
const fetchByKind = {
navidrome: async () => {
const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {});
return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
},
audiobookshelf: async () => {
const handler = audiobookshelfRequestHandler.handler(
{ ...ctx.integration, kind: "audiobookshelf" as const },
{},
);
return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
},
} as const;
return { kind, data: await fetchByKind[kind]() };
}),
});
+1 -10
View File
@@ -18,13 +18,6 @@ const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get calendar events for upcoming and recent media releases. REQUIRED: integrationIds (array of integration IDs from integration_all, filter by kind sonarr/radarr/lidarr/readarr), year (number), month (number), releaseType (array of 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'",
},
})
.input(
z.object({
year: z.number(),
@@ -39,9 +32,7 @@ export const calendarRouter = createTRPCRouter({
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
events: data,
+1 -24
View File
@@ -14,21 +14,12 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp
export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
@@ -72,13 +63,6 @@ export const dnsHoleRouter = createTRPCRouter({
}),
enable: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
@@ -92,13 +76,6 @@ export const dnsHoleRouter = createTRPCRouter({
}),
disable: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Disable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single integration ID). OPTIONAL: duration (number of seconds for temporary disable — omit for permanent disable)",
},
})
.input(
z.object({
duration: z.number().optional(),
+17 -44
View File
@@ -18,13 +18,6 @@ const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
},
})
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
@@ -32,9 +25,7 @@ export const downloadsRouter = createTRPCRouter({
ctx.integrations.map(async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
@@ -77,23 +68,14 @@ export const downloadsRouter = createTRPCRouter({
};
});
}),
pause: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
);
}),
pause: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
);
}),
pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
@@ -105,23 +87,14 @@ export const downloadsRouter = createTRPCRouter({
}),
);
}),
resume: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
);
}),
resume: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
);
}),
resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
@@ -9,13 +9,6 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
},
})
.concat(
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
@@ -23,9 +16,7 @@ export const healthMonitoringRouter = createTRPCRouter({
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
@@ -41,11 +32,7 @@ export const healthMonitoringRouter = createTRPCRouter({
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
.subscription(({ ctx }) => {
return observable<{
integrationId: string;
healthInfo: SystemHealthMonitoring;
timestamp: Date;
}>((emit) => {
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
@@ -66,19 +53,10 @@ export const healthMonitoringRouter = createTRPCRouter({
});
}),
getClusterHealthStatus: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribeClusterHealthStatus: publicProcedure
-3
View File
@@ -7,7 +7,6 @@ export const widgetRouter = createTRPCRouter({
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
@@ -32,8 +31,6 @@ export const widgetRouter = createTRPCRouter({
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
});
@@ -13,21 +13,12 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp
export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
@@ -39,13 +30,8 @@ export const mediaRequestsRouter = createTRPCRouter({
}),
);
return results
.flatMap(({ data, integration }) =>
data.map((request) => ({
...request,
integrationId: integration.id,
})),
)
.toSorted((dataA, dataB) => {
.flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
.sort((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
@@ -83,21 +69,12 @@ export const mediaRequestsRouter = createTRPCRouter({
});
}),
getStats: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
@@ -111,42 +88,25 @@ export const mediaRequestsRouter = createTRPCRouter({
return {
stats: results.flatMap((result) => result.data.stats),
users: results
.map((result) =>
result.data.users.map((user) => ({
...user,
integration: result.integration,
})),
)
.map((result) => result.data.users.map((user) => ({ ...user, integration: result.integration })))
.flat()
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
.sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
};
}),
answerRequest: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.input(
z.object({
requestId: z.number(),
answer: z.enum(["approve", "decline"]),
}),
)
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
.mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = await createIntegrationAsync(integration);
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
const answerActions = {
approve: (id: number) => integrationInstance.approveRequestAsync(id),
decline: (id: number) => integrationInstance.declineRequestAsync(id),
} as const;
await answerActions[input.answer](input.requestId);
if (input.answer === "approve") {
await integrationInstance.approveRequestAsync(input.requestId);
await innerHandler.invalidateAsync();
return;
}
await integrationInstance.declineRequestAsync(input.requestId);
await innerHandler.invalidateAsync();
}),
});
@@ -14,13 +14,6 @@ const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get currently active streams from Plex/Jellyfin/Emby media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
},
})
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
@@ -29,9 +22,7 @@ export const mediaServerRouter = createTRPCRouter({
const innerHandler = mediaServerRequestHandler.handler(integration, {
showOnlyPlaying: input.showOnlyPlaying,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationKind: integration.kind,
@@ -1,12 +0,0 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data.data;
}),
});
+1 -24
View File
@@ -14,20 +14,11 @@ const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
export const smartHomeRouter = createTRPCRouter({
entityState: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get the current state of a Home Assistant entity (light, switch, sensor, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room', 'switch.fan')",
},
})
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribeEntityState: publicProcedure
@@ -51,13 +42,6 @@ export const smartHomeRouter = createTRPCRouter({
});
}),
switchEntity: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
@@ -70,13 +54,6 @@ export const smartHomeRouter = createTRPCRouter({
return success;
}),
executeAutomation: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
@@ -1,55 +0,0 @@
import { observable } from "@trpc/server/observable";
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeToDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.subscription(({ ctx }) => {
return observable<{
integrationId: string;
dashboard: UptimeKumaDashboardData;
timestamp: Date;
}>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
return innerHandler.subscribe((dashboard) => {
emit.next({
integrationId: integration.id,
dashboard,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
+1 -3
View File
@@ -1,7 +1,5 @@
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
import { queryCacheDefaultStaleTimeMs } from "./query-cache";
/**
* Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed
@@ -60,7 +58,7 @@ export const makeQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
staleTime: 5 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
-41
View File
@@ -1,41 +0,0 @@
import { expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { appRouter } from "../router/app";
import { infoRouter } from "../router/info";
import { inviteRouter } from "../router/invite";
import { createTRPCRouter } from "../trpc";
vi.mock("@homarr/auth", () => ({}));
const mcpTestRouter = createTRPCRouter({
app: appRouter,
info: infoRouter,
invite: inviteRouter,
});
test("MCP tools should be extractable from the router", () => {
const act = () => extractToolsFromProcedures(mcpTestRouter);
expect(act).not.toThrow();
});
test("MCP tools should contain expected procedures", () => {
const tools = extractToolsFromProcedures(mcpTestRouter);
const toolNames = tools.map((tool) => tool.name);
expect(tools.length).toBeGreaterThan(0);
expect(toolNames).toContain("app_all");
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");
});
test("MCP tools should have descriptions", () => {
const tools = extractToolsFromProcedures(mcpTestRouter);
for (const tool of tools) {
expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
}
});
-224
View File
@@ -1,224 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import {
isPersistableWidgetQueryKey,
queryCacheDefaultStaleTimeMs,
queryCacheMaxValueBytes,
queryCacheRetentionMs,
} from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => {
const values = new Map<string, { value: string; expiresAt: number }>();
const calls: {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}[] = [];
return {
values,
calls,
createQueryCacheChannel: vi.fn(
(options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => {
calls.push(options);
const storageKey = `${options.userId}:${options.boardId}:${options.key}`;
return {
getAsync: async () => {
const item = values.get(storageKey);
if (!item) return null;
if (Date.now() > item.expiresAt) {
values.delete(storageKey);
return null;
}
return item.value;
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) {
return false;
}
values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs });
return true;
},
removeAsync: async () => {
values.delete(storageKey);
},
};
},
),
};
});
vi.mock("@homarr/redis", () => ({
createQueryCacheChannel: redisMock.createQueryCacheChannel,
}));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => {
vi.useRealTimers();
redisMock.values.clear();
redisMock.calls.length = 0;
redisMock.createQueryCacheChannel.mockClear();
});
const createSession = (userId = createId()): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const insertUserAsync = async (db: Database, id: string) => {
await db.insert(users).values({ id });
};
describe("isPersistableWidgetQueryKey", () => {
test.each([
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
[[["app", "byId"], { type: "query" }], true],
[[["app", "byIds"], { type: "query" }], true],
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
])("matches persist filter for %j", (queryKey, expected) => {
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
});
});
describe("queryCacheRouter", () => {
test("stores, reads, and removes data for a board viewer", async () => {
const db = createDb();
const session = createSession();
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({
stored: true,
});
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await caller.removeItem({ boardId: "board-1", key: "query-key" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
test("does not touch Redis when board access is denied", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
redisMock.createQueryCacheChannel.mockClear();
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found");
expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled();
});
test("scopes anonymous public-board cache entries by anonymous user and board", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "anonymous",
boardId: "board-1",
key: "query-key",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("scopes signed-in cache entries by user and rejects oversized values", async () => {
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1);
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({
stored: false,
});
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "user-1",
boardId: "board-1",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("expires values through the Redis TTL path", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await vi.advanceTimersByTimeAsync(queryCacheRetentionMs + 1);
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
});
describe("query cache stale handling", () => {
test("uses TanStack Query dataUpdatedAt and staleTime to decide when cached data refetches", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z"));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: queryCacheDefaultStaleTimeMs,
},
},
});
const queryKey = [["widget", "example"], { input: { integrationIds: ["integration-1"] }, type: "query" }];
const queryFn = vi.fn().mockResolvedValue("fresh");
queryClient.setQueryData(queryKey, "cached", {
updatedAt: Date.now() - queryCacheDefaultStaleTimeMs - 1,
});
await expect(
queryClient.fetchQuery({
queryKey,
queryFn,
}),
).resolves.toBe("fresh");
expect(queryFn).toHaveBeenCalledOnce();
});
});
+2 -7
View File
@@ -8,7 +8,6 @@
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { McpMeta } from "trpc-to-mcp";
import type { OpenApiMeta } from "trpc-to-openapi";
import { ZodError } from "zod/v4";
@@ -40,11 +39,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
const session = opts.session;
const source = opts.headers.get("x-trpc-source") ?? "unknown";
logger.info("Received tRPC request", {
source,
userId: session?.user.id,
userName: session?.user.name,
});
logger.info("Received tRPC request", { source, userId: session?.user.id, userName: session?.user.name });
return {
session,
@@ -61,7 +56,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
*/
const t = initTRPC
.context<typeof createTRPCContext>()
.meta<OpenApiMeta & McpMeta>()
.meta<OpenApiMeta>()
.create({
transformer: superjson,
errorFormatter: ({ shape, error }) => ({
+1 -14
View File
@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
import { updateBoardName } from "./updater";
@@ -29,8 +28,6 @@ export const BoardProvider = ({
},
);
setActiveQueryCacheBoardId(data.id);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
@@ -45,16 +42,6 @@ export const BoardProvider = ({
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setActiveQueryCacheBoardId(data.id);
return () => {
if (getActiveQueryCacheBoardId() === data.id) {
setActiveQueryCacheBoardId(null);
}
};
}, [data.id]);
return (
<BoardContext.Provider
value={{
@@ -86,7 +73,7 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -27,10 +27,6 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
overseerr: ["overseerr"],
dashDot: ["dashdot", "dash-dot", "dash."],
speedtestTracker: ["speedtest-tracker"],
uptimeKuma: ["uptime-kuma"],
audiobookshelf: ["audiobookshelf"],
navidrome: ["navidrome"],
paperlessNgx: ["paperless-ngx", "paperless"],
searchCh: ["search-ch"],
gitHubContainerRegistry: ["ghcr"],
linuxServerIO: ["linuxserver"],
-38
View File
@@ -17,7 +17,6 @@ export const integrationSecretKindObject = {
privateKey: { isPublic: false, multiline: true },
githubAppId: { isPublic: true, multiline: false },
githubInstallationId: { isPublic: true, multiline: false },
slug: { isPublic: true, multiline: false },
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
@@ -402,14 +401,6 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
defaultPort: 2283,
},
paperlessNgx: {
name: "Paperless-ngx",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg",
category: ["documents"],
documentationUrl: null,
defaultPort: 8000,
},
tracearr: {
name: "Tracearr",
secretKinds: [["apiKey"]],
@@ -426,31 +417,6 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
defaultPort: 80,
},
uptimeKuma: {
name: "Uptime Kuma",
secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg",
category: ["uptimeMonitoring"],
documentationUrl: null,
defaultPort: 3001,
},
audiobookshelf: {
name: "Audiobookshelf",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg",
category: ["mediaLibrary"],
documentationUrl: null,
defaultPort: 13378,
apiKeySettingsPath: "/account",
},
navidrome: {
name: "Navidrome",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
category: ["mediaLibrary"],
documentationUrl: null,
defaultPort: 4533,
},
umami: {
name: "Umami",
secretKinds: [["username", "password"], ["apiKey"]],
@@ -494,7 +460,6 @@ export const integrationDefs = {
"notifications",
"smartHomeServer",
"ups",
"uptimeMonitoring",
],
documentationUrl: null,
},
@@ -583,9 +548,6 @@ export const integrationCategories = [
"speedtest",
"analytics",
"ups",
"documents",
"mediaLibrary",
"uptimeMonitoring",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
@@ -33,11 +33,8 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
timetable: ["searchCh"],
"immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"],
paperlessNgx: ["paperlessNgx"],
tracearr: ["tracearr"],
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
anchorNote: ["anchor"],
};
-9
View File
@@ -35,11 +35,8 @@ export const widgetKinds = [
"timetable",
"immich-serverStats",
"immich-albumCarousel",
"paperlessNgx",
"tracearr",
"speedtestTracker",
"uptimeKuma",
"audioStats",
"umami",
"ups",
"beszelSystemTable",
@@ -48,9 +45,3 @@ export const widgetKinds = [
"beszelSystemStats",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 },
};
@@ -1,133 +0,0 @@
import { 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 {
audiobookshelfLibrariesResponseSchema,
audiobookshelfLibraryStatsSchema,
audiobookshelfListeningStatsSchema,
audiobookshelfOnlineUsersResponseSchema,
} from "./audiobookshelf-types";
import type {
AudiobookshelfDashboardData,
AudiobookshelfLibrary,
AudiobookshelfLibraryStats,
} from "./audiobookshelf-types";
const mediaTypeCountField = {
book: "totalAudiobooks",
podcast: "totalPodcasts",
} as const satisfies Record<
AudiobookshelfLibrary["mediaType"],
keyof Pick<AudiobookshelfDashboardData, "totalAudiobooks" | "totalPodcasts">
>;
export class AudiobookshelfIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const healthResponse = await input.fetchAsync(this.url("/healthcheck"));
if (!healthResponse.ok) {
return TestConnectionError.StatusResult(healthResponse);
}
const authorizeResponse = await input.fetchAsync(this.url("/api/authorize"), {
method: "POST",
headers: this.getAuthHeaders(),
});
if (!authorizeResponse.ok) {
return TestConnectionError.StatusResult(authorizeResponse);
}
return { success: true };
}
public async getDashboardDataAsync(): Promise<AudiobookshelfDashboardData> {
const [libraries, listeningStats, activeSessions] = await Promise.all([
this.getLibrariesAsync(),
this.getListeningStatsAsync(),
this.getActiveSessionsCountAsync(),
]);
const statsByLibrary = await Promise.all(
libraries.map(async (library) => ({
mediaType: library.mediaType,
stats: await this.getLibraryStatsAsync(library.id),
})),
);
const counts = {
totalAudiobooks: 0,
totalPodcasts: 0,
};
for (const item of statsByLibrary) {
const countField = mediaTypeCountField[item.mediaType];
counts[countField] += item.stats.totalItems;
}
return {
libraryCount: libraries.length,
totalAudiobooks: counts.totalAudiobooks,
totalPodcasts: counts.totalPodcasts,
totalListeningTimeSeconds: listeningStats.totalTime,
activeSessions,
};
}
private async getLibrariesAsync(): Promise<AudiobookshelfLibrary[]> {
const response = await this.getAsync("/api/libraries");
if (!response.ok) {
throw new ResponseError(response);
}
return audiobookshelfLibrariesResponseSchema.parse(await response.json()).libraries;
}
private async getLibraryStatsAsync(libraryId: string): Promise<AudiobookshelfLibraryStats> {
const response = await this.getAsync(`/api/libraries/${libraryId}/stats`);
if (!response.ok) {
throw new ResponseError(response);
}
return audiobookshelfLibraryStatsSchema.parse(await response.json());
}
private async getListeningStatsAsync() {
const response = await this.getAsync("/api/me/listening-stats");
if (!response.ok) {
throw new ResponseError(response);
}
return audiobookshelfListeningStatsSchema.parse(await response.json());
}
private async getActiveSessionsCountAsync(): Promise<number> {
const response = await this.getAsync("/api/users/online");
if (!response.ok) {
throw new ResponseError(response);
}
return audiobookshelfOnlineUsersResponseSchema.parse(await response.json()).openSessions.length;
}
private async getAsync(path: `/api/${string}` | "/healthcheck") {
return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
});
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
Accept: "application/json",
};
}
}
@@ -1,36 +0,0 @@
import { z } from "zod/v4";
export const audiobookshelfLibrarySchema = z.object({
id: z.string(),
name: z.string(),
mediaType: z.enum(["book", "podcast"]),
});
export const audiobookshelfLibrariesResponseSchema = z.object({
libraries: z.array(audiobookshelfLibrarySchema),
});
export const audiobookshelfLibraryStatsSchema = z.object({
totalItems: z.number(),
totalDuration: z.number().optional(),
totalSize: z.number().optional(),
});
export const audiobookshelfListeningStatsSchema = z.object({
totalTime: z.number(),
});
export const audiobookshelfOnlineUsersResponseSchema = z.object({
openSessions: z.array(z.unknown()),
});
export interface AudiobookshelfDashboardData {
libraryCount: number;
totalAudiobooks: number;
totalPodcasts: number;
totalListeningTimeSeconds: number;
activeSessions: number;
}
export type AudiobookshelfLibrary = z.infer<typeof audiobookshelfLibrarySchema>;
export type AudiobookshelfLibraryStats = z.infer<typeof audiobookshelfLibraryStatsSchema>;
@@ -3,7 +3,6 @@ import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
@@ -32,14 +31,12 @@ import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integrati
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
import { PeaNutIntegration } from "../peanut/peanut-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
@@ -52,7 +49,6 @@ import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-trac
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -127,12 +123,8 @@ export const integrationCreators = {
glances: GlancesIntegration,
searchCh: SearchChIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
-7
View File
@@ -33,9 +33,6 @@ export { CoolifyIntegration } from "./coolify/coolify-integration";
export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { AudiobookshelfIntegration } from "./audiobookshelf/audiobookshelf-integration";
export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
@@ -64,7 +61,6 @@ export type {
export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types";
export type { Notification } from "./interfaces/notifications/notification-types";
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
export type {
AnchorNote,
AnchorNotePermission,
@@ -74,9 +70,6 @@ export type {
} from "./anchor/anchor-types";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types";
export type { NavidromeDashboardData } from "./navidrome/navidrome-types";
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
// Schemas
@@ -1,171 +0,0 @@
import { ParseError, ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
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 { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types";
import { subsonicResponseSchema } from "./navidrome-types";
const logger = createLogger({ module: "navidromeIntegration" });
const subsonicVersion = "1.16.1";
const subsonicClient = "homarr";
const albumListPageSize = 500;
const maxAlbumListPages = 20;
const emptyLibraryMessage = "Library not found or empty";
const subsonicAuthErrorCodes = new Set([40, 41]);
const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
[value].flat().filter((item): item is TValue => item !== undefined);
export class NavidromeIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/rest/ping.view", this.getAuthParams());
const response = await input.fetchAsync(url);
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
const parsed = subsonicResponseSchema.safeParse(await response.json());
if (!parsed.success) {
return TestConnectionError.ParseResult(new ParseError("Invalid Subsonic response", { cause: parsed.error }));
}
const subsonicResponse = parsed.data["subsonic-response"];
if (subsonicResponse.status === "failed") {
const errorCode = subsonicResponse.error?.code;
if (errorCode !== undefined && subsonicAuthErrorCodes.has(errorCode)) {
return TestConnectionError.UnauthorizedResult(401);
}
return TestConnectionError.UnknownResult(new Error(subsonicResponse.error?.message ?? "Subsonic request failed"));
}
return { success: true };
}
public async getDashboardDataAsync(): Promise<NavidromeDashboardData> {
const [artistCount, libraryCounts, nowPlaying] = await Promise.all([
this.getArtistCountAsync(),
this.getAlbumAndSongCountsAsync(),
this.getNowPlayingAsync(),
]);
return {
artistCount,
albumCount: libraryCounts.albumCount,
songCount: libraryCounts.songCount,
nowPlaying,
};
}
private async getArtistCountAsync(): Promise<number> {
const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
}
private async getAlbumAndSongCountsAsync(): Promise<{ albumCount: number; songCount: number }> {
let offset = 0;
let albumCount = 0;
let songCount = 0;
let page = 0;
while (page < maxAlbumListPages) {
const response = await this.subsonicRequestAsync(
"/rest/getAlbumList2.view",
{
type: "alphabeticalByName",
size: albumListPageSize,
offset,
},
{ tolerateEmptyLibrary: true },
);
const albums = asArray(response.albumList2?.album);
if (albums.length === 0) {
break;
}
albumCount += albums.length;
for (const album of albums) {
songCount += album.songCount ?? 0;
}
if (albums.length < albumListPageSize) {
break;
}
offset += albumListPageSize;
page += 1;
}
if (page >= maxAlbumListPages) {
logger.warn("Reached Navidrome album list page limit", {
integrationId: this.integration.id,
maxPages: maxAlbumListPages,
albumCount,
});
}
return { albumCount, songCount };
}
private async getNowPlayingAsync(): Promise<NavidromeNowPlayingEntry[]> {
const response = await this.subsonicRequestAsync("/rest/getNowPlaying.view", {}, { tolerateEmptyLibrary: true });
return asArray(response.nowPlaying?.entry).map((entry) => ({
title: entry.title ?? "",
artist: entry.artist ?? "",
album: entry.album ?? "",
username: entry.username ?? "",
playerName: entry.playerName ?? "",
}));
}
private async subsonicRequestAsync(
path: `/rest/${string}`,
params: Record<string, string | number> = {},
options: { tolerateEmptyLibrary?: boolean } = {},
): Promise<SubsonicResponseBody> {
const url = this.url(path, { ...this.getAuthParams(), ...params });
const response = await fetchWithTrustedCertificatesAsync(url);
if (!response.ok) {
throw new ResponseError(response);
}
const parsed = subsonicResponseSchema.parse(await response.json());
const subsonicResponse = parsed["subsonic-response"];
if (subsonicResponse.status === "failed") {
const message = subsonicResponse.error?.message ?? "Subsonic request failed";
if (options.tolerateEmptyLibrary && message === emptyLibraryMessage) {
return subsonicResponse;
}
throw new Error(message);
}
return subsonicResponse;
}
private getAuthParams(): Record<string, string> {
return {
u: this.getSecretValue("username"),
p: this.getSecretValue("password"),
v: subsonicVersion,
c: subsonicClient,
f: "json",
};
}
}
@@ -1,71 +0,0 @@
import { z } from "zod/v4";
const subsonicArtistSchema = z.object({
id: z.string(),
name: z.string(),
albumCount: z.number().optional(),
});
const subsonicIndexSchema = z.object({
name: z.string(),
artist: z.union([subsonicArtistSchema, z.array(subsonicArtistSchema)]).optional(),
});
const subsonicAlbumSchema = z.object({
id: z.string(),
name: z.string(),
songCount: z.number().optional(),
});
const subsonicNowPlayingEntrySchema = z.object({
title: z.string().optional(),
artist: z.string().optional(),
album: z.string().optional(),
username: z.string().optional(),
playerName: z.string().optional(),
});
export const subsonicResponseSchema = z.object({
"subsonic-response": z.object({
status: z.enum(["ok", "failed"]),
version: z.string().optional(),
error: z
.object({
code: z.number(),
message: z.string(),
})
.optional(),
artists: z
.object({
index: z.union([subsonicIndexSchema, z.array(subsonicIndexSchema)]).optional(),
})
.optional(),
albumList2: z
.object({
album: z.union([subsonicAlbumSchema, z.array(subsonicAlbumSchema)]).optional(),
})
.optional(),
nowPlaying: z
.object({
entry: z.union([subsonicNowPlayingEntrySchema, z.array(subsonicNowPlayingEntrySchema)]).optional(),
})
.optional(),
}),
});
export type SubsonicResponseBody = z.infer<typeof subsonicResponseSchema>["subsonic-response"];
export interface NavidromeNowPlayingEntry {
title: string;
artist: string;
album: string;
username: string;
playerName: string;
}
export interface NavidromeDashboardData {
artistCount: number;
albumCount: number;
songCount: number;
nowPlaying: NavidromeNowPlayingEntry[];
}
@@ -1,201 +0,0 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { NavidromeIntegration } from "../navidrome-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://navidrome.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const secrets: IntegrationSecret[] = [
{ kind: "username", value: "admin" },
{ kind: "password", value: "secret" },
];
const toUrlString = (url: unknown): string => String(url);
const createIntegration = () =>
new NavidromeIntegration({
id: "test-navidrome",
name: "Test Navidrome",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: secrets,
});
const subsonicOk = <T extends Record<string, unknown>>(data: T) =>
JSON.stringify({
"subsonic-response": { status: "ok", version: "1.16.1", ...data },
});
const subsonicFailed = (message: string) =>
JSON.stringify({
"subsonic-response": { status: "failed", error: { code: 70, message } },
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("NavidromeIntegration.getDashboardDataAsync", () => {
test("paginates album list and counts songs", async () => {
const makeAlbums = (count: number, songCount: number) =>
Array.from({ length: count }, (_, i) => ({
id: `album-${i}`,
name: `Album ${i}`,
songCount,
}));
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getArtists")) {
return Promise.resolve(
new Response(subsonicOk({ artists: { index: [{ name: "A", artist: [{ id: "1", name: "Artist" }] }] } }), {
status: 200,
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getAlbumList2")) {
const offsetMatch = urlStr.match(/offset=(\d+)/);
const offset = Number(offsetMatch?.[1] ?? 0);
if (offset === 0) {
return Promise.resolve(
new Response(subsonicOk({ albumList2: { album: makeAlbums(500, 10) } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (offset === 500) {
return Promise.resolve(
new Response(subsonicOk({ albumList2: { album: makeAlbums(200, 5) } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
const result = await integration.getDashboardDataAsync();
expect(result.albumCount).toBe(700);
expect(result.songCount).toBe(500 * 10 + 200 * 5);
expect(result.artistCount).toBe(1);
expect(result.nowPlaying).toEqual([]);
});
test("handles empty library gracefully", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getArtists")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getAlbumList2")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
const result = await integration.getDashboardDataAsync();
expect(result.artistCount).toBe(0);
expect(result.albumCount).toBe(0);
expect(result.songCount).toBe(0);
expect(result.nowPlaying).toEqual([]);
});
test("throws on non-empty-library subsonic error", async () => {
mockFetch.mockImplementation(
() =>
Promise.resolve(new Response(subsonicFailed("Permission denied"), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>,
);
const integration = createIntegration();
await expect(integration.getDashboardDataAsync()).rejects.toThrow();
});
test("now playing maps entries correctly", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(
new Response(
subsonicOk({
nowPlaying: {
entry: { title: "Song", artist: "Band", album: "LP", username: "user1", playerName: "Chrome" },
},
}),
{ status: 200 },
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getArtists")) {
return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (urlStr.includes("getAlbumList2")) {
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
const result = await integration.getDashboardDataAsync();
expect(result.nowPlaying).toHaveLength(1);
expect(result.nowPlaying[0]).toEqual({
title: "Song",
artist: "Band",
album: "LP",
username: "user1",
playerName: "Chrome",
});
});
});
@@ -1,67 +0,0 @@
import { 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 { PaperlessNgxStats } from "./paperless-ngx-types";
import { paperlessNgxPaginatedCountSchema, paperlessNgxStatisticsSchema } from "./paperless-ngx-types";
export class PaperlessNgxIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const statisticsUrl = this.url("/api/statistics/");
const statisticsResponse = await input.fetchAsync(statisticsUrl, {
headers: this.getAuthHeaders(),
});
if (!statisticsResponse.ok) {
return TestConnectionError.StatusResult(statisticsResponse);
}
return { success: true };
}
public async getStatsAsync(): Promise<PaperlessNgxStats> {
const [statistics, tags, correspondents, documentTypes] = await Promise.all([
this.fetchStatisticsAsync(),
this.fetchCountAsync("/api/tags/"),
this.fetchCountAsync("/api/correspondents/"),
this.fetchCountAsync("/api/document_types/"),
]);
return {
documentsTotal: statistics.documents_total,
documentsInbox: statistics.documents_inbox,
correspondentsCount: correspondents,
tagsCount: tags,
documentTypesCount: documentTypes,
};
}
private async fetchAuthenticatedAsync(path: `/${string}`) {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
private async fetchStatisticsAsync() {
const response = await this.fetchAuthenticatedAsync("/api/statistics/");
return paperlessNgxStatisticsSchema.parse(await response.json());
}
private async fetchCountAsync(path: `/${string}`) {
const response = await this.fetchAuthenticatedAsync(path);
return paperlessNgxPaginatedCountSchema.parse(await response.json()).count;
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Token ${this.getSecretValue("apiKey")}`,
};
}
}
@@ -1,21 +0,0 @@
import { z } from "zod/v4";
export const paperlessNgxStatisticsSchema = z.object({
documents_total: z.number(),
documents_inbox: z
.number()
.nullable()
.transform((value) => value ?? 0),
});
export const paperlessNgxPaginatedCountSchema = z.object({
count: z.number(),
});
export interface PaperlessNgxStats {
documentsTotal: number;
documentsInbox: number;
correspondentsCount: number;
tagsCount: number;
documentTypesCount: number;
}
-4
View File
@@ -17,9 +17,5 @@ export * from "./anchor/anchor-types";
export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
export * from "./uptime-kuma/uptime-kuma-types";
export * from "./audiobookshelf/audiobookshelf-types";
export * from "./navidrome/navidrome-types";
export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
export * from "./beszel/beszel-types";
@@ -1,162 +0,0 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { UptimeKumaIntegration } from "../uptime-kuma-integration";
import { uptimeKumaHeartbeatCategoryMap } from "../uptime-kuma-types";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://uptime.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const toUrlString = (url: unknown): string => String(url);
const createIntegration = (secrets: IntegrationSecret[] = []) =>
new UptimeKumaIntegration({
id: "test-uptime-kuma",
name: "Test Uptime Kuma",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: secrets,
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("uptimeKumaHeartbeatCategoryMap", () => {
test("maps status 0 (down) to 'down'", () => {
expect(uptimeKumaHeartbeatCategoryMap[0]).toBe("down");
});
test("maps status 1 (up) to 'up'", () => {
expect(uptimeKumaHeartbeatCategoryMap[1]).toBe("up");
});
test("maps status 2 (pending) to 'paused'", () => {
expect(uptimeKumaHeartbeatCategoryMap[2]).toBe("paused");
});
test("maps status 3 (maintenance) to 'paused'", () => {
expect(uptimeKumaHeartbeatCategoryMap[3]).toBe("paused");
});
test("returns undefined for unknown status codes", () => {
expect(uptimeKumaHeartbeatCategoryMap[99]).toBeUndefined();
});
});
describe("UptimeKumaIntegration.getDashboardDataAsync", () => {
const statusPageResponse = {
publicGroupList: [
{
id: 1,
name: "Services",
monitorList: [
{ id: 10, name: "Web" },
{ id: 20, name: "API" },
{ id: 30, name: "DB" },
],
},
],
};
const heartbeatResponse = {
heartbeatList: {
"10": [{ status: 1, time: "2025-01-01" }],
"20": [{ status: 0, time: "2025-01-01" }],
"30": [{ status: 2, time: "2025-01-01" }],
},
uptimeList: {
"10_24": 0.995,
"20_24": 0.5,
"30_24": 0.8,
},
};
const mockResponses = () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("/heartbeat/")) {
return Promise.resolve(
new Response(JSON.stringify(heartbeatResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(
new Response(JSON.stringify(statusPageResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
});
};
test("aggregates monitor counts correctly", async () => {
mockResponses();
const integration = createIntegration([{ kind: "slug", value: "default" }]);
const result = await integration.getDashboardDataAsync();
expect(result.totalMonitors).toBe(3);
expect(result.upCount).toBe(1);
expect(result.downCount).toBe(1);
expect(result.pausedCount).toBe(1);
});
test("computes average uptime from uptimeList", async () => {
mockResponses();
const integration = createIntegration([{ kind: "slug", value: "default" }]);
const result = await integration.getDashboardDataAsync();
const expectedAverage = ((0.995 + 0.5 + 0.8) * 100) / 3;
expect(result.averageUptimePercent).toBeCloseTo(expectedAverage, 5);
});
test("defaults unknown heartbeat status to 'down'", async () => {
const unknownHeartbeat = {
heartbeatList: { "10": [{ status: 99, time: "2025-01-01" }] },
uptimeList: { "10_24": 1 },
};
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("/heartbeat/")) {
return Promise.resolve(
new Response(JSON.stringify(unknownHeartbeat), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(
new Response(
JSON.stringify({
publicGroupList: [{ id: 1, name: "G", monitorList: [{ id: 10, name: "Svc" }] }],
}),
{ status: 200 },
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
});
const integration = createIntegration([{ kind: "slug", value: "default" }]);
const result = await integration.getDashboardDataAsync();
expect(result.monitors[0]?.status).toBe("down");
expect(result.downCount).toBe(1);
});
test("handles empty monitor list gracefully", async () => {
mockFetch.mockImplementation(
() =>
Promise.resolve(
new Response(JSON.stringify({ publicGroupList: [], heartbeatList: {}, uptimeList: {} }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>,
);
const integration = createIntegration([{ kind: "slug", value: "default" }]);
const result = await integration.getDashboardDataAsync();
expect(result.totalMonitors).toBe(0);
expect(result.averageUptimePercent).toBe(0);
});
});
@@ -1,140 +0,0 @@
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 {
uptimeKumaHeartbeatCategoryMap,
uptimeKumaHeartbeatResponseSchema,
uptimeKumaStatusPageResponseSchema,
} from "./uptime-kuma-types";
import type { UptimeKumaDashboardData, UptimeKumaMonitor, UptimeKumaMonitorCategory } from "./uptime-kuma-types";
export class UptimeKumaIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const statusPageResponse = await input.fetchAsync(this.statusPageUrl());
if (!statusPageResponse.ok) {
return TestConnectionError.StatusResult(statusPageResponse);
}
const statusPageParsed = uptimeKumaStatusPageResponseSchema.safeParse(await statusPageResponse.json());
if (!statusPageParsed.success) {
return TestConnectionError.ParseResult(
new ParseError("Invalid Uptime Kuma status page response", { cause: statusPageParsed.error }),
);
}
const heartbeatResponse = await input.fetchAsync(this.heartbeatUrl());
if (!heartbeatResponse.ok) {
return TestConnectionError.StatusResult(heartbeatResponse);
}
const heartbeatParsed = uptimeKumaHeartbeatResponseSchema.safeParse(await heartbeatResponse.json());
if (!heartbeatParsed.success) {
return TestConnectionError.ParseResult(
new ParseError("Invalid Uptime Kuma heartbeat response", { cause: heartbeatParsed.error }),
);
}
return { success: true };
}
public async getDashboardDataAsync(): Promise<UptimeKumaDashboardData> {
const [statusPageResponse, heartbeatResponse] = await Promise.all([
this.getStatusPageAsync(),
this.getHeartbeatAsync(),
]);
const statusPage = uptimeKumaStatusPageResponseSchema.parse(await statusPageResponse.json());
const heartbeat = uptimeKumaHeartbeatResponseSchema.parse(await heartbeatResponse.json());
const monitors = statusPage.publicGroupList.flatMap((group) =>
group.monitorList.map((monitor) => this.mapMonitor(monitor, heartbeat)),
);
return this.buildDashboardData(monitors);
}
private mapMonitor(
monitor: { id: number; name: string },
heartbeat: { heartbeatList: Record<string, { status: number }[]>; uptimeList: Record<string, number> },
): UptimeKumaMonitor {
const heartbeats = heartbeat.heartbeatList[String(monitor.id)] ?? [];
const latestHeartbeat = heartbeats.at(-1);
const uptimeKey = `${monitor.id}_24`;
const uptimeRaw = heartbeat.uptimeList[uptimeKey];
const uptimePercent24h = typeof uptimeRaw === "number" ? uptimeRaw * 100 : null;
const status: UptimeKumaMonitorCategory =
(latestHeartbeat && (uptimeKumaHeartbeatCategoryMap[latestHeartbeat.status] ?? "down")) || "paused";
return {
id: monitor.id,
name: monitor.name,
status,
uptimePercent24h,
};
}
private buildDashboardData(monitors: UptimeKumaMonitor[]): UptimeKumaDashboardData {
const counts = monitors.reduce(
(acc, monitor) => {
acc[monitor.status] += 1;
return acc;
},
{ up: 0, down: 0, paused: 0 },
);
const uptimeValues = monitors
.map((monitor) => monitor.uptimePercent24h)
.filter((value): value is number => value !== null);
const averageUptimePercent = uptimeValues.reduce((sum, value) => sum + value, 0) / Math.max(uptimeValues.length, 1);
return {
totalMonitors: monitors.length,
upCount: counts.up,
downCount: counts.down,
pausedCount: counts.paused,
averageUptimePercent,
monitors,
};
}
private async fetchOkAsync(url: string | URL) {
const response = await fetchWithTrustedCertificatesAsync(url);
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
private getStatusPageAsync() {
return this.fetchOkAsync(this.statusPageUrl());
}
private getHeartbeatAsync() {
return this.fetchOkAsync(this.heartbeatUrl());
}
private statusPageUrl() {
return this.url(`/api/status-page/${this.getSlug()}`);
}
private heartbeatUrl() {
return this.url(`/api/status-page/heartbeat/${this.getSlug()}`);
}
private getSlug() {
if (this.hasSecretValue("slug")) {
return this.getSecretValue("slug").toLowerCase();
}
return "default";
}
}
@@ -1,62 +0,0 @@
import { z } from "zod";
export const uptimeKumaHeartbeatStatus = {
down: 0,
up: 1,
pending: 2,
maintenance: 3,
} as const;
export type UptimeKumaMonitorCategory = "up" | "down" | "paused";
export const uptimeKumaHeartbeatCategoryMap: Record<number, UptimeKumaMonitorCategory> = {
[uptimeKumaHeartbeatStatus.down]: "down",
[uptimeKumaHeartbeatStatus.up]: "up",
[uptimeKumaHeartbeatStatus.pending]: "paused",
[uptimeKumaHeartbeatStatus.maintenance]: "paused",
};
export const uptimeKumaStatusPageMonitorSchema = z.object({
id: z.number().int(),
name: z.string(),
});
export const uptimeKumaStatusPageGroupSchema = z.object({
id: z.number().int(),
name: z.string(),
monitorList: z.array(uptimeKumaStatusPageMonitorSchema),
});
export const uptimeKumaStatusPageResponseSchema = z.object({
publicGroupList: z.array(uptimeKumaStatusPageGroupSchema),
});
export const uptimeKumaHeartbeatEntrySchema = z.object({
status: z.number().int(),
time: z.string(),
ping: z.number().nullable().optional(),
});
export const uptimeKumaHeartbeatResponseSchema = z.object({
heartbeatList: z.record(z.string(), z.array(uptimeKumaHeartbeatEntrySchema)),
uptimeList: z.record(z.string(), z.number()),
});
export interface UptimeKumaMonitor {
id: number;
name: string;
status: UptimeKumaMonitorCategory;
uptimePercent24h: number | null;
}
export interface UptimeKumaDashboardData {
totalMonitors: number;
upCount: number;
downCount: number;
pausedCount: number;
averageUptimePercent: number;
monitors: UptimeKumaMonitor[];
}
export type UptimeKumaStatusPageResponse = z.infer<typeof uptimeKumaStatusPageResponseSchema>;
export type UptimeKumaHeartbeatResponse = z.infer<typeof uptimeKumaHeartbeatResponseSchema>;
-2
View File
@@ -13,8 +13,6 @@ export {
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
createQueryCacheChannel,
getAllQueryCacheAsync,
} from "./lib/channel";
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
-43
View File
@@ -125,49 +125,6 @@ export const createGetSetChannel = <TData>(name: string) => {
};
};
interface QueryCacheChannelOptions {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}
const queryCacheHashKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
export const createQueryCacheChannel = ({ userId, boardId, key, ttlMs, maxValueBytes }: QueryCacheChannelOptions) => {
const hkey = queryCacheHashKey(userId, boardId);
return {
name: `${hkey}:${key}`,
getAsync: async () => {
return await getSetClient.hget(hkey, key);
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
logger.warn("Query cache value exceeded maximum size", {
channel: hkey,
valueBytes: Buffer.byteLength(value, "utf8"),
maxValueBytes,
});
return false;
}
await getSetClient.hset(hkey, key, value);
await getSetClient.pexpire(hkey, ttlMs);
return true;
},
removeAsync: async () => {
await getSetClient.hdel(hkey, key);
},
};
};
export const getAllQueryCacheAsync = async (userId: string, boardId: string): Promise<Record<string, string>> => {
const hkey = queryCacheHashKey(userId, boardId);
return await getSetClient.hgetall(hkey);
};
/**
* Creates a new cache channel.
* @param name name of the channel
@@ -1,19 +0,0 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandler<
AudiobookshelfDashboardData,
"audiobookshelf",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(10, "minutes"),
queryKey: "audiobookshelfDashboard",
});
-19
View File
@@ -1,19 +0,0 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { NavidromeDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const navidromeRequestHandler = createCachedIntegrationRequestHandler<
NavidromeDashboardData,
"navidrome",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(10, "minutes"),
queryKey: "navidromeDashboard",
});
@@ -1,19 +0,0 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { PaperlessNgxStats } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHandler<
PaperlessNgxStats,
"paperlessNgx",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
},
cacheDuration: dayjs.duration(15, "minute"),
queryKey: "paperless-ngx-stats",
});
@@ -1,19 +0,0 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler<
UptimeKumaDashboardData,
"uptimeKuma",
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "uptimeKumaDashboard",
});
+2 -274
View File
@@ -811,13 +811,10 @@
"firewall": "Firewall",
"timetable": "Timetable",
"photoService": "Photos",
"documents": "Documents",
"notes": "Notes",
"mediaMonitoring": "Monitoring",
"speedtest": "Speed Test",
"analytics": "Analytics",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring"
"analytics": "Analytics"
},
"testConnection": {
"action": {
@@ -1090,10 +1087,6 @@
"privateKey": {
"label": "Private key",
"newLabel": "New private key"
},
"slug": {
"label": "Status page slug",
"newLabel": "New status page slug"
}
}
},
@@ -1101,30 +1094,6 @@
"use": "Select integrations in items",
"interact": "Interact with integrations",
"full": "Full integration access"
},
"audiobookshelf": {
"name": "Audiobookshelf",
"description": "Self-hosted audiobook and podcast server"
},
"navidrome": {
"name": "Navidrome",
"description": "Self-hosted music streaming server with Subsonic API compatibility"
},
"uptimeKuma": {
"name": "Uptime Kuma",
"description": "Self-hosted uptime monitoring with public status pages",
"secret": {
"slug": {
"description": "The slug of your public status page. Defaults to \"default\" if not set."
},
"apiKey": {
"description": "Optional API key for authenticated /metrics access"
}
}
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "Document management system for archiving and organizing scanned documents"
}
},
"media": {
@@ -2463,51 +2432,6 @@
}
}
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "Overview of your Paperless-ngx document archive",
"documentsTotal": "Total Documents",
"documentsInbox": "Inbox",
"correspondents": "Correspondents",
"tags": "Tags",
"documentTypes": "Document Types",
"inboxRatio": "Inbox Ratio",
"inbox": "inbox",
"total": "total",
"option": {
"showInboxRatio": {
"label": "Show inbox ratio",
"description": "Display the inbox-to-total document ratio in the hero section"
},
"showInboxRing": {
"label": "Show inbox ring",
"description": "Display a progress ring alongside the inbox ratio"
},
"showDocumentsTotal": {
"label": "Show total documents",
"description": "Show the total number of documents in your archive"
},
"showDocumentsInbox": {
"label": "Show inbox count",
"description": "Show the number of unprocessed documents in the inbox"
},
"showCorrespondents": {
"label": "Show correspondents",
"description": "Show the total number of correspondents"
},
"showTags": {
"label": "Show tags",
"description": "Show the total number of tags"
},
"showDocumentTypes": {
"label": "Show document types",
"description": "Show the total number of document types"
}
},
"error": {
"internalServerError": "Failed to fetch Paperless-ngx data"
}
},
"immich-serverStats": {
"name": "Immich Server Stats",
"description": "Information about your Immich instance",
@@ -3116,113 +3040,6 @@
"internalServerError": "Failed to fetch speedtest data"
}
},
"uptimeKuma": {
"name": "Uptime Kuma",
"description": "Monitor uptime statistics from your Uptime Kuma status page",
"option": {
"showAverageUptime": {
"label": "Show average uptime",
"description": "Display the combined 24-hour average uptime percentage"
},
"showUptimeRing": {
"label": "Show uptime ring",
"description": "Display a progress ring alongside the average uptime value"
},
"showTotalMonitors": {
"label": "Show total monitors",
"description": "Display the total number of configured monitors"
},
"showUpCount": {
"label": "Show up count",
"description": "Display how many monitors are currently up"
},
"showDownCount": {
"label": "Show down count",
"description": "Display how many monitors are currently down"
},
"showPausedCount": {
"label": "Show paused count",
"description": "Display how many monitors are currently paused"
}
},
"averageUptime": "Average uptime (24h)",
"stats": {
"totalMonitors": "Total monitors",
"upCount": "Up",
"downCount": "Down",
"pausedCount": "Paused"
},
"error": {
"internalServerError": "Failed to fetch uptime data"
}
},
"audioStats": {
"name": "Audio stats",
"description": "Library and playback statistics from Navidrome or Audiobookshelf",
"option": {
"showArtists": {
"label": "Show artists",
"description": "Show the total number of artists in your music library (Navidrome)"
},
"showAlbums": {
"label": "Show albums",
"description": "Show the total number of albums in your music library (Navidrome)"
},
"showSongs": {
"label": "Show songs",
"description": "Show the total number of songs in your music library (Navidrome)"
},
"showNowPlaying": {
"label": "Show now playing",
"description": "Show tracks currently being played (Navidrome)"
},
"showNowPlayingList": {
"label": "Show now playing list",
"description": "Show individual tracks instead of only the active stream count (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Max now playing items",
"description": "Maximum number of tracks to show in the now playing list (Navidrome)"
},
"showLibraryCount": {
"label": "Show library count",
"description": "Show the number of libraries on your instance (Audiobookshelf)"
},
"showAudiobooks": {
"label": "Show audiobooks",
"description": "Show the total number of audiobooks across all libraries (Audiobookshelf)"
},
"showPodcasts": {
"label": "Show podcasts",
"description": "Show the total number of podcasts across all libraries (Audiobookshelf)"
},
"showListeningTime": {
"label": "Show listening time",
"description": "Show your total listening time (Audiobookshelf)"
},
"showActiveSessions": {
"label": "Show active sessions",
"description": "Show the number of currently active playback sessions (Audiobookshelf)"
},
"compactMode": {
"label": "Compact mode",
"description": "Use a denser layout with smaller tiles, ideal for small widget sizes"
}
},
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"libraryCount": "Libraries",
"audiobooks": "Audiobooks",
"podcasts": "Podcasts",
"listeningTime": "Listening Time",
"activeSessions": "Active Sessions",
"nowPlaying": "Now Playing",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"error": {
"internalServerError": "Failed to fetch audio stats data"
}
},
"systemResources": {
"name": "System resources",
"description": "CPU, Memory, Disk and other hardware usage of your system",
@@ -3895,7 +3712,7 @@
"docker": "Docker",
"kubernetes": "Kubernetes",
"logs": "Logs",
"api": "API & MCP",
"api": "API",
"certificates": "Certificates",
"tasks": "Tasks",
"backup": "Backup"
@@ -4436,95 +4253,6 @@
"actions": "Actions"
}
}
},
"mcp": {
"label": "AI / MCP",
"title": "AI Integration",
"subtitle": "Model Context Protocol (MCP) Server",
"description": "Connect AI assistants to your Homarr instance and control your entire homelab through natural language. Search and request media on Overseerr, manage Docker containers, toggle Pi-hole, check server health, control Home Assistant devices, and more — all from your AI assistant.",
"noApiKey": "You need an API key to authenticate with the MCP server.",
"createApiKeyButton": "Create API Key",
"endpoint": {
"title": "MCP Endpoint"
},
"authentication": {
"title": "Authentication",
"intro": "You can authenticate with your MCP server using either an API key or OAuth. Both methods grant the same level of access.",
"apiKey": {
"title": "API Key (manual)",
"description": "Pass the ApiKey header with your full API key in the MCP client configuration."
},
"oauth": {
"title": "OAuth (browser login)",
"description": "Clients that support OAuth (such as Claude Code, opencode, or Cursor) can authenticate by opening a browser window. Run the auth command in your client, log in to Homarr in the browser, and you're connected — an API key is created automatically.",
"hint": "OAuth tokens appear as regular API keys in the Authentication tab. You can revoke them at any time."
},
"permissions": "The API key determines the permissions available to the AI — it operates with the same access level as the key owner."
},
"clientConfig": {
"title": "Client Configuration",
"streamableHttp": {
"recommended": "Recommended",
"title": "Streamable HTTP",
"worksWith": "Works with: Cursor, Claude Desktop, VS Code Copilot, Windsurf, Claude Code",
"addToConfig": "Add to your MCP client configuration file:",
"cursor": "{name}: {file} in your project or global settings",
"claudeDesktop": "{name}: {file}",
"claudeCode": "{name}: {file} or use {command}",
"vsCodeCopilot": "{name}: {file} in workspace settings",
"replaceHint": "Replace the placeholder with the API key you received when creating it."
},
"stdio": {
"title": "stdio (legacy clients via mcp-remote bridge)",
"description": "For clients that only support stdio transport. Requires {dependency} (Node.js) installed locally:"
}
},
"testConnection": {
"title": "Test Your Connection",
"description": "Run this in your terminal to verify the endpoint is reachable and your API key works:",
"hint": "A successful response returns the MCP server capabilities. If you see an error, check the JSON response for hints."
},
"availableTools": {
"title": "Available Tools",
"description": "{count} tools available. Expand a category to see individual tools and their descriptions.",
"columnType": "Type",
"columnName": "Tool",
"columnDescription": "Description"
},
"troubleshooting": {
"title": "Troubleshooting",
"missingHeader": {
"title": "\"Missing ApiKey header\"",
"description": "Your client is not sending the {header} header. Verify the {section} section in your MCP config is correct. Some clients require restarting after config changes."
},
"invalidFormat": {
"title": "\"Invalid ApiKey format\"",
"description": "Make sure you copied the full API key, not just part of it."
},
"invalidKey": {
"title": "\"Invalid API key\"",
"description": "The key ID was not found or the token is wrong. API keys can only be viewed once when created. If you lost it, delete the old key and create a new one."
},
"connectionRefused": {
"title": "Connection refused / timeout",
"description": "Make sure the URL is reachable from your AI client. If Homarr runs behind a reverse proxy or in Docker, use the external URL (not {localhost}). Verify the port is correct (default: 7575 for Docker)."
},
"toolsNotAppearing": {
"title": "Tools not appearing",
"description": "Some tools require specific integrations to be configured (e.g. Docker, Overseerr). The tool list depends on what's available in your Homarr instance."
}
},
"copy": "Copy",
"oneClick": {
"title": "Install in One Click",
"subtitle": "Uses OAuth — authenticate in your browser on first use",
"vsCode": "Install in VS Code",
"cursor": "Install in Cursor",
"copyUrl": "Copy MCP URL",
"copied": "Copied!",
"copyHint": "For ChatGPT, Claude Code, or any MCP client",
"manualConfig": "Manual Configuration"
}
}
}
},
-25
View File
@@ -5,7 +5,6 @@ import {
backgroundImageRepeats,
backgroundImageSizes,
boardPermissions,
widgetKinds,
} from "@homarr/definitions";
import { zodEnumFromArray } from "./enums";
@@ -95,27 +94,3 @@ export const boardCreateSchema = z.object({
});
export const boardSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions));
export const boardSummarySchema = z.object({
id: z.string(),
name: z.string(),
logoImageUrl: z.string().nullable(),
isPublic: z.boolean(),
creator: z
.object({
id: z.string(),
name: z.string().nullable(),
image: z.string().nullable(),
email: z.string().nullable(),
})
.nullable(),
isHome: z.boolean(),
isMobileHome: z.boolean(),
});
export const addItemToBoardSchema = z.object({
boardId: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown()).default({}),
integrationIds: z.array(z.string()).default([]),
});
+16 -21
View File
@@ -14,7 +14,6 @@ import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import "react-quill-new/dist/quill.snow.css";
@@ -145,13 +144,13 @@ interface AnchorNoteWidgetContentProps {
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery(
const [note, { refetch }] = clientApi.widget.anchorNotes.getNote.useSuspenseQuery(
{
integrationId,
noteId,
},
{
staleTime: 15 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -160,32 +159,31 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState(note?.title ?? "");
const [draftContent, setDraftContent] = useState(note?.content ?? "");
const [draftTitle, setDraftTitle] = useState(note.title);
const [draftContent, setDraftContent] = useState(note.content ?? "");
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
if (isEditing || !note) return;
if (isEditing) return;
startTransition(() => {
setDraftTitle(note.title);
setDraftContent(note.content ?? "");
});
}, [isEditing, note]);
}, [isEditing, note.content, note.title]);
const canEdit = canEditPermission(note?.permission ?? "viewer");
const isViewer = !note || note.permission === "viewer";
const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]);
const canEdit = canEditPermission(note.permission);
const isViewer = note.permission === "viewer";
const updatedAt = useMemo(() => new Date(note.updatedAt), [note.updatedAt]);
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
const hasChanges = useMemo(() => {
if (!note) return false;
const normalizedTitle = draftTitle.trim() || note.title;
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
}, [draftContent, draftTitle, note]);
}, [draftContent, draftTitle, note.content, note.title]);
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
const readOnlyValue = useMemo(() => parseStoredContent(note.content), [note.content]);
const handleEditorChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
@@ -196,27 +194,26 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
);
const handleEdit = useCallback(() => {
if (!canEdit || !note) return;
if (!canEdit) return;
startTransition(() => {
setDraftTitle(note.title);
setDraftContent(note.content ?? "");
setSaveError(null);
setIsEditing(true);
});
}, [canEdit, note]);
}, [canEdit, note.content, note.title]);
const handleCancel = useCallback(() => {
if (!note) return;
startTransition(() => {
setDraftTitle(note.title);
setDraftContent(note.content ?? "");
setSaveError(null);
setIsEditing(false);
});
}, [note]);
}, [note.content, note.title]);
const handleSave = useCallback(async () => {
if (!canEdit || !note) return;
if (!canEdit) return;
const normalizedTitle = draftTitle.trim() || note.title || t("untitled");
@@ -263,9 +260,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
},
},
);
}, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note, noteId, refetch, t, updateNoteAsync]);
if (!note) return <WidgetEmptyState />;
}, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note.title, noteId, refetch, t, updateNoteAsync]);
return (
<Stack h="100%" gap="xs" p="sm">
+7 -8
View File
@@ -3,7 +3,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import { IconLoader } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
@@ -13,7 +13,6 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
@@ -23,19 +22,20 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const { data: app } = clientApi.app.byId.useQuery(
const [app] = clientApi.app.byId.useSuspenseQuery(
{
id: options.appId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
useRegisterSpotlightContextResults(
`app-${app?.id ?? options.appId}`,
app?.href
`app-${app.id}`,
app.href
? [
{
id: app.id,
@@ -44,6 +44,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
interaction() {
return {
type: "link",
// We checked above that app.href is defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
newTab: options.openInNewTab,
@@ -55,8 +56,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, options.openInNewTab],
);
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
@@ -131,7 +130,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
@@ -1,7 +1,8 @@
import { IconCheck, IconMinus, IconX } from "@tabler/icons-react";
import { useState } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { PingDot } from "./ping-dot";
@@ -10,24 +11,27 @@ interface PingIndicatorProps {
}
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const t = useI18n();
const { data: pingResult } = clientApi.widget.app.ping.useQuery(
{ id: appId },
{ refetchOnMount: false, refetchOnWindowFocus: false },
const [ping] = clientApi.widget.app.ping.useSuspenseQuery(
{
id: appId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
const utils = clientApi.useUtils();
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"]>(ping);
clientApi.widget.app.updatedPing.useSubscription(
{ id: appId },
{
onData(data) {
utils.widget.app.ping.setData({ id: appId }, data);
setPingResult(data);
},
},
);
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
const isError = "error" in pingResult || pingResult.statusCode >= 500;
return (
@@ -1,125 +0,0 @@
"use client";
import type { CSSProperties } from "react";
import { Badge, Text } from "@mantine/core";
import { IconHeadphones } from "@tabler/icons-react";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
const rootClassByCompact = {
true: classes.rootCompact,
false: "",
} as const;
const gridClassByCompact = {
true: classes.gridCompact,
false: "",
} as const;
const statTileClassByCompact = {
true: classes.statTileCompact,
false: "",
} as const;
interface AudioStatsContentProps {
backend: AudioStatsBackend;
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
options: AudioStatsDisplayOptions;
width: number;
}
export function AudioStatsContent({ backend, stats, options, width }: AudioStatsContentProps) {
const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const visibleStats = getVisibleStats(backend, options, stats);
const compactKey = String(options.compactMode ?? false) as keyof typeof rootClassByCompact;
const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false);
const iconSize = getIconSize(width, options.compactMode ?? false);
const navidromeStatsByBackend: Record<AudioStatsBackend, NavidromeDashboardData | null> = {
navidrome: stats as NavidromeDashboardData,
audiobookshelf: null,
};
const navidromeStats = navidromeStatsByBackend[backend];
const showNowPlayingSection =
navidromeStats !== null && Boolean(options.showNowPlaying) && navidromeStats.nowPlaying.length > 0;
const nowPlayingLimitByListVisibility = {
true: options.maxNowPlayingItems ?? 3,
false: 0,
} as const;
const nowPlayingTracks =
navidromeStats?.nowPlaying.slice(
0,
nowPlayingLimitByListVisibility[
String(options.showNowPlayingList) as keyof typeof nowPlayingLimitByListVisibility
],
) ?? [];
const hasContent = visibleStats.length > 0 || showNowPlayingSection;
return (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
{visibleStats.length > 0 && (
<div
className={`${classes.grid} ${gridClassByCompact[compactKey]}`}
style={{ "--stat-cols": gridCols } as CSSProperties}
>
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={classes.statValue}>{value}</span>
<span className={classes.statLabel}>{t(statKey as never)}</span>
</div>
))}
</div>
)}
{showNowPlayingSection && navidromeStats && (
<div className={classes.nowPlaying}>
<div className={classes.nowPlayingHeader}>
<IconHeadphones size={iconSize} stroke={1.5} />
<span className={classes.nowPlayingTitle}>{t("nowPlaying" as never)}</span>
{!options.showNowPlayingList && (
<Badge className={classes.nowPlayingCount} variant="light" size="sm">
{(t as unknown as (key: string, params?: { count: number }) => string)("nowPlayingCount", {
count: navidromeStats.nowPlaying.length,
})}
</Badge>
)}
</div>
{options.showNowPlayingList && (
<div className={classes.nowPlayingList}>
{nowPlayingTracks.map((track, index) => (
<div key={`${track.username}-${track.title}-${index}`} className={classes.nowPlayingItem}>
<div className={classes.trackInfo}>
<span className={classes.trackTitle}>{track.title}</span>
<span className={classes.trackArtist}>
{[track.artist, track.album].filter(Boolean).join(" · ")}
</span>
</div>
<Badge className={classes.trackUser} variant="outline" size="xs">
{track.username || track.playerName}
</Badge>
</div>
))}
</div>
)}
</div>
)}
{!hasContent && (
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
)}
</div>
);
}
@@ -1,94 +0,0 @@
import { describe, expect, test } from "vitest";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { getGridCols, getIconSize, getVisibleStats } from "./shared";
describe("getGridCols", () => {
test("caps columns to visible stat count", () => {
expect(getGridCols(500, 2, false)).toBe(2);
});
test("returns max cols when many stats visible", () => {
expect(getGridCols(500, 10, false)).toBe(5);
});
test("compact mode uses smaller breakpoints", () => {
expect(getGridCols(290, 5, true)).toBe(5);
expect(getGridCols(290, 5, false)).toBe(2);
});
test("returns at least 1 col even with 0 visible stats", () => {
expect(getGridCols(500, 0, false)).toBe(1);
});
});
describe("getIconSize", () => {
test("returns large size for wide container", () => {
expect(getIconSize(400, false)).toBe(22);
});
test("returns compact size for narrow container in compact mode", () => {
expect(getIconSize(100, true)).toBe(14);
});
test("returns fallback for very narrow non-compact container", () => {
expect(getIconSize(50, false)).toBe(16);
});
});
describe("getVisibleStats", () => {
const navidromeStats: NavidromeDashboardData = {
artistCount: 100,
albumCount: 200,
songCount: 3000,
nowPlaying: [],
};
const audiobookshelfStats: AudiobookshelfDashboardData = {
libraryCount: 3,
totalAudiobooks: 150,
totalPodcasts: 20,
totalListeningTimeSeconds: 86400,
activeSessions: 2,
};
test("returns only enabled navidrome stats", () => {
const result = getVisibleStats(
"navidrome",
{ showArtists: true, showAlbums: false, showSongs: true },
navidromeStats,
);
expect(result).toHaveLength(2);
expect(result.map((s) => s.statKey)).toEqual(["artists", "songs"]);
expect(result[0]?.value).toBe(100);
expect(result[1]?.value).toBe(3000);
});
test("returns only enabled audiobookshelf stats", () => {
const result = getVisibleStats(
"audiobookshelf",
{
showLibraryCount: true,
showAudiobooks: true,
showPodcasts: false,
showListeningTime: false,
showActiveSessions: true,
},
audiobookshelfStats,
);
expect(result).toHaveLength(3);
expect(result.map((s) => s.statKey)).toEqual(["libraryCount", "audiobooks", "activeSessions"]);
});
test("returns empty array when all options disabled", () => {
const result = getVisibleStats("navidrome", {}, navidromeStats);
expect(result).toEqual([]);
});
test("formats listening time as duration string", () => {
const result = getVisibleStats("audiobookshelf", { showListeningTime: true }, audiobookshelfStats);
expect(result).toHaveLength(1);
expect(typeof result[0]?.value).toBe("string");
});
});
@@ -1,185 +0,0 @@
.root {
container-type: size;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: clamp(6px, 3cqi, 12px);
gap: clamp(4px, 2cqi, 8px);
overflow: hidden;
}
.rootCompact {
padding: clamp(4px, 2cqi, 8px);
gap: clamp(2px, 1cqi, 4px);
}
.grid {
display: grid;
flex: 1;
width: 100%;
min-height: 0;
gap: clamp(4px, 2cqi, 8px);
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr;
align-content: stretch;
}
.gridCompact {
gap: clamp(2px, 1cqi, 4px);
}
.statTile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
height: 100%;
gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
.statTileCompact {
gap: clamp(1px, 0.8cqi, 2px);
padding: clamp(2px, 1.2cqi, 5px);
border-radius: var(--mantine-radius-sm);
}
.statIcon {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
line-height: 0;
}
.statLabel {
font-size: clamp(8px, 2.5cqi, 11px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statTileCompact .statLabel {
font-size: clamp(7px, 2cqi, 9px);
}
.statValue {
font-size: clamp(13px, 5cqi, 20px);
font-weight: 700;
color: var(--mantine-primary-color-filled);
line-height: 1.1;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statTileCompact .statValue {
font-size: clamp(11px, 4cqi, 16px);
}
.nowPlaying {
display: flex;
flex-direction: column;
gap: clamp(3px, 1.2cqi, 5px);
flex: 1;
min-height: 0;
overflow: hidden;
}
.nowPlayingHeader {
display: flex;
align-items: center;
gap: clamp(4px, 1.5cqi, 6px);
font-size: clamp(10px, 2.8cqi, 12px);
font-weight: 600;
flex-shrink: 0;
min-width: 0;
}
.nowPlayingTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nowPlayingCount {
flex-shrink: 0;
margin-inline-start: auto;
}
.nowPlayingList {
display: flex;
flex-direction: column;
gap: clamp(2px, 0.8cqi, 4px);
flex: 1;
min-height: 0;
overflow: hidden;
}
.nowPlayingItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(4px, 1.5cqi, 6px);
padding: clamp(3px, 1.2cqi, 6px) clamp(4px, 1.5cqi, 8px);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
min-width: 0;
flex-shrink: 0;
}
.trackInfo {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.trackTitle {
font-size: clamp(9px, 2.6cqi, 12px);
font-weight: 500;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackArtist {
font-size: clamp(8px, 2.2cqi, 10px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackUser {
flex-shrink: 0;
max-width: clamp(40px, 22cqi, 88px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 0;
color: var(--mantine-color-dimmed);
font-size: clamp(11px, 3cqi, 13px);
text-align: center;
}
@@ -1,18 +0,0 @@
"use client";
import { clientApi } from "@homarr/api/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
const { data: response } = clientApi.widget.audioStats.getStats.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ staleTime: 5 * 60 * 1000 },
);
if (!response) return <WidgetEmptyState />;
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
}
-62
View File
@@ -1,62 +0,0 @@
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const hideUnlessNavidrome = {
shouldHide: (_: unknown, integrationKinds: string[]) => !integrationKinds.includes("navidrome"),
};
const hideUnlessAudiobookshelf = {
shouldHide: (_: unknown, integrationKinds: string[]) => !integrationKinds.includes("audiobookshelf"),
};
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from(
(factory) => ({
showArtists: factory.switch({ defaultValue: true, withDescription: true }),
showAlbums: factory.switch({ defaultValue: true, withDescription: true }),
showSongs: factory.switch({ defaultValue: true, withDescription: true }),
showNowPlaying: factory.switch({ defaultValue: true, withDescription: true }),
showNowPlayingList: factory.switch({ defaultValue: true, withDescription: true }),
maxNowPlayingItems: factory.number({
defaultValue: 3,
validate: z.number().min(1).max(5),
step: 1,
withDescription: true,
}),
showLibraryCount: factory.switch({ defaultValue: true, withDescription: true }),
showAudiobooks: factory.switch({ defaultValue: true, withDescription: true }),
showPodcasts: factory.switch({ defaultValue: true, withDescription: true }),
showListeningTime: factory.switch({ defaultValue: true, withDescription: true }),
showActiveSessions: factory.switch({ defaultValue: true, withDescription: true }),
compactMode: factory.switch({ defaultValue: false, withDescription: true }),
}),
{
showArtists: hideUnlessNavidrome,
showAlbums: hideUnlessNavidrome,
showSongs: hideUnlessNavidrome,
showNowPlaying: hideUnlessNavidrome,
showNowPlayingList: hideUnlessNavidrome,
maxNowPlayingItems: hideUnlessNavidrome,
showLibraryCount: hideUnlessAudiobookshelf,
showAudiobooks: hideUnlessAudiobookshelf,
showPodcasts: hideUnlessAudiobookshelf,
showListeningTime: hideUnlessAudiobookshelf,
showActiveSessions: hideUnlessAudiobookshelf,
},
);
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.audioStats.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
-169
View File
@@ -1,169 +0,0 @@
import type { TablerIcon } from "@tabler/icons-react";
import {
IconAlbum,
IconBook,
IconBooks,
IconDisc,
IconHeadphones,
IconMicrophone,
IconMicrophone2,
IconPlayerPlay,
} from "@tabler/icons-react";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { formatDuration } from "@homarr/common";
export type AudioStatsBackend = "navidrome" | "audiobookshelf";
export interface NavidromeDisplayOptions {
showArtists: boolean;
showAlbums: boolean;
showSongs: boolean;
showNowPlaying: boolean;
showNowPlayingList: boolean;
maxNowPlayingItems: number;
compactMode?: boolean;
}
export interface AudiobookshelfDisplayOptions {
showLibraryCount: boolean;
showAudiobooks: boolean;
showPodcasts: boolean;
showListeningTime: boolean;
showActiveSessions: boolean;
compactMode: boolean;
}
export type AudioStatsDisplayOptions = Partial<NavidromeDisplayOptions & AudiobookshelfDisplayOptions>;
const navidromeStatVisibilityOptions = {
showArtists: "artists",
showAlbums: "albums",
showSongs: "songs",
} as const;
const audiobookshelfStatVisibilityOptions = {
showLibraryCount: "libraryCount",
showAudiobooks: "audiobooks",
showPodcasts: "podcasts",
showListeningTime: "listeningTime",
showActiveSessions: "activeSessions",
} as const;
const statVisibilityOptionsByBackend = {
navidrome: navidromeStatVisibilityOptions,
audiobookshelf: audiobookshelfStatVisibilityOptions,
} as const;
const navidromeStatIcons = {
artists: IconMicrophone2,
albums: IconAlbum,
songs: IconDisc,
} as const;
const audiobookshelfStatIcons = {
libraryCount: IconBooks,
audiobooks: IconBook,
podcasts: IconMicrophone,
listeningTime: IconHeadphones,
activeSessions: IconPlayerPlay,
} as const;
const statIconsByBackend = {
navidrome: navidromeStatIcons,
audiobookshelf: audiobookshelfStatIcons,
} as const;
const gridColsByCompact = {
true: [
{ minWidth: 280, cols: 5 },
{ minWidth: 180, cols: 3 },
{ minWidth: 110, cols: 2 },
{ minWidth: 0, cols: 1 },
],
false: [
{ minWidth: 420, cols: 5 },
{ minWidth: 300, cols: 3 },
{ minWidth: 180, cols: 2 },
{ minWidth: 0, cols: 1 },
],
} as const;
const iconSizeByCompact = {
true: [
{ minWidth: 260, size: 18 },
{ minWidth: 160, size: 16 },
{ minWidth: 0, size: 14 },
],
false: [
{ minWidth: 320, size: 22 },
{ minWidth: 200, size: 18 },
{ minWidth: 0, size: 16 },
],
} as const;
export interface VisibleStat {
optionKey: string;
statKey: string;
value: string | number;
Icon: TablerIcon;
}
const getNavidromeStatValues = (stats: NavidromeDashboardData) =>
({
artists: stats.artistCount,
albums: stats.albumCount,
songs: stats.songCount,
}) as const;
const getAudiobookshelfStatValues = (stats: AudiobookshelfDashboardData) =>
({
libraryCount: stats.libraryCount,
audiobooks: stats.totalAudiobooks,
podcasts: stats.totalPodcasts,
listeningTime: formatDuration(stats.totalListeningTimeSeconds * 1000),
activeSessions: stats.activeSessions,
}) as const;
const statValueGetterByBackend = {
navidrome: (stats: NavidromeDashboardData | AudiobookshelfDashboardData) =>
getNavidromeStatValues(stats as NavidromeDashboardData),
audiobookshelf: (stats: NavidromeDashboardData | AudiobookshelfDashboardData) =>
getAudiobookshelfStatValues(stats as AudiobookshelfDashboardData),
} as const;
export const getVisibleStats = (
backend: AudioStatsBackend,
options: AudioStatsDisplayOptions,
stats: NavidromeDashboardData | AudiobookshelfDashboardData,
): VisibleStat[] => {
const visibilityOptions = statVisibilityOptionsByBackend[backend];
const statIcons = statIconsByBackend[backend];
const statValues = statValueGetterByBackend[backend](stats);
return Object.entries(visibilityOptions)
.filter(([optionKey]) => Boolean(options[optionKey as keyof typeof visibilityOptions]))
.map(([optionKey, statKey]) => ({
optionKey,
statKey,
value: statValues[statKey as keyof typeof statValues],
Icon: statIcons[statKey as keyof typeof statIcons],
}));
};
export const getGridCols = (width: number, visibleCount: number, compact: boolean): number => {
const colsTable = gridColsByCompact[String(compact) as keyof typeof gridColsByCompact];
const match = colsTable.find(({ minWidth }) => width >= minWidth);
const maxCols = match?.cols ?? 1;
return Math.min(maxCols, Math.max(visibleCount, 1));
};
const fallbackIconSize = { true: 14, false: 16 } as const;
export const getIconSize = (width: number, compact: boolean): number => {
const sizeTable = iconSizeByCompact[String(compact) as keyof typeof iconSizeByCompact];
const match = sizeTable.find(({ minWidth }) => width >= minWidth);
return match?.size ?? fallbackIconSize[String(compact) as keyof typeof fallbackIconSize];
};
export const AUDIO_STATS_TRANSLATION_SCOPE = "widget.audioStats" as const;
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more