Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
008b506dfa | ||
|
|
3b0f9bec1e | ||
|
|
7c6706b82b | ||
|
|
6cd947fc67 | ||
|
|
c8a2be553a | ||
|
|
80d61f5428 | ||
|
|
a3a26c0c4f | ||
|
|
29bd7101b1 | ||
|
|
1079a5a4c5 | ||
|
|
69e87fd3fe | ||
|
|
6baff7be3d | ||
|
|
d77c6f8d4e | ||
|
|
a4db9cf0dc | ||
|
|
5d1f268438 | ||
|
|
484b8cf9f1 | ||
|
|
b3cdab579b | ||
|
|
44a0c6ce54 | ||
|
|
7f05e0325d | ||
|
|
f71b145403 | ||
|
|
607fd498f4 | ||
|
|
3f2fb54cdf | ||
|
|
69412f2d10 | ||
|
|
f6d5f97933 | ||
|
|
a447a9f615 | ||
|
|
87d3f6a0a1 | ||
|
|
f2705eeb57 | ||
|
|
ac37d2c73f | ||
|
|
e00ea624cd | ||
|
|
20b62c42e3 | ||
|
|
59971991c7 | ||
|
|
a629681ae5 | ||
|
|
572d5fb5ec | ||
|
|
91a1858743 | ||
|
|
f00d3627c9 | ||
|
|
7760e01ccb | ||
|
|
8a1ead3674 | ||
|
|
9c05f44916 | ||
|
|
efd37dca93 | ||
|
|
796c679140 | ||
|
|
76fd4c5fb1 | ||
|
|
68f24e34d5 | ||
|
|
22d7ebab50 | ||
|
|
4f5f3e14a1 | ||
|
|
dbf021e04a | ||
|
|
cc24f070db | ||
|
|
0b663a7fc6 | ||
|
|
93b2ed88ed | ||
|
|
ef27d739bb | ||
|
|
ff1e4c1381 | ||
|
|
21bb20ce0c | ||
|
|
9fc0e5f94c | ||
|
|
60ae487842 | ||
|
|
4bfde12b65 | ||
|
|
5899e1b44b | ||
|
|
201a461729 | ||
|
|
87798a9789 | ||
|
|
682f6bd3ea | ||
|
|
f49ae79c99 | ||
|
|
f7f6d1e382 | ||
|
|
6c623a3a2d | ||
|
|
b65eef08ea | ||
|
|
598307c7bf | ||
|
|
94cbbde576 | ||
|
|
1b003bfc50 | ||
|
|
394debbea1 | ||
|
|
cb5ae65f8a | ||
|
|
fc80a61aef | ||
|
|
a2668740c0 | ||
|
|
92b75cb924 | ||
|
|
1ea008b71c |
No files matched your search
@@ -0,0 +1,197 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
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`
|
||||
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.64.0
|
||||
- 1.62.0
|
||||
- 1.61.0
|
||||
- 1.60.1-beta.1
|
||||
|
||||
+1
-2
@@ -66,8 +66,7 @@ e2e/shared/tmp
|
||||
#personal backgrounds
|
||||
apps/nextjs/public/images/background.png
|
||||
|
||||
# next-intl
|
||||
en.d.json.ts
|
||||
|
||||
|
||||
# personal agent config
|
||||
AGENTS.md
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[tools]
|
||||
node = "24.16.0"
|
||||
pnpm = "10.33.4"
|
||||
@@ -1,3 +1,67 @@
|
||||
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
|
||||
|
||||
### Features
|
||||
|
||||
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
|
||||
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
|
||||
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
|
||||
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
|
||||
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
|
||||
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
|
||||
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
|
||||
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
|
||||
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
|
||||
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
|
||||
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
|
||||
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
|
||||
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
|
||||
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
|
||||
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
|
||||
|
||||
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
|
||||
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
|
||||
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
|
||||
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
|
||||
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
|
||||
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
|
||||
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
|
||||
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
|
||||
|
||||
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
|
||||
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
|
||||
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
|
||||
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
|
||||
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
|
||||
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
|
||||
|
||||
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -5,18 +5,30 @@ import "@homarr/common/env";
|
||||
import "@homarr/core/infrastructure/logs/env";
|
||||
import "@homarr/docker/env";
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const homarrVersion = (() => {
|
||||
for (const candidate of ["../../package.json", "./package.json"]) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
||||
if (pkg.name === "homarr") return pkg.version as string;
|
||||
} catch {}
|
||||
}
|
||||
return "unknown";
|
||||
})();
|
||||
|
||||
// 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",
|
||||
});
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: {
|
||||
HOMARR_VERSION: homarrVersion,
|
||||
},
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
// react compiler breaks mantine-react-table, so disabled for now
|
||||
@@ -27,7 +39,7 @@ const nextConfig: NextConfig = {
|
||||
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
||||
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
||||
*/
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@dnd-kit/modifiers": "catalog:",
|
||||
"@dnd-kit/sortable": "catalog:",
|
||||
"@dnd-kit/utilities": "catalog:",
|
||||
"@gfazioli/mantine-onboarding-tour": "catalog:",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
@@ -55,7 +56,10 @@
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@mantine/modals": "catalog:",
|
||||
"@mantine/tiptap": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/query-persist-client-core": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
"@tanstack/react-query-next-experimental": "catalog:",
|
||||
@@ -71,11 +75,18 @@
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"flag-icons": "catalog:",
|
||||
"glob": "catalog:",
|
||||
"adm-zip": "catalog:",
|
||||
"better-sqlite3": "catalog:",
|
||||
"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:",
|
||||
@@ -84,20 +95,22 @@
|
||||
"react-error-boundary": "catalog:",
|
||||
"react-simple-code-editor": "catalog:",
|
||||
"sass": "catalog:",
|
||||
"sql.js": "catalog:",
|
||||
"superjson": "catalog:",
|
||||
"swagger-ui-react": "catalog:",
|
||||
"trpc-to-mcp": "catalog:",
|
||||
"use-deep-compare-effect": "catalog:",
|
||||
"yaml": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/adm-zip": "catalog:",
|
||||
"@types/better-sqlite3": "catalog:",
|
||||
"@types/chroma-js": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/prismjs": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/swagger-ui-react": "catalog:",
|
||||
"concurrently": "catalog:",
|
||||
"node-loader": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
|
||||
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import {
|
||||
getActiveQueryCacheBoardId,
|
||||
isPersistableWidgetQueryKey,
|
||||
queryCacheStoragePrefix,
|
||||
} from "@homarr/api/query-cache";
|
||||
|
||||
const queryCacheStorage = {
|
||||
async getItem(_key) {
|
||||
return null;
|
||||
},
|
||||
async setItem(key, value) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
|
||||
try {
|
||||
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
|
||||
} catch {
|
||||
// Query persistence is best-effort; failed writes should not affect widget rendering.
|
||||
}
|
||||
},
|
||||
async removeItem(key) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
|
||||
try {
|
||||
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
|
||||
} catch {
|
||||
// Query persistence is best-effort; failed removals should not affect widget rendering.
|
||||
}
|
||||
},
|
||||
} satisfies AsyncStorage<string>;
|
||||
|
||||
export const createWidgetQueryPersister = () =>
|
||||
experimental_createQueryPersister<string>({
|
||||
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
||||
maxAge: Number.POSITIVE_INFINITY,
|
||||
prefix: queryCacheStoragePrefix,
|
||||
refetchOnRestore: true,
|
||||
serialize: (query) => stringify(query),
|
||||
deserialize: (value) => parse<PersistedQuery>(value),
|
||||
filters: {
|
||||
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
|
||||
},
|
||||
});
|
||||
@@ -21,10 +21,13 @@ import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
|
||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
// window is not defined on server side
|
||||
if (typeof window === "undefined") {
|
||||
@@ -52,12 +55,14 @@ const wsClient = createWSClient({
|
||||
});
|
||||
|
||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
const [queryPersister] = useState(() => createWidgetQueryPersister());
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 1000,
|
||||
persister: queryPersister.persisterFn,
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Placed here because gridstack styles are used for board content
|
||||
import "~/styles/gridstack.scss";
|
||||
|
||||
import type { DehydratedState } from "@tanstack/react-query";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { parse } from "superjson";
|
||||
|
||||
import { getQueryClient } from "@homarr/api/server";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
@@ -14,6 +17,7 @@ import { isNullOrWhitespace } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getAllQueryCacheAsync } from "@homarr/redis";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
@@ -38,6 +42,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
@@ -71,9 +76,14 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
),
|
||||
]);
|
||||
|
||||
const userId = session?.user.id ?? "anonymous";
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<Suspense>
|
||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||
</Suspense>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
@@ -107,3 +117,37 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface PersistedEntry {
|
||||
state: { data: unknown; dataUpdatedAt: number; status: string };
|
||||
queryKey: unknown[];
|
||||
queryHash: string;
|
||||
}
|
||||
|
||||
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
|
||||
try {
|
||||
const entries = await getAllQueryCacheAsync(userId, boardId);
|
||||
const queries: DehydratedState["queries"] = [];
|
||||
|
||||
for (const serialized of Object.values(entries)) {
|
||||
try {
|
||||
const persisted = parse<PersistedEntry>(serialized);
|
||||
if (persisted.state.status !== "success") continue;
|
||||
|
||||
queries.push({
|
||||
state: persisted.state as DehydratedState["queries"][number]["state"],
|
||||
queryKey: persisted.queryKey,
|
||||
queryHash: persisted.queryHash,
|
||||
});
|
||||
} catch {
|
||||
// skip malformed entries
|
||||
}
|
||||
}
|
||||
|
||||
if (queries.length === 0) return null;
|
||||
|
||||
return <HydrationBoundary state={{ mutations: [], queries }} />;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
@@ -57,9 +58,11 @@ export const BoardContentHeaderActions = () => {
|
||||
<EditModeMenu demoMode={demoMode ?? false} />
|
||||
|
||||
{!demoMode && (
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
@@ -195,9 +198,11 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -205,27 +210,31 @@ const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</OnboardingTour.Target>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -266,7 +275,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
};
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return; // Allow to reload in development
|
||||
if (env.NODE_ENV === "development") return;
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
@@ -20,14 +21,21 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
|
||||
if (!hasSession) return <>{children}</>;
|
||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
||||
};
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
withTour?: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour = false,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
const Layout = async ({
|
||||
params,
|
||||
@@ -61,14 +69,16 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
|
||||
@@ -2,13 +2,16 @@ import { Stack, Text } from "@mantine/core";
|
||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { InitSqliteRestore } from "./sqlite-restore";
|
||||
import { InitStartButton } from "./next-button";
|
||||
|
||||
export const InitStart = async () => {
|
||||
const tStart = await getScopedI18n("init.step.start");
|
||||
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
@@ -27,6 +30,7 @@ export const InitStart = async () => {
|
||||
>
|
||||
{tStart("action.importOldmarr")}
|
||||
</InitStartButton>
|
||||
{isSqlite && <InitSqliteRestore />}
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Collapse, Stack, Text } from "@mantine/core";
|
||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||
|
||||
export const InitSqliteRestore = () => {
|
||||
const t = useScopedI18n("init.step.start.action");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
{t("backToOptions")}
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("restoreSqliteDescription")}
|
||||
</Text>
|
||||
<DatabaseRestoreFlow variant="standalone" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setExpanded(true)}
|
||||
variant="default"
|
||||
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("restoreSqlite")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
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";
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
@@ -45,9 +46,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
<TourTarget id="manage-apps-create">
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
@@ -56,11 +59,13 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
<TourTarget id="manage-apps-list">
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
</TourTarget>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
@@ -42,18 +43,26 @@ export default async function ManageBoardsPage() {
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
|
||||
primaryAction={
|
||||
canCreateBoards ? (
|
||||
<TourTarget id="manage-boards-create">
|
||||
<CreateBoardButton />
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||
{boards.length > 0 && (
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
<TourTarget id="manage-boards-list">
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
</TourTarget>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
+2
@@ -8,6 +8,7 @@ import {
|
||||
IconPasswordUser,
|
||||
IconPlug,
|
||||
IconServer,
|
||||
IconTag,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
@@ -28,4 +29,5 @@ export const integrationSecretIcons = {
|
||||
githubAppId: IconCode,
|
||||
githubInstallationId: IconPlug,
|
||||
privateKey: IconKey,
|
||||
slug: IconTag,
|
||||
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
||||
@@ -29,6 +29,7 @@ import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
@@ -60,14 +61,18 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
canCreateIntegrations ? (
|
||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||
{t("action.create")}
|
||||
</MobileAffixButton>
|
||||
<TourTarget id="manage-integrations-create">
|
||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||
{t("action.create")}
|
||||
</MobileAffixButton>
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateIntegrations}
|
||||
>
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
<TourTarget id="manage-integrations-list">
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
</TourTarget>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconBrandTablerFilled,
|
||||
IconCertificate,
|
||||
IconClipboardListFilled,
|
||||
IconDatabaseExport,
|
||||
IconDirectionsFilled,
|
||||
IconGitFork,
|
||||
IconHelpSquareRoundedFilled,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -36,6 +38,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||
|
||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
const t = await getScopedI18n("management.navbar");
|
||||
@@ -45,11 +48,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.home"),
|
||||
icon: IconHomeFilled,
|
||||
href: "/manage",
|
||||
"data-onboarding-tour-id": "manage-welcome",
|
||||
},
|
||||
{
|
||||
icon: IconLayoutDashboardFilled,
|
||||
href: "/manage/boards",
|
||||
label: t("items.boards"),
|
||||
"data-onboarding-tour-id": "manage-boards",
|
||||
},
|
||||
{
|
||||
icon: IconBox,
|
||||
@@ -59,12 +64,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
"data-onboarding-tour-id": "manage-apps",
|
||||
},
|
||||
{
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
@@ -74,17 +81,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
"data-onboarding-tour-id": "manage-search-engines",
|
||||
},
|
||||
{
|
||||
icon: IconPhotoFilled,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session,
|
||||
"data-onboarding-tour-id": "manage-medias",
|
||||
},
|
||||
{
|
||||
icon: IconUserFilled,
|
||||
label: t("items.users.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
"data-onboarding-tour-id": "manage-users",
|
||||
items: [
|
||||
{
|
||||
label: t("items.users.items.manage"),
|
||||
@@ -146,6 +156,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/tools/tasks",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.backup"),
|
||||
icon: IconDatabaseExport,
|
||||
href: "/manage/tools/backup",
|
||||
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -153,6 +169,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/settings",
|
||||
icon: IconSettingsFilled,
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
"data-onboarding-tour-id": "manage-settings",
|
||||
},
|
||||
{
|
||||
label: t("items.help.label"),
|
||||
@@ -191,11 +208,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
|
||||
const shell = (
|
||||
<ClientShell hasNavigation>
|
||||
<MainHeader></MainHeader>
|
||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
if (!session) return shell;
|
||||
|
||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"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
@@ -0,0 +1,6 @@
|
||||
.toolRow {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr;
|
||||
align-items: first baseline;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"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,16 +1,57 @@
|
||||
import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
||||
import { Stack } 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();
|
||||
@@ -30,24 +71,25 @@ export default async function ApiPage() {
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
|
||||
const requestHeaders = await headers();
|
||||
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
|
||||
const document = openApiDocument(baseUrl);
|
||||
const apiKeys = await api.apiKeys.getAll();
|
||||
const t = await getScopedI18n("management.page.tool.api.tab");
|
||||
const toolGroups = getMcpToolGroups();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +0,0 @@
|
||||
.swagger-ui .info {
|
||||
margin: 0 !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
|
||||
|
||||
export const BackupExportCard = () => {
|
||||
const t = useScopedI18n("management.page.tool.backup.export");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/backup/export");
|
||||
if (!response.ok) {
|
||||
const errorMessage = await response.json().then(
|
||||
(data) => data.error,
|
||||
() => `Server returned ${response.status}`,
|
||||
);
|
||||
throw new Error(errorMessage ?? t("error"));
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setShowConfirm(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm">
|
||||
<IconDatabaseExport size={24} />
|
||||
<Title order={4}>{t("title")}</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showConfirm ? (
|
||||
<DangerousActionConfirmation
|
||||
title={t("confirm.title")}
|
||||
warningTitle={t("confirm.warningTitle")}
|
||||
warningBody={t("confirm.warningBody")}
|
||||
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
|
||||
submitLabel={t("confirm.submit")}
|
||||
submitIcon={<IconDownload size={16} />}
|
||||
cancelLabel={t("confirm.cancel")}
|
||||
onConfirm={handleExport}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
disabled={loading}
|
||||
color="orange"
|
||||
/>
|
||||
) : (
|
||||
<Group>
|
||||
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
|
||||
{t("button")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||
|
||||
export const BackupImportCard = () => {
|
||||
const t = useScopedI18n("management.page.tool.backup.import");
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<IconDatabaseImport size={24} />
|
||||
<Title order={4}>{t("title")}</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<DatabaseRestoreFlow />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { BackupExportCard } from "./_components/backup-export-card";
|
||||
import { BackupImportCard } from "./_components/backup-import-card";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.tool.backup");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BackupPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.tool.backup");
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("title")}</Title>
|
||||
<BackupExportCard />
|
||||
<BackupImportCard />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconCategoryPlus,
|
||||
IconDots,
|
||||
IconFileText,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconRefresh,
|
||||
IconRotateClockwise,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
|
||||
import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap } from "@homarr/docker/shared";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -27,25 +30,30 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { OverflowBadge } from "@homarr/ui";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
const createColumns = (
|
||||
t: TranslationFunction,
|
||||
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||
|
||||
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("docker.field.name.label"),
|
||||
Cell({ renderedCellValue, row }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius="lg"
|
||||
radius="sm"
|
||||
size="md"
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={row.original.iconUrl}
|
||||
>
|
||||
{row.original.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Text>{renderedCellValue}</Text>
|
||||
<Text truncate="end" maw={500}>
|
||||
{renderedCellValue}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -76,11 +84,11 @@ const createColumns = (
|
||||
maxSize: 200,
|
||||
Cell({ renderedCellValue, cell }) {
|
||||
return (
|
||||
<Box maw={200}>
|
||||
<Text truncate="end" title={cell.row.original.image}>
|
||||
{renderedCellValue}
|
||||
</Text>
|
||||
</Box>
|
||||
<Tooltip label={cell.row.original.image} multiline maw={400}>
|
||||
<Box maw={200}>
|
||||
<Text truncate="end">{renderedCellValue}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -95,13 +103,57 @@ const createColumns = (
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpuUsage",
|
||||
accessorKey: "cpuUsage",
|
||||
size: 120,
|
||||
header: t("docker.field.stats.cpu.label"),
|
||||
enableHiding: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const cpuUsageA = safeValue(rowA.original.cpuUsage);
|
||||
const cpuUsageB = safeValue(rowB.original.cpuUsage);
|
||||
|
||||
return cpuUsageA - cpuUsageB;
|
||||
},
|
||||
Cell({ row }) {
|
||||
const cpuUsage = safeValue(row.original.cpuUsage);
|
||||
|
||||
return (
|
||||
<Text size="sm" c={cpuUsageColor(cpuUsage, row.original.state)}>
|
||||
{cpuUsage.toFixed(2)}%
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memoryUsage",
|
||||
accessorKey: "memoryUsage",
|
||||
size: 140,
|
||||
header: t("docker.field.stats.memory.label"),
|
||||
enableHiding: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const memoryUsageA = safeValue(rowA.original.memoryUsage);
|
||||
const memoryUsageB = safeValue(rowB.original.memoryUsage);
|
||||
|
||||
return memoryUsageA - memoryUsageB;
|
||||
},
|
||||
Cell({ row }) {
|
||||
const bytesUsage = safeValue(row.original.memoryUsage);
|
||||
|
||||
return (
|
||||
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||
{humanFileSize(bytesUsage)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
|
||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
initialData: { containers, timestamp },
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
@@ -131,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
enableColumnFilters: false,
|
||||
enablePagination: false,
|
||||
enableRowSelection: true,
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: "last",
|
||||
positionToolbarAlertBanner: "top",
|
||||
enableTableFooter: false,
|
||||
enableBottomToolbar: false,
|
||||
@@ -141,7 +195,10 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
autoFocus: true,
|
||||
},
|
||||
|
||||
initialState: { density: "xs", showGlobalFilter: true },
|
||||
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
|
||||
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
|
||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||
{tDocker("action.refresh.label")}
|
||||
@@ -174,28 +231,104 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
);
|
||||
}
|
||||
|
||||
const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [
|
||||
{ action: "start", icon: IconPlayerPlay, color: "green" },
|
||||
{ action: "stop", icon: IconPlayerStop, color: "red" },
|
||||
{ action: "restart", icon: IconRotateClockwise, color: "orange" },
|
||||
{ action: "remove", icon: IconTrash, color: "red" },
|
||||
];
|
||||
|
||||
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
|
||||
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
|
||||
clientApi.docker[`${action}All`].useMutation({
|
||||
async onSettled() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${action}.notification.success.title`),
|
||||
message: t(`${action}.notification.success.message`),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t(`${action}.notification.error.title`),
|
||||
message: t(`${action}.notification.error.message`),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mutations = {
|
||||
start: useContainerAction("start"),
|
||||
stop: useContainerAction("stop"),
|
||||
restart: useContainerAction("restart"),
|
||||
remove: useContainerAction("remove"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={(e) => e.stopPropagation()}>
|
||||
<IconDots size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size="1rem" />}
|
||||
onClick={() => router.push(createContainerLogsPath(container))}
|
||||
>
|
||||
{t("logs.label")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{containerActions.map(({ action, icon: Icon, color }) => (
|
||||
<Menu.Item
|
||||
key={action}
|
||||
leftSection={<Icon size="1rem" />}
|
||||
color={color}
|
||||
onClick={() => mutations[action].mutate({ ids: [container.id] })}
|
||||
>
|
||||
{t(`${action}.label`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconCategoryPlus size="1rem" />}
|
||||
color="red"
|
||||
onClick={() => openModal({ selectedContainers: [container] })}
|
||||
>
|
||||
{t("addToHomarr.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContainerActionBarProps {
|
||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
selectedContainers: DockerContainer[];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const handleClick = () => {
|
||||
openModal({
|
||||
selectedContainers,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedIds = selectedContainers.map((container) => container.id);
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
|
||||
{containerActions.map(({ action, icon, color }) => (
|
||||
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
|
||||
))}
|
||||
<Button
|
||||
leftSection={<IconCategoryPlus />}
|
||||
color="red"
|
||||
onClick={() => openModal({ selectedContainers })}
|
||||
variant="light"
|
||||
>
|
||||
{t("addToHomarr.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const ClientSideDockerLogsTerminal = dynamic(
|
||||
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Box, Group } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { ClientSideDockerLogsTerminal } from "./client";
|
||||
|
||||
interface DockerContainerLogsPageProps {
|
||||
params: Promise<{ containerId: string }>;
|
||||
searchParams: Promise<{ name?: string }>;
|
||||
}
|
||||
|
||||
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { containerId } = await params;
|
||||
const { name } = await searchParams;
|
||||
|
||||
try {
|
||||
await api.docker.logs({ id: containerId, tail: 1 });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<DynamicBreadcrumb
|
||||
dynamicMappings={new Map([[containerId, name ?? containerId]])}
|
||||
nonInteractable={[containerId]}
|
||||
/>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideDockerLogsTerminal containerId={containerId} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.outerTerminal > div {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import classes from "./terminal.module.css";
|
||||
|
||||
interface DockerLogsTerminalProps {
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const pendingLogsRef = useRef<string[]>([]);
|
||||
const isTerminalReadyRef = useRef(false);
|
||||
|
||||
const writeToTerminal = useCallback((data: string) => {
|
||||
if (!isTerminalReadyRef.current || !terminalRef.current) {
|
||||
pendingLogsRef.current.push(data);
|
||||
return;
|
||||
}
|
||||
|
||||
terminalRef.current.write(data);
|
||||
terminalRef.current.refresh(0, terminalRef.current.rows - 1);
|
||||
}, []);
|
||||
|
||||
clientApi.docker.subscribeLogs.useSubscription(
|
||||
{ id: containerId, tail: 200 },
|
||||
{
|
||||
onData: writeToTerminal,
|
||||
onError(err) {
|
||||
writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const canvasAddon = new CanvasAddon();
|
||||
|
||||
terminalRef.current = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
});
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
|
||||
isTerminalReadyRef.current = true;
|
||||
const terminal = terminalRef.current;
|
||||
pendingLogsRef.current.forEach((data) => terminal.write(data));
|
||||
pendingLogsRef.current = [];
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
|
||||
setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
});
|
||||
|
||||
return () => {
|
||||
isTerminalReadyRef.current = false;
|
||||
pendingLogsRef.current = [];
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [writeToTerminal]);
|
||||
|
||||
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
|
||||
};
|
||||
@@ -57,8 +57,9 @@ export const TerminalComponent = () => {
|
||||
});
|
||||
|
||||
return () => {
|
||||
terminalRef.current?.dispose();
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const ResetTours = () => {
|
||||
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("resetSuccess.title"),
|
||||
message: t("resetSuccess.message"),
|
||||
});
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const tours = [
|
||||
{ key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
|
||||
{ key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
{tours.map(({ key, completed }) => (
|
||||
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
|
||||
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
w="fit-content"
|
||||
onClick={() => resetTours()}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { canAccessUserEditPage } from "../access";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||
import { ResetTours } from "./_components/_reset-tours";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
@@ -87,6 +88,13 @@ export default async function EditUserPage(props: Props) {
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{session?.user.id === user.id && (
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
|
||||
<ResetTours />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={t("user.action.delete.label")}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
const session = await auth();
|
||||
const t = await getI18n();
|
||||
const tUser = await getScopedI18n("management.page.user");
|
||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
|
||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||
|
||||
@@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) {
|
||||
.getById({
|
||||
userId: params.userId,
|
||||
})
|
||||
.catch(catchTrpcNotFound);
|
||||
.catch(catchTrpcNotFound)
|
||||
.catch(catchTrpcUnauthorized);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
@@ -38,14 +39,18 @@ export default async function UsersPage() {
|
||||
title={t("list.title")}
|
||||
primaryAction={
|
||||
credentialsProviderEnabled ? (
|
||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||
{t("create.title")}
|
||||
</MobileAffixButton>
|
||||
<TourTarget id="manage-users-create">
|
||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||
{t("create.title")}
|
||||
</MobileAffixButton>
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={credentialsProviderEnabled}
|
||||
>
|
||||
<UserListComponent initialUserList={userList} />
|
||||
<TourTarget id="manage-users-list">
|
||||
<UserListComponent initialUserList={userList} />
|
||||
</TourTarget>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import AdmZip from "adm-zip";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (dbEnv.DRIVER !== "better-sqlite3") {
|
||||
return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbPath = dbEnv.URL as string | undefined;
|
||||
if (!dbPath || !fs.existsSync(dbPath)) {
|
||||
return NextResponse.json({ error: "Database file not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite");
|
||||
let sourceDb: InstanceType<typeof Database> | null = null;
|
||||
|
||||
try {
|
||||
sourceDb = new Database(dbPath, { readonly: true });
|
||||
sourceDb.pragma("wal_checkpoint(TRUNCATE)");
|
||||
sourceDb.exec(`VACUUM INTO '${tempPath}'`);
|
||||
sourceDb.close();
|
||||
sourceDb = null;
|
||||
|
||||
const dbBuffer = fs.readFileSync(tempPath);
|
||||
|
||||
const metadata = {
|
||||
homarrVersion: process.env.HOMARR_VERSION ?? "unknown",
|
||||
exportedAt: new Date().toISOString(),
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: env.SECRET_ENCRYPTION_KEY,
|
||||
};
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", dbBuffer);
|
||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2)));
|
||||
|
||||
const zipBuffer = zip.toBuffer();
|
||||
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
const filename = `homarr-backup-${date}.zip`;
|
||||
|
||||
return new NextResponse(zipBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": zipBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[backup/export] Export failed:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 });
|
||||
} finally {
|
||||
sourceDb?.close();
|
||||
try {
|
||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||
} catch (cleanupErr) {
|
||||
console.error("[backup/export] Failed to clean up temp file:", cleanupErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import AdmZip from "adm-zip";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { findMigrationsFolder } from "../shared";
|
||||
|
||||
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
||||
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
||||
if (importedKeyHex === currentKeyHex) return;
|
||||
|
||||
if (!HEX_KEY_REGEX.test(importedKeyHex)) {
|
||||
throw new Error("Imported encryption key is not a valid 64-character hex string");
|
||||
}
|
||||
|
||||
const oldKey = Buffer.from(importedKeyHex, "hex");
|
||||
const newKey = Buffer.from(currentKeyHex, "hex");
|
||||
|
||||
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
|
||||
integration_id: string;
|
||||
kind: string;
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
|
||||
const transaction = tempDb.transaction(() => {
|
||||
for (const row of rows) {
|
||||
const parts = row.value.split(".");
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(
|
||||
`Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`,
|
||||
);
|
||||
}
|
||||
const [data, dataIv] = parts as [string, string];
|
||||
|
||||
try {
|
||||
const iv = Buffer.from(dataIv, "hex");
|
||||
const encrypted = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
||||
|
||||
const newIv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv);
|
||||
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
||||
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
||||
|
||||
updateStmt.run(newValue, row.integration_id, row.kind);
|
||||
} catch {
|
||||
throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
transaction();
|
||||
};
|
||||
|
||||
const isOnboardingActiveAsync = async (): Promise<boolean> => {
|
||||
const onboardingRow = await db.query.onboarding.findFirst();
|
||||
if (!onboardingRow) return false;
|
||||
return onboardingRow.step === "start";
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
|
||||
|
||||
if (!isAdmin && !isOnboarding) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (dbEnv.DRIVER !== "better-sqlite3") {
|
||||
return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbPath = dbEnv.URL as string | undefined;
|
||||
if (!dbPath) {
|
||||
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await req.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(Buffer.from(arrayBuffer));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
|
||||
if (missingEntries.length > 0) {
|
||||
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const migrationsFolder = findMigrationsFolder();
|
||||
if (!migrationsFolder) {
|
||||
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
|
||||
let tempDb: InstanceType<typeof Database> | null = null;
|
||||
|
||||
try {
|
||||
const dbEntry = zip.getEntry("db.sqlite")!;
|
||||
fs.writeFileSync(tempPath, dbEntry.getData());
|
||||
|
||||
tempDb = new Database(tempPath);
|
||||
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
|
||||
migrate(drizzleDb, { migrationsFolder });
|
||||
|
||||
const metadataEntry = zip.getEntry("metadata.json")!;
|
||||
const metadata = JSON.parse(metadataEntry.getData().toString());
|
||||
|
||||
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
|
||||
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
|
||||
|
||||
const secretCount = (
|
||||
tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }
|
||||
).count;
|
||||
|
||||
if (secretCount > 0 && !importedKey) {
|
||||
throw new Error(
|
||||
"Backup contains integration secrets but no encryption key. " +
|
||||
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
|
||||
);
|
||||
}
|
||||
|
||||
if (importedKey) {
|
||||
reEncryptSecrets(tempDb, importedKey);
|
||||
}
|
||||
|
||||
tempDb.close();
|
||||
tempDb = null;
|
||||
|
||||
fs.renameSync(tempPath, dbPath);
|
||||
|
||||
for (const suffix of ["-wal", "-shm"] as const) {
|
||||
try {
|
||||
const sidecar = `${dbPath}${suffix}`;
|
||||
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
||||
} catch (cleanupErr) {
|
||||
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Database restored, restarting server...");
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Restore failed:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
|
||||
} finally {
|
||||
tempDb?.close();
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// temp file may have been renamed already
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { findMigrationsFolder } from "../shared";
|
||||
|
||||
interface JournalEntry {
|
||||
idx: number;
|
||||
version: string;
|
||||
when: number;
|
||||
tag: string;
|
||||
breakpoints: boolean;
|
||||
}
|
||||
|
||||
interface MigrationFile {
|
||||
idx: number;
|
||||
tag: string;
|
||||
sql: string;
|
||||
when: number;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const migrationsFolder = findMigrationsFolder();
|
||||
if (!migrationsFolder) {
|
||||
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
return NextResponse.json({ error: "Migration journal not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] };
|
||||
|
||||
const migrations: MigrationFile[] = [];
|
||||
for (const entry of journal.entries) {
|
||||
const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`);
|
||||
if (!fs.existsSync(sqlPath)) continue;
|
||||
|
||||
migrations.push({
|
||||
idx: entry.idx,
|
||||
tag: entry.tag,
|
||||
sql: fs.readFileSync(sqlPath, "utf-8"),
|
||||
when: entry.when,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ total: migrations.length, migrations },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[backup/migrations] Failed to read migrations:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const migrationsFolderCandidates = [
|
||||
path.join(process.cwd(), "db/migrations/sqlite"),
|
||||
path.join(process.cwd(), "packages/db/migrations/sqlite"),
|
||||
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
|
||||
"/app/db/migrations/sqlite",
|
||||
];
|
||||
|
||||
export const findMigrationsFolder = () =>
|
||||
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
@@ -0,0 +1,30 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const wasmCandidates = [
|
||||
path.join(process.cwd(), "public/sql-wasm.wasm"),
|
||||
path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"),
|
||||
path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"),
|
||||
path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"),
|
||||
];
|
||||
|
||||
let cachedWasm: Buffer | null = null;
|
||||
|
||||
export async function GET() {
|
||||
if (!cachedWasm) {
|
||||
const found = wasmCandidates.find((p) => fs.existsSync(p));
|
||||
if (!found) {
|
||||
return NextResponse.json({ error: "WASM binary not found" }, { status: 500 });
|
||||
}
|
||||
cachedWasm = fs.readFileSync(found);
|
||||
}
|
||||
|
||||
return new NextResponse(cachedWasm, {
|
||||
headers: {
|
||||
"Content-Type": "application/wasm",
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import AdmZip from "adm-zip";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
|
||||
|
||||
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
|
||||
|
||||
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
|
||||
const key = Buffer.from(keyHex, "hex");
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
|
||||
};
|
||||
|
||||
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
|
||||
const key = Buffer.from(keyHex, "hex");
|
||||
const [data, dataIv] = value.split(".") as [string, string];
|
||||
const iv = Buffer.from(dataIv, "hex");
|
||||
const encrypted = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
let decrypted = decipher.update(encrypted);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
};
|
||||
|
||||
const createTestDb = (dbPath: string) => {
|
||||
const sqlite = new Database(dbPath);
|
||||
const db = drizzle(sqlite, { casing: DB_CASING });
|
||||
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
return { db, sqlite };
|
||||
};
|
||||
|
||||
describe("SQLite backup", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("export ZIP structure", () => {
|
||||
it("should create a ZIP containing all required entries", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
||||
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
||||
sqlite.close();
|
||||
|
||||
const dbBuffer = fs.readFileSync(backupPath);
|
||||
const metadata = {
|
||||
homarrVersion: "test",
|
||||
exportedAt: new Date().toISOString(),
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: generateHexKey(),
|
||||
};
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", dbBuffer);
|
||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
|
||||
|
||||
const zipPath = path.join(tmpDir, "backup.zip");
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
const readBack = new AdmZip(zipPath);
|
||||
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
|
||||
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
|
||||
|
||||
const dbEntry = readBack.getEntry("db.sqlite");
|
||||
expect(dbEntry).toBeDefined();
|
||||
expect(dbEntry!.header.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should produce a valid SQLite database in the ZIP", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
||||
sqlite.close();
|
||||
|
||||
const backupDb = new Database(backupPath, { readonly: true });
|
||||
const tables = backupDb
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
|
||||
expect(tables.map((t) => t.name)).toContain("board");
|
||||
expect(tables.map((t) => t.name)).toContain("user");
|
||||
expect(tables.map((t) => t.name)).toContain("integrationSecret");
|
||||
|
||||
backupDb.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("import ZIP validation", () => {
|
||||
it("should detect missing required entries", () => {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const required = ["db.sqlite", "metadata.json"];
|
||||
const missing = required.filter((name) => !entries.includes(name));
|
||||
|
||||
expect(missing).toEqual(["metadata.json"]);
|
||||
});
|
||||
|
||||
it("should accept valid backup ZIP with all entries", () => {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const required = ["db.sqlite", "metadata.json"];
|
||||
const missing = required.filter((name) => !entries.includes(name));
|
||||
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should accept legacy backup ZIP with encryption-key.txt", () => {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
||||
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
|
||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const required = ["db.sqlite", "metadata.json"];
|
||||
const missing = required.filter((name) => !entries.includes(name));
|
||||
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret re-encryption", () => {
|
||||
it("should re-encrypt secrets from old key to new key", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
const oldKey = generateHexKey();
|
||||
const newKey = generateHexKey();
|
||||
const plaintext = "my-secret-password";
|
||||
|
||||
const encryptedValue = encryptWithKey(plaintext, oldKey);
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO "integration" ("id", "name", "url", "kind")
|
||||
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
||||
`);
|
||||
sqlite.exec(`
|
||||
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
||||
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
||||
`);
|
||||
|
||||
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
|
||||
expect(decryptedBefore).toBe(plaintext);
|
||||
|
||||
const oldKeyBuf = Buffer.from(oldKey, "hex");
|
||||
const newKeyBuf = Buffer.from(newKey, "hex");
|
||||
|
||||
const rows = sqlite
|
||||
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
|
||||
.all() as { integration_id: string; kind: string; value: string }[];
|
||||
|
||||
const updateStmt = sqlite.prepare(
|
||||
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const [data, dataIv] = row.value.split(".") as [string, string];
|
||||
const iv = Buffer.from(dataIv, "hex");
|
||||
const encrypted = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
||||
|
||||
const newIv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
|
||||
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
||||
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
||||
|
||||
updateStmt.run(newValue, row.integration_id, row.kind);
|
||||
}
|
||||
|
||||
const updatedRow = sqlite
|
||||
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
||||
.get("int-1") as { value: string };
|
||||
|
||||
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
||||
expect(decryptedAfter).toBe(plaintext);
|
||||
|
||||
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("should skip re-encryption when keys match", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
const sameKey = generateHexKey();
|
||||
const plaintext = "my-secret";
|
||||
const encryptedValue = encryptWithKey(plaintext, sameKey);
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO "integration" ("id", "name", "url", "kind")
|
||||
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
||||
`);
|
||||
sqlite.exec(`
|
||||
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
||||
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
||||
`);
|
||||
|
||||
const rowBefore = sqlite
|
||||
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
||||
.get("int-1") as { value: string };
|
||||
|
||||
expect(rowBefore.value).toBe(encryptedValue);
|
||||
|
||||
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VACUUM INTO consistency", () => {
|
||||
it("should produce a consistent copy via VACUUM INTO", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO "app" ("id", "name", "icon_url")
|
||||
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
|
||||
`);
|
||||
|
||||
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
|
||||
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
||||
|
||||
const backupDb = new Database(backupPath, { readonly: true });
|
||||
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
|
||||
|
||||
expect(app).toBeDefined();
|
||||
expect(app!.name).toBe("TestApp");
|
||||
|
||||
backupDb.close();
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration on imported DB", () => {
|
||||
it("should successfully migrate an imported database", () => {
|
||||
const sourcePath = path.join(tmpDir, "source.sqlite");
|
||||
const { sqlite: sourceDb } = createTestDb(sourcePath);
|
||||
|
||||
sourceDb.exec(`
|
||||
INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status")
|
||||
VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
|
||||
`);
|
||||
sourceDb.close();
|
||||
|
||||
const importPath = path.join(tmpDir, "import.sqlite");
|
||||
fs.copyFileSync(sourcePath, importPath);
|
||||
|
||||
const importDb = new Database(importPath);
|
||||
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
|
||||
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
|
||||
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
|
||||
expect(board).toBeDefined();
|
||||
expect(board!.name).toBe("TestBoard");
|
||||
|
||||
importDb.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryption key validation", () => {
|
||||
it("should reject invalid hex keys", () => {
|
||||
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
|
||||
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
for (const key of invalidKeys) {
|
||||
expect(hexKeyRegex.test(key)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept valid 64-char hex keys", () => {
|
||||
const validKey = generateHexKey();
|
||||
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
||||
expect(hexKeyRegex.test(validKey)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
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 };
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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,7 +44,8 @@ const handler = auth(async (req) => {
|
||||
const response = await fetchRequestHandler({
|
||||
endpoint: trpcPath,
|
||||
router: appRouter,
|
||||
req,
|
||||
// mcp-handler globally augments Request with `auth?: AuthInfo`, conflicting with NextAuth's `auth: Session | null`
|
||||
req: req as unknown as Request,
|
||||
createContext: () => createTRPCContext({ session, headers: req.headers }),
|
||||
onError({ error, path, type }) {
|
||||
logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error }));
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { BackupAnalysis } from "./types";
|
||||
import { PREVIEW_TABLE_KEYS } from "./types";
|
||||
|
||||
interface BackupPreviewPanelProps {
|
||||
analysis: BackupAnalysis;
|
||||
}
|
||||
|
||||
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore.preview");
|
||||
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
|
||||
const pendingCount = analysis.migrations.pending.length;
|
||||
const hasPendingMigrations = pendingCount > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="xl">
|
||||
<IconDatabase size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={5}>{t("title")}</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("exported", { date: exportDate })}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="light" color="gray" ml="auto">
|
||||
Homarr {analysis.metadata.homarrVersion}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Table striped withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("entity")}</Table.Th>
|
||||
<Table.Th ta="right">{t("count")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{PREVIEW_TABLE_KEYS.map((key) => (
|
||||
<Table.Tr key={key}>
|
||||
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap={4} justify="flex-end">
|
||||
{(analysis.counts[key] ?? 0) > 0 && (
|
||||
<IconCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
)}
|
||||
<Text size="sm" ff="monospace">{analysis.counts[key] ?? 0}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{analysis.boardNames.length > 0 && (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" fw={500}>{t("boards")}:</Text>
|
||||
{analysis.boardNames.map((name) => (
|
||||
<Badge key={name} variant="light" size="sm">{name}</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{hasPendingMigrations && (
|
||||
<Paper p="md" radius="md" withBorder bd="1px solid var(--mantine-color-orange-8)">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="orange" size="lg" radius="xl">
|
||||
<IconGitBranch size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
{pendingCount === 1
|
||||
? t("migrationsApplied", { count: String(pendingCount) })
|
||||
: t("migrationsAppliedPlural", { count: String(pendingCount) })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{pendingCount === 1
|
||||
? t("migrationsOlderVersion", { count: String(pendingCount) })
|
||||
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="light" color="orange" ml="auto">
|
||||
{t("migrationsPending", { count: String(pendingCount) })}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!hasPendingMigrations && (
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="green" size="lg" radius="xl">
|
||||
<IconGitBranch size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{t("migrationsUpToDate", { count: String(analysis.migrations.total) })}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react";
|
||||
|
||||
const CONFIRMATION_PHRASE = "I understand";
|
||||
|
||||
interface DangerousActionConfirmationProps {
|
||||
title: string;
|
||||
warningTitle: string;
|
||||
warningBody: string;
|
||||
typePrompt: string;
|
||||
submitLabel: string;
|
||||
submitIcon: ReactNode;
|
||||
cancelLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
disabled?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const DangerousActionConfirmation = ({
|
||||
title,
|
||||
warningTitle,
|
||||
warningBody,
|
||||
typePrompt,
|
||||
submitLabel,
|
||||
submitIcon,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
disabled,
|
||||
color = "red",
|
||||
}: DangerousActionConfirmationProps) => {
|
||||
const [value, setValue] = useState("");
|
||||
const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase();
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="md" withBorder bd={`1px solid var(--mantine-color-${color}-9)`}>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<IconShieldExclamation size={24} color={`var(--mantine-color-${color}-6)`} />
|
||||
<Text size="lg" fw={700} c={color}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>{warningTitle}</Text>
|
||||
<Text size="sm">{warningBody}</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{typePrompt}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
placeholder={CONFIRMATION_PHRASE}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
input: {
|
||||
fontFamily: "var(--mantine-font-family-monospace)",
|
||||
borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
color={color}
|
||||
leftSection={submitIcon}
|
||||
onClick={onConfirm}
|
||||
disabled={!isMatch || disabled}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
||||
|
||||
import "@mantine/dropzone/styles.css";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { RestoreStep } from "./types";
|
||||
import { BackupPreviewPanel } from "./backup-preview-panel";
|
||||
import { MigrationProgressPanel } from "./migration-progress-panel";
|
||||
import { RestoreConfirmation } from "./restore-confirmation";
|
||||
import { RestoreProgressPanel } from "./restore-progress-panel";
|
||||
import { useBackupAnalysis } from "./use-backup-analysis";
|
||||
|
||||
interface DatabaseRestoreFlowProps {
|
||||
variant?: "card" | "standalone";
|
||||
onRestoreComplete?: () => void;
|
||||
}
|
||||
|
||||
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore");
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const [step, setStep] = useState<RestoreStep>("upload");
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const apiDoneRef = useRef(false);
|
||||
const animDoneRef = useRef(false);
|
||||
const apiErrorRef = useRef<string | null>(null);
|
||||
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
(files: FileWithPath[]) => {
|
||||
const droppedFile = files[0];
|
||||
if (!droppedFile) return;
|
||||
setFile(droppedFile);
|
||||
setStep("preview");
|
||||
setImportError(null);
|
||||
void analyzeFile(droppedFile);
|
||||
},
|
||||
[analyzeFile],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setFile(null);
|
||||
setStep("upload");
|
||||
setImportError(null);
|
||||
apiDoneRef.current = false;
|
||||
animDoneRef.current = false;
|
||||
apiErrorRef.current = null;
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const tryFinalize = useCallback(() => {
|
||||
if (!apiDoneRef.current) return;
|
||||
|
||||
if (apiErrorRef.current) {
|
||||
setImportError(apiErrorRef.current);
|
||||
setStep("error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!animDoneRef.current) return;
|
||||
onRestoreComplete?.();
|
||||
window.location.reload();
|
||||
}, [onRestoreComplete]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setStep("restoring");
|
||||
setImportError(null);
|
||||
apiDoneRef.current = false;
|
||||
animDoneRef.current = false;
|
||||
apiErrorRef.current = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const data = await response.json();
|
||||
apiErrorRef.current = data.error ?? t("failed.title");
|
||||
} catch {
|
||||
apiErrorRef.current = `Server returned ${response.status}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiErrorRef.current = t("failed.title");
|
||||
} finally {
|
||||
apiDoneRef.current = true;
|
||||
tryFinalize();
|
||||
}
|
||||
}, [file, t, tryFinalize]);
|
||||
|
||||
const handleAnimationComplete = useCallback(() => {
|
||||
animDoneRef.current = true;
|
||||
tryFinalize();
|
||||
}, [tryFinalize]);
|
||||
|
||||
if (step === "upload") {
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
acceptColor="blue.6"
|
||||
rejectColor="red.6"
|
||||
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
|
||||
multiple={false}
|
||||
maxSize={1024 * 1024 * 256}
|
||||
radius="md"
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
<div>
|
||||
<Text size="xl" inline>
|
||||
{t("dropzone.title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
{t("dropzone.description")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "preview") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<IconFileZip size={20} />
|
||||
<div>
|
||||
<Text size="sm" fw={500}>{file?.name}</Text>
|
||||
<Text size="xs" c="dimmed">{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button variant="subtle" size="xs" onClick={handleClear}>
|
||||
{t("changeFile")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{loading && migrationProgress && (
|
||||
<MigrationProgressPanel progress={migrationProgress} />
|
||||
)}
|
||||
|
||||
{loading && !migrationProgress && (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{analysisError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("analysisError")}>
|
||||
{analysisError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
<BackupPreviewPanel analysis={analysis} />
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => setStep("confirm")}
|
||||
>
|
||||
{t("continueToRestore")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "confirm") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{analysis && <BackupPreviewPanel analysis={analysis} />}
|
||||
<RestoreConfirmation
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setStep("preview")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "restoring") {
|
||||
return (
|
||||
<RestoreProgressPanel
|
||||
active
|
||||
migrations={analysis?.migrations.pending ?? []}
|
||||
onComplete={handleAnimationComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "error") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("failed.title")} radius="md">
|
||||
<Text size="sm">{importError}</Text>
|
||||
</Alert>
|
||||
<Group>
|
||||
<Button variant="subtle" onClick={handleClear}>
|
||||
{t("tryAgain")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DatabaseRestoreFlow } from "./database-restore-flow";
|
||||
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
||||
export type { BackupAnalysis, RestoreStep } from "./types";
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { MigrationProgress } from "./use-backup-analysis";
|
||||
|
||||
interface MigrationProgressPanelProps {
|
||||
progress: MigrationProgress;
|
||||
}
|
||||
|
||||
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore.migration");
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
|
||||
<IconDatabase size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("title", { current: String(progress.current), total: String(progress.total) })}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box pl={4}>
|
||||
<Timeline active={progress.current - 1} bulletSize={20} lineWidth={2} color="blue">
|
||||
{Array.from({ length: progress.total }, (_, i) => {
|
||||
const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done");
|
||||
const isActive = i === progress.current - 1 && progress.phase === "applying";
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={i}
|
||||
bullet={
|
||||
isDone ? (
|
||||
<IconCheck size={12} />
|
||||
) : isActive ? (
|
||||
<Loader size={10} color="white" />
|
||||
) : (
|
||||
<IconLoader size={12} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Text size="xs" ff="monospace" c={isDone ? "dimmed" : isActive ? "white" : "dimmed"}>
|
||||
{i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
||||
|
||||
interface RestoreConfirmationProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
|
||||
|
||||
return (
|
||||
<DangerousActionConfirmation
|
||||
title={t("title")}
|
||||
warningTitle={t("warningTitle")}
|
||||
warningBody={t("warningBody")}
|
||||
typePrompt={t("typePrompt", { phrase: "I understand" })}
|
||||
submitLabel={t("submit")}
|
||||
submitIcon={<IconDatabaseImport size={16} />}
|
||||
cancelLabel={t("cancel")}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconDatabaseImport,
|
||||
IconFileZip,
|
||||
IconKey,
|
||||
IconLoader,
|
||||
IconRefresh,
|
||||
IconTransform,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { MigrationFile } from "./types";
|
||||
|
||||
const BASE_PHASE_DURATIONS: Record<string, number> = {
|
||||
extracting: 1200,
|
||||
encrypting: 1500,
|
||||
swapping: 800,
|
||||
restarting: 10000,
|
||||
};
|
||||
|
||||
const MIGRATION_STEP_DURATION = 100;
|
||||
|
||||
interface TimelineStep {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: Icon;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface RestoreProgressPanelProps {
|
||||
active: boolean;
|
||||
migrations: MigrationFile[];
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore.progress");
|
||||
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
|
||||
|
||||
const steps = useMemo((): TimelineStep[] => {
|
||||
const result: TimelineStep[] = [
|
||||
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
|
||||
];
|
||||
|
||||
if (migrations.length > 0) {
|
||||
for (const migration of migrations) {
|
||||
result.push({
|
||||
key: `migration-${migration.idx}`,
|
||||
label: migration.tag,
|
||||
icon: IconDatabaseImport,
|
||||
duration: MIGRATION_STEP_DURATION,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
key: "migrating",
|
||||
label: t("migrating"),
|
||||
icon: IconDatabaseImport,
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
|
||||
result.push(
|
||||
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! },
|
||||
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! },
|
||||
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! },
|
||||
);
|
||||
|
||||
return result;
|
||||
}, [migrations, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (cancelled) return;
|
||||
setCurrentStepIdx(i);
|
||||
await new Promise((r) => setTimeout(r, steps[i]!.duration));
|
||||
}
|
||||
if (!cancelled) {
|
||||
onComplete?.();
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [active, onComplete, steps]);
|
||||
|
||||
if (!active && currentStepIdx === -1) return null;
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" color="blue" />
|
||||
<Text size="sm" fw={600}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box pl={4}>
|
||||
<Timeline
|
||||
active={currentStepIdx}
|
||||
bulletSize={24}
|
||||
lineWidth={2}
|
||||
color="blue"
|
||||
>
|
||||
{steps.map((step, i) => {
|
||||
const isDone = i < currentStepIdx;
|
||||
const isActive = i === currentStepIdx;
|
||||
const isMigration = step.key.startsWith("migration-");
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={step.key}
|
||||
bullet={
|
||||
isDone ? (
|
||||
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
|
||||
<IconCheck size={14} />
|
||||
</ThemeIcon>
|
||||
) : isActive ? (
|
||||
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
|
||||
<Loader size={12} color="white" />
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
|
||||
<IconLoader size={14} />
|
||||
</ThemeIcon>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Group gap="xs" mt={-4}>
|
||||
<step.icon size={16} color={isDone ? "var(--mantine-color-green-6)" : isActive ? "var(--mantine-color-blue-5)" : "var(--mantine-color-dimmed)"} />
|
||||
<Text
|
||||
size={isMigration ? "xs" : "sm"}
|
||||
fw={isActive ? 600 : 400}
|
||||
ff={isMigration ? "monospace" : undefined}
|
||||
c={isDone ? "green" : isActive ? "white" : "dimmed"}
|
||||
>
|
||||
{step.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface BackupMetadata {
|
||||
homarrVersion: string;
|
||||
exportedAt: string;
|
||||
dbDialect: string;
|
||||
encryptionKey?: string;
|
||||
}
|
||||
|
||||
export interface MigrationFile {
|
||||
idx: number;
|
||||
tag: string;
|
||||
sql: string;
|
||||
when: number;
|
||||
}
|
||||
|
||||
export interface MigrationStatus {
|
||||
applied: number;
|
||||
pending: MigrationFile[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface BackupAnalysis {
|
||||
metadata: BackupMetadata;
|
||||
counts: Record<string, number>;
|
||||
boardNames: string[];
|
||||
migrations: MigrationStatus;
|
||||
}
|
||||
|
||||
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
|
||||
|
||||
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
|
||||
import { PREVIEW_TABLE_KEYS } from "./types";
|
||||
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const getAppliedMigrationCount = (db: any): number => {
|
||||
try {
|
||||
const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`);
|
||||
return (result[0]?.values[0]?.[0] as number) ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMigrations = async (): Promise<MigrationFile[]> => {
|
||||
const res = await fetch("/api/backup/migrations");
|
||||
if (!res.ok) throw new Error("Failed to fetch migration files from server");
|
||||
const data = await res.json();
|
||||
return data.migrations ?? [];
|
||||
};
|
||||
|
||||
const countEntities = (db: any): Record<string, number> => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const key of PREVIEW_TABLE_KEYS) {
|
||||
try {
|
||||
const result = db.exec(`SELECT COUNT(*) FROM "${key}"`);
|
||||
counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0;
|
||||
} catch {
|
||||
counts[key] = 0;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const getBoardNames = (db: any): string[] => {
|
||||
try {
|
||||
const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"');
|
||||
return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export interface MigrationProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
tag: string;
|
||||
phase: "pending" | "applying" | "done" | "error";
|
||||
}
|
||||
|
||||
export const useBackupAnalysis = () => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore");
|
||||
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress | null>(null);
|
||||
const dbRef = useRef<any>(null);
|
||||
|
||||
const analyzeFile = useCallback(async (file: File) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAnalysis(null);
|
||||
setMigrationProgress(null);
|
||||
|
||||
try {
|
||||
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
|
||||
import("jszip").then((m) => m.default),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
import("sql.js").then((m: any) => m.default),
|
||||
fetchMigrations(),
|
||||
fetch("/api/backup/sql-wasm").then((r) => {
|
||||
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
|
||||
return r.arrayBuffer();
|
||||
}),
|
||||
]);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const metadataFile = zip.file("metadata.json");
|
||||
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
|
||||
const dbFile = zip.file("db.sqlite");
|
||||
if (!dbFile) throw new Error(t("invalidMissingDb"));
|
||||
|
||||
const metadata = JSON.parse(await metadataFile.async("string"));
|
||||
const dbBuffer = await dbFile.async("uint8array");
|
||||
|
||||
const SQL = await initSqlJs({ wasmBinary });
|
||||
|
||||
const db = new SQL.Database(dbBuffer);
|
||||
dbRef.current = db;
|
||||
|
||||
try {
|
||||
const appliedCount = getAppliedMigrationCount(db);
|
||||
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
|
||||
|
||||
const migrations: MigrationStatus = {
|
||||
applied: appliedCount,
|
||||
pending: pendingMigrations,
|
||||
total: allMigrations.length,
|
||||
};
|
||||
|
||||
if (pendingMigrations.length > 0) {
|
||||
for (let i = 0; i < pendingMigrations.length; i++) {
|
||||
const migration = pendingMigrations[i]!;
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "applying",
|
||||
});
|
||||
|
||||
await delay(100);
|
||||
|
||||
try {
|
||||
const statements = migration.sql
|
||||
.split("--> statement-breakpoint")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const stmt of statements) {
|
||||
db.run(stmt);
|
||||
}
|
||||
|
||||
db.run(
|
||||
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
|
||||
[migration.tag, Date.now()],
|
||||
);
|
||||
} catch (migrationErr) {
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "error",
|
||||
});
|
||||
throw new Error(
|
||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "done",
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
}
|
||||
}
|
||||
|
||||
const counts = countEntities(db);
|
||||
const boardNames = getBoardNames(db);
|
||||
|
||||
setMigrationProgress(null);
|
||||
setAnalysis({ metadata, counts, boardNames, migrations });
|
||||
} finally {
|
||||
db.close();
|
||||
dbRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (dbRef.current) {
|
||||
try { dbRef.current.close(); } catch { /* already closed */ }
|
||||
dbRef.current = null;
|
||||
}
|
||||
setAnalysis(null);
|
||||
setError(null);
|
||||
setMigrationProgress(null);
|
||||
}, []);
|
||||
|
||||
return { analysis, loading, error, migrationProgress, analyzeFile, reset };
|
||||
};
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -19,7 +20,7 @@ export const createItemCallback =
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return previous;
|
||||
@@ -28,7 +29,7 @@ export const createItemCallback =
|
||||
id: id ?? createId(),
|
||||
kind,
|
||||
options,
|
||||
layouts: createItemLayouts(previous, firstSection),
|
||||
layouts: createItemLayouts(previous, firstSection, kind),
|
||||
integrationIds,
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -48,15 +49,20 @@ export const createItemCallback =
|
||||
};
|
||||
};
|
||||
|
||||
const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayout[] => {
|
||||
const defaultItemSize = { width: 1, height: 1 };
|
||||
|
||||
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
|
||||
|
||||
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): 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)
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -64,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayo
|
||||
}
|
||||
|
||||
return {
|
||||
width: 1,
|
||||
height: 1,
|
||||
width: itemSize.width,
|
||||
height: itemSize.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export type Translator = {
|
||||
(key: string): string;
|
||||
};
|
||||
|
||||
export interface HoverMetadataItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const maxMetadataItems = 3;
|
||||
|
||||
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
|
||||
app: true,
|
||||
anchorNote: true,
|
||||
dynamicSelect: true,
|
||||
sortableItemList: true,
|
||||
multiReleasesRepositories: true,
|
||||
umamiEventName: true,
|
||||
umamiEventNames: true,
|
||||
umamiWebsite: true,
|
||||
};
|
||||
|
||||
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
|
||||
|
||||
type OptionDefinitionWithRules = WidgetOptionDefinition & {
|
||||
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
|
||||
options?: readonly { value: unknown; label: unknown }[];
|
||||
};
|
||||
|
||||
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
|
||||
|
||||
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
|
||||
const match = definition.options?.find((option) => option.value === value);
|
||||
const label = match?.label;
|
||||
const fallbackLabel = () => String(value);
|
||||
const labelResolvers: Record<string, () => string> = {
|
||||
function: () => (label as (translator: Translator) => string)(t),
|
||||
string: () => label as string,
|
||||
missing: fallbackLabel,
|
||||
};
|
||||
const labelType = typeof label;
|
||||
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
|
||||
return resolveLabel();
|
||||
};
|
||||
|
||||
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
|
||||
switch: (value, _, t) => {
|
||||
const switchLabels: Record<string, string> = {
|
||||
true: t("common.action.show"),
|
||||
false: t("common.action.hide"),
|
||||
};
|
||||
return switchLabels[String(Boolean(value))] ?? null;
|
||||
},
|
||||
text: (value) => {
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
},
|
||||
number: (value) => String(value),
|
||||
slider: (value) => String(value),
|
||||
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
|
||||
multiSelect: (value) => {
|
||||
const items = value as unknown[];
|
||||
return items.length > 0 ? String(items.length) : null;
|
||||
},
|
||||
multiText: (value) => {
|
||||
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
|
||||
return items.length > 0 ? items.join(", ") : null;
|
||||
},
|
||||
location: (value) => {
|
||||
const location = value as { name?: string };
|
||||
const name = location.name?.trim() ?? "";
|
||||
return name.length > 0 ? name : null;
|
||||
},
|
||||
};
|
||||
|
||||
const formatOptionValue = (
|
||||
type: WidgetOptionType,
|
||||
value: unknown,
|
||||
definition: OptionDefinitionWithRules,
|
||||
t: Translator,
|
||||
) => {
|
||||
const formatter = optionTypeFormatters[type];
|
||||
return formatter?.(value, definition, t) ?? null;
|
||||
};
|
||||
|
||||
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
|
||||
const labelKey = `widget.${kind}.option.${key}.label`;
|
||||
const translated = t(labelKey);
|
||||
return translated === labelKey ? key : translated;
|
||||
};
|
||||
|
||||
export const collectWidgetHoverMetadata = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
integrationKinds: IntegrationKind[],
|
||||
t: Translator,
|
||||
): HoverMetadataItem[] => {
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
|
||||
return objectEntries(optionDefinitions)
|
||||
.filter(([key, optionDefinition]) => {
|
||||
const skip = skippedOptionTypes[optionDefinition.type];
|
||||
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
|
||||
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
|
||||
return !skip && !hidden && !unchanged;
|
||||
})
|
||||
.map(([key, optionDefinition]) => {
|
||||
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
|
||||
return {
|
||||
label: getOptionLabel(item.kind, key, t),
|
||||
value: formattedValue ?? "",
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value.length > 0)
|
||||
.slice(0, maxMetadataItems);
|
||||
};
|
||||
|
||||
interface DisplayNameSource {
|
||||
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
|
||||
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
const displayNameSources: DisplayNameSource[] = [
|
||||
{
|
||||
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
|
||||
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
|
||||
},
|
||||
{
|
||||
matches: (item, options) =>
|
||||
item.kind === "clock" &&
|
||||
Boolean(options.customTitleToggle) &&
|
||||
String(options.customTitle ?? "").trim().length > 0,
|
||||
resolve: (_, options) => String(options.customTitle).trim(),
|
||||
},
|
||||
];
|
||||
|
||||
export const resolveWidgetDisplayName = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
t: Translator,
|
||||
) => {
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
|
||||
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: calc(100% - 10px);
|
||||
margin-bottom: 1px;
|
||||
z-index: 200;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@mixin dark {
|
||||
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
|
||||
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
|
||||
}
|
||||
@mixin light {
|
||||
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
|
||||
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--background-color);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nameSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.widgetIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.integrations {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.integrationAvatar {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.metadataItem {
|
||||
@mixin dark {
|
||||
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
|
||||
}
|
||||
@mixin light {
|
||||
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 140px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--chip-background);
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metadataLabel {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metadataValue {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Translator } from "./widget-hover-metadata";
|
||||
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
|
||||
import classes from "./widget-hover-overlay.module.css";
|
||||
|
||||
interface WidgetHoverOverlayProps {
|
||||
item: SectionItem;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
}
|
||||
|
||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const WidgetIcon = definition.icon;
|
||||
|
||||
const translate = ((key: string) => t(key as never)) as Translator;
|
||||
const displayName = resolveWidgetDisplayName(item, settings, translate);
|
||||
|
||||
const connectedIntegrations = (integrations ?? []).filter((integration) =>
|
||||
item.integrationIds.includes(integration.id),
|
||||
);
|
||||
const metadataItems = collectWidgetHoverMetadata(
|
||||
item,
|
||||
settings,
|
||||
connectedIntegrations.map((integration) => integration.kind),
|
||||
translate,
|
||||
);
|
||||
const hasIntegrations = connectedIntegrations.length > 0;
|
||||
const hasMetadata = metadataItems.length > 0;
|
||||
|
||||
if (isEditMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.panel}>
|
||||
<div className={classes.nameSection}>
|
||||
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
|
||||
<Text className={classes.name} title={displayName}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{hasIntegrations && (
|
||||
<Avatar.Group spacing="xs" className={classes.integrations}>
|
||||
{connectedIntegrations.map((integration) => (
|
||||
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
|
||||
<Avatar
|
||||
src={getIconUrl(integration.kind)}
|
||||
size={20}
|
||||
radius="sm"
|
||||
className={classes.integrationAvatar}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
)}
|
||||
|
||||
{hasMetadata && (
|
||||
<Group className={classes.metadata} gap={4}>
|
||||
{metadataItems.map((metadataItem) => (
|
||||
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
|
||||
<span className={classes.metadataLabel}>{metadataItem.label}</span>
|
||||
<span className={classes.metadataValue}>{metadataItem.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,8 +2,12 @@ import { useMemo } from "react";
|
||||
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
|
||||
import { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
import { useSectionContext } from "./section-context";
|
||||
@@ -11,6 +15,7 @@ import { useSectionItems } from "./use-section-items";
|
||||
|
||||
export const SectionContent = () => {
|
||||
const { innerSections, items, refs } = useSectionContext();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery();
|
||||
|
||||
/**
|
||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||
@@ -18,7 +23,7 @@ export const SectionContent = () => {
|
||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||
*/
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...items, ...innerSections].sort((itemA, itemB) => {
|
||||
return [...items, ...innerSections].toSorted((itemA, itemB) => {
|
||||
if (itemA.yOffset === itemB.yOffset) {
|
||||
return itemA.xOffset - itemB.xOffset;
|
||||
}
|
||||
@@ -30,7 +35,7 @@ export const SectionContent = () => {
|
||||
return (
|
||||
<>
|
||||
{sortedItems.map((item) => (
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -39,12 +44,12 @@ export const SectionContent = () => {
|
||||
interface ItemProps {
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
}
|
||||
|
||||
const Item = ({ item, innerRef }: ItemProps) => {
|
||||
const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||
const minWidth = useMinSize(item, "x");
|
||||
const minHeight = useMinSize(item, "y");
|
||||
|
||||
return (
|
||||
<GridStackItem
|
||||
key={item.id}
|
||||
@@ -59,6 +64,7 @@ const Item = ({ item, innerRef }: ItemProps) => {
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
>
|
||||
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||
</GridStackItem>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,9 @@ 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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
import { UserButton } from "./header/user";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
|
||||
@@ -25,11 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<DesktopSearchInput />
|
||||
<TourTarget id="board-search">
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<UserButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<Spotlight />
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
|
||||
<OnboardingTour.Target id={id}>
|
||||
<Box data-tour-target={id}>{children}</Box>
|
||||
</OnboardingTour.Target>
|
||||
);
|
||||
@@ -1,7 +1,9 @@
|
||||
"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";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
@@ -9,9 +11,28 @@ import { Link } from "@homarr/ui";
|
||||
export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
||||
|
||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
||||
if (!id) return <>{children}</>;
|
||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||
};
|
||||
|
||||
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
const useClientPathname = () => {
|
||||
const pathname = usePathname();
|
||||
return props.external ? (
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
return { pathname, isClient };
|
||||
};
|
||||
|
||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
const { pathname, isClient } = useClientPathname();
|
||||
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" />
|
||||
) : (
|
||||
<NavLink
|
||||
@@ -19,26 +40,29 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
active={pathname === props.href}
|
||||
active={isClient && pathMatches(pathname, props.href)}
|
||||
/>
|
||||
);
|
||||
return <TourTarget id={tourId}>{link}</TourTarget>;
|
||||
};
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = props.items.some((item) => item.href === pathname);
|
||||
return (
|
||||
const { pathname, isClient } = useClientPathname();
|
||||
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
|
||||
const nav = (
|
||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
||||
};
|
||||
|
||||
interface CommonNavigationLinkProps {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
"data-onboarding-tour-id"?: string;
|
||||
}
|
||||
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
|
||||
@@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
||||
return null;
|
||||
}
|
||||
|
||||
const { icon: TablerIcon, iconProps, ...props } = link;
|
||||
const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link;
|
||||
const Icon =
|
||||
typeof TablerIcon === "string" ? (
|
||||
<Image src={TablerIcon} w={20} h={20} />
|
||||
@@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
||||
if ("items" in props) {
|
||||
clientLink = {
|
||||
...props,
|
||||
"data-onboarding-tour-id": tourId,
|
||||
items: props.items
|
||||
.filter((item) => !item.hidden)
|
||||
.map((item) => {
|
||||
const { "data-onboarding-tour-id": itemTourId, ...rest } = item;
|
||||
return {
|
||||
...item,
|
||||
...rest,
|
||||
"data-onboarding-tour-id": itemTourId,
|
||||
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
|
||||
};
|
||||
}),
|
||||
} as ClientNavigationLink;
|
||||
} else {
|
||||
clientLink = props as ClientNavigationLink;
|
||||
clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink;
|
||||
}
|
||||
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
|
||||
})}
|
||||
@@ -63,6 +66,7 @@ interface CommonNavigationLinkProps {
|
||||
icon: TablerIcon | string;
|
||||
iconProps?: TablerIconProps;
|
||||
hidden?: boolean;
|
||||
"data-onboarding-tour-id"?: string;
|
||||
}
|
||||
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
|
||||
@@ -58,19 +58,23 @@ export const DynamicBreadcrumb = ({
|
||||
}
|
||||
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
|
||||
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
|
||||
const mappedValue = dynamicMappings?.get(pathnamePart);
|
||||
|
||||
if (mappedValue) {
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{mappedValue}</Text>;
|
||||
}
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{mappedValue}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
|
||||
}
|
||||
|
||||
if (dynamicMappings?.has(pathnamePart)) {
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{dynamicMappings.get(pathnamePart)}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{t(`${translationKey}.label` as TranslationKeys)}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { TourShell } from "./tour-shell";
|
||||
import { TourStepContent } from "./tour-step-content";
|
||||
|
||||
export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useScopedI18n("onboardingTour.board");
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||
onSuccess() {
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
|
||||
|
||||
const handleEnd = () => {
|
||||
utils.user.getTourStatus.setData(undefined, {
|
||||
completedManageTour: tourStatus?.completedManageTour ?? false,
|
||||
completedBoardTour: true,
|
||||
});
|
||||
completeTour({ tour: "board" });
|
||||
};
|
||||
|
||||
const steps = useMemo(() => {
|
||||
const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [
|
||||
{
|
||||
id: "board-search",
|
||||
title: t("welcome.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("welcome.description")}
|
||||
documentationHref={createDocumentationLink(
|
||||
"/docs/getting-started/after-the-installation",
|
||||
"#creating-your-first-board",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-edit-mode",
|
||||
title: t("editMode.title"),
|
||||
requiresChangeAccess: true,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("editMode.description")}
|
||||
documentationHref={createDocumentationLink(
|
||||
"/docs/getting-started/after-the-installation",
|
||||
"#arrange-and-organize-your-board",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-settings",
|
||||
title: t("settings.title"),
|
||||
requiresChangeAccess: true,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("settings.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-switcher",
|
||||
title: t("switcher.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("switcher.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-user-menu",
|
||||
title: t("userMenu.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("userMenu.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[];
|
||||
}, [hasChangeAccess, t]);
|
||||
|
||||
return (
|
||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||
{children}
|
||||
</TourShell>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import {
|
||||
IconAffiliateFilled,
|
||||
IconAppsFilled,
|
||||
IconLayoutDashboardFilled,
|
||||
IconPlus,
|
||||
IconUserFilled,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { TourShell } from "./tour-shell";
|
||||
import { TourStepContent } from "./tour-step-content";
|
||||
|
||||
interface ManageTourProviderProps extends PropsWithChildren {
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const stepRoutes: Record<string, string> = {
|
||||
"manage-welcome": "/manage",
|
||||
"manage-boards-list": "/manage/boards",
|
||||
"manage-boards-create": "/manage/boards",
|
||||
"manage-apps-list": "/manage/apps",
|
||||
"manage-apps-create": "/manage/apps",
|
||||
"manage-integrations-list": "/manage/integrations",
|
||||
"manage-integrations-create": "/manage/integrations",
|
||||
"manage-users-list": "/manage/users",
|
||||
"manage-users-create": "/manage/users",
|
||||
};
|
||||
|
||||
const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
|
||||
withReveal: false,
|
||||
popoverProps: {
|
||||
position: "bottom",
|
||||
middlewares: { shift: { padding: 16 }, flip: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
|
||||
const t = useScopedI18n("onboardingTour.manage");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||
onSuccess() {
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname);
|
||||
const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname);
|
||||
|
||||
const [tourActive, setTourActive] = useState(false);
|
||||
const [tourDismissed, setTourDismissed] = useState(false);
|
||||
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
|
||||
|
||||
useEffect(() => {
|
||||
if (tourDismissed) return;
|
||||
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
|
||||
setTourActive(true);
|
||||
}
|
||||
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManageSection) {
|
||||
setTourActive(false);
|
||||
}
|
||||
}, [isManageSection]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasCompleted = wasManageTourCompletedRef.current;
|
||||
const isCompleted = tourStatus?.completedManageTour;
|
||||
if (wasCompleted === true && isCompleted === false) {
|
||||
setTourDismissed(false);
|
||||
}
|
||||
wasManageTourCompletedRef.current = isCompleted;
|
||||
}, [tourStatus?.completedManageTour]);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
setTourDismissed(true);
|
||||
setTourActive(false);
|
||||
utils.user.getTourStatus.setData(undefined, {
|
||||
completedManageTour: true,
|
||||
completedBoardTour: tourStatus?.completedBoardTour ?? false,
|
||||
});
|
||||
completeTour({ tour: "manage" });
|
||||
router.push("/manage");
|
||||
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
|
||||
|
||||
const steps = useMemo(() => {
|
||||
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
|
||||
{
|
||||
id: "manage-welcome",
|
||||
title: t("welcome.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("welcome.description")}
|
||||
documentationHref={createDocumentationLink("/docs/category/management")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-boards-list",
|
||||
title: t("boardsList.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("boardsList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
icon={<IconLayoutDashboardFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-boards-create",
|
||||
title: t("boardsCreate.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("boardsCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-apps-list",
|
||||
title: t("appsList.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("appsList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/apps")}
|
||||
icon={<IconAppsFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-apps-create",
|
||||
title: t("appsCreate.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("appsCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/apps")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-integrations-list",
|
||||
title: t("integrationsList.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("integrationsList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/integrations")}
|
||||
icon={<IconAffiliateFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-integrations-create",
|
||||
title: t("integrationsCreate.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("integrationsCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/integrations")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-users-list",
|
||||
title: t("usersList.title"),
|
||||
adminOnly: true,
|
||||
focusRevealProps: usersStepFocusRevealProps,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("usersList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||
icon={<IconUserFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-users-create",
|
||||
title: t("usersCreate.title"),
|
||||
adminOnly: true,
|
||||
focusRevealProps: usersStepFocusRevealProps,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("usersCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
|
||||
}, [isAdmin, t]);
|
||||
|
||||
return (
|
||||
<TourShell
|
||||
steps={steps}
|
||||
started={tourActive}
|
||||
onEnd={handleEnd}
|
||||
stepRoutes={stepRoutes}
|
||||
position={{ base: "bottom", sm: "right" }}
|
||||
>
|
||||
{children}
|
||||
</TourShell>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { OnboardingTourController, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
|
||||
import type { FloatingPosition } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
|
||||
interface TourShellProps extends PropsWithChildren {
|
||||
steps: OnboardingTourStep[];
|
||||
started: boolean;
|
||||
onEnd: () => void;
|
||||
stepRoutes?: Record<string, string>;
|
||||
position?: FloatingPosition | Record<string, FloatingPosition>;
|
||||
}
|
||||
|
||||
type TourController = OnboardingTourController;
|
||||
|
||||
const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0;
|
||||
|
||||
const stepAtOffset = (controller: TourController, offset: number) => {
|
||||
const index = stepIndexFromController(controller) + offset;
|
||||
return controller.tour[index];
|
||||
};
|
||||
|
||||
const pollForElement = (targetId: string, callback: () => void) => {
|
||||
const interval = setInterval(() => {
|
||||
const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
|
||||
if (targetElement) {
|
||||
clearInterval(interval);
|
||||
callback();
|
||||
}
|
||||
}, 50);
|
||||
const timeout = setTimeout(() => clearInterval(interval), 5000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
};
|
||||
|
||||
const isTypingTarget = (target: EventTarget | null) => {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
|
||||
};
|
||||
|
||||
interface TourForwardButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
|
||||
return (
|
||||
<Button size="sm" variant="light" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
interface TourDoneButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
|
||||
return (
|
||||
<Button size="sm" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const forwardActionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const navigateAndAdvance = useCallback(
|
||||
(currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
|
||||
if (!stepRoutes || !targetStepId) {
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
|
||||
const targetRoute = stepRoutes[targetStepId];
|
||||
|
||||
if (targetRoute && targetRoute !== currentRoute) {
|
||||
router.push(targetRoute);
|
||||
pollForElement(targetStepId, advance);
|
||||
} else {
|
||||
advance();
|
||||
}
|
||||
},
|
||||
[router, stepRoutes],
|
||||
);
|
||||
|
||||
const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
|
||||
forwardActionRef.current = action;
|
||||
return action;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) {
|
||||
forwardActionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (isTypingTarget(event.target)) return;
|
||||
event.preventDefault();
|
||||
forwardActionRef.current?.();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [started]);
|
||||
|
||||
return (
|
||||
<OnboardingTour
|
||||
tour={steps}
|
||||
started={started}
|
||||
onOnboardingTourEnd={onEnd}
|
||||
withStepper={false}
|
||||
header={(controller) => (
|
||||
<>
|
||||
<Group justify="center" gap={4} py={4}>
|
||||
<Text size="sm" fw={600}>
|
||||
{stepIndexFromController(controller) + 1}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
/
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{controller.tour.length}
|
||||
</Text>
|
||||
</Group>
|
||||
{stepIndexFromController(controller) === 0 && (
|
||||
<Center py="xs">
|
||||
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
nextStepNavigation={(controller) => {
|
||||
const nextStep = stepAtOffset(controller, 1);
|
||||
const action = bindForwardAction(controller, () => {
|
||||
navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
|
||||
});
|
||||
return <TourForwardButton label={t("onboardingTour.next")} onClick={action} />;
|
||||
}}
|
||||
endStepNavigation={(controller) => {
|
||||
const action = bindForwardAction(controller, () => controller.endTour());
|
||||
return <TourDoneButton label={t("onboardingTour.done")} onClick={action} />;
|
||||
}}
|
||||
prevStepNavigation={(controller) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
const prevStep = stepAtOffset(controller, -1);
|
||||
navigateAndAdvance(controller.currentStep?.id, prevStep?.id, () => controller.prevStep());
|
||||
}}
|
||||
>
|
||||
{t("onboardingTour.prev")}
|
||||
</Button>
|
||||
)}
|
||||
skipNavigation={(controller) => (
|
||||
<Button size="sm" variant="subtle" color="gray" onClick={() => controller.skipTour()}>
|
||||
{t("onboardingTour.skip")}
|
||||
</Button>
|
||||
)}
|
||||
withPrevButton
|
||||
focusRevealProps={{
|
||||
disableTargetInteraction: true,
|
||||
popoverProps: {
|
||||
position: position ?? { base: "bottom", sm: "right" },
|
||||
width: 420,
|
||||
shadow: "xl",
|
||||
radius: "lg",
|
||||
middlewares: { shift: { padding: 16 }, flip: true },
|
||||
},
|
||||
}}
|
||||
cutoutPadding={12}
|
||||
cutoutRadius={12}
|
||||
>
|
||||
{children}
|
||||
</OnboardingTour>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface TourStepContentProps {
|
||||
description: string;
|
||||
documentationHref: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{icon && (
|
||||
<ThemeIcon variant="light" size="lg" radius="md" mt={2}>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<Text size="sm" lh={1.7} style={{ flex: 1 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Anchor href={documentationHref} target="_blank" size="xs" fw={500}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconExternalLink size={14} />
|
||||
{t("onboardingTour.learnMore")}
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
window.location.assign(logoutUrl);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
router.push("/auth/login");
|
||||
},
|
||||
});
|
||||
}, [logoutUrl, openModal, router]);
|
||||
|
||||
Vendored
+1
@@ -2,3 +2,4 @@ declare module "*.png";
|
||||
declare module "*.svg";
|
||||
declare module "*.jpeg";
|
||||
declare module "*.jpg";
|
||||
declare module "sql.js";
|
||||
@@ -102,6 +102,10 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item[data-type="item"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-animate {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
+17
-10
@@ -249,13 +249,20 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg" alt="PeaNUT" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">PeaNUT</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Pi-hole</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -296,15 +303,15 @@
|
||||
<br/>
|
||||
<p align="center">Radarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Readarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -345,15 +352,15 @@
|
||||
<br/>
|
||||
<p align="center">Speedtest<br/>Tracker</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Tdarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
client_max_body_size 32M;
|
||||
client_max_body_size 256M;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.61.1",
|
||||
"version": "1.64.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -78,7 +78,7 @@
|
||||
"overrides": {
|
||||
"@babel/helpers@<7.26.10": ">=7.29.7",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.7",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.17.0",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
||||
"esbuild@<=0.24.2": ">=0.28.0",
|
||||
@@ -88,13 +88,13 @@
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
"proxmox-api>undici": "7.24.6",
|
||||
"react-is": "^19.2.6",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
|
||||
"react-is": "^19.2.7",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
"tmp@<=0.2.3": ">=0.2.7",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./query-cache": "./src/query-cache.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
"./open-api": "./src/open-api.ts"
|
||||
"./open-api": "./src/open-api.ts",
|
||||
"./mcp": "./src/mcp.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
@@ -50,6 +52,7 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"superjson": "catalog:",
|
||||
"trpc-to-mcp": "catalog:",
|
||||
"trpc-to-openapi": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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,6 +3,7 @@ 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";
|
||||
@@ -10,6 +11,7 @@ import { createTRPCRouter } from "./trpc";
|
||||
|
||||
export const openApiRouter = createTRPCRouter({
|
||||
appRouter,
|
||||
boardRouter,
|
||||
infoRouter,
|
||||
inviteRouter,
|
||||
userRouter,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const queryCacheRetentionMs = 1000 * 60 * 60 * 24;
|
||||
export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5;
|
||||
export const queryCacheMaxValueBytes = 1024 * 1024;
|
||||
export const queryCacheStoragePrefix = "homarr-widget-query";
|
||||
|
||||
let activeQueryCacheBoardId: string | null = null;
|
||||
|
||||
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
|
||||
activeQueryCacheBoardId = boardId;
|
||||
};
|
||||
|
||||
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
|
||||
|
||||
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping"]);
|
||||
|
||||
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||
const first = queryKey[0];
|
||||
if (!Array.isArray(first)) return null;
|
||||
|
||||
const parts = first.filter((part): part is string => typeof part === "string");
|
||||
return parts.length > 0 ? parts : null;
|
||||
};
|
||||
|
||||
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
|
||||
const path = getTrpcPathFromQueryKey(queryKey);
|
||||
if (!path) return false;
|
||||
|
||||
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
|
||||
|
||||
return persistableQueryPaths.has(path.join("."));
|
||||
};
|
||||
@@ -24,6 +24,7 @@ export const appRouter = createTRPCRouter({
|
||||
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
|
||||
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
|
||||
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
|
||||
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
|
||||
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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";
|
||||
@@ -9,39 +10,58 @@ import { apiKeys } from "@homarr/db/schema";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
export const apiKeysRouter = createTRPCRouter({
|
||||
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,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Create a new API key for the current user (admin only)",
|
||||
},
|
||||
});
|
||||
}),
|
||||
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}`,
|
||||
};
|
||||
}),
|
||||
})
|
||||
.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}`,
|
||||
};
|
||||
}),
|
||||
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,7 +21,19 @@ 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 } })
|
||||
.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",
|
||||
},
|
||||
})
|
||||
.query(async ({ input, ctx }) => {
|
||||
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
|
||||
const totalCount = await ctx.db.$count(apps, whereQuery);
|
||||
@@ -41,16 +53,40 @@ export const appRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(z.array(selectAppSchema))
|
||||
.meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/api/apps",
|
||||
tags: ["apps"],
|
||||
protect: true,
|
||||
},
|
||||
mcp: { enabled: true, description: "List all apps" },
|
||||
})
|
||||
.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 } })
|
||||
.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)",
|
||||
},
|
||||
})
|
||||
.query(({ ctx, input }) => {
|
||||
return ctx.db.query.apps.findMany({
|
||||
where: like(apps.name, `%${input.query}%`),
|
||||
@@ -62,7 +98,14 @@ 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({
|
||||
@@ -89,7 +132,15 @@ export const appRouter = createTRPCRouter({
|
||||
byId: publicProcedure
|
||||
.input(byIdSchema)
|
||||
.output(selectAppSchema)
|
||||
.meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
|
||||
.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)" },
|
||||
})
|
||||
.query(async ({ ctx, input }) => {
|
||||
const repository = new AppRepository(ctx.db, ctx.session?.user ?? null);
|
||||
const app = await repository.getByIdAsync(input.id);
|
||||
@@ -111,7 +162,19 @@ 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 } })
|
||||
.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)",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
const insertValues = {
|
||||
@@ -146,7 +209,19 @@ 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 } })
|
||||
.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)",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const app = await ctx.db.query.apps.findFirst({
|
||||
where: eq(apps.id, input.id),
|
||||
@@ -173,7 +248,15 @@ 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 } })
|
||||
.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)" },
|
||||
})
|
||||
.input(byIdSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(apps).where(eq(apps.id, input.id));
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
integrationGroupPermissions,
|
||||
integrationItems,
|
||||
integrationUserPermissions,
|
||||
integrations,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
@@ -39,6 +40,7 @@ import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import {
|
||||
addItemToBoardSchema,
|
||||
boardByNameSchema,
|
||||
boardChangeVisibilitySchema,
|
||||
boardCreateSchema,
|
||||
@@ -48,6 +50,7 @@ import {
|
||||
boardSavePartialSettingsSchema,
|
||||
boardSavePermissionsSchema,
|
||||
boardSaveSchema,
|
||||
boardSummarySchema,
|
||||
} from "@homarr/validation/board";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import { zodUnionFromArray } from "@homarr/validation/enums";
|
||||
@@ -114,78 +117,89 @@ export const boardRouter = createTRPCRouter({
|
||||
where: boardWhere,
|
||||
});
|
||||
}),
|
||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||
});
|
||||
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",
|
||||
},
|
||||
})
|
||||
.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: {},
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const boardIds = permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
);
|
||||
});
|
||||
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 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 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,
|
||||
},
|
||||
},
|
||||
userPermissions: {
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where:
|
||||
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
userPermissions: {
|
||||
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
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 }) => {
|
||||
@@ -262,7 +276,16 @@ 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();
|
||||
|
||||
@@ -547,11 +570,22 @@ export const boardRouter = createTRPCRouter({
|
||||
.set({ isPublic: input.visibility === "public" })
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
@@ -613,7 +647,7 @@ export const boardRouter = createTRPCRouter({
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
|
||||
const sortedLayouts = board.layouts.toSorted((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
|
||||
@@ -1215,7 +1249,7 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
return {
|
||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
||||
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
users: userPermissions
|
||||
@@ -1223,7 +1257,7 @@ export const boardRouter = createTRPCRouter({
|
||||
user,
|
||||
permission,
|
||||
}))
|
||||
.sort((permissionA, permissionB) => {
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||
}),
|
||||
groups: dbGroupBoardPermission
|
||||
@@ -1234,7 +1268,7 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
permission,
|
||||
}))
|
||||
.sort((permissionA, permissionB) => {
|
||||
.toSorted((permissionA, permissionB) => {
|
||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||
}),
|
||||
};
|
||||
@@ -1327,6 +1361,119 @@ 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 };
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1586,7 +1733,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
...otherBoardProperties,
|
||||
layouts: layouts
|
||||
.map(({ boardId: _, ...layout }) => layout)
|
||||
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
||||
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
||||
sections: sections.map(({ collapseStates, ...section }) =>
|
||||
parseSection({
|
||||
...section,
|
||||
@@ -1604,22 +1751,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||
}),
|
||||
),
|
||||
items: items.map(({ integrations: itemIntegrations, ...item }) =>
|
||||
parseItem({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => ({
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
layoutId: layout.layoutId,
|
||||
sectionId: layout.sectionId,
|
||||
})),
|
||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||
}),
|
||||
),
|
||||
items: items
|
||||
.map(({ integrations: itemIntegrations, ...item }) =>
|
||||
parseItem({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => ({
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
layoutId: layout.layoutId,
|
||||
sectionId: layout.sectionId,
|
||||
})),
|
||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||
}),
|
||||
)
|
||||
.filter((item) => item !== null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1635,7 +1784,7 @@ const parseItem = (item: unknown) => {
|
||||
const result = outputItemSchema.safeParse(item);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.message);
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,11 @@ import { z } from "zod/v4";
|
||||
|
||||
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||
import {
|
||||
dockerContainersRequestHandler,
|
||||
getContainerLogsAsync,
|
||||
streamContainerLogsAsync,
|
||||
} from "@homarr/request-handler/docker";
|
||||
|
||||
import { dockerMiddleware } from "../../middlewares/docker";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
@@ -12,10 +16,18 @@ 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;
|
||||
|
||||
@@ -46,6 +58,12 @@ 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 }) => {
|
||||
@@ -61,6 +79,12 @@ 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 }) => {
|
||||
@@ -76,6 +100,13 @@ 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 }) => {
|
||||
@@ -91,6 +122,13 @@ 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 }) => {
|
||||
@@ -104,6 +142,91 @@ export const dockerRouter = createTRPCRouter({
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
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({
|
||||
id: z.string(),
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
|
||||
|
||||
if (!logs) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Container not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
};
|
||||
}),
|
||||
subscribeLogs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
return observable<string>((emit) => {
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
let isSubscribed = true;
|
||||
|
||||
const initializeStreamAsync = async () => {
|
||||
try {
|
||||
cleanupFn = await streamContainerLogsAsync(
|
||||
input.id,
|
||||
input.tail ?? 200,
|
||||
(data) => {
|
||||
if (isSubscribed) {
|
||||
emit.next(data);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (isSubscribed) {
|
||||
emit.error(
|
||||
new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: err.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (isSubscribed) {
|
||||
emit.error(
|
||||
new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: err instanceof Error ? err.message : "Container not found",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void initializeStreamAsync();
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
cleanupFn?.();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
|
||||
|
||||
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user