Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79cb9277e6 | ||
|
|
3d2c4979a6 | ||
|
|
79b27815fe | ||
|
|
76eacf8353 | ||
|
|
0c14e715a0 |
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).
|
||||
@@ -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`
|
||||
+2
-1
@@ -66,7 +66,8 @@ e2e/shared/tmp
|
||||
#personal backgrounds
|
||||
apps/nextjs/public/images/background.png
|
||||
|
||||
|
||||
# next-intl
|
||||
en.d.json.ts
|
||||
|
||||
# personal agent config
|
||||
AGENTS.md
|
||||
|
||||
@@ -22,6 +22,9 @@ const homarrVersion = (() => {
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin({
|
||||
experimental: {
|
||||
createMessagesDeclaration: "../../packages/translation/src/lang/en.json",
|
||||
},
|
||||
requestConfig: "../../packages/translation/src/request.ts",
|
||||
});
|
||||
|
||||
|
||||
@@ -56,8 +56,6 @@
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@mantine/modals": "catalog:",
|
||||
"@mantine/tiptap": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
@@ -82,10 +80,7 @@
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mantine-datatable": "catalog:",
|
||||
"mantine-react-table": "catalog:",
|
||||
"mcp-handler": "catalog:",
|
||||
"next": "catalog:",
|
||||
"postcss-preset-mantine": "catalog:",
|
||||
"prismjs": "catalog:",
|
||||
@@ -96,7 +91,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:"
|
||||
@@ -110,6 +105,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 });
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import "@gfazioli/mantine-onboarding-tour/styles.css";
|
||||
import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
import "@homarr/ui/styles.css";
|
||||
import "mantine-datatable/styles.css";
|
||||
import "~/styles/color-scheme.scss";
|
||||
import "~/styles/scroll-area.scss";
|
||||
|
||||
|
||||
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
-6
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"./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",
|
||||
@@ -51,7 +50,6 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"superjson": "catalog:",
|
||||
"trpc-to-mcp": "catalog:",
|
||||
"trpc-to-openapi": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -647,7 +613,7 @@ export const boardRouter = createTRPCRouter({
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
|
||||
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
|
||||
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
|
||||
const layoutToClone =
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@@ -1249,7 +1215,7 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
return {
|
||||
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
|
||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
users: userPermissions
|
||||
@@ -1257,7 +1223,7 @@ export const boardRouter = createTRPCRouter({
|
||||
user,
|
||||
permission,
|
||||
}))
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
.sort((permissionA, permissionB) => {
|
||||
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||
}),
|
||||
groups: dbGroupBoardPermission
|
||||
@@ -1268,7 +1234,7 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
permission,
|
||||
}))
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
.sort((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
};
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1733,7 +1586,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
...otherBoardProperties,
|
||||
layouts: layouts
|
||||
.map(({ boardId: _, ...layout }) => layout)
|
||||
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
||||
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
||||
sections: sections.map(({ collapseStates, ...section }) =>
|
||||
parseSection({
|
||||
...section,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
+16
-143
@@ -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,135 +0,0 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
beszelAlertsRequestHandler,
|
||||
beszelStatsRequestHandler,
|
||||
beszelSystemsRequestHandler,
|
||||
} from "@homarr/request-handler/beszel";
|
||||
import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const beszelRouter = createTRPCRouter({
|
||||
getSystems: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
systems: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeSystems: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((systems) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
systems,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsubscribes.forEach((fn) => fn());
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getAlerts: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
includeHistory: z.boolean().default(true),
|
||||
maxHistoryItems: z.number().min(1).max(100).default(10),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const [alertsResult, systemsResult] = await Promise.all([
|
||||
alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
|
||||
systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
|
||||
]);
|
||||
const systemNameMap: Record<string, string> = {};
|
||||
for (const system of systemsResult.data) {
|
||||
systemNameMap[system.id] = system.name;
|
||||
}
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
alerts: alertsResult.data.alerts,
|
||||
history: alertsResult.data.history,
|
||||
systemNameMap,
|
||||
updatedAt: alertsResult.timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeAlerts: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
includeHistory: z.boolean().default(true),
|
||||
maxHistoryItems: z.number().min(1).max(100).default(10),
|
||||
}),
|
||||
)
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
return innerHandler.subscribe((data) => {
|
||||
emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() });
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsubscribes.forEach((fn) => fn());
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getSystemStats: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]),
|
||||
includeDocker: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = ctx.integrations[0];
|
||||
if (!integration) return null;
|
||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
includeDocker: input.includeDocker,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,11 +3,9 @@ import { lazy } from "@trpc/server";
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
|
||||
export const widgetRouter = createTRPCRouter({
|
||||
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
|
||||
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 +30,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;
|
||||
}),
|
||||
});
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -10,9 +10,6 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
|
||||
(integration, itemOptions: Record<string, never>) => {
|
||||
const { kind } = integration;
|
||||
|
||||
if (kind === "beszel") {
|
||||
return null as never;
|
||||
}
|
||||
if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") {
|
||||
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
} from "../schema";
|
||||
import type { Integration } from "../schema";
|
||||
|
||||
const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
|
||||
const isTruthyEnv = (value: string | undefined) =>
|
||||
["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
|
||||
|
||||
export const seedDataAsync = async (db: Database) => {
|
||||
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
|
||||
@@ -409,13 +410,7 @@ const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
|
||||
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
|
||||
}),
|
||||
),
|
||||
// Row 9: beszelSystemGrid + beszelAlerts = 12
|
||||
{ kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true },
|
||||
{ kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true },
|
||||
// Row 10: beszelSystemTable + beszelSystemStats = 12
|
||||
{ kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true },
|
||||
{ kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true },
|
||||
// Row 11: notebook + dockerContainers + weather = 12
|
||||
// Row 9: notebook + dockerContainers + weather = 12
|
||||
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
|
||||
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
|
||||
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"]],
|
||||
@@ -467,14 +433,6 @@ export const integrationDefs = {
|
||||
category: ["ups"],
|
||||
documentationUrl: null,
|
||||
},
|
||||
beszel: {
|
||||
name: "Beszel",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: null,
|
||||
defaultPort: 8090,
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
name: "Mock",
|
||||
@@ -494,7 +452,6 @@ export const integrationDefs = {
|
||||
"notifications",
|
||||
"smartHomeServer",
|
||||
"ups",
|
||||
"uptimeMonitoring",
|
||||
],
|
||||
documentationUrl: null,
|
||||
},
|
||||
@@ -583,9 +540,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"],
|
||||
};
|
||||
|
||||
|
||||
@@ -35,22 +35,9 @@ export const widgetKinds = [
|
||||
"timetable",
|
||||
"immich-serverStats",
|
||||
"immich-albumCarousel",
|
||||
"paperlessNgx",
|
||||
"tracearr",
|
||||
"speedtestTracker",
|
||||
"uptimeKuma",
|
||||
"audioStats",
|
||||
"umami",
|
||||
"ups",
|
||||
"beszelSystemTable",
|
||||
"beszelSystemGrid",
|
||||
"beszelAlerts",
|
||||
"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>;
|
||||
@@ -1,9 +1,7 @@
|
||||
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 +30,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 +48,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,14 +122,9 @@ 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>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { SessionStore } from "../base/session-store";
|
||||
import { createSessionStore } from "../base/session-store";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type {
|
||||
BeszelAlert,
|
||||
BeszelAlertHistory,
|
||||
BeszelAuthResponse,
|
||||
BeszelContainer,
|
||||
BeszelContainerStatsRecord,
|
||||
BeszelSmartDevice,
|
||||
BeszelSystem,
|
||||
BeszelSystemDetails,
|
||||
BeszelSystemdService,
|
||||
BeszelSystemStatsRecord,
|
||||
CreateAlertInput,
|
||||
PocketBaseListResponse,
|
||||
UpdateAlertInput,
|
||||
} from "./beszel-types";
|
||||
|
||||
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
|
||||
interface BeszelSession {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export class BeszelIntegration extends Integration {
|
||||
private readonly sessionStore: SessionStore<BeszelSession>;
|
||||
|
||||
constructor(integration: IntegrationInput) {
|
||||
super(integration);
|
||||
this.sessionStore = createSessionStore(integration);
|
||||
}
|
||||
|
||||
private async authenticateAsync(): Promise<BeszelSession> {
|
||||
const existingSession = await this.sessionStore.getAsync();
|
||||
if (existingSession) {
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
identity: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BeszelAuthResponse;
|
||||
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
||||
await this.sessionStore.setAsync(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
|
||||
let session = await this.authenticateAsync();
|
||||
|
||||
const doFetch = (token: string) =>
|
||||
fetchWithTrustedCertificatesAsync(url, {
|
||||
method: options.method ?? "GET",
|
||||
headers: {
|
||||
Authorization: token,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: options.body,
|
||||
});
|
||||
|
||||
let response = await doFetch(session.token);
|
||||
|
||||
if (response.status === 401) {
|
||||
await this.sessionStore.clearAsync();
|
||||
session = await this.authenticateAsync();
|
||||
response = await doFetch(session.token);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async getSystemsAsync(): Promise<BeszelSystem[]> {
|
||||
const response = await this.fetchWithAuthAsync(
|
||||
this.url("/api/collections/systems/records", { perPage: "500", sort: "-updated" }),
|
||||
);
|
||||
const data = (await response.json()) as PocketBaseListResponse<BeszelSystem>;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
public async getSystemDetailsAsync(systemId: string): Promise<BeszelSystemDetails> {
|
||||
const response = await this.fetchWithAuthAsync(
|
||||
this.url(`/api/collections/system_details/records/${systemId}` as `/${string}`),
|
||||
);
|
||||
return (await response.json()) as BeszelSystemDetails;
|
||||
}
|
||||
|
||||
public async getSystemStatsAsync(systemId: string, type = "1m", perPage = 60): Promise<BeszelSystemStatsRecord[]> {
|
||||
const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
|
||||
const response = await this.fetchWithAuthAsync(
|
||||
this.url("/api/collections/system_stats/records", {
|
||||
filter,
|
||||
sort: "-created",
|
||||
perPage: String(perPage),
|
||||
}),
|
||||
);
|
||||
const data = (await response.json()) as PocketBaseListResponse<BeszelSystemStatsRecord>;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
public async getContainersAsync(systemId: string): Promise<BeszelContainer[]> {
|
||||
const response = await this.fetchWithAuthAsync(
|
||||
this.url("/api/collections/containers/records", {
|
||||
filter: `system='${escapeFilterValue(systemId)}'`,
|
||||
sort: "-updated",
|
||||
perPage: "500",
|
||||
}),
|
||||
);
|
||||
const data = (await response.json()) as PocketBaseListResponse<BeszelContainer>;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
public async getContainerStatsAsync(
|
||||
systemId: string,
|
||||
type = "1m",
|
||||
perPage = 60,
|
||||
): Promise<BeszelContainerStatsRecord[]> {
|
||||
const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
|
||||
const response = await this.fetchWithAuthAsync(
|
||||
this.url("/api/collections/container_stats/records", {
|
||||
filter,
|
||||
sort: "-created",
|
||||
perPage: String(perPage),
|
||||
}),
|
||||
);
|
||||
const data = (await response.json()) as PocketBaseListResponse<BeszelContainerStatsRecord>;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
public async getSmartDevicesAsync(systemId: string): Promise<BeszelSmartDevice[]> {
|
||||
const response = await this.fetchWithAuthAsync(
|
||||
this.url("/api/collections/smart_devices/records", {
|
||||
filter: `system='${escapeFilterValue(systemId)}'`,
|
||||
}),
|
||||
);
|
||||
const data = (await response.json()) as PocketBaseListResponse<BeszelSmartDevice>;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
public async getSystemdServicesAsync(systemId: string): Promise<BeszelSystemdService[]> {
|
||||
const response = await this.fetchWithAuthAsync(
|
||||
this.url("/api/collections/systemd_services/records", {
|
||||
filter: `system='${escapeFilterValue(systemId)}'`,
|
||||
sort: "name",
|
||||
}),
|
||||
);
|
||||
const data = (await response.json()) as PocketBaseListResponse<BeszelSystemdService>;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
public async getAlertsAsync(systemId?: string): Promise<BeszelAlert[]> {
|
||||
const params: Record<string, string> = { perPage: "500" };
|
||||
if (systemId) {
|
||||
params.filter = `system='${escapeFilterValue(systemId)}'`;
|
||||
}
|
||||
const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records", params));
|
||||
const data = (await response.json()) as PocketBaseListResponse<BeszelAlert>;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
public async getAlertHistoryAsync(systemId?: string, perPage = 50): Promise<BeszelAlertHistory[]> {
|
||||
const params: Record<string, string> = {
|
||||
sort: "-created",
|
||||
perPage: String(perPage),
|
||||
};
|
||||
if (systemId) {
|
||||
params.filter = `system='${escapeFilterValue(systemId)}'`;
|
||||
}
|
||||
const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts_history/records", params));
|
||||
const data = (await response.json()) as PocketBaseListResponse<BeszelAlertHistory>;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
public async createAlertAsync(systemId: string, input: CreateAlertInput): Promise<BeszelAlert> {
|
||||
const session = await this.authenticateAsync();
|
||||
const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
system: systemId,
|
||||
user: session.userId,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
min: input.min ?? 0,
|
||||
triggered: false,
|
||||
}),
|
||||
});
|
||||
return (await response.json()) as BeszelAlert;
|
||||
}
|
||||
|
||||
public async updateAlertAsync(alertId: string, input: UpdateAlertInput): Promise<BeszelAlert> {
|
||||
const response = await this.fetchWithAuthAsync(
|
||||
this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`),
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
return (await response.json()) as BeszelAlert;
|
||||
}
|
||||
|
||||
public async deleteAlertAsync(alertId: string): Promise<void> {
|
||||
await this.fetchWithAuthAsync(this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
public async pauseSystemAsync(systemId: string): Promise<void> {
|
||||
await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "paused" }),
|
||||
});
|
||||
}
|
||||
|
||||
public async resumeSystemAsync(systemId: string): Promise<void> {
|
||||
await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "pending" }),
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteSystemAsync(systemId: string): Promise<void> {
|
||||
await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
identity: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult({ status: response.status, url: response.url });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BeszelAuthResponse;
|
||||
if (!data.token) {
|
||||
return TestConnectionError.UnauthorizedResult(401);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
export interface BeszelAuthResponse {
|
||||
token: string;
|
||||
record: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
verified: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type BeszelSystemStatus = "up" | "down" | "paused" | "pending";
|
||||
|
||||
/**
|
||||
* Real-time system info snapshot from Beszel agent.
|
||||
* Returned as `system.info` on each BeszelSystem record.
|
||||
* All resource values are percentages or already-scaled summaries.
|
||||
*/
|
||||
export interface BeszelSystemInfo {
|
||||
/** hostname */
|
||||
h?: string;
|
||||
/** CPU usage (%) */
|
||||
cpu: number;
|
||||
/** temperature (°C), overall */
|
||||
t?: number;
|
||||
/** CPU core count */
|
||||
c?: number;
|
||||
/** CPU thread count */
|
||||
ct?: number;
|
||||
/** CPU model string */
|
||||
m?: string;
|
||||
/** load average [1m, 5m, 15m] */
|
||||
la?: [number, number, number];
|
||||
/** OS identifier string */
|
||||
o?: string;
|
||||
/** uptime (seconds) */
|
||||
u: number;
|
||||
/** memory usage (%) */
|
||||
mp: number;
|
||||
/** disk usage (%) */
|
||||
dp: number;
|
||||
/** battery [level%, charging state] */
|
||||
bat?: [number, number];
|
||||
/** bandwidth — public interface (Mbps, legacy). Use bb when available */
|
||||
b?: number;
|
||||
/** bandwidth — public interface (bytes/s, newer format). Preferred over b */
|
||||
bb?: number;
|
||||
/** agent version */
|
||||
v: string;
|
||||
/** podman mode */
|
||||
p?: boolean;
|
||||
/** GPU usage (%) */
|
||||
g?: number;
|
||||
/** disk temperature (°C) */
|
||||
dt?: number;
|
||||
/** OS type identifier */
|
||||
os?: number;
|
||||
/** extra filesystem usage (%) keyed by mount path */
|
||||
efs?: Record<string, number>;
|
||||
/** systemd services [running, total] */
|
||||
sv?: [number, number];
|
||||
}
|
||||
|
||||
export interface BeszelSystem {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: string;
|
||||
status: BeszelSystemStatus;
|
||||
info: BeszelSystemInfo;
|
||||
users?: string[];
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface BeszelSystemDetails {
|
||||
id: string;
|
||||
system: string;
|
||||
hostname: string;
|
||||
kernel: string;
|
||||
cores: number;
|
||||
threads: number;
|
||||
cpu: string;
|
||||
os: number;
|
||||
os_name: string;
|
||||
arch: string;
|
||||
memory: number;
|
||||
podman: boolean;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical system stats record from Beszel PocketBase.
|
||||
* Stored per-interval (1m, 10m, 20m, 120m, 480m depending on time range).
|
||||
*
|
||||
* Units:
|
||||
* - CPU/GPU/memory/disk percentages: 0–100
|
||||
* - Memory/disk absolute values (mu, du, etc.): bytes
|
||||
* - Disk I/O (dr, dw): bytes/s
|
||||
* - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges)
|
||||
* - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts
|
||||
*/
|
||||
export interface BeszelSystemStats {
|
||||
/** CPU usage (%) */
|
||||
cpu: number;
|
||||
/** CPU usage max (%) */
|
||||
cpum?: number;
|
||||
/** per-core busy (%) */
|
||||
cpub?: number[];
|
||||
/** per-core speed (MHz) */
|
||||
cpus?: number[];
|
||||
/** load average [1m, 5m, 15m] */
|
||||
la?: [number, number, number];
|
||||
/** total memory (bytes) */
|
||||
m: number;
|
||||
/** memory used (bytes) */
|
||||
mu: number;
|
||||
/** memory usage (%) */
|
||||
mp: number;
|
||||
/** memory buffer/cache (bytes) */
|
||||
mb: number;
|
||||
/** memory max used (bytes) */
|
||||
mm?: number;
|
||||
/** memory zfs arc (bytes) */
|
||||
mz?: number;
|
||||
/** total swap (bytes) */
|
||||
s: number;
|
||||
/** swap used (bytes) */
|
||||
su: number;
|
||||
/** total disk (bytes) */
|
||||
d: number;
|
||||
/** disk used (bytes) */
|
||||
du: number;
|
||||
/** disk usage (%) */
|
||||
dp: number;
|
||||
/** disk read (bytes/s) */
|
||||
dr: number;
|
||||
/** disk write (bytes/s) */
|
||||
dw: number;
|
||||
/** disk read max (bytes/s) */
|
||||
drm?: number;
|
||||
/** disk write max (bytes/s) */
|
||||
dwm?: number;
|
||||
/** disk IOPS [read, write] */
|
||||
dio?: [number, number];
|
||||
/** disk IOPS max [read, write] */
|
||||
diom?: [number, number];
|
||||
/** network sent — all interfaces (bytes/s) */
|
||||
ns: number;
|
||||
/** network received — all interfaces (bytes/s) */
|
||||
nr: number;
|
||||
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
|
||||
b?: [number, number];
|
||||
/** network sent max (bytes/s) */
|
||||
nsm?: number;
|
||||
/** network received max (bytes/s) */
|
||||
nrm?: number;
|
||||
/** bandwidth max [sent, recv] (bytes/s) */
|
||||
bm?: [number, number];
|
||||
/** temperatures keyed by sensor name (°C) */
|
||||
t?: Record<string, number>;
|
||||
/** extra filesystem stats keyed by mount path */
|
||||
efs?: Record<string, BeszelExtraFsStats>;
|
||||
/** GPU stats keyed by device id */
|
||||
g?: Record<string, BeszelGPUData>;
|
||||
/** battery [level%, charging state] */
|
||||
bat?: [number, number];
|
||||
/** network interfaces [rx bytes/s, tx bytes/s, rx max, tx max] */
|
||||
ni?: Record<string, [number, number, number, number]>;
|
||||
}
|
||||
|
||||
export interface BeszelExtraFsStats {
|
||||
/** total disk (bytes) */
|
||||
d: number;
|
||||
/** disk used (bytes) */
|
||||
du: number;
|
||||
/** read (bytes/s) */
|
||||
r: number;
|
||||
/** write (bytes/s) */
|
||||
w: number;
|
||||
/** read max (bytes/s) */
|
||||
rm: number;
|
||||
/** write max (bytes/s) */
|
||||
wm: number;
|
||||
/** read (IOPS) */
|
||||
rb: number;
|
||||
/** write (IOPS) */
|
||||
wb: number;
|
||||
/** read max (IOPS) */
|
||||
rbm: number;
|
||||
/** write max (IOPS) */
|
||||
wbm: number;
|
||||
}
|
||||
|
||||
export interface BeszelGPUData {
|
||||
/** GPU name/model */
|
||||
n: string;
|
||||
/** memory used (bytes) */
|
||||
mu?: number;
|
||||
/** memory total (bytes) */
|
||||
mt?: number;
|
||||
/** GPU utilization (%) */
|
||||
u: number;
|
||||
/** power draw (W) */
|
||||
p?: number;
|
||||
/** power peak (W) */
|
||||
pp?: number;
|
||||
/** encoder utilization keyed by engine name (%) */
|
||||
e?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BeszelSystemStatsRecord {
|
||||
id: string;
|
||||
system: string;
|
||||
stats: BeszelSystemStats;
|
||||
type: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface BeszelContainer {
|
||||
id: string;
|
||||
system: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
health: number;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
net: number;
|
||||
ports?: string;
|
||||
updated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-container resource stats from Beszel agent.
|
||||
* One entry per container in a BeszelContainerStatsRecord.
|
||||
*
|
||||
* Network fields: b (bandwidth) is preferred when available; fall back to ns/nr.
|
||||
*/
|
||||
export interface BeszelContainerStats {
|
||||
/** container name */
|
||||
n: string;
|
||||
/** CPU usage (%) */
|
||||
c: number;
|
||||
/** memory usage (bytes) */
|
||||
m: number;
|
||||
/** network sent (bytes/s) — legacy, use b when available */
|
||||
ns?: number;
|
||||
/** network received (bytes/s) — legacy, use b when available */
|
||||
nr?: number;
|
||||
/** network bandwidth [sent, recv] (bytes/s) — preferred over ns/nr */
|
||||
b?: [number, number];
|
||||
}
|
||||
|
||||
export interface BeszelContainerStatsRecord {
|
||||
id: string;
|
||||
system: string;
|
||||
stats: BeszelContainerStats[];
|
||||
type: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface BeszelSmartDevice {
|
||||
id: string;
|
||||
system: string;
|
||||
name: string;
|
||||
model: string;
|
||||
state: string;
|
||||
capacity: number;
|
||||
temp: number;
|
||||
firmware: string;
|
||||
serial: string;
|
||||
type: string;
|
||||
hours: number;
|
||||
cycles: number;
|
||||
attributes: BeszelSmartAttribute[];
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface BeszelSmartAttribute {
|
||||
/** attribute ID */
|
||||
id?: number;
|
||||
/** attribute name */
|
||||
n: string;
|
||||
/** current value */
|
||||
v: number;
|
||||
/** worst value */
|
||||
w?: number;
|
||||
/** threshold */
|
||||
t?: number;
|
||||
/** raw value (numeric) */
|
||||
rv?: number;
|
||||
/** raw value (string) */
|
||||
rs?: string;
|
||||
/** when failed */
|
||||
wf?: string;
|
||||
}
|
||||
|
||||
export interface BeszelSystemdService {
|
||||
id: string;
|
||||
system: string;
|
||||
name: string;
|
||||
state: number;
|
||||
sub: number;
|
||||
cpu: number;
|
||||
cpuPeak: number;
|
||||
memory: number;
|
||||
memPeak: number;
|
||||
updated: number;
|
||||
}
|
||||
|
||||
export interface BeszelAlert {
|
||||
id: string;
|
||||
user: string;
|
||||
system: string;
|
||||
name: string;
|
||||
triggered: boolean;
|
||||
value: number;
|
||||
min: number;
|
||||
}
|
||||
|
||||
export interface BeszelAlertHistory {
|
||||
id: string;
|
||||
alert: string;
|
||||
user: string;
|
||||
system: string;
|
||||
name: string;
|
||||
val: number;
|
||||
created: string;
|
||||
resolved?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAlertInput {
|
||||
name: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
}
|
||||
|
||||
export interface UpdateAlertInput {
|
||||
value?: number;
|
||||
min?: number;
|
||||
}
|
||||
|
||||
export interface PocketBaseListResponse<T> {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized view model for a Beszel-monitored system.
|
||||
* Constructed by mapToSystemRow() in request-handler from BeszelSystem + BeszelSystemDetails.
|
||||
* This is the shape consumed by grid/table widgets — all values are pre-scaled for display.
|
||||
*/
|
||||
export interface BeszelSystemRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: BeszelSystemStatus;
|
||||
/** CPU usage (%) */
|
||||
cpu: number;
|
||||
/** memory usage (%) */
|
||||
memory: number;
|
||||
/** disk usage (%) */
|
||||
disk: number;
|
||||
/** GPU usage (%) */
|
||||
gpu: number;
|
||||
/** load average [1m, 5m, 15m] */
|
||||
loadAvg: [number, number, number] | null;
|
||||
/** public interface bandwidth (bytes/s). From info.bb or info.b * 1M */
|
||||
netBytes: number;
|
||||
/** temperature (°C) */
|
||||
temp: number | null;
|
||||
/** battery [level%, charging state] */
|
||||
battery: [number, number] | null;
|
||||
/** running systemd services count */
|
||||
services: number;
|
||||
/** uptime (seconds) */
|
||||
uptime: number;
|
||||
agentVersion: string;
|
||||
hostname: string;
|
||||
cpuModel: string;
|
||||
cores: number;
|
||||
/** total memory (bytes) */
|
||||
memoryTotal: number;
|
||||
osName: string;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
process.env.ENABLE_DNS_CACHING = "false";
|
||||
});
|
||||
|
||||
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
|
||||
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
|
||||
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
createGetSetChannel: () => ({
|
||||
getAsync: vi.fn().mockResolvedValue(null),
|
||||
setAsync: vi.fn().mockResolvedValue(undefined),
|
||||
removeAsync: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
ErrorWithMetadata: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
|
||||
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
|
||||
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
import { BeszelIntegration } from "../beszel-integration";
|
||||
|
||||
function createBeszelIntegration() {
|
||||
return new BeszelIntegration({
|
||||
id: "test-beszel",
|
||||
name: "Test Beszel",
|
||||
url: BESZEL_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: BESZEL_EMAIL },
|
||||
{ kind: "password", value: BESZEL_PASSWORD },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const shouldRun = BESZEL_EMAIL && BESZEL_PASSWORD;
|
||||
|
||||
describe.skipIf(!shouldRun)("BeszelIntegration (real instance)", () => {
|
||||
let integration: BeszelIntegration;
|
||||
let systemId: string;
|
||||
|
||||
beforeAll(() => {
|
||||
integration = createBeszelIntegration();
|
||||
});
|
||||
|
||||
describe("getSystemsAsync", () => {
|
||||
test("returns an array of systems", async () => {
|
||||
const systems = await integration.getSystemsAsync();
|
||||
expect(systems).toBeInstanceOf(Array);
|
||||
expect(systems.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("each system has required fields", async () => {
|
||||
const systems = await integration.getSystemsAsync();
|
||||
const system = systems[0];
|
||||
if (!system) {
|
||||
throw new Error("Expected at least one system");
|
||||
}
|
||||
systemId = system.id;
|
||||
|
||||
expect(system.id).toBeDefined();
|
||||
expect(system.name).toBeDefined();
|
||||
expect(system.status).toMatch(/^(up|down|paused|pending)$/);
|
||||
expect(system.info).toBeDefined();
|
||||
expect(typeof system.info.cpu).toBe("number");
|
||||
expect(typeof system.info.mp).toBe("number");
|
||||
expect(typeof system.info.dp).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSystemDetailsAsync", () => {
|
||||
test("returns system details with hardware info", async () => {
|
||||
const systems = await integration.getSystemsAsync();
|
||||
systemId = systems[0]?.id ?? "";
|
||||
|
||||
const details = await integration.getSystemDetailsAsync(systemId);
|
||||
|
||||
expect(details.hostname).toBeDefined();
|
||||
expect(details.cpu).toBeDefined();
|
||||
expect(typeof details.cores).toBe("number");
|
||||
expect(details.cores).toBeGreaterThan(0);
|
||||
expect(typeof details.memory).toBe("number");
|
||||
expect(details.memory).toBeGreaterThan(0);
|
||||
expect(details.os_name).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSystemStatsAsync", () => {
|
||||
test("returns time-series stats records", async () => {
|
||||
const systems = await integration.getSystemsAsync();
|
||||
systemId = systems[0]?.id ?? "";
|
||||
|
||||
const stats = await integration.getSystemStatsAsync(systemId, "1m", 5);
|
||||
|
||||
expect(stats).toBeInstanceOf(Array);
|
||||
if (stats.length === 0) return;
|
||||
|
||||
const record = stats[0];
|
||||
expect(record?.stats).toBeDefined();
|
||||
expect(typeof record?.stats.cpu).toBe("number");
|
||||
expect(typeof record?.stats.mp).toBe("number");
|
||||
expect(typeof record?.stats.dp).toBe("number");
|
||||
expect(record?.created).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContainersAsync", () => {
|
||||
test("returns container list", async () => {
|
||||
const systems = await integration.getSystemsAsync();
|
||||
systemId = systems[0]?.id ?? "";
|
||||
|
||||
const containers = await integration.getContainersAsync(systemId);
|
||||
expect(containers).toBeInstanceOf(Array);
|
||||
|
||||
if (containers.length > 0) {
|
||||
const container = containers[0];
|
||||
expect(container?.name).toBeDefined();
|
||||
expect(container?.image).toBeDefined();
|
||||
expect(container?.status).toBeDefined();
|
||||
expect(typeof container?.cpu).toBe("number");
|
||||
expect(typeof container?.memory).toBe("number");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("alert CRUD", () => {
|
||||
let alertId: string;
|
||||
|
||||
test("creates an alert", async () => {
|
||||
const systems = await integration.getSystemsAsync();
|
||||
systemId = systems[0]?.id ?? "";
|
||||
|
||||
const alert = await integration.createAlertAsync(systemId, {
|
||||
name: "CPU",
|
||||
value: 90,
|
||||
min: 1,
|
||||
});
|
||||
|
||||
expect(alert.id).toBeDefined();
|
||||
expect(alert.name).toBe("CPU");
|
||||
expect(alert.value).toBe(90);
|
||||
expect(alert.system).toBe(systemId);
|
||||
alertId = alert.id;
|
||||
});
|
||||
|
||||
test("lists alerts including the created one", async () => {
|
||||
const alerts = await integration.getAlertsAsync(systemId);
|
||||
const found = alerts.find((a) => a.id === alertId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.name).toBe("CPU");
|
||||
});
|
||||
|
||||
test("updates alert threshold", async () => {
|
||||
const updated = await integration.updateAlertAsync(alertId, { value: 95 });
|
||||
expect(updated.value).toBe(95);
|
||||
});
|
||||
|
||||
test("deletes alert", async () => {
|
||||
await integration.deleteAlertAsync(alertId);
|
||||
const alerts = await integration.getAlertsAsync(systemId);
|
||||
const found = alerts.find((a) => a.id === alertId);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("system actions", () => {
|
||||
test("pauses and resumes a system", async () => {
|
||||
const systems = await integration.getSystemsAsync();
|
||||
systemId = systems[0]?.id ?? "";
|
||||
|
||||
await integration.pauseSystemAsync(systemId);
|
||||
const afterPause = await integration.getSystemsAsync();
|
||||
const paused = afterPause.find((s) => s.id === systemId);
|
||||
expect(paused?.status).toBe("paused");
|
||||
|
||||
await integration.resumeSystemAsync(systemId);
|
||||
const afterResume = await integration.getSystemsAsync();
|
||||
const resumed = afterResume.find((s) => s.id === systemId);
|
||||
expect(resumed?.status).toMatch(/^(up|pending)$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication", () => {
|
||||
test("authenticates with valid credentials", async () => {
|
||||
const systems = await integration.getSystemsAsync();
|
||||
expect(systems.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("fails with invalid credentials", async () => {
|
||||
const badIntegration = new BeszelIntegration({
|
||||
id: "test-bad",
|
||||
name: "Bad Beszel",
|
||||
url: BESZEL_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: "wrong@example.com" },
|
||||
{ kind: "password", value: "wrongpassword" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(badIntegration.getSystemsAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,343 +0,0 @@
|
||||
import type {
|
||||
BeszelAlert,
|
||||
BeszelAlertHistory,
|
||||
BeszelContainerStats,
|
||||
BeszelContainerStatsRecord,
|
||||
BeszelSystem,
|
||||
BeszelSystemDetails,
|
||||
BeszelSystemStats,
|
||||
BeszelSystemStatsRecord,
|
||||
} from "../../beszel/beszel-types";
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const MB = 1024 * 1024;
|
||||
const KB = 1024;
|
||||
|
||||
interface MockSystemDef {
|
||||
name: string;
|
||||
host: string;
|
||||
os: string;
|
||||
cpu: string;
|
||||
cores: number;
|
||||
mem: number;
|
||||
arch: string;
|
||||
baseCpu: number;
|
||||
baseMem: number;
|
||||
baseDisk: number;
|
||||
containers: number[];
|
||||
}
|
||||
|
||||
const mockSystems: MockSystemDef[] = [
|
||||
{
|
||||
name: "web-server",
|
||||
host: "192.168.1.10",
|
||||
os: "Ubuntu 24.04",
|
||||
cpu: "AMD Ryzen 9 7950X",
|
||||
cores: 16,
|
||||
mem: 64 * GB,
|
||||
arch: "amd64",
|
||||
baseCpu: 35,
|
||||
baseMem: 55,
|
||||
baseDisk: 42,
|
||||
containers: [0, 1, 2, 3, 5],
|
||||
},
|
||||
{
|
||||
name: "nas-01",
|
||||
host: "192.168.1.20",
|
||||
os: "Debian 12",
|
||||
cpu: "Intel Xeon E-2388G",
|
||||
cores: 8,
|
||||
mem: 32 * GB,
|
||||
arch: "amd64",
|
||||
baseCpu: 12,
|
||||
baseMem: 70,
|
||||
baseDisk: 68,
|
||||
containers: [1, 2, 6],
|
||||
},
|
||||
{
|
||||
name: "pi-cluster-1",
|
||||
host: "192.168.1.30",
|
||||
os: "Raspberry Pi OS",
|
||||
cpu: "ARM Cortex-A76",
|
||||
cores: 4,
|
||||
mem: 8 * GB,
|
||||
arch: "aarch64",
|
||||
baseCpu: 45,
|
||||
baseMem: 82,
|
||||
baseDisk: 35,
|
||||
containers: [0, 2, 7],
|
||||
},
|
||||
{
|
||||
name: "media-server",
|
||||
host: "192.168.1.40",
|
||||
os: "Proxmox VE 8.2",
|
||||
cpu: "Intel i7-13700K",
|
||||
cores: 16,
|
||||
mem: 128 * GB,
|
||||
arch: "amd64",
|
||||
baseCpu: 22,
|
||||
baseMem: 48,
|
||||
baseDisk: 55,
|
||||
containers: [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
},
|
||||
{
|
||||
name: "backup-node",
|
||||
host: "192.168.1.50",
|
||||
os: "TrueNAS SCALE",
|
||||
cpu: "Intel Xeon E5-2680",
|
||||
cores: 12,
|
||||
mem: 48 * GB,
|
||||
arch: "amd64",
|
||||
baseCpu: 8,
|
||||
baseMem: 30,
|
||||
baseDisk: 78,
|
||||
containers: [1, 6],
|
||||
},
|
||||
];
|
||||
|
||||
const containerProfiles: { name: string; cpuBase: number; memMB: number; netKB: number }[] = [
|
||||
{ name: "nginx", cpuBase: 2, memMB: 120, netKB: 800 },
|
||||
{ name: "postgres", cpuBase: 8, memMB: 450, netKB: 200 },
|
||||
{ name: "redis", cpuBase: 3, memMB: 180, netKB: 500 },
|
||||
{ name: "grafana", cpuBase: 5, memMB: 280, netKB: 150 },
|
||||
{ name: "prometheus", cpuBase: 6, memMB: 350, netKB: 300 },
|
||||
{ name: "traefik", cpuBase: 4, memMB: 90, netKB: 1200 },
|
||||
{ name: "minio", cpuBase: 3, memMB: 200, netKB: 600 },
|
||||
{ name: "gitea", cpuBase: 4, memMB: 250, netKB: 100 },
|
||||
];
|
||||
|
||||
const rand = (min: number, max: number) => min + Math.random() * (max - min);
|
||||
const randInt = (min: number, max: number) => Math.floor(rand(min, max));
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
|
||||
function smoothWalk(base: number, amplitude: number, steps: number, lo: number, hi: number): number[] {
|
||||
const values: number[] = [];
|
||||
let current = base + rand(-amplitude * 0.3, amplitude * 0.3);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const spike = Math.random() < 0.08 ? rand(-amplitude * 2, amplitude * 3) : 0;
|
||||
current += rand(-amplitude, amplitude) + spike;
|
||||
current = clamp(current, lo, hi);
|
||||
values.push(current);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function resolveSystem(systemId: string): MockSystemDef {
|
||||
const idx = Number(systemId.replace("mock-sys-", "")) - 1;
|
||||
return mockSystems[idx] ?? mockSystems[0] ?? (mockSystems.find(Boolean) as MockSystemDef);
|
||||
}
|
||||
|
||||
const typeToMinutes: Record<string, number> = {
|
||||
"1m": 1,
|
||||
"10m": 10,
|
||||
"20m": 20,
|
||||
"120m": 120,
|
||||
"480m": 480,
|
||||
};
|
||||
|
||||
function generateSystemStatsSeries(count: number, sys: MockSystemDef): BeszelSystemStats[] {
|
||||
const cpuWalk = smoothWalk(sys.baseCpu, 3, count, 1, 98);
|
||||
const memPctWalk = smoothWalk(sys.baseMem, 1.5, count, 10, 95);
|
||||
const diskPctWalk = smoothWalk(sys.baseDisk, 0.2, count, 5, 95);
|
||||
const diskReadWalk = smoothWalk(5, 3, count, 0, 80);
|
||||
const diskWriteWalk = smoothWalk(3, 2, count, 0, 50);
|
||||
const netSendWalk = smoothWalk(500, 200, count, 10, 8000);
|
||||
const netRecvWalk = smoothWalk(800, 300, count, 10, 12000);
|
||||
|
||||
const memTotal = sys.mem;
|
||||
const diskTotal = rand(200, 2000) * GB;
|
||||
|
||||
return cpuWalk.map((cpu, i) => {
|
||||
const memPct = memPctWalk[i] ?? sys.baseMem;
|
||||
const diskPct = diskPctWalk[i] ?? sys.baseDisk;
|
||||
return {
|
||||
cpu,
|
||||
la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number],
|
||||
m: memTotal,
|
||||
mu: (memTotal * memPct) / 100,
|
||||
mp: memPct,
|
||||
mb: rand(0.5, 4) * GB,
|
||||
s: 4 * GB,
|
||||
su: rand(0, 2) * GB,
|
||||
d: diskTotal,
|
||||
du: (diskTotal * diskPct) / 100,
|
||||
dp: diskPct,
|
||||
dr: (diskReadWalk[i] ?? 5) * MB,
|
||||
dw: (diskWriteWalk[i] ?? 3) * MB,
|
||||
ns: (netSendWalk[i] ?? 500) * KB,
|
||||
nr: (netRecvWalk[i] ?? 800) * KB,
|
||||
b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generateContainerStatsSeries(count: number, profiles: typeof containerProfiles): BeszelContainerStats[][] {
|
||||
const walks = profiles.map((p) => ({
|
||||
profile: p,
|
||||
cpuWalk: smoothWalk(p.cpuBase, 1.5, count, 0, 50),
|
||||
memWalk: smoothWalk(p.memMB, p.memMB * 0.1, count, p.memMB * 0.5, p.memMB * 2),
|
||||
netWalk: smoothWalk(p.netKB, p.netKB * 0.3, count, 0, p.netKB * 4),
|
||||
}));
|
||||
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({
|
||||
n: profile.name,
|
||||
c: cpuWalk[i] ?? profile.cpuBase,
|
||||
m: (memWalk[i] ?? profile.memMB) * MB,
|
||||
b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function buildTimeSeries<T>(
|
||||
systemId: string,
|
||||
count: number,
|
||||
intervalMinutes: number,
|
||||
type: string,
|
||||
dataPoints: T[],
|
||||
): (T & { id: string; system: string; type: string; created: string; updated: string })[] {
|
||||
const now = Date.now();
|
||||
const items = dataPoints.slice(0, count).map((data, i) => {
|
||||
const ts = new Date(now - (count - i) * intervalMinutes * 60_000).toISOString();
|
||||
return { id: `mock-stat-${i}`, system: systemId, type, created: ts, updated: ts, ...data };
|
||||
});
|
||||
return items.toReversed();
|
||||
}
|
||||
|
||||
const statusByIndex: Record<number, "up" | "paused" | "down"> = { 2: "paused", 4: "down" };
|
||||
|
||||
export class BeszelMockService {
|
||||
public async getSystemsAsync(): Promise<BeszelSystem[]> {
|
||||
return mockSystems.map((sys, i) => ({
|
||||
id: `mock-sys-${i + 1}`,
|
||||
name: sys.name,
|
||||
host: sys.host,
|
||||
port: "45876",
|
||||
status: statusByIndex[i] ?? "up",
|
||||
info: {
|
||||
h: sys.name,
|
||||
cpu: clamp(sys.baseCpu + rand(-5, 10), 0, 100),
|
||||
c: sys.cores,
|
||||
ct: sys.cores * 2,
|
||||
m: sys.cpu,
|
||||
la: [sys.baseCpu / 20, sys.baseCpu / 25, sys.baseCpu / 30] as [number, number, number],
|
||||
o: sys.os,
|
||||
u: randInt(86400, 2_000_000),
|
||||
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
|
||||
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
|
||||
bb: rand(200, 3000) * KB,
|
||||
v: "0.8.2",
|
||||
g: i === 3 ? rand(15, 45) : 0,
|
||||
dt: rand(32, 58),
|
||||
sv: [randInt(8, 25), randInt(25, 40)] as [number, number],
|
||||
},
|
||||
users: [],
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
public async getSystemDetailsAsync(systemId: string): Promise<BeszelSystemDetails> {
|
||||
const sys = resolveSystem(systemId);
|
||||
return {
|
||||
id: systemId,
|
||||
system: systemId,
|
||||
hostname: sys.name,
|
||||
kernel: "6.8.0-40-generic",
|
||||
cores: sys.cores,
|
||||
threads: sys.cores * 2,
|
||||
cpu: sys.cpu,
|
||||
os: 1,
|
||||
os_name: sys.os,
|
||||
arch: sys.arch,
|
||||
memory: sys.mem,
|
||||
podman: false,
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
public async getSystemStatsAsync(
|
||||
systemId: string,
|
||||
type: string,
|
||||
perPage: number,
|
||||
): Promise<BeszelSystemStatsRecord[]> {
|
||||
const sys = resolveSystem(systemId);
|
||||
const intervalMinutes = typeToMinutes[type] ?? 1;
|
||||
const statsSeries = generateSystemStatsSeries(perPage, sys);
|
||||
return buildTimeSeries(
|
||||
systemId,
|
||||
perPage,
|
||||
intervalMinutes,
|
||||
type,
|
||||
statsSeries.map((stats) => ({ stats })),
|
||||
);
|
||||
}
|
||||
|
||||
public async getContainerStatsAsync(
|
||||
systemId: string,
|
||||
type: string,
|
||||
perPage: number,
|
||||
): Promise<BeszelContainerStatsRecord[]> {
|
||||
const sys = resolveSystem(systemId);
|
||||
const intervalMinutes = typeToMinutes[type] ?? 1;
|
||||
const selectedProfiles = sys.containers
|
||||
.map((i) => containerProfiles[i])
|
||||
.filter(Boolean) as typeof containerProfiles;
|
||||
const series = generateContainerStatsSeries(perPage, selectedProfiles);
|
||||
return buildTimeSeries(
|
||||
systemId,
|
||||
perPage,
|
||||
intervalMinutes,
|
||||
type,
|
||||
series.map((stats) => ({ stats })),
|
||||
);
|
||||
}
|
||||
|
||||
public async getAlertsAsync(): Promise<BeszelAlert[]> {
|
||||
return [
|
||||
{ id: "mock-alert-1", user: "mock-user", system: "mock-sys-1", name: "CPU", triggered: true, value: 90, min: 5 },
|
||||
{
|
||||
id: "mock-alert-2",
|
||||
user: "mock-user",
|
||||
system: "mock-sys-2",
|
||||
name: "Memory",
|
||||
triggered: false,
|
||||
value: 80,
|
||||
min: 10,
|
||||
},
|
||||
{
|
||||
id: "mock-alert-3",
|
||||
user: "mock-user",
|
||||
system: "mock-sys-1",
|
||||
name: "Disk",
|
||||
triggered: false,
|
||||
value: 85,
|
||||
min: 15,
|
||||
},
|
||||
{
|
||||
id: "mock-alert-4",
|
||||
user: "mock-user",
|
||||
system: "mock-sys-4",
|
||||
name: "Temperature",
|
||||
triggered: true,
|
||||
value: 75,
|
||||
min: 3,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise<BeszelAlertHistory[]> {
|
||||
const now = Date.now();
|
||||
const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"];
|
||||
return Array.from({ length: Math.min(perPage, 5) }, (_, i) => ({
|
||||
id: `mock-history-${i}`,
|
||||
alert: `mock-alert-${(i % 4) + 1}`,
|
||||
user: "mock-user",
|
||||
system: `mock-sys-${(i % 5) + 1}`,
|
||||
name: names[i % names.length] ?? "CPU",
|
||||
val: rand(60, 95),
|
||||
created: new Date(now - (i + 1) * 3_600_000).toISOString(),
|
||||
resolved: i % 2 === 0 ? new Date(now - i * 1_800_000).toISOString() : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
|
||||
import { NotificationsMockService } from "./data/notifications";
|
||||
import { SmartHomeMockService } from "./data/smart-home";
|
||||
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
||||
import { BeszelMockService } from "./data/beszel";
|
||||
import { UpsSummaryMockService } from "./data/ups-summary";
|
||||
|
||||
export class MockIntegration
|
||||
@@ -62,7 +61,6 @@ export class MockIntegration
|
||||
private static readonly networkController = new NetworkControllerSummaryMockService();
|
||||
private static readonly notifications = new NotificationsMockService();
|
||||
private static readonly smartHome = new SmartHomeMockService();
|
||||
private static readonly beszel = new BeszelMockService();
|
||||
private static readonly upsSummary = new UpsSummaryMockService();
|
||||
|
||||
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
||||
@@ -132,12 +130,4 @@ export class MockIntegration
|
||||
|
||||
// UpsSummaryIntegration
|
||||
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
|
||||
|
||||
// BeszelIntegration
|
||||
getSystemsAsync = MockIntegration.beszel.getSystemsAsync.bind(MockIntegration.beszel);
|
||||
getSystemDetailsAsync = MockIntegration.beszel.getSystemDetailsAsync.bind(MockIntegration.beszel);
|
||||
getSystemStatsAsync = MockIntegration.beszel.getSystemStatsAsync.bind(MockIntegration.beszel);
|
||||
getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel);
|
||||
getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel);
|
||||
getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,9 +17,4 @@ 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>;
|
||||
@@ -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",
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type {
|
||||
BeszelAlert,
|
||||
BeszelAlertHistory,
|
||||
BeszelContainerStatsRecord,
|
||||
BeszelSystem,
|
||||
BeszelSystemDetails,
|
||||
BeszelSystemRow,
|
||||
BeszelSystemStatsRecord,
|
||||
} from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||
|
||||
/**
|
||||
* Maps raw Beszel API responses (BeszelSystem + BeszelSystemDetails) into a
|
||||
* normalized BeszelSystemRow for widget consumption.
|
||||
*
|
||||
* BeszelSystemInfo uses short field names (see beszel-types.ts for full docs):
|
||||
* cpu=CPU%, mp=memory%, dp=disk%, g=GPU%, la=loadAvg, u=uptime(s), v=version,
|
||||
* bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery,
|
||||
* sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads
|
||||
*/
|
||||
function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow {
|
||||
const info = system.info;
|
||||
return {
|
||||
id: system.id,
|
||||
name: system.name,
|
||||
status: system.status,
|
||||
cpu: info.cpu,
|
||||
memory: info.mp,
|
||||
disk: info.dp,
|
||||
gpu: info.g ?? 0,
|
||||
loadAvg: info.la ?? null,
|
||||
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
|
||||
netBytes: info.bb ?? (info.b ?? 0) * 1_000_000,
|
||||
temp: info.dt ?? null,
|
||||
battery: info.bat ?? null,
|
||||
services: info.sv?.[0] ?? 0,
|
||||
uptime: info.u,
|
||||
agentVersion: info.v,
|
||||
hostname: details?.hostname ?? info.h ?? system.name,
|
||||
cpuModel: details?.cpu ?? info.m ?? "Unknown",
|
||||
cores: details?.cores ?? info.ct ?? info.c ?? 1,
|
||||
memoryTotal: details?.memory ?? 0,
|
||||
osName: details?.os_name ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
BeszelSystemRow[],
|
||||
"beszel" | "mock",
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const systems = await instance.getSystemsAsync();
|
||||
const enriched = await Promise.all(
|
||||
systems.map(async (system) => {
|
||||
const details = await instance.getSystemDetailsAsync(system.id).catch(() => null);
|
||||
return mapToSystemRow(system, details);
|
||||
}),
|
||||
);
|
||||
return enriched;
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "beszelSystems",
|
||||
});
|
||||
|
||||
export interface BeszelAlertsData {
|
||||
alerts: BeszelAlert[];
|
||||
history: BeszelAlertHistory[];
|
||||
}
|
||||
|
||||
export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
BeszelAlertsData,
|
||||
"beszel" | "mock",
|
||||
{ includeHistory: boolean; maxHistoryItems: number }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const alerts = await instance.getAlertsAsync();
|
||||
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
||||
return { alerts, history };
|
||||
},
|
||||
cacheDuration: dayjs.duration(15, "seconds"),
|
||||
queryKey: "beszelAlerts",
|
||||
});
|
||||
|
||||
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
|
||||
"1h": { type: "1m", perPage: 60 },
|
||||
"12h": { type: "10m", perPage: 72 },
|
||||
"24h": { type: "20m", perPage: 72 },
|
||||
"1w": { type: "120m", perPage: 84 },
|
||||
"30d": { type: "480m", perPage: 90 },
|
||||
};
|
||||
|
||||
export interface BeszelStatsData {
|
||||
systemStats: BeszelSystemStatsRecord[];
|
||||
containerStats: BeszelContainerStatsRecord[];
|
||||
}
|
||||
|
||||
export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
BeszelStatsData,
|
||||
"beszel" | "mock",
|
||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||
const containerStats = input.includeDocker
|
||||
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
||||
: [];
|
||||
return { systemStats, containerStats };
|
||||
},
|
||||
cacheDuration: dayjs.duration(30, "seconds"),
|
||||
queryKey: "beszelStats",
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-
|
||||
|
||||
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
|
||||
SystemHealthMonitoring,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
@@ -3287,219 +3104,6 @@
|
||||
"status": {
|
||||
"unhealthy": "Unhealthy"
|
||||
}
|
||||
},
|
||||
"beszelSystemTable": {
|
||||
"name": "Beszel Systems (Table)",
|
||||
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
|
||||
"option": {
|
||||
"statusFilter": {
|
||||
"label": "Status filter"
|
||||
},
|
||||
"sortBy": {
|
||||
"label": "Sort by"
|
||||
},
|
||||
"sortDirection": {
|
||||
"label": "Sort direction"
|
||||
},
|
||||
"showCpu": {
|
||||
"label": "Show CPU"
|
||||
},
|
||||
"showMemory": {
|
||||
"label": "Show memory"
|
||||
},
|
||||
"showDisk": {
|
||||
"label": "Show disk"
|
||||
},
|
||||
"showGpu": {
|
||||
"label": "Show GPU"
|
||||
},
|
||||
"showLoadAvg": {
|
||||
"label": "Show load average"
|
||||
},
|
||||
"showNet": {
|
||||
"label": "Show network"
|
||||
},
|
||||
"showTemp": {
|
||||
"label": "Show temperature"
|
||||
},
|
||||
"showBattery": {
|
||||
"label": "Show battery"
|
||||
},
|
||||
"showServices": {
|
||||
"label": "Show services"
|
||||
},
|
||||
"showUptime": {
|
||||
"label": "Show uptime"
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Show agent version"
|
||||
}
|
||||
},
|
||||
"noRecords": "No systems found",
|
||||
"column": {
|
||||
"system": "System",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"gpu": "GPU",
|
||||
"loadAvg": "Load Avg",
|
||||
"net": "Net",
|
||||
"temp": "Temp",
|
||||
"battery": "Bat",
|
||||
"services": "Services",
|
||||
"uptime": "Uptime",
|
||||
"agent": "Agent"
|
||||
}
|
||||
},
|
||||
"beszelSystemGrid": {
|
||||
"name": "Beszel Systems (Grid)",
|
||||
"description": "Card grid view of all Beszel-monitored systems with real-time metrics",
|
||||
"option": {
|
||||
"statusFilter": {
|
||||
"label": "Status filter"
|
||||
},
|
||||
"showCpu": {
|
||||
"label": "Show CPU"
|
||||
},
|
||||
"showMemory": {
|
||||
"label": "Show memory"
|
||||
},
|
||||
"showDisk": {
|
||||
"label": "Show disk"
|
||||
},
|
||||
"showGpu": {
|
||||
"label": "Show GPU"
|
||||
},
|
||||
"showLoadAvg": {
|
||||
"label": "Show load average"
|
||||
},
|
||||
"showNet": {
|
||||
"label": "Show network"
|
||||
},
|
||||
"showTemp": {
|
||||
"label": "Show temperature"
|
||||
},
|
||||
"showBattery": {
|
||||
"label": "Show battery"
|
||||
},
|
||||
"showServices": {
|
||||
"label": "Show services"
|
||||
},
|
||||
"showUptime": {
|
||||
"label": "Show uptime"
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Show agent version"
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"gpu": "GPU",
|
||||
"loadAvg": "Load Avg",
|
||||
"net": "Net",
|
||||
"temp": "Temp",
|
||||
"battery": "Bat",
|
||||
"services": "Services",
|
||||
"uptime": "Uptime",
|
||||
"agent": "Agent"
|
||||
}
|
||||
},
|
||||
"beszelAlerts": {
|
||||
"name": "Beszel Alerts",
|
||||
"description": "View Beszel alert configurations and history",
|
||||
"option": {
|
||||
"showHistory": {
|
||||
"label": "Show alert history"
|
||||
},
|
||||
"maxHistoryItems": {
|
||||
"label": "Max history items"
|
||||
}
|
||||
},
|
||||
"empty": "No alerts configured",
|
||||
"history": "History",
|
||||
"resolved": "Resolved",
|
||||
"status": {
|
||||
"triggered": "Triggered",
|
||||
"ok": "OK"
|
||||
}
|
||||
},
|
||||
"beszelSystemStats": {
|
||||
"name": "Beszel System Stats",
|
||||
"description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics",
|
||||
"option": {
|
||||
"timePeriod": {
|
||||
"label": "Time period"
|
||||
},
|
||||
"showCpu": {
|
||||
"label": "Show CPU chart"
|
||||
},
|
||||
"showMemory": {
|
||||
"label": "Show memory chart"
|
||||
},
|
||||
"showDisk": {
|
||||
"label": "Show disk chart"
|
||||
},
|
||||
"showDiskIO": {
|
||||
"label": "Show disk I/O chart"
|
||||
},
|
||||
"showNetwork": {
|
||||
"label": "Show network chart"
|
||||
},
|
||||
"showDockerCpu": {
|
||||
"label": "Show Docker CPU chart"
|
||||
},
|
||||
"showDockerMemory": {
|
||||
"label": "Show Docker memory chart"
|
||||
},
|
||||
"showDockerNetwork": {
|
||||
"label": "Show Docker network chart"
|
||||
}
|
||||
},
|
||||
"selectSystem": "Select system",
|
||||
"chart": {
|
||||
"cpu": {
|
||||
"title": "CPU Usage",
|
||||
"subtitle": "Average system-wide CPU utilization",
|
||||
"series": "CPU"
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory Usage",
|
||||
"subtitle": "Precise utilization at the recorded time",
|
||||
"series": "Used",
|
||||
"cache": "Cache/Buffers"
|
||||
},
|
||||
"disk": {
|
||||
"title": "Disk Usage",
|
||||
"subtitle": "Usage of root partition",
|
||||
"series": "Used"
|
||||
},
|
||||
"diskIO": {
|
||||
"title": "Disk I/O",
|
||||
"subtitle": "Throughput of root filesystem",
|
||||
"read": "Read",
|
||||
"write": "Write"
|
||||
},
|
||||
"network": {
|
||||
"title": "Bandwidth",
|
||||
"subtitle": "Network traffic of public interfaces",
|
||||
"sent": "Sent",
|
||||
"recv": "Received"
|
||||
},
|
||||
"dockerCpu": {
|
||||
"title": "Docker CPU Usage",
|
||||
"subtitle": "Average CPU utilization of containers"
|
||||
},
|
||||
"dockerMemory": {
|
||||
"title": "Docker Memory Usage",
|
||||
"subtitle": "Memory usage of docker containers"
|
||||
},
|
||||
"dockerNetwork": {
|
||||
"title": "Docker Network I/O",
|
||||
"subtitle": "Network traffic of docker containers"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetPreview": {
|
||||
@@ -3895,7 +3499,7 @@
|
||||
"docker": "Docker",
|
||||
"kubernetes": "Kubernetes",
|
||||
"logs": "Logs",
|
||||
"api": "API & MCP",
|
||||
"api": "API",
|
||||
"certificates": "Certificates",
|
||||
"tasks": "Tasks",
|
||||
"backup": "Backup"
|
||||
@@ -4436,95 +4040,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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([]),
|
||||
});
|
||||
@@ -70,8 +70,6 @@
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mantine-datatable": "catalog:",
|
||||
"mantine-react-table": "catalog:",
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
|
||||
@@ -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,14 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AudioStatsContent } from "./audio-stats-content";
|
||||
|
||||
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
|
||||
const [response] = clientApi.widget.audioStats.getStats.useSuspenseQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
|
||||
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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;
|
||||
@@ -1,264 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer } from "lucide-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
const alertIconMap: Record<string, LucideIcon> = {
|
||||
CPU: Cpu,
|
||||
Memory: MemoryStick,
|
||||
Disk: HardDrive,
|
||||
Bandwidth: Network,
|
||||
Temperature: Thermometer,
|
||||
"Load Average": Activity,
|
||||
LoadAvg1: Activity,
|
||||
LoadAvg5: Activity,
|
||||
LoadAvg15: Activity,
|
||||
Status: Server,
|
||||
GPU: Monitor,
|
||||
Battery: Battery,
|
||||
};
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function BeszelAlertsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
}: WidgetComponentProps<"beszelAlerts">) {
|
||||
const t = useScopedI18n("widget.beszelAlerts");
|
||||
const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({
|
||||
integrationIds,
|
||||
includeHistory: options.showHistory,
|
||||
maxHistoryItems: options.maxHistoryItems,
|
||||
});
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
clientApi.widget.beszel.subscribeAlerts.useSubscription(
|
||||
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
|
||||
{
|
||||
enabled: !isEditMode,
|
||||
onData(data) {
|
||||
utils.widget.beszel.getAlerts.setData(
|
||||
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
|
||||
(prev) => {
|
||||
if (!prev) return prev;
|
||||
return prev.map((r) =>
|
||||
r.integrationId === data.integrationId
|
||||
? { ...r, alerts: data.alerts.alerts, history: data.alerts.history, updatedAt: data.timestamp }
|
||||
: r,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const systemNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of results) {
|
||||
if (r.systemNameMap) {
|
||||
Object.assign(map, r.systemNameMap);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [results]);
|
||||
|
||||
const alerts = useMemo(
|
||||
() => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))),
|
||||
[results],
|
||||
);
|
||||
|
||||
const history = useMemo(() => {
|
||||
const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` })));
|
||||
return all
|
||||
.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||
.slice(0, options.maxHistoryItems);
|
||||
}, [results, options.maxHistoryItems]);
|
||||
|
||||
const triggeredAlerts = alerts.filter((a) => a.triggered);
|
||||
const okAlerts = alerts.filter((a) => !a.triggered);
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{alerts.length === 0 && (
|
||||
<Stack align="center" justify="center" py="xl" gap="xs">
|
||||
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||
<IconBellOff size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="red">
|
||||
{t("status.triggered")} ({triggeredAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{triggeredAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||
|
||||
{okAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("status.ok")} ({okAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{okAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered={false}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{options.showHistory && history.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconHistory size={14} opacity={0.5} />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("history")}
|
||||
</Text>
|
||||
</Group>
|
||||
{history.map((entry) => {
|
||||
const systemName = systemNameMap[entry.system] ?? entry.system;
|
||||
const isResolved = !!entry.resolved;
|
||||
const HistoryIcon = alertIconMap[entry.name] ?? Server;
|
||||
return (
|
||||
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={3}
|
||||
h={24}
|
||||
style={{ borderRadius: 2, flexShrink: 0 }}
|
||||
bg={isResolved ? "green.6" : "red.6"}
|
||||
/>
|
||||
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500} truncate>
|
||||
{entry.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
|
||||
{isResolved ? t("resolved") : t("status.triggered")}
|
||||
</Badge>
|
||||
<Text size="10px" c="dimmed">
|
||||
{dayjs(entry.created).fromNow()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertRowProps {
|
||||
name: string;
|
||||
value: number;
|
||||
min: number;
|
||||
systemName: string;
|
||||
triggered: boolean;
|
||||
}
|
||||
|
||||
const unitSuffixMap: Record<string, string> = {
|
||||
CPU: "%",
|
||||
Memory: "%",
|
||||
Disk: "%",
|
||||
GPU: "%",
|
||||
Battery: "%",
|
||||
Temperature: "°",
|
||||
Bandwidth: " MB/s",
|
||||
"Load Average": "",
|
||||
LoadAvg1: "",
|
||||
LoadAvg5: "",
|
||||
LoadAvg15: "",
|
||||
Status: "",
|
||||
};
|
||||
|
||||
function formatAlertDescription(name: string, value: number, min: number): string {
|
||||
const suffix = unitSuffixMap[name] ?? "";
|
||||
if (name === "Status") return `down for ${min} min`;
|
||||
return `exceeds ${value}${suffix} over ${min} min`;
|
||||
}
|
||||
|
||||
function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
|
||||
const Icon = alertIconMap[name] ?? Server;
|
||||
return (
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
py={4}
|
||||
pr={8}
|
||||
pl={12}
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
|
||||
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
|
||||
}}
|
||||
>
|
||||
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{formatAlertDescription(name, value, min)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { IconBell } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
|
||||
icon: IconBell,
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showHistory: factory.switch({ defaultValue: true }),
|
||||
maxHistoryItems: factory.number({ defaultValue: 10, validate: z.number().min(1).max(100) }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -1,378 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
BatteryCharging,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
fontSize: string;
|
||||
progressSize: "xs" | "sm";
|
||||
labelMiw: number;
|
||||
valueMiw: number;
|
||||
rowHeight: number;
|
||||
cardPadding: number;
|
||||
badgeHeight: number;
|
||||
badgeSize: "xs" | "sm";
|
||||
gap: number;
|
||||
}
|
||||
|
||||
const defaultSizeConfig: SizeConfig = {
|
||||
iconSize: 10,
|
||||
fontSize: "10px",
|
||||
progressSize: "xs",
|
||||
labelMiw: 40,
|
||||
valueMiw: 30,
|
||||
rowHeight: 18,
|
||||
cardPadding: 4,
|
||||
badgeHeight: 20,
|
||||
badgeSize: "xs",
|
||||
gap: 2,
|
||||
};
|
||||
|
||||
const sizeThresholds: [number, SizeConfig][] = [
|
||||
[0, defaultSizeConfig],
|
||||
[
|
||||
160,
|
||||
{
|
||||
iconSize: 12,
|
||||
fontSize: "xs",
|
||||
progressSize: "xs",
|
||||
labelMiw: 50,
|
||||
valueMiw: 34,
|
||||
rowHeight: 22,
|
||||
cardPadding: 6,
|
||||
badgeHeight: 22,
|
||||
badgeSize: "xs",
|
||||
gap: 6,
|
||||
},
|
||||
],
|
||||
[
|
||||
280,
|
||||
{
|
||||
iconSize: 14,
|
||||
fontSize: "xs",
|
||||
progressSize: "sm",
|
||||
labelMiw: 55,
|
||||
valueMiw: 38,
|
||||
rowHeight: 24,
|
||||
cardPadding: 8,
|
||||
badgeHeight: 26,
|
||||
badgeSize: "sm",
|
||||
gap: 8,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const getSizeConfig = (cellWidth: number, cellHeight: number): SizeConfig => {
|
||||
const basis = Math.min(cellWidth, cellHeight);
|
||||
let config = defaultSizeConfig;
|
||||
for (const [threshold, cfg] of sizeThresholds) {
|
||||
if (basis >= threshold) config = cfg;
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
|
||||
const available = cellHeight - size.cardPadding * 2 - size.badgeHeight - size.gap;
|
||||
return Math.max(1, Math.floor(available / size.rowHeight));
|
||||
};
|
||||
|
||||
const MIN_CELL_WIDTH = 120;
|
||||
const MIN_CELL_HEIGHT = 80;
|
||||
|
||||
const getColCount = (width: number, itemCount: number): number => {
|
||||
if (itemCount <= 1) return 1;
|
||||
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
|
||||
|
||||
let best = 1;
|
||||
for (let c = 1; c <= maxCols; c++) {
|
||||
const emptyCells = (c - (itemCount % c)) % c;
|
||||
const bestEmpty = (best - (itemCount % best)) % best;
|
||||
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
interface MetricRowProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
progress?: { value: number; color: string };
|
||||
size: SizeConfig;
|
||||
}
|
||||
|
||||
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
|
||||
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
{progress && (
|
||||
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
|
||||
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
|
||||
{icon}
|
||||
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
|
||||
{label}
|
||||
</Text>
|
||||
<MetricValue value={value} progress={progress} size={size} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
interface SystemCardProps {
|
||||
system: BeszelSystemRow & { _key: string };
|
||||
options: WidgetComponentProps<"beszelSystemGrid">["options"];
|
||||
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
|
||||
size: SizeConfig;
|
||||
maxMetrics: number;
|
||||
}
|
||||
|
||||
const metricRenderers = [
|
||||
{
|
||||
key: "showCpu",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="cpu"
|
||||
icon={<Cpu size={sz.iconSize} />}
|
||||
label={t("metric.cpu")}
|
||||
value={formatPercent(s.cpu)}
|
||||
progress={{ value: s.cpu, color: thresholdColor(s.cpu) }}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu,
|
||||
},
|
||||
{
|
||||
key: "showMemory",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="mem"
|
||||
icon={<MemoryStick size={sz.iconSize} />}
|
||||
label={t("metric.memory")}
|
||||
value={formatPercent(s.memory)}
|
||||
progress={{ value: s.memory, color: thresholdColor(s.memory) }}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory,
|
||||
},
|
||||
{
|
||||
key: "showDisk",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="disk"
|
||||
icon={<HardDrive size={sz.iconSize} />}
|
||||
label={t("metric.disk")}
|
||||
value={formatPercent(s.disk)}
|
||||
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
|
||||
},
|
||||
{
|
||||
key: "showGpu",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="gpu"
|
||||
icon={<Monitor size={sz.iconSize} />}
|
||||
label={t("metric.gpu")}
|
||||
value={formatPercent(s.gpu)}
|
||||
progress={{ value: s.gpu, color: thresholdColor(s.gpu) }}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0,
|
||||
},
|
||||
{
|
||||
key: "showLoadAvg",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="la"
|
||||
icon={<Activity size={sz.iconSize} />}
|
||||
label={t("metric.loadAvg")}
|
||||
value={formatLoadAvg(s.loadAvg)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null,
|
||||
},
|
||||
{
|
||||
key: "showNet",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="net"
|
||||
icon={<Network size={sz.iconSize} />}
|
||||
label={t("metric.net")}
|
||||
value={formatByteRate(s.netBytes)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet,
|
||||
},
|
||||
{
|
||||
key: "showTemp",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="temp"
|
||||
icon={<Thermometer size={sz.iconSize} />}
|
||||
label={t("metric.temp")}
|
||||
value={formatTemp(s.temp, false)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null,
|
||||
},
|
||||
{
|
||||
key: "showBattery",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => {
|
||||
const batteryIcons = [Battery, BatteryCharging] as const;
|
||||
const Icon = batteryIcons[Math.min(1, s.battery?.[1] ?? 0)] ?? Battery;
|
||||
return (
|
||||
<MetricRow
|
||||
key="bat"
|
||||
icon={<Icon size={sz.iconSize} />}
|
||||
label={t("metric.battery")}
|
||||
value={`${s.battery?.[0] ?? 0}%`}
|
||||
size={sz}
|
||||
/>
|
||||
);
|
||||
},
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null,
|
||||
},
|
||||
{
|
||||
key: "showServices",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="svc"
|
||||
icon={<Server size={sz.iconSize} />}
|
||||
label={t("metric.services")}
|
||||
value={String(s.services)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices,
|
||||
},
|
||||
{
|
||||
key: "showUptime",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="up"
|
||||
icon={<Activity size={sz.iconSize} />}
|
||||
label={t("metric.uptime")}
|
||||
value={formatUptime(s.uptime)}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime,
|
||||
},
|
||||
{
|
||||
key: "showAgent",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="agent"
|
||||
icon={<Wifi size={sz.iconSize} />}
|
||||
label={t("metric.agent")}
|
||||
value={s.agentVersion}
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SystemCard = ({ system, options, t, size, maxMetrics }: SystemCardProps) => {
|
||||
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding={size.cardPadding}
|
||||
radius="sm"
|
||||
withBorder
|
||||
h="100%"
|
||||
style={{ overflow: "hidden", display: "flex", flexDirection: "column" as const }}
|
||||
>
|
||||
<Group gap="xs" mb={2}>
|
||||
<Badge
|
||||
size={size.badgeSize}
|
||||
variant="dot"
|
||||
color={statusColorMap[system.status]}
|
||||
styles={{ root: { textTransform: "none" } }}
|
||||
>
|
||||
{system.name}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
|
||||
{visibleMetrics.map((m) => m.render(system, t, size))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default function BeszelSystemGridWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||
const t = useScopedI18n("widget.beszelSystemGrid");
|
||||
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
|
||||
|
||||
useBeszelSystemsSubscription(integrationIds, !isEditMode);
|
||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||
|
||||
const cols = getColCount(width, filteredSystems.length);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
|
||||
const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight;
|
||||
const cellWidth = width / cols;
|
||||
const size = getSizeConfig(cellWidth, effectiveCellHeight);
|
||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
|
||||
return (
|
||||
<Box
|
||||
h="100%"
|
||||
style={{
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||
gap: size.gap,
|
||||
overflow: scrollEnabled ? "auto" : "hidden",
|
||||
}}
|
||||
>
|
||||
{filteredSystems.map((system) => (
|
||||
<SystemCard key={system._key} system={system} options={options} t={t} size={size} maxMetrics={maxMetrics} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { statusOptions } from "../beszel/_shared/options";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
|
||||
icon: IconLayoutGrid,
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
|
||||
showCpu: factory.switch({ defaultValue: true }),
|
||||
showMemory: factory.switch({ defaultValue: true }),
|
||||
showDisk: factory.switch({ defaultValue: true }),
|
||||
showGpu: factory.switch({ defaultValue: true }),
|
||||
showLoadAvg: factory.switch({ defaultValue: true }),
|
||||
showNet: factory.switch({ defaultValue: true }),
|
||||
showTemp: factory.switch({ defaultValue: true }),
|
||||
showBattery: factory.switch({ defaultValue: true }),
|
||||
showServices: factory.switch({ defaultValue: true }),
|
||||
showUptime: factory.switch({ defaultValue: true }),
|
||||
showAgent: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -1,248 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { containerColors } from "../beszel/_shared/colors";
|
||||
import {
|
||||
BeszelAreaChart,
|
||||
ChartPanel,
|
||||
useContainerNames,
|
||||
useDockerChartData,
|
||||
useSystemChartData,
|
||||
} from "../beszel/_shared/chart";
|
||||
import {
|
||||
chartAxisFormatters,
|
||||
formatByteRate,
|
||||
formatGB,
|
||||
formatPercent,
|
||||
formatStorageBytes,
|
||||
} from "../beszel/_shared/format";
|
||||
import { makeTooltipProps } from "../beszel/_shared/tooltip";
|
||||
|
||||
const tooltipPercent = makeTooltipProps(formatPercent);
|
||||
const tooltipGB = makeTooltipProps(formatGB, true);
|
||||
const tooltipRate = makeTooltipProps(formatByteRate, true);
|
||||
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
|
||||
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
|
||||
|
||||
const CHART_HEIGHT = 180;
|
||||
|
||||
export default function BeszelSystemStatsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
}: WidgetComponentProps<"beszelSystemStats">) {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const [systemsResult] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
|
||||
|
||||
const systems = useMemo(
|
||||
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
|
||||
[systemsResult],
|
||||
);
|
||||
|
||||
const [selectedSystem, setSelectedSystem] = useState<string | null>(systems[0]?.value ?? null);
|
||||
|
||||
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
|
||||
|
||||
const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({
|
||||
integrationIds,
|
||||
systemId: selectedSystem ?? systems[0]?.value ?? "",
|
||||
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
|
||||
includeDocker: showDocker,
|
||||
});
|
||||
|
||||
// BeszelSystemStats fields are documented in beszel-types.ts.
|
||||
// mu/du = bytes, dr/dw = bytes/s, b = public bandwidth [sent,recv] bytes/s, ns/nr = all-interface bytes/s
|
||||
const BYTES_TO_GB = 1024 * 1024 * 1024;
|
||||
|
||||
const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]);
|
||||
const memoryMap = useCallback(
|
||||
(s: { mu: number; mb: number }) => ({
|
||||
[t("chart.memory.series")]: s.mu / BYTES_TO_GB,
|
||||
[t("chart.memory.cache")]: (s.mb ?? 0) / BYTES_TO_GB,
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du / BYTES_TO_GB }), [t]);
|
||||
const diskIOMap = useCallback(
|
||||
(s: { dr?: number; dw?: number }) => ({
|
||||
[t("chart.diskIO.read")]: s.dr ?? 0,
|
||||
[t("chart.diskIO.write")]: s.dw ?? 0,
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
// b = public interface bandwidth (preferred), ns/nr = all-interface traffic (fallback)
|
||||
const networkMap = useCallback(
|
||||
(s: { ns: number; nr: number; b?: [number, number] }) => ({
|
||||
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
|
||||
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const cpuData = useSystemChartData(statsResult?.systemStats, cpuMap);
|
||||
const memoryData = useSystemChartData(statsResult?.systemStats, memoryMap);
|
||||
const diskData = useSystemChartData(statsResult?.systemStats, diskMap);
|
||||
const diskIOData = useSystemChartData(statsResult?.systemStats, diskIOMap);
|
||||
const networkData = useSystemChartData(statsResult?.systemStats, networkMap);
|
||||
|
||||
const containerNames = useContainerNames(statsResult?.containerStats);
|
||||
const dockerCpuData = useDockerChartData(statsResult?.containerStats, containerNames, "cpu");
|
||||
const dockerMemData = useDockerChartData(statsResult?.containerStats, containerNames, "memory");
|
||||
const dockerNetData = useDockerChartData(statsResult?.containerStats, containerNames, "network");
|
||||
|
||||
const containerSeries = containerNames.map((name, i) => ({
|
||||
name,
|
||||
color: containerColors[i % containerColors.length] as string,
|
||||
}));
|
||||
|
||||
const cols = width > 600 ? 2 : 1;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="md" p="sm">
|
||||
<Select
|
||||
size="xs"
|
||||
data={systems}
|
||||
value={selectedSystem}
|
||||
onChange={setSelectedSystem}
|
||||
placeholder={t("selectSystem")}
|
||||
/>
|
||||
|
||||
{statsResult && (
|
||||
<SimpleGrid cols={cols} spacing="md">
|
||||
{options.showCpu && cpuData.length > 0 && (
|
||||
<ChartPanel title={t("chart.cpu.title")} subtitle={t("chart.cpu.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={cpuData}
|
||||
series={[{ name: t("chart.cpu.series"), color: "teal.6" }]}
|
||||
fillOpacity={0.4}
|
||||
yAxisFormatter={chartAxisFormatters.percent}
|
||||
yAxisDomain={[0, "auto"]}
|
||||
tooltipProps={tooltipPercent}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showMemory && memoryData.length > 0 && (
|
||||
<ChartPanel title={t("chart.memory.title")} subtitle={t("chart.memory.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={memoryData}
|
||||
series={[
|
||||
{ name: t("chart.memory.series"), color: "teal.5" },
|
||||
{ name: t("chart.memory.cache"), color: "teal.8" },
|
||||
]}
|
||||
fillOpacity={0.4}
|
||||
yAxisFormatter={chartAxisFormatters.gb}
|
||||
tooltipProps={tooltipGB}
|
||||
withLegend
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showDisk && diskData.length > 0 && (
|
||||
<ChartPanel title={t("chart.disk.title")} subtitle={t("chart.disk.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={diskData}
|
||||
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
|
||||
fillOpacity={0.4}
|
||||
yAxisFormatter={chartAxisFormatters.gb}
|
||||
tooltipProps={tooltipGB}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showDiskIO && diskIOData.length > 0 && (
|
||||
<ChartPanel title={t("chart.diskIO.title")} subtitle={t("chart.diskIO.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={diskIOData}
|
||||
series={[
|
||||
{ name: t("chart.diskIO.write"), color: "orange.6" },
|
||||
{ name: t("chart.diskIO.read"), color: "blue.6" },
|
||||
]}
|
||||
fillOpacity={0.3}
|
||||
yAxisFormatter={chartAxisFormatters.rate}
|
||||
tooltipProps={tooltipRate}
|
||||
withLegend
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showNetwork && networkData.length > 0 && (
|
||||
<ChartPanel title={t("chart.network.title")} subtitle={t("chart.network.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={networkData}
|
||||
series={[
|
||||
{ name: t("chart.network.sent"), color: "blue.6" },
|
||||
{ name: t("chart.network.recv"), color: "teal.6" },
|
||||
]}
|
||||
fillOpacity={0.3}
|
||||
yAxisFormatter={chartAxisFormatters.rate}
|
||||
tooltipProps={tooltipRate}
|
||||
withLegend
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{showDocker && containerSeries.length > 0 && (
|
||||
<>
|
||||
{options.showDockerCpu && dockerCpuData.length > 0 && (
|
||||
<ChartPanel title={t("chart.dockerCpu.title")} subtitle={t("chart.dockerCpu.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={dockerCpuData}
|
||||
type="stacked"
|
||||
series={containerSeries}
|
||||
fillOpacity={0.6}
|
||||
yAxisFormatter={chartAxisFormatters.percent}
|
||||
tooltipProps={tooltipPercentTotal}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showDockerMemory && dockerMemData.length > 0 && (
|
||||
<ChartPanel title={t("chart.dockerMemory.title")} subtitle={t("chart.dockerMemory.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={dockerMemData}
|
||||
type="stacked"
|
||||
series={containerSeries}
|
||||
fillOpacity={0.6}
|
||||
yAxisFormatter={chartAxisFormatters.bytes}
|
||||
tooltipProps={tooltipBytesTotal}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
|
||||
{options.showDockerNetwork && dockerNetData.length > 0 && (
|
||||
<ChartPanel title={t("chart.dockerNetwork.title")} subtitle={t("chart.dockerNetwork.subtitle")}>
|
||||
<BeszelAreaChart
|
||||
h={CHART_HEIGHT}
|
||||
data={dockerNetData}
|
||||
type="stacked"
|
||||
series={containerSeries}
|
||||
fillOpacity={0.6}
|
||||
yAxisFormatter={chartAxisFormatters.rate}
|
||||
tooltipProps={tooltipRate}
|
||||
/>
|
||||
</ChartPanel>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { IconChartAreaLine } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const timePeriodOptions = [
|
||||
{ value: "1h", label: "1 Hour" },
|
||||
{ value: "12h", label: "12 Hours" },
|
||||
{ value: "24h", label: "24 Hours" },
|
||||
{ value: "1w", label: "1 Week" },
|
||||
{ value: "30d", label: "30 Days" },
|
||||
];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
|
||||
icon: IconChartAreaLine,
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions }),
|
||||
showCpu: factory.switch({ defaultValue: true }),
|
||||
showMemory: factory.switch({ defaultValue: true }),
|
||||
showDisk: factory.switch({ defaultValue: true }),
|
||||
showDiskIO: factory.switch({ defaultValue: true }),
|
||||
showNetwork: factory.switch({ defaultValue: true }),
|
||||
showDockerCpu: factory.switch({ defaultValue: true }),
|
||||
showDockerMemory: factory.switch({ defaultValue: true }),
|
||||
showDockerNetwork: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user