Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f324f58e79 | ||
|
|
ea30c818c6 | ||
|
|
c20522946c | ||
|
|
15e061cf03 | ||
|
|
93c008ff4f | ||
|
|
8598159894 | ||
|
|
2fb7bde900 | ||
|
|
7567842c4c | ||
|
|
d16571d4c4 | ||
|
|
905f795b56 | ||
|
|
28ceb37cbf |
No files matched your search
@@ -1,197 +0,0 @@
|
||||
---
|
||||
name: Revert Demo MCP
|
||||
overview: Revert the demo-specific MCP accommodations (API key bypass, read-only filtering, demo UI) since demo MCP is an edge case with poor ROI. Replace with a clear "MCP not available in demo mode" message. Keep the core MCP feature intact for real instances.
|
||||
todos:
|
||||
- id: revert-backend
|
||||
content: Revert demoSafe procedure, apiKeys demo bypass, isDemoMode export from mcp.ts
|
||||
status: pending
|
||||
- id: revert-mcp-route
|
||||
content: Remove isDemoMode filtering and demo SERVER_INSTRUCTIONS from MCP route
|
||||
status: pending
|
||||
- id: revert-ui
|
||||
content: Remove isDemoMode prop plumbing; add demo-not-supported Alert via info.isDemoMode
|
||||
status: pending
|
||||
- id: verify
|
||||
content: Run lint + typecheck; smoke-test demo vs non-demo MCP tab
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Revert Demo MCP Accommodations
|
||||
|
||||
## Why Docker works but integrations do not
|
||||
|
||||
Demo mode has **two different mock mechanisms**:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph dockerPath [Docker - works in demo]
|
||||
MCP1[MCP docker_getContainers]
|
||||
Handler[docker.ts request handler]
|
||||
MockContainers[Hardcoded mockContainers]
|
||||
MCP1 --> Handler
|
||||
Handler -->|"if DEMO_MODE"| MockContainers
|
||||
end
|
||||
|
||||
subgraph integrationPath [Integrations - confusing in demo]
|
||||
MCP2[MCP widget tools e.g. calendar_findAllEvents]
|
||||
TRPC[tRPC procedure]
|
||||
RH[cached-integration-request-handler]
|
||||
Kind{integration.kind}
|
||||
Mock[MockIntegration fake data]
|
||||
Real[Sonarr/Radarr HTTP - fails]
|
||||
MCP2 --> TRPC --> RH --> Kind
|
||||
Kind -->|mock| Mock
|
||||
Kind -->|sonarr/radarr| Real
|
||||
end
|
||||
```
|
||||
|
||||
- **Docker**:
|
||||
[`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
|
||||
checks `DEMO_MODE` directly and returns inline fake containers. No integration
|
||||
record needed.
|
||||
- **Everything else**: Demo seed creates **one** integration with `kind: "mock"`
|
||||
([`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts)), not
|
||||
Sonarr/Radarr/Overseerr.
|
||||
[`MockIntegration`](packages/integrations/src/mock/mock-integration.ts) can
|
||||
serve calendar, downloads, health, DNS, media server, etc. **if** callers pass
|
||||
that integration ID.
|
||||
- **Why it felt broken**: MCP tool descriptions tell AI clients to look for
|
||||
"Overseerr/Sonarr IDs". Demo has none. Tools like
|
||||
`integration_searchMediaRequests` hard-filter to `mediaSearch` kinds
|
||||
(`overseerr`, `jellyseerr`, `seerr`) — `mock` is excluded from that category
|
||||
in
|
||||
[`packages/definitions/src/integration.ts`](packages/definitions/src/integration.ts).
|
||||
|
||||
Fixing this properly means extending the mock integration system (new
|
||||
`searchAsync`, category changes, middleware relaxations, instruction rewrites)
|
||||
for a feature almost nobody will use on the public demo.
|
||||
|
||||
## Option comparison
|
||||
|
||||
| | **Revert (chosen)** | **Minimal fix** | **Full fix** |
|
||||
| -------------------- | --------------------------- | ---------------------------------------------- | --------------------------------------------------- |
|
||||
| **Effort** | ~1 hour, ~100 lines removed | ~2 hours | 1-2 days |
|
||||
| **Files touched** | 8 MCP/demo-specific files | 8 + route instructions | 15+ across integrations, definitions, seed, routers |
|
||||
| **Risk** | Low | Low-medium | Medium (mock/search semantics) |
|
||||
| **Demo MCP outcome** | Clearly unsupported | Partial (widget reads only, no search/request) | Near-full read coverage |
|
||||
| **Maintenance** | None ongoing | Instructions drift with tool changes | Mock parity with real integrations |
|
||||
|
||||
**Recommendation: Revert.** The demo accommodation code added special-case
|
||||
plumbing (`demoSafePermissionRequiredProcedure`, dual mutation guards, hardcoded
|
||||
UI strings) for a workflow that still cannot deliver a convincing "homelab AI"
|
||||
experience without substantial integration-layer work.
|
||||
|
||||
## What to revert (demo MCP accommodations only)
|
||||
|
||||
These changes were added specifically for demo + MCP. **Do not revert** the core
|
||||
MCP server, OAuth, Scalar docs, tool descriptions, or `normalizeSchema`.
|
||||
|
||||
### Backend (3 files)
|
||||
|
||||
1. [`packages/api/src/trpc.ts`](packages/api/src/trpc.ts)
|
||||
- Remove `demoSafePermissionRequiredProcedure` export (~12 lines)
|
||||
- Keep existing `enforceDemoModeReadOnly` (pre-dates MCP; used by whole app)
|
||||
|
||||
2. [`packages/api/src/router/apiKeys.ts`](packages/api/src/router/apiKeys.ts)
|
||||
- Change `create` back to `permissionRequiredProcedure` (mutations blocked in
|
||||
demo again)
|
||||
- Remove `isDemoMode` guard on `delete` (global demo middleware already
|
||||
blocks delete mutations)
|
||||
- Remove `TRPCError` / `isDemoMode` imports if unused
|
||||
|
||||
3. [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts)
|
||||
- Stop exporting `isDemoMode`; keep only `createTRPCContext` and `mcpRouter`
|
||||
|
||||
### MCP route (1 file)
|
||||
|
||||
4. [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
|
||||
- Remove `isDemoMode` import and all demo branches:
|
||||
- `ListToolsRequestSchema` mutation filter
|
||||
- `CallToolRequestSchema` mutation rejection
|
||||
- `SERVER_INSTRUCTIONS` demo section
|
||||
- Keep `getToolType` / `buildToolTypeMap` only if still needed elsewhere;
|
||||
otherwise remove dead code from demo-only filtering
|
||||
|
||||
### UI (4 files)
|
||||
|
||||
5. [`apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx)
|
||||
- Remove `isDemoMode` import from `@homarr/api/mcp`
|
||||
- Stop passing `isDemoMode` to child components
|
||||
|
||||
6. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx)
|
||||
- Remove `isDemoMode` prop from interface and `McpInstructions` call
|
||||
|
||||
7. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
|
||||
- Remove `isDemoMode` prop
|
||||
- Replace current demo banner with a single **Alert**: MCP requires a
|
||||
self-hosted instance; demo mode does not support MCP (API key creation and
|
||||
write tools are disabled)
|
||||
- Use `clientApi.info.isDemoMode.useQuery()` or pass `isDemoMode` from server
|
||||
via existing `api.info.isDemoMode()` tRPC query (already exists in
|
||||
[`packages/api/src/router/info.ts`](packages/api/src/router/info.ts)) —
|
||||
avoids re-exporting from `@homarr/api/mcp`
|
||||
|
||||
8. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx)
|
||||
- Remove `isDemoMode` prop and conditional delete-column spread
|
||||
- Delete button hidden automatically in demo because `apiKeys.delete`
|
||||
mutation is blocked by global `enforceDemoModeReadOnly`
|
||||
|
||||
## What to add instead
|
||||
|
||||
- **MCP tab**: When `info.isDemoMode` is true, show a prominent notice at the
|
||||
top of
|
||||
[`mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
|
||||
explaining MCP is for self-hosted instances only.
|
||||
- **Optional**: Disable/hide the "Create API Key" button on the MCP tab in demo
|
||||
(mutations will fail anyway).
|
||||
- **No changes** to integrations, definitions, seed, or mock services.
|
||||
|
||||
## Files NOT impacted by revert
|
||||
|
||||
Core MCP feature stays as-is:
|
||||
|
||||
- [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
|
||||
— auth, rate limiting, `normalizeSchema`, `SERVER_INSTRUCTIONS` (minus demo
|
||||
section)
|
||||
- OAuth routes under `apps/nextjs/src/app/api/mcp/oauth/`
|
||||
- All MCP-enabled routers in `packages/api/src/router/`
|
||||
- [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts) — eager router (minus
|
||||
`isDemoMode` export)
|
||||
- Scalar UI, dynamic tools table,
|
||||
[`.cursor/rules/mcp-integration.mdc`](.cursor/rules/mcp-integration.mdc)
|
||||
|
||||
Pre-existing demo infrastructure (unchanged):
|
||||
|
||||
- [`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
|
||||
— Docker mock containers
|
||||
- [`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts) — mock
|
||||
integration + demo board
|
||||
- [`packages/integrations/src/mock/`](packages/integrations/src/mock/) — mock
|
||||
services for widgets on demo board UI
|
||||
|
||||
## Verification
|
||||
|
||||
1. `pnpm lint` and `pnpm typecheck` pass
|
||||
2. Demo mode (`DEMO_MODE=true`):
|
||||
- MCP tab shows "not supported" notice
|
||||
- API key create fails with existing "Mutations are disabled in demo mode"
|
||||
- `docker_getContainers` still works on demo board UI (unchanged; separate
|
||||
code path)
|
||||
3. Non-demo mode: MCP unchanged (all tools listed, mutations work with
|
||||
permissions)
|
||||
|
||||
## Commit strategy
|
||||
|
||||
Single focused commit on `feat/add-mcp-server`:
|
||||
|
||||
```
|
||||
revert(mcp): drop demo-mode accommodations for MCP
|
||||
|
||||
MCP is intended for self-hosted instances. Remove demo-specific API key
|
||||
bypass, read-only tool filtering, and demo UI plumbing. Show a clear
|
||||
notice on the MCP tab when demo mode is active.
|
||||
```
|
||||
|
||||
Then push to the existing PR (no force-push needed unless you want another
|
||||
squash).
|
||||
@@ -22,7 +22,7 @@
|
||||
automerge: true,
|
||||
},
|
||||
{
|
||||
// disallow automerge for onlyBuiltDependencies
|
||||
// disallow automerge for allowBuilds dependencies (native builds need manual verification)
|
||||
matchPackageNames: [
|
||||
"@tree-sitter-grammars/tree-sitter-yaml",
|
||||
"bcrypt",
|
||||
@@ -46,7 +46,7 @@
|
||||
updateInternalDeps: true,
|
||||
rangeStrategy: "bump",
|
||||
automerge: false,
|
||||
baseBranches: ["dev"],
|
||||
baseBranchPatterns: ["dev"],
|
||||
dependencyDashboard: false,
|
||||
// prevent special characters in branch names (otherwise git errors occur on windows)
|
||||
branchNameStrict: true,
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
|
||||
|
||||
- name: Commit fixes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "style: auto-fix lint and format"
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ jobs:
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Upload pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: "apps/docs/build"
|
||||
|
||||
@@ -53,4 +53,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
run: pnpm api-dump-contributors
|
||||
|
||||
- name: Commit updated contributors
|
||||
uses: EndBug/add-and-commit@v9
|
||||
uses: EndBug/add-and-commit@v10
|
||||
with:
|
||||
add: "apps/docs/static"
|
||||
default_author: github_actor
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
echo "OpenAPI spec updated ($(jq '.paths | length' /tmp/openapi.json) paths)"
|
||||
|
||||
- name: Commit updated spec
|
||||
uses: EndBug/add-and-commit@v9
|
||||
uses: EndBug/add-and-commit@v10
|
||||
with:
|
||||
add: "apps/docs/static/api/"
|
||||
default_author: github_actor
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
[tools]
|
||||
node = "24.16.0"
|
||||
pnpm = "10.34.1"
|
||||
@@ -1,6 +1,2 @@
|
||||
# Expo doesn't play nice with pnpm by default.
|
||||
# The symbolic links of pnpm break the rules of Expo monorepos.
|
||||
# @link https://docs.expo.dev/guides/monorepos/#common-issues
|
||||
node-linker=hoisted
|
||||
strict-peer-dependencies=false
|
||||
engine-strict=true
|
||||
# pnpm v11: non-auth settings moved to pnpm-workspace.yaml
|
||||
# Keep this file for any future registry/auth configuration
|
||||
@@ -1,3 +1,31 @@
|
||||
## [1.65.0](https://github.com/homarr-labs/homarr/compare/v1.64.0...v1.65.0) (2026-06-12)
|
||||
|
||||
### Features
|
||||
|
||||
* add MCP server for AI assistant integration ([#5882](https://github.com/homarr-labs/homarr/issues/5882)) ([6baff7b](https://github.com/homarr-labs/homarr/commit/6baff7be3dc8f2473d93d17862b6d0b2df5c4d15))
|
||||
* add Technitium DNS integration, usable with DNS Hole widgets ([#5832](https://github.com/homarr-labs/homarr/issues/5832)) ([cd1a82a](https://github.com/homarr-labs/homarr/commit/cd1a82a640101cdca88d1e825edb11bd51f5ce66))
|
||||
* add Uptime Kuma, Audiobookshelf, Paperless-ngx, and Navidrome integrations ([#5910](https://github.com/homarr-labs/homarr/issues/5910)) ([d77c6f8](https://github.com/homarr-labs/homarr/commit/d77c6f8d4e50108f50831ee365e382a535acb254))
|
||||
* **beszel:** add Beszel integration with system monitoring widgets ([#5900](https://github.com/homarr-labs/homarr/issues/5900)) ([a4db9cf](https://github.com/homarr-labs/homarr/commit/a4db9cf0dcd8034015064f9d23f2d351b1d0c50a))
|
||||
* **board:** add widget hover overlay on dashboard ([#5888](https://github.com/homarr-labs/homarr/issues/5888)) ([29bd710](https://github.com/homarr-labs/homarr/commit/29bd7101b180acfb750c7d4414c30bd6450065e2))
|
||||
* custom widgets ([#5889](https://github.com/homarr-labs/homarr/issues/5889)) ([aecbca2](https://github.com/homarr-labs/homarr/commit/aecbca2b81ad1693c599e3168fd0c0723311b3b4))
|
||||
* **docs:** migrate documentation site into monorepo ([#5912](https://github.com/homarr-labs/homarr/issues/5912)) ([9e421c6](https://github.com/homarr-labs/homarr/commit/9e421c67bd87b5b7065118afedd08e7eb2140773))
|
||||
* **lang:** add Finnish language support ([#5892](https://github.com/homarr-labs/homarr/issues/5892)) ([2fb7bde](https://github.com/homarr-labs/homarr/commit/2fb7bde9002c2670aa49db804e352c3821fa8097))
|
||||
* search widgets by integration type when adding items ([#5909](https://github.com/homarr-labs/homarr/issues/5909)) ([48e1844](https://github.com/homarr-labs/homarr/commit/48e1844b5b9dc6d322bc22a972fdd29b6e03d3d2))
|
||||
* SQLite backup & restore with WASM preview ([#5887](https://github.com/homarr-labs/homarr/issues/5887)) ([5d1f268](https://github.com/homarr-labs/homarr/commit/5d1f268438b84bd19f5a79658a43931dd874cd41))
|
||||
* **ui:** add global query cache leveraging Redis ([#5811](https://github.com/homarr-labs/homarr/issues/5811)) ([74443a4](https://github.com/homarr-labs/homarr/commit/74443a49b2330b7d71c5ccdb873c61b270d1c72a))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **boards:** add permissions to board summary schema to fix manage page crash ([#5917](https://github.com/homarr-labs/homarr/issues/5917)) ([a42af0e](https://github.com/homarr-labs/homarr/commit/a42af0e678b58b6c63a8bac4978fb2951c6ddef8))
|
||||
* **ci:** rename baseBranches to baseBranchPatterns in renovate config ([15e061c](https://github.com/homarr-labs/homarr/commit/15e061cf03f2301996aaf11a25095194e5759fb5))
|
||||
* **db:** inline custom widget seed data to fix CJS migration crash ([f26007a](https://github.com/homarr-labs/homarr/commit/f26007a29a0b04765baba72122d38a9e5c639a0b))
|
||||
* **definitions:** gracefully handle sitemap fetch failure in codegen ([7f8b012](https://github.com/homarr-labs/homarr/commit/7f8b0121b303fa09a213c73e6a690463e9fc6b44))
|
||||
* **docs:** add trailingSlash for GitHub Pages compatibility ([28ceb37](https://github.com/homarr-labs/homarr/commit/28ceb37cbfe45f8ce45fd88e6a8c87ffe4c9b5c2))
|
||||
* **docs:** disable swcHtmlMinimizer and fix broken links ([905f795](https://github.com/homarr-labs/homarr/commit/905f795b5607744d0aeec4bb65d84aabd1505047))
|
||||
* gracefully skip widgets with unknown kind instead of crashing ([#5915](https://github.com/homarr-labs/homarr/issues/5915)) ([484b8cf](https://github.com/homarr-labs/homarr/commit/484b8cf9f18122a298dc7fae1027653cd287c09c))
|
||||
* **image-proxy:** use ArrayBuffer instead of Blob to prevent OOM on Node v24 ([#5926](https://github.com/homarr-labs/homarr/issues/5926)) ([3e464a7](https://github.com/homarr-labs/homarr/commit/3e464a76f2d1e5985f259270a4258f3db4f17c18))
|
||||
* **integrations:** support truenas json-rpc api, certificate trust, and api key auth ([#5861](https://github.com/homarr-labs/homarr/issues/5861)) ([f6d5f97](https://github.com/homarr-labs/homarr/commit/f6d5f97933cd257ee8851f2d6a27f56e2942076d))
|
||||
|
||||
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
|
||||
};
|
||||
@@ -22,4 +22,4 @@ Homarr is completely free to use. We'd highly appreciate if you consider donatin
|
||||
## Where to donate
|
||||
We accept donations on our official [OpenCollective](https://opencollective.com/homarr).
|
||||
You may also sponsor us directly by providing a useful service / product to us other means.
|
||||
You can get into contact with us [here](./get-in-touch).
|
||||
You can get into contact with us [here](/docs/community/get-in-touch).
|
||||
@@ -21,7 +21,7 @@ import manageImportDecryptionToken from "./img/old-homarr-import-decryption-toke
|
||||
|
||||
:::tip
|
||||
|
||||
Read our [glossary](./glossary) if you are new to Homarr.
|
||||
Read our [glossary](/docs/getting-started/glossary) if you are new to Homarr.
|
||||
|
||||
:::
|
||||
|
||||
@@ -77,8 +77,8 @@ Matching integrations are pre-selected in the integration list, saving you from
|
||||
#### URL templating
|
||||
You can define a base URL template to auto-fill integration URLs in bulk.
|
||||
Two modes are supported:
|
||||
- **Subdomain mode** — e.g. `{app}.example.com` where `{app}` is replaced with the integration name
|
||||
- **Port mode** — e.g. `192.168.1.1:{port}` where `{port}` is replaced with the integration's default port
|
||||
- **Subdomain mode** - e.g. `{app}.example.com` where `{app}` is replaced with the integration name
|
||||
- **Port mode** - e.g. `192.168.1.1:{port}` where `{port}` is replaced with the integration's default port
|
||||
|
||||
#### API key helper links
|
||||
For integrations that require API keys (such as Sonarr, Radarr, SABnzbd, or Seerr), a helper link opens the relevant settings page in the target application so you can quickly copy your key.
|
||||
@@ -345,12 +345,12 @@ When you first open a board, a tour highlights the board header elements: search
|
||||
You're done with the basic setup 🔥. Now it's time to organize and customize even more.
|
||||
We highly recommend you to experiment with different options, styles and configurations.
|
||||
See these curated pages for your next steps:
|
||||
- Create new apps using the [apps management](../management/apps/)
|
||||
- Customize your board or add more boards using the [boards management](../management/boards/)
|
||||
- Integrate software that you use using the [integrations management](../management/integrations)
|
||||
- Create new apps using the [apps management](/docs/management/apps/)
|
||||
- Customize your board or add more boards using the [boards management](/docs/management/boards/)
|
||||
- Integrate software that you use using the [integrations management](/docs/management/integrations/)
|
||||
- Choose from our beautiful and interactive [widgets](/docs/category/widgets)
|
||||
- See [all supported integrations](/docs/category/integrations)
|
||||
- Integrate with Homarr with your own application using [the API](../management/api/)
|
||||
- Integrate with Homarr with your own application using [the API](/docs/management/api/)
|
||||
|
||||
:::danger
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 948 KiB |
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: 'Beszel'
|
||||
description: "Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from '@site/src/components/integrations/header';
|
||||
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
|
||||
import { AddingIntegration } from '@site/src/components/integrations/adding';
|
||||
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
|
||||
import { beszelIntegration } from '.';
|
||||
import { beszelSystemStatsWidget } from '@site/docs/widgets/beszel-system-stats';
|
||||
import { beszelSystemGridWidget } from '@site/docs/widgets/beszel-system-grid';
|
||||
import { beszelSystemTableWidget } from '@site/docs/widgets/beszel-system-table';
|
||||
import { beszelAlertsWidget } from '@site/docs/widgets/beszel-alerts';
|
||||
|
||||
import beszelDashboard from './img/beszel-dashboard.png';
|
||||
|
||||
<IntegrationHeader
|
||||
integration={beszelIntegration}
|
||||
categories={['System Monitoring']}
|
||||
/>
|
||||
|
||||
Beszel monitors your servers in real time via lightweight agents, collecting CPU, memory, disk, network, GPU, temperature, and Docker container metrics. It stores historical data and supports configurable alerts.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<img src={beszelDashboard} alt="Beszel dashboard overview showing all four widgets" />
|
||||
|
||||
### Widgets & Capabilities
|
||||
<IntegrationCapabilites
|
||||
items={[
|
||||
{ widget: beszelSystemStatsWidget },
|
||||
{ widget: beszelSystemGridWidget },
|
||||
{ widget: beszelSystemTableWidget },
|
||||
{ widget: beszelAlertsWidget },
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
<IntegrationSecrets secrets={[{
|
||||
credentials: ['username', 'password'],
|
||||
steps: [
|
||||
'Open your Beszel hub instance (default port 8090)',
|
||||
'Use the same credentials you use to log in to the Beszel web UI',
|
||||
]
|
||||
}]} />
|
||||
|
||||
:::tip
|
||||
Beszel uses PocketBase under the hood. The default admin credentials are set during the initial Beszel setup.
|
||||
:::
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const beszelIntegration: IntegrationDefinition = {
|
||||
name: "Beszel",
|
||||
description:
|
||||
"Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
path: "../../integrations/beszel",
|
||||
};
|
||||
@@ -58,12 +58,12 @@ In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/ru
|
||||
### Managing your Docker containers within Homarr
|
||||
Homarr allows you to interact with Docker containers running on your system.
|
||||
Each container row has an action menu with the following options:
|
||||
- **View logs** — opens a dedicated logs page with a live terminal
|
||||
- **Start** — start a stopped container
|
||||
- **Stop** — stop a running container
|
||||
- **Restart** — restart a container
|
||||
- **Remove** — remove a container
|
||||
- **Add to Homarr** — create a bookmark app from the container
|
||||
- **View logs** - opens a dedicated logs page with a live terminal
|
||||
- **Start** - start a stopped container
|
||||
- **Stop** - stop a running container
|
||||
- **Restart** - restart a container
|
||||
- **Remove** - remove a container
|
||||
- **Add to Homarr** - create a bookmark app from the container
|
||||
|
||||
You can also search and filter containers by **container** or **image** name:
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { dnsHoleControlsWidget } from '@site/docs/widgets/dns-hole-controls';
|
||||
/>
|
||||
|
||||
<Alert type="note" className='my-2'>
|
||||
Homarr supports Technitium DNS v11 and above. The correct API version is detected automatically — no manual configuration is required.
|
||||
Homarr supports Technitium DNS v11 and above. The correct API version is detected automatically - no manual configuration is required.
|
||||
</Alert>
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
@@ -44,7 +44,7 @@ import Alert from '@theme/Admonition';
|
||||
steps: [
|
||||
"In the TrueNAS web interface, click your user avatar in the top-right corner and select \"API Keys\".",
|
||||
"Click \"Add\", give the key a name such as \"Homarr\", and confirm.",
|
||||
"Copy the generated key immediately — TrueNAS only shows it once — and paste it as the API Key when creating the integration in Homarr.",
|
||||
"Copy the generated key immediately - TrueNAS only shows it once - and paste it as the API Key when creating the integration in Homarr.",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -47,7 +47,7 @@ import { umamiWidget } from '../../widgets/umami';
|
||||
credentials: ['username', 'password'],
|
||||
steps: [
|
||||
'Use the username and password of your Umami account',
|
||||
'Note: username/password auth is for self-hosted instances only — Umami Cloud requires an API key',
|
||||
'Note: username/password auth is for self-hosted instances only - Umami Cloud requires an API key',
|
||||
]
|
||||
}
|
||||
]} />
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 533 KiB |
@@ -6,8 +6,6 @@ tags:
|
||||
- Interface
|
||||
---
|
||||
|
||||
import { ApiReferenceReact } from "@scalar/api-reference-react"
|
||||
|
||||
Using the Homarr API, you can interface with the application with your own code and business logic.
|
||||
It enables you to trigger almost all actions that you can perform from the Homarr UI.
|
||||
With the API, you can build resilient systems that automatically update Homarr with data - for example syncing it with
|
||||
@@ -15,20 +13,25 @@ your deployments in a cluster or dynamically adding integrations depending on ho
|
||||
|
||||
This page is language-neutral, and you must implement the API on your own in your desired language.
|
||||
It requires you to know your programming language well and have a basic understanding of HTTP, JSON and networking.
|
||||
You can test out API endpoints using any OpenAPI client - for example [Postman](https://www.postman.com/), [Insomnia](https://insomnia.rest/) or [Hoppscotch](https://hoppscotch.io/).
|
||||
You can test out API endpoints using any OpenAPI client - for example [Yaak](https://yaak.app/).
|
||||
|
||||
## Opening the API specification
|
||||
|
||||
The specification is implemented according to the OpenAPI standard.
|
||||
To access the documentation, you'll need to spin up an instance of Homarr.
|
||||
Navigate to ``Management`` > ``Tools`` > ``API``.
|
||||
Navigate to `Management` > `Tools` > `API`.
|
||||
You can copy the JSON url into your OpenAPI client or try out the endpoints directly from your browser.
|
||||
|
||||
Alternatively, you can [open the full interactive API reference](/api-reference) directly in your browser.
|
||||
|
||||
## Authentication
|
||||
|
||||
Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key.
|
||||
You can obtain API keys from the "Authentication" tab at the top of the page.
|
||||
API keys will not expire and can be used indefinitely.
|
||||
|
||||
### API Key Format
|
||||
|
||||
API keys are provided in the format `<id>.<token>`. When making requests, include the key in the `ApiKey` header:
|
||||
|
||||
```
|
||||
@@ -36,49 +39,35 @@ ApiKey: abc123def456.xyz789tokenvalue...
|
||||
```
|
||||
|
||||
### Supported Endpoints
|
||||
|
||||
API key authentication works with both endpoint types:
|
||||
|
||||
| Endpoint Type | Path Pattern | API Key Support |
|
||||
|--------------|--------------|-----------------|
|
||||
| OpenAPI | `/api/...` | ✅ Supported |
|
||||
| tRPC | `/api/trpc/...` | ✅ Supported |
|
||||
| Endpoint Type | Path Pattern | API Key Support |
|
||||
| ------------- | --------------- | --------------- |
|
||||
| OpenAPI | `/api/...` | ✅ Supported |
|
||||
| tRPC | `/api/trpc/...` | ✅ Supported |
|
||||
|
||||
If no API key is provided, Homarr falls back to session cookie authentication (for browser-based access).
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**OpenAPI endpoint:**
|
||||
|
||||
```bash
|
||||
curl -H 'ApiKey: <id>.<token>' http://localhost:7575/api/health/live
|
||||
```
|
||||
|
||||
**tRPC endpoint:**
|
||||
|
||||
```bash
|
||||
curl -H 'ApiKey: <id>.<token>' http://localhost:7575/api/trpc/app.all
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
API key authentication enables programmatic access for:
|
||||
|
||||
- Automation scripts and CI/CD pipelines
|
||||
- Third-party integrations
|
||||
- Custom dashboards and monitoring tools
|
||||
- Container orchestration systems that need to sync app definitions
|
||||
|
||||
## Specification
|
||||
|
||||
:::info
|
||||
|
||||
This section is automatically being updated hourly.
|
||||
Last update:
|
||||
``TEST`` <!-- OPEN_API_SPEC_LAST_UPDATED -->
|
||||
|
||||
:::
|
||||
|
||||
<ApiReferenceReact
|
||||
configuration={{
|
||||
url: "/api/open-api-schema.json",
|
||||
theme: "kepler",
|
||||
hideModels: false,
|
||||
hideDownloadButton: false,
|
||||
}}
|
||||
/>
|
||||
- Container orchestration systems that need to sync app definitions
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
tags:
|
||||
- Backup
|
||||
- SQLite
|
||||
- Management
|
||||
- Data
|
||||
---
|
||||
|
||||
# Backup & Restore
|
||||
|
||||
Homarr provides a built-in backup and restore system for **SQLite** instances. You can export your entire database - including apps, integrations, boards, users, and encryption keys - as a ZIP archive, and restore it on the same or a different Homarr instance.
|
||||
|
||||
:::warning
|
||||
|
||||
Backup and restore is only available when using the **SQLite** database driver. MySQL and PostgreSQL instances do not show this page.
|
||||
|
||||
:::
|
||||
|
||||
## Accessing the Backup Page
|
||||
|
||||
Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permissions to access this page.
|
||||
|
||||
## Exporting a Backup
|
||||
|
||||
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
|
||||
|
||||
| File | Description |
|
||||
| -------------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
|
||||
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
|
||||
|
||||
## Restoring a Backup
|
||||
|
||||
### 1. Upload
|
||||
|
||||
Drag and drop or select a `.zip` backup file. The import card validates that the file contains a valid `db.sqlite` and metadata.
|
||||
|
||||
### 2. Preview
|
||||
|
||||
Before restoring, Homarr uses **sql.js WASM** to analyze the backup directly in your browser. You'll see:
|
||||
|
||||
- **Entity counts** - how many apps, integrations, boards, users, etc. are in the backup
|
||||
- **Board names** - which boards will be restored
|
||||
- **Migration status** - Drizzle migrations are applied in-browser with a visual timeline showing each migration step
|
||||
|
||||
### 3. Confirm
|
||||
|
||||
Type `I understand` to acknowledge that restoring will replace your current database. This cannot be undone.
|
||||
|
||||
### 4. Restore
|
||||
|
||||
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
|
||||
|
||||
- **The Next.js server restarts automatically** to reload the new database state
|
||||
- Nginx and Redis (if running in Docker) stay up - only the Next.js process restarts
|
||||
- The page reloads once the server is ready
|
||||
|
||||
## Cross-Instance Restore
|
||||
|
||||
You can restore a backup from one Homarr instance onto another. Homarr handles:
|
||||
|
||||
- **Encryption key re-encryption** - If the target instance has a different `SECRET_ENCRYPTION_KEY`, integration secrets are decrypted with the old key and re-encrypted with the new one during import
|
||||
- **Version compatibility** - Backups include the Homarr version they were created with. Backward compatibility is maintained for older backup formats
|
||||
|
||||
## Backup During Onboarding
|
||||
|
||||
When setting up a new SQLite Homarr instance, the onboarding flow includes a **Restore from SQLite backup** step. You can upload a backup ZIP directly during onboarding to skip manual setup.
|
||||
|
||||
## Security
|
||||
|
||||
- Export requires admin permissions
|
||||
- The `I understand` confirmation dialog prevents accidental exports and overwrites
|
||||
- Backups contain encrypted integration secrets - they cannot be read without the `SECRET_ENCRYPTION_KEY`
|
||||
- Import only works on SQLite instances - the API routes refuse requests from MySQL/PostgreSQL instances
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
tags:
|
||||
- Custom Widgets
|
||||
- API
|
||||
- Widgets
|
||||
- JSX
|
||||
---
|
||||
|
||||
# Custom Widgets
|
||||
|
||||
:::warning
|
||||
|
||||
Custom widgets are a new feature. Share feedback, ask questions, and discuss ideas in our [GitHub Discussions](https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets).
|
||||
|
||||
:::
|
||||
|
||||
Custom widgets allow you to fetch data from any HTTP API and display it on your Homarr dashboard. You define the API endpoint, authentication, and how the response should be rendered - no code compilation or server restart required.
|
||||
|
||||
## Creating a Custom Widget
|
||||
|
||||
To create a custom widget, navigate to the **Custom Widgets** section in the management sidebar and click the **Add** button. Fill in the configuration:
|
||||
|
||||
- **Name** - Display name shown in the widget picker and on the dashboard
|
||||
- **Description** - Optional description shown in tooltips and management UI
|
||||
- **Icon URL** - Optional URL to an icon image for the widget
|
||||
- **URL** - The API endpoint to fetch data from (must include protocol, e.g. `https://api.example.com/stats`)
|
||||
- **Authentication** - How the API request is authenticated:
|
||||
- `none` - Public APIs with no authentication
|
||||
- `bearer` - Bearer token authentication
|
||||
- `basic` - HTTP Basic auth (username + password)
|
||||
- `apiKeyHeader` - API key sent as a custom HTTP header
|
||||
- `apiKeyQuery` - API key sent as a query parameter
|
||||
- **Header Name** - Custom header or query parameter name for API key auth (e.g. `X-API-Key`)
|
||||
- **HTTP Method** - `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`
|
||||
- **Request Body** - JSON string body for `POST`/`PUT`/`PATCH` requests
|
||||
- **Display Type** - How the API response is rendered (see below)
|
||||
|
||||
## Display Types
|
||||
|
||||
| Display Type | Description | Best For |
|
||||
| -------------------- | ---------------------------------------------------- | ---------------------------------------- |
|
||||
| **Single Value** | Extracts a single value with a label and unit | Metrics: temperature, uptime, user count |
|
||||
| **Key Value** | Multiple labeled key-value pairs with optional units | System stats: CPU, memory, disk |
|
||||
| **Table** | API array data rendered as a sortable table | Lists of items with columns |
|
||||
| **Stat Grid** | Labeled values in colored stat cards | Counts: movies, series, albums |
|
||||
| **Progress Bars** | Values as progress bars with percentages | Usage: disk, quota, capacity |
|
||||
| **Status Indicator** | Green/red status dots for health checks | Service monitoring |
|
||||
| **Count Grid** | Simple counts in a grid (no colors) | Library counts |
|
||||
| **Raw** | Pretty-prints the raw JSON response | Debugging |
|
||||
| **Action Button** | Clickable button executing the API request | Triggers: restart, clear cache |
|
||||
| **Custom JSX** | Custom JSX template with Mantine v9 components | Full layout control |
|
||||
|
||||
### AI-Powered JSX Generation
|
||||
|
||||
When editing or creating a custom widget with the **Custom JSX** display type, you'll find a **Copy Prompt** button. Clicking it copies a pre-formatted prompt describing your widget's name, API endpoint, and available Mantine components. Paste this into your preferred AI assistant (ChatGPT, Claude, Copilot, etc.) to generate a JSX template tailored to your data.
|
||||
|
||||
<details>
|
||||
<summary>Example: Pokédex widget using Custom JSX</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "homarr-custom-widget-v2",
|
||||
"name": "Pokédex",
|
||||
"description": "Browseable Pokémon list",
|
||||
"iconUrl": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
|
||||
"url": "https://pokeapi.co/api/v2/pokemon?limit=75",
|
||||
"authType": "none",
|
||||
"method": "GET",
|
||||
"displayType": "customJsx",
|
||||
"displayConfig": {
|
||||
"type": "customJsx",
|
||||
"template": "<Stack gap=\"md\" p=\"xs\">\n <Card withBorder radius=\"xl\" p=\"md\" shadow=\"md\">\n <Group justify=\"space-between\">\n <Title order={3}>Pokédex</Title>\n </Group>\n </Card>\n <PaginatedList pageSize={12}>\n <Grid gutter=\"sm\">\n {data.results.map((pokemon, i) =>\n <Grid.Col span={1.5}>\n <Anchor href={pokemon.url} target=\"_blank\" underline=\"never\">\n <Card withBorder radius=\"xl\" p=\"xs\" shadow=\"md\">\n <Stack gap=\"sm\" align=\"center\">\n <Group justify=\"space-between\" style={{ width: \"100%\" }}>\n <Badge size=\"sm\" color=\"red\">#{i + 1}</Badge>\n <Text fw={800} tt=\"capitalize\">{pokemon.name}</Text>\n </Group>\n <Avatar\n src={\"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/\" + (i + 1) + \".png\"}\n size={92}\n radius=\"xl\"\n />\n </Stack>\n </Card>\n </Anchor>\n </Grid.Col>\n )}\n </Grid>\n </PaginatedList>\n</Stack>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Importing and Exporting
|
||||
|
||||
Custom widgets can be exported and imported as JSON files. To export, select the widget in the list and click the export button. To import, click the import button and paste the JSON. Secrets (API keys, passwords) are never included in exports and must be configured after import.
|
||||
|
||||
The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version.
|
||||
|
||||
## Available Mantine Components for Custom JSX
|
||||
|
||||
When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security.
|
||||
|
||||
### Layout
|
||||
|
||||
| Component | Doc |
|
||||
| ------------------- | --------------------------------------------- |
|
||||
| `Stack` | [docs](https://mantine.dev/core/stack) |
|
||||
| `Group` | [docs](https://mantine.dev/core/group) |
|
||||
| `Flex` | [docs](https://mantine.dev/core/flex) |
|
||||
| `Grid` / `Grid.Col` | [docs](https://mantine.dev/core/grid) |
|
||||
| `SimpleGrid` | [docs](https://mantine.dev/core/simple-grid) |
|
||||
| `Center` | [docs](https://mantine.dev/core/center) |
|
||||
| `Container` | [docs](https://mantine.dev/core/container) |
|
||||
| `Space` | [docs](https://mantine.dev/core/space) |
|
||||
| `AspectRatio` | [docs](https://mantine.dev/core/aspect-ratio) |
|
||||
|
||||
### Typography & Content
|
||||
|
||||
| Component | Doc |
|
||||
| ----------------- | ------------------------------------------------- |
|
||||
| `Text` | [docs](https://mantine.dev/core/text) |
|
||||
| `Title` | [docs](https://mantine.dev/core/title) |
|
||||
| `Code` | [docs](https://mantine.dev/core/code) |
|
||||
| `Highlight` | [docs](https://mantine.dev/core/highlight) |
|
||||
| `Mark` | [docs](https://mantine.dev/core/mark) |
|
||||
| `Kbd` | [docs](https://mantine.dev/core/kbd) |
|
||||
| `Blockquote` | [docs](https://mantine.dev/core/blockquote) |
|
||||
| `Anchor` | [docs](https://mantine.dev/core/anchor) |
|
||||
| `NumberFormatter` | [docs](https://mantine.dev/core/number-formatter) |
|
||||
|
||||
### Data Display
|
||||
|
||||
| Component | Doc |
|
||||
| ------------------------- | ------------------------------------------------- |
|
||||
| `Badge` | [docs](https://mantine.dev/core/badge) |
|
||||
| `Card` / `Card.Section` | [docs](https://mantine.dev/core/card) |
|
||||
| `Paper` | [docs](https://mantine.dev/core/paper) |
|
||||
| `Alert` | [docs](https://mantine.dev/core/alert) |
|
||||
| `ThemeIcon` | [docs](https://mantine.dev/core/theme-icon) |
|
||||
| `ColorSwatch` | [docs](https://mantine.dev/core/color-swatch) |
|
||||
| `Avatar` / `Avatar.Group` | [docs](https://mantine.dev/core/avatar) |
|
||||
| `Image` | [docs](https://mantine.dev/core/image) |
|
||||
| `BackgroundImage` | [docs](https://mantine.dev/core/background-image) |
|
||||
| `Indicator` | [docs](https://mantine.dev/core/indicator) |
|
||||
| `Pill` | [docs](https://mantine.dev/core/pill) |
|
||||
| `Spoiler` | [docs](https://mantine.dev/core/spoiler) |
|
||||
|
||||
### Tables
|
||||
|
||||
| Component | Doc |
|
||||
| --------------------------------------- | -------------------------------------- |
|
||||
| `Table` / `Table.Thead` / `Table.Tbody` | [docs](https://mantine.dev/core/table) |
|
||||
| `Table.Tr` / `Table.Th` / `Table.Td` | [docs](https://mantine.dev/core/table) |
|
||||
|
||||
### Lists
|
||||
|
||||
| Component | Doc |
|
||||
| ---------------------------- | ----------------------------------------- |
|
||||
| `List` / `List.Item` | [docs](https://mantine.dev/core/list) |
|
||||
| `Timeline` / `Timeline.Item` | [docs](https://mantine.dev/core/timeline) |
|
||||
|
||||
### Feedback & Loading
|
||||
|
||||
| Component | Doc |
|
||||
| ------------------------------- | ---------------------------------------------- |
|
||||
| `Progress` / `Progress.Section` | [docs](https://mantine.dev/core/progress) |
|
||||
| `RingProgress` | [docs](https://mantine.dev/core/ring-progress) |
|
||||
| `Skeleton` | [docs](https://mantine.dev/core/skeleton) |
|
||||
| `Loader` | [docs](https://mantine.dev/core/loader) |
|
||||
|
||||
### Navigation & Overlay
|
||||
|
||||
| Component | Doc |
|
||||
| ------------------------------------------------------------------------ | -------------------------------------------- |
|
||||
| `TabsContainer` / `TabPanel` | Custom interactive component |
|
||||
| `Accordion` / `Accordion.Item` / `Accordion.Control` / `Accordion.Panel` | [docs](https://mantine.dev/core/accordion) |
|
||||
| `ScrollArea` | [docs](https://mantine.dev/core/scroll-area) |
|
||||
| `Tooltip` | [docs](https://mantine.dev/core/tooltip) |
|
||||
|
||||
### Charts
|
||||
|
||||
| Component | Doc |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| `AreaChart` | [docs](https://mantine.dev/charts/area-chart) |
|
||||
| `BarChart` | [docs](https://mantine.dev/charts/bar-chart) |
|
||||
| `LineChart` | [docs](https://mantine.dev/charts/line-chart) |
|
||||
| `DonutChart` | [docs](https://mantine.dev/charts/donut-chart) |
|
||||
| `PieChart` | [docs](https://mantine.dev/charts/pie-chart) |
|
||||
| `RadarChart` | [docs](https://mantine.dev/charts/radar-chart) |
|
||||
| `RadialBarChart` | [docs](https://mantine.dev/charts/radial-bar-chart) |
|
||||
| `Sparkline` | [docs](https://mantine.dev/charts/sparkline) |
|
||||
|
||||
### Interactive Components
|
||||
|
||||
| Component | Description |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| `PaginatedList` | Client-side pagination wrapper |
|
||||
| `Collapsible` | Expandable/collapsible section |
|
||||
| `StatBar` | Horizontal stat bar |
|
||||
| `TypeBadge` | Type-styled badge |
|
||||
| `Divider` | [docs](https://mantine.dev/core/divider) |
|
||||
|
||||
## Template Bindings
|
||||
|
||||
Your template receives:
|
||||
|
||||
- `data` - The sanitized API response object. Access fields via `data.fieldName` or `data.results[0].name`.
|
||||
- `String(v)` - Type-safe string conversion
|
||||
- `Number(v)` - Type-safe number conversion
|
||||
- `Boolean(v)` - Type-safe boolean conversion
|
||||
- `Math` - Safe subset: `round`, `floor`, `ceil`, `abs`, `min`, `max`, `pow`, `sqrt`, `PI`
|
||||
- `JSON.stringify(v)` - JSON serialization
|
||||
- `Array.isArray(v)` - Array type check
|
||||
- `Object.keys(v)`, `Object.values(v)`, `Object.entries(v)` - Object utilities
|
||||
|
||||
## Security Constraints
|
||||
|
||||
- Event handlers (`onClick`, `onChange`, etc.) are stripped
|
||||
- Dangerous HTML attributes (`dangerouslySetInnerHTML`) are stripped
|
||||
- `script`, `iframe`, `object`, `embed`, `form`, `style`, `link`, `meta`, `base` tags are forbidden
|
||||
- Template strings containing `eval`, `Function`, `import`, `require`, `globalThis`, `window`, `document`, `fetch` are rejected
|
||||
- Anchor `href` attributes are validated to only allow `https://`, relative (`/`), and hash (`#`) URLs
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
tags:
|
||||
- MCP
|
||||
- API
|
||||
- AI
|
||||
- Automation
|
||||
---
|
||||
|
||||
# Model Context Protocol (MCP)
|
||||
|
||||
Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language.
|
||||
|
||||
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The MCP endpoint lives under the **API** page at `Management` → `Tools` → `API` → **MCP** tab.
|
||||
|
||||

|
||||
|
||||
### 1. Create an API Key
|
||||
|
||||
Navigate to the **Authentication** tab in the API page and create an API key. API keys follow the format `<id>.<token>`.
|
||||
You can also create one directly from the MCP tab if you don't have any keys yet.
|
||||
|
||||
### 2. Choose a Transport
|
||||
|
||||
| Transport | Best For | Requirements |
|
||||
| ------------------- | ------------------------------------------------------------ | ------------------------------ |
|
||||
| **Streamable HTTP** | Cursor, Claude Desktop, VS Code Copilot, Claude Code | Direct HTTP access to Homarr |
|
||||
| **STDIO** | Firewall-restricted environments, offline-compatible clients | `npx` and `mcp-remote` package |
|
||||
|
||||
**Streamable HTTP** is recommended for most setups. It works with all major AI clients.
|
||||
|
||||
### 3. Configure Your Client
|
||||
|
||||
Add the following to your client's MCP configuration file:
|
||||
|
||||
**Streamable HTTP** (recommended):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"homarr": {
|
||||
"url": "https://your-homarr-instance.com/api/mcp/mcp",
|
||||
"headers": {
|
||||
"ApiKey": "<your-api-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where to place this config:
|
||||
|
||||
| Client | Config File |
|
||||
| --------------- | ----------------------------------------------- |
|
||||
| Cursor | `.cursor/mcp.json` |
|
||||
| Claude Desktop | `claude_desktop_config.json` |
|
||||
| Claude Code | `.claude/mcp.json` (or `claude mcp add homarr`) |
|
||||
| VS Code Copilot | `.vscode/mcp.json` |
|
||||
|
||||
:::warning
|
||||
|
||||
Replace `<your-api-key>` with your actual API key. Never commit API keys to version control.
|
||||
|
||||
:::
|
||||
|
||||
**STDIO** (alternative):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"homarr": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://your-homarr-instance.com/api/mcp/mcp",
|
||||
"--header",
|
||||
"ApiKey:<your-api-key>"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This option tunnels MCP communication through STDIO using the `mcp-remote` package. Useful when the AI client cannot make HTTP requests directly.
|
||||
|
||||
## Authentication
|
||||
|
||||
MCP supports two authentication methods:
|
||||
|
||||
| Method | Description | Best For |
|
||||
| -------------------- | ----------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **API Key** | Include `ApiKey: <id>.<token>` header in all requests | Scripts, CI/CD, direct client configs |
|
||||
| **OAuth 2.1 + PKCE** | Full OAuth flow with dynamic client registration | MCP clients with OAuth support (e.g. `opencode mcp auth homarr`) |
|
||||
|
||||
API key authentication is simpler and recommended for most setups. The API key inherits the permissions of the user who created it. Tools that require higher privileges will return permission errors if the key lacks access.
|
||||
|
||||
## Testing the Connection
|
||||
|
||||
Verify your MCP endpoint is working:
|
||||
|
||||
```bash
|
||||
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}' \
|
||||
https://your-homarr-instance.com/api/mcp/mcp
|
||||
```
|
||||
|
||||
A successful response includes the server capabilities and available tools. Use `tools/list` to see all available tools:
|
||||
|
||||
```bash
|
||||
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":"tools/list","id":1}' \
|
||||
https://your-homarr-instance.com/api/mcp/mcp
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** (GET, read-only) or **mutation** (POST, write action).
|
||||
|
||||
| Namespace | Example Tools | Type |
|
||||
| ------------------ | -------------------------------------------------------- | ---------- |
|
||||
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
|
||||
| `apiKeys` | List API keys | GET |
|
||||
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
|
||||
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
|
||||
| `icon` | Search icons | GET |
|
||||
| `info` | Server health, version info | GET |
|
||||
| `integration` | List integrations, test connection, delete integration | GET + POST |
|
||||
| `invite` | List invitations, delete invite | GET + POST |
|
||||
| `calendar` | Calendar events from media servers | GET |
|
||||
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
|
||||
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
|
||||
| `healthMonitoring` | Resource usage stats | GET |
|
||||
| `mediaRequests` | Approve/decline media requests | GET + POST |
|
||||
| `mediaServer` | Media server sessions and stats | GET |
|
||||
| `smartHome` | Execute automations, device states | GET + POST |
|
||||
|
||||
The tools table on the MCP tab is dynamically generated - it always reflects the latest available procedures from your Homarr instance.
|
||||
|
||||
## Security
|
||||
|
||||
- API keys are required for authentication - unauthenticated requests are rejected
|
||||
- Tools inherit the permissions of the API key's owner
|
||||
- OAuth endpoints are rate-limited to prevent abuse
|
||||
- Tool execution errors are contained - a failing tool call does not crash the MCP server
|
||||
- All communication uses HTTPS (configure HTTP → HTTPS redirects on your reverse proxy)
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **"Show me all apps on my Homarr dashboard"** - AI lists all apps with their names and URLs
|
||||
- **"Restart the Plex Docker container"** - AI finds and restarts the container via Docker integration
|
||||
- **"What's downloading right now?"** - AI fetches active download status across all download integrations
|
||||
- **"Create a new app pointing to my Grafana instance"** - AI creates an app with the correct icon and URL
|
||||
- **"How many users are on my Homarr instance?"** - AI lists all users (admin only)
|
||||
- **"Toggle Pi-hole blocking for 5 minutes"** - AI interacts with DNS controls
|
||||
@@ -42,7 +42,7 @@ Homarr will replace the ``%s`` with the term that the user entered.
|
||||
## Media request search
|
||||
|
||||
Jellyseerr, Overseerr, and Seerr integrations have a dedicated **Spotlight mode** for searching and requesting media.
|
||||
This mode does not require creating a search engine entry — it is available automatically when you have a compatible integration configured.
|
||||
This mode does not require creating a search engine entry - it is available automatically when you have a compatible integration configured.
|
||||
|
||||
You can access it from:
|
||||
- The **Modes list** in Spotlight (opened with `CTRL + K`)
|
||||
@@ -51,10 +51,10 @@ You can access it from:
|
||||
### Availability indicators
|
||||
|
||||
Search results display colored status badges showing the media's current state in your integration:
|
||||
- **Available** — fully available in your media library
|
||||
- **Partial** — some content (e.g. seasons) is available
|
||||
- **Processing** — currently being downloaded or processed
|
||||
- **Requested** — requested but not yet available
|
||||
- **Available** - fully available in your media library
|
||||
- **Partial** - some content (e.g. seasons) is available
|
||||
- **Processing** - currently being downloaded or processed
|
||||
- **Requested** - requested but not yet available
|
||||
|
||||
### Requesting TV show seasons
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"label": "Tools",
|
||||
"collapsed": true,
|
||||
"position": 10
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: SQLite Backup & Restore
|
||||
tags:
|
||||
- Management
|
||||
- Backup
|
||||
- Restore
|
||||
- SQLite
|
||||
---
|
||||
|
||||
The SQLite Backup & Restore feature allows you to export and import your entire Homarr database as a portable ZIP file. This is useful for creating backups, migrating between instances, or recovering from failures.
|
||||
|
||||
:::info SQLite only
|
||||
This feature is only available when using the SQLite database driver (`DB_DRIVER=better-sqlite3`). MySQL and PostgreSQL users should use their respective database backup tools.
|
||||
:::
|
||||
|
||||
## Accessing Backup & Restore
|
||||
|
||||
Navigate to **Management → Tools → Backup** from the sidebar. This page is only visible when running with SQLite.
|
||||
|
||||
## Exporting a Backup
|
||||
|
||||
Click the **Export** button to download a ZIP archive containing:
|
||||
|
||||
- **`db.sqlite`** — A consistent snapshot of your database (created via `VACUUM INTO`)
|
||||
- **`metadata.json`** — Contains:
|
||||
- `homarrVersion` — The version of Homarr that created the backup
|
||||
- `exportedAt` — Timestamp of the export
|
||||
- `dbDialect` — The database dialect (`sqlite`)
|
||||
- `encryptionKey` — Your `SECRET_ENCRYPTION_KEY` (needed to decrypt integration secrets)
|
||||
|
||||
:::caution
|
||||
The exported ZIP contains your encryption key and database. Store it securely and do not share it publicly.
|
||||
:::
|
||||
|
||||
A confirmation dialog will ask you to type **"I understand"** before the export proceeds.
|
||||
|
||||
## Importing a Backup
|
||||
|
||||
1. Click the **Import** button and upload a previously exported ZIP file
|
||||
2. Homarr analyzes the backup **entirely in your browser** using WebAssembly (sql.js) — no data is sent to the server during preview
|
||||
3. The preview shows:
|
||||
- **Entity counts** — How many boards, apps, users, integrations, etc. are in the backup
|
||||
- **Board names** — A list of all boards contained in the backup
|
||||
- **Migration status** — If the backup is from an older version, Homarr shows each pending database migration as animated timeline steps
|
||||
4. Review the preview and type **"I understand"** to confirm
|
||||
5. Homarr applies any pending migrations, re-encrypts integration secrets if the `SECRET_ENCRYPTION_KEY` differs between instances, and replaces the live database
|
||||
6. The server automatically restarts to pick up the new database
|
||||
|
||||
:::tip
|
||||
The import process is **backward-compatible** with older backups that used a separate `encryption-key.txt` file instead of embedding the key in `metadata.json`.
|
||||
:::
|
||||
|
||||
## Onboarding Integration
|
||||
|
||||
When setting up a fresh Homarr instance with SQLite, the onboarding wizard includes a **"Restore from SQLite backup"** option. This lets you upload a backup ZIP to bootstrap your new instance with data from a previous installation.
|
||||
|
||||
## Docker Restart Behavior
|
||||
|
||||
After a successful import, only the Next.js process restarts — nginx and Redis remain running. The `run.sh` script automatically restarts Next.js in a loop, so the application comes back online within seconds. Running `docker stop` still works gracefully.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Back up regularly** — Create exports before major updates or configuration changes
|
||||
- **Test restores** — Periodically verify your backups by importing them on a test instance
|
||||
- **Secure storage** — Keep backups in an encrypted or access-controlled location since they contain your encryption key
|
||||
- **Check versions** — Backups from newer versions cannot be imported into older Homarr instances; always import into the same or newer version
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: 'Beszel Alerts'
|
||||
description: 'View Beszel alert configurations and history with triggered/ok status indicators.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { beszelAlertsWidget } from '.';
|
||||
import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
|
||||
import beszelAlerts from './img/beszel-alerts.png';
|
||||
|
||||
<WidgetHeader
|
||||
widget={beszelAlertsWidget}
|
||||
categories={['System Monitoring', 'Alerts']}
|
||||
/>
|
||||
|
||||
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<img src={beszelAlerts} alt="Beszel Alerts widget showing triggered and ok alerts with history" />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays alert configurations and trigger history"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={beszelAlertsWidget.configuration} />
|
||||
@@ -0,0 +1,25 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconBell } from "@tabler/icons-react";
|
||||
|
||||
export const beszelAlertsWidget: WidgetDefinition = {
|
||||
icon: IconBell,
|
||||
name: "Beszel Alerts",
|
||||
description: "View Beszel alert configurations and history with triggered/ok status indicators.",
|
||||
path: "../../widgets/beszel-alerts",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show history",
|
||||
description: "Displays the alert trigger history",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Max history items",
|
||||
description: "Maximum number of history entries to display (1-100)",
|
||||
values: "Number (1-100)",
|
||||
defaultValue: "10",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: 'Beszel Systems (Grid)'
|
||||
description: 'Card grid view of all Beszel-monitored systems with real-time metrics.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { beszelSystemGridWidget } from '.';
|
||||
import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
|
||||
import beszelSystemGrid from './img/beszel-system-grid.png';
|
||||
|
||||
<WidgetHeader
|
||||
widget={beszelSystemGridWidget}
|
||||
categories={['System Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<img src={beszelSystemGrid} alt="Beszel System Grid widget showing system cards with metrics" />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays all monitored systems as a card grid"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={beszelSystemGridWidget.configuration} />
|
||||
@@ -0,0 +1,88 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
|
||||
export const beszelSystemGridWidget: WidgetDefinition = {
|
||||
icon: IconLayoutGrid,
|
||||
name: "Beszel Systems (Grid)",
|
||||
description: "Card grid view of all Beszel-monitored systems with real-time metrics.",
|
||||
path: "../../widgets/beszel-system-grid",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Status filter",
|
||||
description: "Filter systems by their current status",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["All Systems", "Up", "Down", "Paused"],
|
||||
},
|
||||
defaultValue: "All Systems",
|
||||
},
|
||||
{
|
||||
name: "Show CPU",
|
||||
description: "Displays CPU usage on each system card",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show memory",
|
||||
description: "Displays memory usage on each system card",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show disk",
|
||||
description: "Displays disk usage on each system card",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show GPU",
|
||||
description: "Displays GPU usage on each system card",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show load average",
|
||||
description: "Displays system load average",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show network",
|
||||
description: "Displays network throughput",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show temperature",
|
||||
description: "Displays system temperature",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show battery",
|
||||
description: "Displays battery level (if available)",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show services",
|
||||
description: "Displays running Docker services count",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show uptime",
|
||||
description: "Displays system uptime",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show agent version",
|
||||
description: "Displays the Beszel agent version",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 688 KiB |
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: 'Beszel System Stats'
|
||||
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { beszelSystemStatsWidget } from '.';
|
||||
import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
|
||||
import beszelSystemStats from './img/beszel-system-stats.png';
|
||||
|
||||
<WidgetHeader
|
||||
widget={beszelSystemStatsWidget}
|
||||
categories={['System Monitoring', 'Charts']}
|
||||
/>
|
||||
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<img src={beszelSystemStats} alt="Beszel System Stats widget showing CPU, memory, disk and network charts" />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays time-series charts for a selected system"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
|
||||
@@ -0,0 +1,70 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconChartAreaLine } from "@tabler/icons-react";
|
||||
|
||||
export const beszelSystemStatsWidget: WidgetDefinition = {
|
||||
icon: IconChartAreaLine,
|
||||
name: "Beszel System Stats",
|
||||
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.",
|
||||
path: "../../widgets/beszel-system-stats",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Time period",
|
||||
description: "The time range for chart data",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"],
|
||||
},
|
||||
defaultValue: "1 Hour",
|
||||
},
|
||||
{
|
||||
name: "Show CPU",
|
||||
description: "Displays the CPU usage chart",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show memory",
|
||||
description: "Displays the memory usage chart",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show disk",
|
||||
description: "Displays the disk usage chart",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show disk I/O",
|
||||
description: "Displays the disk I/O chart",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show network",
|
||||
description: "Displays the network chart",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show Docker CPU",
|
||||
description: "Displays Docker container CPU usage chart",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show Docker memory",
|
||||
description: "Displays Docker container memory usage chart",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show Docker network",
|
||||
description: "Displays Docker container network chart",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: 'Beszel Systems (Table)'
|
||||
description: 'Table view of all Beszel-monitored systems with sortable columns and status indicators.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { beszelSystemTableWidget } from '.';
|
||||
import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={beszelSystemTableWidget}
|
||||
categories={['System Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays all monitored systems in a sortable table"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={beszelSystemTableWidget.configuration} />
|
||||
@@ -0,0 +1,106 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
|
||||
export const beszelSystemTableWidget: WidgetDefinition = {
|
||||
icon: IconTable,
|
||||
name: "Beszel Systems (Table)",
|
||||
description: "Table view of all Beszel-monitored systems with sortable columns and status indicators.",
|
||||
path: "../../widgets/beszel-system-table",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Status filter",
|
||||
description: "Filter systems by their current status",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["All Systems", "Up", "Down", "Paused"],
|
||||
},
|
||||
defaultValue: "All Systems",
|
||||
},
|
||||
{
|
||||
name: "Sort by",
|
||||
description: "Default column to sort the table by",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["System", "CPU", "Memory", "Disk", "GPU", "Load Avg", "Net", "Temp", "Services", "Uptime", "Agent"],
|
||||
},
|
||||
defaultValue: "System",
|
||||
},
|
||||
{
|
||||
name: "Sort direction",
|
||||
description: "Default sort direction",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["Ascending", "Descending"],
|
||||
},
|
||||
defaultValue: "Ascending",
|
||||
},
|
||||
{
|
||||
name: "Show CPU",
|
||||
description: "Displays the CPU column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show memory",
|
||||
description: "Displays the memory column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show disk",
|
||||
description: "Displays the disk column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show GPU",
|
||||
description: "Displays the GPU column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show load average",
|
||||
description: "Displays the load average column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show network",
|
||||
description: "Displays the network column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show temperature",
|
||||
description: "Displays the temperature column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show battery",
|
||||
description: "Displays the battery column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show services",
|
||||
description: "Displays the services column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show uptime",
|
||||
description: "Displays the uptime column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show agent version",
|
||||
description: "Displays the agent version column",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: 'Custom API'
|
||||
description: 'Displays data from a custom API endpoint using configurable display types and optional JSX templates.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { customApiWidget } from '.';
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={customApiWidget}
|
||||
categories={['Custom', 'API']}
|
||||
/>
|
||||
|
||||
This widget fetches data from a custom API endpoint defined in [Custom Widgets management](/docs/management/custom-widgets) and renders it using configurable display types.
|
||||
|
||||
Custom API widgets are created and configured through the admin management page. Each widget definition specifies the API endpoint, authentication, display type, and refresh interval.
|
||||
|
||||
See the [Custom Widgets documentation](/docs/management/custom-widgets) for full details on creating and configuring custom widgets.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
:::note
|
||||
Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker.
|
||||
:::
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
export const customApiWidget: WidgetDefinition = {
|
||||
icon: IconApi,
|
||||
name: "Custom API",
|
||||
description: "Displays data from a custom API endpoint using configurable display types and optional JSX templates.",
|
||||
path: "../../widgets/custom-api",
|
||||
};
|
||||
@@ -41,10 +41,10 @@ import dockerTable from './img/table.png';
|
||||
### Interactions
|
||||
|
||||
Each container row has an action menu (accessible via the three-dot icon) with the following options:
|
||||
- **View logs** — opens a dedicated terminal page streaming the container's logs
|
||||
- **Start** / **Stop** / **Restart** — manage the container's state
|
||||
- **Remove** — remove the container
|
||||
- **Add to Homarr** — create a bookmark app from the container
|
||||
- **View logs** - opens a dedicated terminal page streaming the container's logs
|
||||
- **Start** / **Stop** / **Restart** - manage the container's state
|
||||
- **Remove** - remove the container
|
||||
- **Add to Homarr** - create a bookmark app from the container
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { umamiIntegration } from '@site/docs/integrations/umami';
|
||||
categories={['Analytics']}
|
||||
/>
|
||||
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance — pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists.
|
||||
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
@@ -37,7 +37,7 @@ The Umami Analytics widget displays key visitor metrics from a website tracked b
|
||||
| View | Description |
|
||||
|---|---|
|
||||
| **Chart** | Bar chart or sparkline of unique visitors over the selected time frame. Optionally overlay a single named event as a second series. |
|
||||
| **Events chart** | Multi-select one or more named events and chart each as its own colored series — no visitor baseline. Supports bar and sparkline. |
|
||||
| **Events chart** | Multi-select one or more named events and chart each as its own colored series - no visitor baseline. Supports bar and sparkline. |
|
||||
| **Top pages** | Ranked list of URLs by pageview count for the selected time frame. |
|
||||
| **Top referrers** | Ranked list of referrer sources by session count. Direct traffic (no referrer) is labeled "Direct". |
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const config: Config = {
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
url: "https://homarr.dev",
|
||||
baseUrl: "/",
|
||||
trailingSlash: true,
|
||||
favicon: "img/logo.png",
|
||||
organizationName: "homarr-labs",
|
||||
projectName: "homarr",
|
||||
@@ -16,7 +17,6 @@ const config: Config = {
|
||||
locales: ["en"],
|
||||
},
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenMarkdownLinks: "throw",
|
||||
onBrokenAnchors: "throw",
|
||||
onDuplicateRoutes: "throw",
|
||||
|
||||
@@ -28,11 +28,17 @@ const config: Config = {
|
||||
fasterByDefault: true,
|
||||
mdx1CompatDisabledByDefault: false,
|
||||
},
|
||||
faster: {
|
||||
swcHtmlMinimizer: false,
|
||||
},
|
||||
},
|
||||
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
format: "detect",
|
||||
hooks: {
|
||||
onBrokenMarkdownLinks: "throw",
|
||||
},
|
||||
},
|
||||
|
||||
themes: ["@docusaurus/theme-mermaid"],
|
||||
@@ -56,7 +62,7 @@ const config: Config = {
|
||||
sidebarPath: require.resolve("./sidebars.js"),
|
||||
editUrl: ({ docPath }) => `https://github.com/homarr-labs/homarr/edit/dev/apps/docs/docs/${docPath}`,
|
||||
remarkPlugins: [a11yEmoji],
|
||||
exclude: ["**/custom-widget.mdx"],
|
||||
exclude: [],
|
||||
showLastUpdateAuthor: false,
|
||||
showLastUpdateTime: false,
|
||||
},
|
||||
@@ -285,13 +291,11 @@ const config: Config = {
|
||||
};
|
||||
},
|
||||
"@signalwire/docusaurus-plugin-llms-txt",
|
||||
async function tailwindCssPlugin(context, options) {
|
||||
async function tailwindCssPlugin() {
|
||||
return {
|
||||
name: "docusaurus-tailwindcss",
|
||||
configurePostCss(postcssOptions) {
|
||||
// Appends TailwindCSS and AutoPrefixer.
|
||||
postcssOptions.plugins.push(require("tailwindcss"));
|
||||
postcssOptions.plugins.push(require("autoprefixer"));
|
||||
postcssOptions.plugins.push(require("@tailwindcss/postcss"));
|
||||
return postcssOptions;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,25 +32,25 @@
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
"leader-line-new": "^1.1.9",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss": "^8.5.10",
|
||||
"posthog-docusaurus": "^2.0.5",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"random-word-slugs": "^0.1.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"swiper": "^12.2.0",
|
||||
"tailwindcss": "^3.4.19"
|
||||
"tailwindcss": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||
"@docusaurus/tsconfig": "^3.10.1",
|
||||
"@docusaurus/types": "^3.10.1",
|
||||
"@fec/remark-a11y-emoji": "^3.1.0",
|
||||
"@fec/remark-a11y-emoji": "^4.0.2",
|
||||
"@tsconfig/docusaurus": "^2.0.9",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
||||
@@ -32,7 +32,7 @@ export const IntegrationCapabilites = ({ items }: IntegrationCapabilitesProps) =
|
||||
className="flex gap-6 rounded-xl border border-solid dark:border-[#333] border-[#e5e7eb] p-4 shadow-sm w-full items-center justify-between"
|
||||
>
|
||||
<div className="flex gap-6 items-center">
|
||||
<div className="w-10 h-10 flex justify-center items-center bg-red-500 bg-opacity-10 rounded-md">
|
||||
<div className="w-10 h-10 flex justify-center items-center bg-red-500/10 rounded-md">
|
||||
<capability.icon size={24} stroke={1.5} className="stroke-red-500" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { AnyApiReferenceConfiguration } from "@scalar/types/api-reference";
|
||||
import { ApiReferenceReact } from "@scalar/api-reference-react";
|
||||
import { useColorMode } from "@docusaurus/theme-common";
|
||||
|
||||
import "@scalar/api-reference-react/style.css";
|
||||
|
||||
export function ThemedApiReference({ configuration }: { configuration: AnyApiReferenceConfiguration }) {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<ApiReferenceReact
|
||||
configuration={{
|
||||
...configuration,
|
||||
forceDarkModeState: colorMode === "dark" ? "dark" : "light",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export const WidgetHeader = (props: WidgetHeaderProps) => {
|
||||
title={props.widget.name}
|
||||
description={props.widget.description}
|
||||
icon={
|
||||
<div className="w-12 h-12 flex justify-center items-center bg-red-500 bg-opacity-10 rounded-md">
|
||||
<div className="w-12 h-12 flex justify-center items-center bg-red-500/10 rounded-md">
|
||||
<props.widget.icon size={32} stroke={1.5} className="stroke-red-500" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss/theme" layer(theme);
|
||||
@import "tailwindcss/utilities" layer(utilities);
|
||||
|
||||
@import url(https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap);
|
||||
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
@theme {
|
||||
--spacing-128: 32rem;
|
||||
--breakpoint-3xl: 1697px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--ifm-color-primary: #fa5252;
|
||||
--ifm-color-primary-dark: #f93232;
|
||||
@@ -87,7 +93,7 @@
|
||||
}
|
||||
|
||||
.menu__list {
|
||||
transition: all 0.s ease-in-out !important;
|
||||
transition: all 0.5s ease-in-out !important;
|
||||
}
|
||||
|
||||
.announcementBar_node_modules-\@docusaurus-theme-classic-lib-theme-AnnouncementBar-styles-module {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import Layout from "@theme/Layout";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
|
||||
export default function ApiReferencePage() {
|
||||
return (
|
||||
<Layout title="API Reference" description="Homarr OpenAPI Reference">
|
||||
<BrowserOnly fallback={<div style={{ height: "100vh" }} />}>
|
||||
{() => {
|
||||
const { ThemedApiReference } = require("@site/src/components/themed-api-reference");
|
||||
return (
|
||||
<ThemedApiReference
|
||||
configuration={{
|
||||
url: "/api/open-api-schema.json",
|
||||
theme: "kepler",
|
||||
hideModels: false,
|
||||
hideDownloadButton: false,
|
||||
mcp: { disabled: true },
|
||||
customCss: `
|
||||
.scalar-sidebar-footer a[href="https://www.scalar.com"] { display: none !important; }
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</BrowserOnly>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,jsx,ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
spacing: {
|
||||
128: "32rem",
|
||||
},
|
||||
screens: {
|
||||
"3xl": "1697px",
|
||||
},
|
||||
},
|
||||
},
|
||||
darkMode: ["class", '[data-theme="dark"]'],
|
||||
plugins: [],
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
};
|
||||
+28
-34
@@ -1,35 +1,29 @@
|
||||
{
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.jsx",
|
||||
"**/*.js",
|
||||
"**/*.tsx",
|
||||
"**/*.md",
|
||||
"**/*.mdx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.jsx",
|
||||
"**/*.js",
|
||||
"**/*.tsx",
|
||||
"**/*.md",
|
||||
"**/*.mdx",
|
||||
],
|
||||
"exclude": ["node_modules"],
|
||||
}
|
||||
@@ -16,11 +16,7 @@ export const InitSqliteRestore = () => {
|
||||
if (expanded) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<Button variant="subtle" size="xs" onClick={() => setExpanded(false)}>
|
||||
{t("backToOptions")}
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/5916";
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
return (
|
||||
|
||||
@@ -150,7 +150,13 @@ const requiredFieldValidators: Record<string, (data: Record<string, unknown>, ct
|
||||
});
|
||||
},
|
||||
statusIndicator: (data, ctx) => {
|
||||
(data.statusItems as Array<{ label: string; jsonPath: string; goodValues: string }>)?.forEach((item, i) => {
|
||||
(
|
||||
data.statusItems as Array<{
|
||||
label: string;
|
||||
jsonPath: string;
|
||||
goodValues: string;
|
||||
}>
|
||||
)?.forEach((item, i) => {
|
||||
if (!item.label)
|
||||
ctx.addIssue({
|
||||
code: "too_small",
|
||||
@@ -260,15 +266,34 @@ const formSchema = z
|
||||
columns: z.array(z.object({ header: z.string(), jsonPath: z.string() })),
|
||||
striped: z.boolean(),
|
||||
compact: z.boolean(),
|
||||
statGridItems: z.array(z.object({ label: z.string(), jsonPath: z.string(), unit: z.string(), color: z.string() })),
|
||||
statGridItems: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
jsonPath: z.string(),
|
||||
unit: z.string(),
|
||||
color: z.string(),
|
||||
}),
|
||||
),
|
||||
statGridColumns: z.number(),
|
||||
cardStyle: z.string(),
|
||||
progressBars: z.array(
|
||||
z.object({ label: z.string(), valuePath: z.string(), maxPath: z.string(), unit: z.string(), color: z.string() }),
|
||||
z.object({
|
||||
label: z.string(),
|
||||
valuePath: z.string(),
|
||||
maxPath: z.string(),
|
||||
unit: z.string(),
|
||||
color: z.string(),
|
||||
}),
|
||||
),
|
||||
showPercentage: z.boolean(),
|
||||
barSize: z.string(),
|
||||
statusItems: z.array(z.object({ label: z.string(), jsonPath: z.string(), goodValues: z.string() })),
|
||||
statusItems: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
jsonPath: z.string(),
|
||||
goodValues: z.string(),
|
||||
}),
|
||||
),
|
||||
statusLayout: z.string(),
|
||||
dotSize: z.string(),
|
||||
countGridItems: z.array(z.object({ label: z.string(), jsonPath: z.string(), unit: z.string() })),
|
||||
@@ -281,7 +306,13 @@ const formSchema = z
|
||||
confirmText: z.string(),
|
||||
successMessage: z.string(),
|
||||
template: z.string(),
|
||||
secrets: z.array(z.object({ kind: z.string(), value: z.string(), hasValue: z.boolean().optional() })),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: z.string(),
|
||||
value: z.string(),
|
||||
hasValue: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const validator = requiredFieldValidators[data.displayType];
|
||||
@@ -362,12 +393,31 @@ const displayConfigBuilders: Record<string, (values: z.infer<typeof formSchema>)
|
||||
valueSize: v.valueSize,
|
||||
labelPosition: v.labelPosition,
|
||||
}),
|
||||
keyValue: (v) => ({ type: "keyValue", mappings: v.mappings, layout: v.kvLayout, columns: v.kvColumns }),
|
||||
table: (v) => ({ type: "table", tablePath: v.tablePath, columns: v.columns, striped: v.striped, compact: v.compact }),
|
||||
statGrid: (v) => ({ type: "statGrid", items: v.statGridItems, columns: v.statGridColumns, cardStyle: v.cardStyle }),
|
||||
keyValue: (v) => ({
|
||||
type: "keyValue",
|
||||
mappings: v.mappings,
|
||||
layout: v.kvLayout,
|
||||
columns: v.kvColumns,
|
||||
}),
|
||||
table: (v) => ({
|
||||
type: "table",
|
||||
tablePath: v.tablePath,
|
||||
columns: v.columns,
|
||||
striped: v.striped,
|
||||
compact: v.compact,
|
||||
}),
|
||||
statGrid: (v) => ({
|
||||
type: "statGrid",
|
||||
items: v.statGridItems,
|
||||
columns: v.statGridColumns,
|
||||
cardStyle: v.cardStyle,
|
||||
}),
|
||||
progressBars: (v) => ({
|
||||
type: "progressBars",
|
||||
bars: v.progressBars.map((b) => ({ ...b, maxPath: b.maxPath || undefined })),
|
||||
bars: v.progressBars.map((b) => ({
|
||||
...b,
|
||||
maxPath: b.maxPath || undefined,
|
||||
})),
|
||||
showPercentage: v.showPercentage,
|
||||
barSize: v.barSize,
|
||||
}),
|
||||
@@ -389,7 +439,11 @@ const displayConfigBuilders: Record<string, (values: z.infer<typeof formSchema>)
|
||||
columns: v.countGridColumns,
|
||||
valueSize: v.countValueSize,
|
||||
}),
|
||||
raw: (v) => ({ type: "raw", jsonPath: v.rawJsonPath, maxHeight: v.rawMaxHeight }),
|
||||
raw: (v) => ({
|
||||
type: "raw",
|
||||
jsonPath: v.rawJsonPath,
|
||||
maxHeight: v.rawMaxHeight,
|
||||
}),
|
||||
actionButton: (v) => ({
|
||||
type: "actionButton",
|
||||
buttonLabel: v.buttonLabel,
|
||||
@@ -415,7 +469,10 @@ const serverToFormFieldMap: Record<string, Record<string, string>> = {
|
||||
|
||||
function extractServerErrors(err: unknown, displayType: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const trpcErr = err as { data?: { zodError?: { fieldErrors?: Record<string, string[]> } }; message?: string };
|
||||
const trpcErr = err as {
|
||||
data?: { zodError?: { fieldErrors?: Record<string, string[]> } };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (trpcErr?.data?.zodError?.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(trpcErr.data.zodError.fieldErrors)) {
|
||||
@@ -427,7 +484,10 @@ function extractServerErrors(err: unknown, displayType: string): Record<string,
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = JSON.parse(trpcErr?.message ?? "[]") as Array<{ path: (string | number)[]; message: string }>;
|
||||
const issues = JSON.parse(trpcErr?.message ?? "[]") as Array<{
|
||||
path: (string | number)[];
|
||||
message: string;
|
||||
}>;
|
||||
const fieldMap = serverToFormFieldMap[displayType] ?? {};
|
||||
|
||||
for (const issue of issues) {
|
||||
@@ -451,7 +511,13 @@ const listItemDefaults = {
|
||||
mapping: { label: "", jsonPath: "$", unit: "" },
|
||||
column: { header: "", jsonPath: "$" },
|
||||
statGridItem: { label: "", jsonPath: "$", unit: "", color: "blue" },
|
||||
progressBar: { label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" },
|
||||
progressBar: {
|
||||
label: "",
|
||||
valuePath: "$",
|
||||
maxPath: "",
|
||||
unit: "",
|
||||
color: "blue",
|
||||
},
|
||||
statusItem: { label: "", jsonPath: "$", goodValues: "online,true" },
|
||||
countGridItem: { label: "", jsonPath: "$", unit: "" },
|
||||
} as const;
|
||||
@@ -521,7 +587,10 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
displayConfig: displayConfig as never,
|
||||
secrets: values.secrets
|
||||
.filter((s) => s.value)
|
||||
.map((s) => ({ kind: s.kind as "apiKey" | "username" | "password", value: s.value })),
|
||||
.map((s) => ({
|
||||
kind: s.kind as "apiKey" | "username" | "password",
|
||||
value: s.value,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -535,7 +604,10 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
router.push(`/manage/custom-widgets/edit/${result.id}`);
|
||||
} else if (definitionId) {
|
||||
await updateMutation.mutateAsync({ id: definitionId, ...payload });
|
||||
showSuccessNotification({ title: t("action.save"), message: t("notification.updated", { name: values.name }) });
|
||||
showSuccessNotification({
|
||||
title: t("action.save"),
|
||||
message: t("notification.updated", { name: values.name }),
|
||||
});
|
||||
setPreviewRefreshSignal((n) => n + 1);
|
||||
await utils.customWidget.all.invalidate();
|
||||
await utils.customWidget.byId.invalidate({ id: definitionId });
|
||||
@@ -547,7 +619,10 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
form.setErrors(serverErrors);
|
||||
}
|
||||
const errorKey = mode === "create" ? "notification.createError" : "notification.updateError";
|
||||
showErrorNotification({ title: t("action.save"), message: t(errorKey as never) });
|
||||
showErrorNotification({
|
||||
title: t("action.save"),
|
||||
message: t(errorKey as never),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -566,7 +641,10 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
displayConfig: buildConfig?.(values) as z.infer<typeof displayConfigSchema>,
|
||||
secrets: values.secrets
|
||||
.filter((s) => s.value)
|
||||
.map((s) => ({ kind: s.kind as CustomWidgetSecretKind, value: s.value })),
|
||||
.map((s) => ({
|
||||
kind: s.kind as CustomWidgetSecretKind,
|
||||
value: s.value,
|
||||
})),
|
||||
definitionId,
|
||||
};
|
||||
}, [form.values, definitionId]);
|
||||
@@ -749,7 +827,18 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Box w={340} style={{ flexShrink: 0, position: "sticky", top: 100, alignSelf: "start" }} visibleFrom="md">
|
||||
<Box
|
||||
w={480}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
position: "sticky",
|
||||
top: 80,
|
||||
alignSelf: "start",
|
||||
maxHeight: "calc(100vh - 100px)",
|
||||
overflow: "auto",
|
||||
}}
|
||||
visibleFrom="md"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Button type="submit" fullWidth loading={createMutation.isPending || updateMutation.isPending}>
|
||||
{mode === "create" ? t("action.create") : t("action.save")}
|
||||
@@ -817,13 +906,19 @@ function DisplayTypeFields({
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.valueSize")}
|
||||
data={["sm", "md", "lg", "xl"].map((v) => ({ value: v, label: t(`sizeOption.${v}` as never) }))}
|
||||
data={["sm", "md", "lg", "xl"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("valueSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.labelPosition")}
|
||||
data={["above", "below"].map((v) => ({ value: v, label: t(`labelPositionOption.${v}` as never) }))}
|
||||
data={["above", "below"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`labelPositionOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("labelPosition")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
@@ -838,7 +933,10 @@ function DisplayTypeFields({
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.kvLayout")}
|
||||
data={["list", "grid"].map((v) => ({ value: v, label: t(`layoutOption.${v}` as never) }))}
|
||||
data={["list", "grid"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`layoutOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("kvLayout")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
@@ -958,7 +1056,10 @@ function DisplayTypeFields({
|
||||
<NumberInput label={t("field.gridColumns")} min={1} max={4} {...form.getInputProps("statGridColumns")} />
|
||||
<Select
|
||||
label={t("field.cardStyle")}
|
||||
data={["filled", "outline", "subtle"].map((v) => ({ value: v, label: t(`cardStyleOption.${v}` as never) }))}
|
||||
data={["filled", "outline", "subtle"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`cardStyleOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("cardStyle")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
@@ -1027,7 +1128,10 @@ function DisplayTypeFields({
|
||||
<Switch label={t("field.showPercentage")} {...form.getInputProps("showPercentage", { type: "checkbox" })} />
|
||||
<Select
|
||||
label={t("field.barSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({ value: v, label: t(`sizeOption.${v}` as never) }))}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("barSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
@@ -1103,13 +1207,19 @@ function DisplayTypeFields({
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("field.kvLayout")}
|
||||
data={["list", "grid"].map((v) => ({ value: v, label: t(`layoutOption.${v}` as never) }))}
|
||||
data={["list", "grid"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`layoutOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("statusLayout")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("field.dotSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({ value: v, label: t(`sizeOption.${v}` as never) }))}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("dotSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
@@ -1171,7 +1281,10 @@ function DisplayTypeFields({
|
||||
<NumberInput label={t("field.gridColumns")} min={2} max={4} {...form.getInputProps("countGridColumns")} />
|
||||
<Select
|
||||
label={t("field.valueSize")}
|
||||
data={["sm", "md", "lg"].map((v) => ({ value: v, label: t(`sizeOption.${v}` as never) }))}
|
||||
data={["sm", "md", "lg"].map((v) => ({
|
||||
value: v,
|
||||
label: t(`sizeOption.${v}` as never),
|
||||
}))}
|
||||
{...form.getInputProps("countValueSize")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
@@ -109,7 +109,7 @@ export function CustomWidgetPreview({
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mih={120}>
|
||||
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mah={320} style={{ overflow: "auto" }}>
|
||||
<PreviewDisplay data={displayData} />
|
||||
</Card>
|
||||
|
||||
@@ -119,7 +119,9 @@ export function CustomWidgetPreview({
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
onClick={() => {
|
||||
const blob = new Blob([fetchResult.rawResponse ?? ""], { type: "application/json" });
|
||||
const blob = new Blob([fetchResult.rawResponse ?? ""], {
|
||||
type: "application/json",
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
|
||||
@@ -163,7 +163,7 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, definition.name]])} nonInteractable={["edit"]} />
|
||||
<Container size="xl">
|
||||
<Container fluid>
|
||||
<Stack>
|
||||
<Title>{definition.name}</Title>
|
||||
<CustomWidgetBetaBanner />
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function NewCustomWidgetPage() {
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Container size="xl">
|
||||
<Container fluid>
|
||||
<Stack>
|
||||
<div>
|
||||
<Title>{t("page.create.title")}</Title>
|
||||
|
||||
@@ -19,6 +19,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
||||
const t = useScopedI18n("integration.page.delete");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
||||
|
||||
return (
|
||||
@@ -43,6 +44,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
||||
router.replace("/manage/integrations");
|
||||
}
|
||||
void revalidatePathActionAsync("/manage/integrations");
|
||||
void utils.integration.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
|
||||
@@ -54,6 +54,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
|
||||
const hasUrlSecret = initialSecretsKinds.includes("url");
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const router = useRouter();
|
||||
const form = useZodForm(formSchema, {
|
||||
initialValues: {
|
||||
@@ -102,6 +103,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void utils.integration.invalidate();
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -97,13 +97,19 @@ export const NewIntegrationForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/integrations");
|
||||
await utils.integration.invalidate();
|
||||
},
|
||||
});
|
||||
const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } =
|
||||
clientApi.onboard.createIntegration.useMutation();
|
||||
clientApi.onboard.createIntegration.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.integration.invalidate();
|
||||
},
|
||||
});
|
||||
const isPending = isCreatePending || isOnboardingCreatePending;
|
||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { SegmentedControlItem } from "@mantine/core";
|
||||
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
|
||||
import { WidgetIntegrationSelect } from "@homarr/widgets/widget-integration-select";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
@@ -29,7 +29,8 @@ export const BackupExportCard = () => {
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
||||
const filename =
|
||||
response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
|
||||
@@ -39,7 +39,9 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex:
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
|
||||
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(".");
|
||||
@@ -148,14 +150,13 @@ export async function POST(req: Request) {
|
||||
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;
|
||||
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.",
|
||||
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,4 @@ const migrationsFolderCandidates = [
|
||||
"/app/db/migrations/sqlite",
|
||||
];
|
||||
|
||||
export const findMigrationsFolder = () =>
|
||||
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
export const findMigrationsFolder = () => migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
@@ -80,7 +80,10 @@ describe("SQLite backup", () => {
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
const readBack = new AdmZip(zipPath);
|
||||
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
|
||||
const entryNames = readBack
|
||||
.getEntries()
|
||||
.map((e) => e.entryName)
|
||||
.toSorted();
|
||||
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
|
||||
|
||||
const dbEntry = readBack.getEntry("db.sqlite");
|
||||
@@ -97,9 +100,9 @@ describe("SQLite backup", () => {
|
||||
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 }[];
|
||||
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");
|
||||
@@ -124,7 +127,10 @@ describe("SQLite backup", () => {
|
||||
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() })));
|
||||
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"];
|
||||
@@ -173,9 +179,11 @@ describe("SQLite backup", () => {
|
||||
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 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" = ?',
|
||||
@@ -253,7 +261,9 @@ describe("SQLite backup", () => {
|
||||
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;
|
||||
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");
|
||||
@@ -281,7 +291,9 @@ describe("SQLite backup", () => {
|
||||
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;
|
||||
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");
|
||||
|
||||
|
||||
@@ -50,10 +50,10 @@ export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
|
||||
<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>
|
||||
{(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>
|
||||
@@ -63,9 +63,13 @@ export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
|
||||
|
||||
{analysis.boardNames.length > 0 && (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" fw={500}>{t("boards")}:</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("boards")}:
|
||||
</Text>
|
||||
{analysis.boardNames.map((name) => (
|
||||
<Badge key={name} variant="light" size="sm">{name}</Badge>
|
||||
<Badge key={name} variant="light" size="sm">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -49,7 +49,9 @@ export const DangerousActionConfirmation = ({
|
||||
|
||||
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>{warningTitle}</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{warningTitle}
|
||||
</Text>
|
||||
<Text size="sm">{warningBody}</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
@@ -76,12 +78,7 @@ export const DangerousActionConfirmation = ({
|
||||
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
color={color}
|
||||
leftSection={submitIcon}
|
||||
onClick={onConfirm}
|
||||
disabled={!isMatch || disabled}
|
||||
>
|
||||
<Button color={color} leftSection={submitIcon} onClick={onConfirm} disabled={!isMatch || disabled}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -115,13 +115,19 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
>
|
||||
<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} />
|
||||
<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} />
|
||||
<IconFileZip
|
||||
style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</Dropzone.Idle>
|
||||
<div>
|
||||
<Text size="xl" inline>
|
||||
@@ -143,8 +149,12 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
<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>
|
||||
<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}>
|
||||
@@ -152,14 +162,14 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{loading && migrationProgress && (
|
||||
<MigrationProgressPanel progress={migrationProgress} />
|
||||
)}
|
||||
{loading && migrationProgress && <MigrationProgressPanel progress={migrationProgress} />}
|
||||
|
||||
{loading && !migrationProgress && (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("analyzing")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -173,10 +183,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
<>
|
||||
<BackupPreviewPanel analysis={analysis} />
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => setStep("confirm")}
|
||||
>
|
||||
<Button rightSection={<IconArrowRight size={16} />} onClick={() => setStep("confirm")}>
|
||||
{t("continueToRestore")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -190,10 +197,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{analysis && <BackupPreviewPanel analysis={analysis} />}
|
||||
<RestoreConfirmation
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setStep("preview")}
|
||||
/>
|
||||
<RestoreConfirmation onConfirm={handleConfirm} onCancel={() => setStep("preview")} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, onComplete, steps]);
|
||||
|
||||
if (!active && currentStepIdx === -1) return null;
|
||||
@@ -107,12 +109,7 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore
|
||||
</Group>
|
||||
|
||||
<Box pl={4}>
|
||||
<Timeline
|
||||
active={currentStepIdx}
|
||||
bulletSize={24}
|
||||
lineWidth={2}
|
||||
color="blue"
|
||||
>
|
||||
<Timeline active={currentStepIdx} bulletSize={24} lineWidth={2} color="blue">
|
||||
{steps.map((step, i) => {
|
||||
const isDone = i < currentStepIdx;
|
||||
const isActive = i === currentStepIdx;
|
||||
@@ -138,7 +135,16 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore
|
||||
}
|
||||
>
|
||||
<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)"} />
|
||||
<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}
|
||||
|
||||
@@ -64,117 +64,124 @@ export const useBackupAnalysis = () => {
|
||||
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;
|
||||
const analyzeFile = useCallback(
|
||||
async (file: File) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAnalysis(null);
|
||||
setMigrationProgress(null);
|
||||
|
||||
try {
|
||||
const appliedCount = getAppliedMigrationCount(db);
|
||||
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
|
||||
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 migrations: MigrationStatus = {
|
||||
applied: appliedCount,
|
||||
pending: pendingMigrations,
|
||||
total: allMigrations.length,
|
||||
};
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
if (pendingMigrations.length > 0) {
|
||||
for (const [i, migration] of pendingMigrations.entries()) {
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "applying",
|
||||
});
|
||||
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"));
|
||||
|
||||
await delay(100);
|
||||
const metadata = JSON.parse(await metadataFile.async("string"));
|
||||
const dbBuffer = await dbFile.async("uint8array");
|
||||
|
||||
try {
|
||||
const statements = migration.sql
|
||||
.split("--> statement-breakpoint")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const SQL = await initSqlJs({ wasmBinary });
|
||||
|
||||
for (const stmt of statements) {
|
||||
db.run(stmt);
|
||||
}
|
||||
const db = new SQL.Database(dbBuffer);
|
||||
dbRef.current = db;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
|
||||
[migration.tag, Date.now()],
|
||||
);
|
||||
} catch (migrationErr) {
|
||||
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 (const [i, migration] of pendingMigrations.entries()) {
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "error",
|
||||
phase: "applying",
|
||||
});
|
||||
throw new Error(
|
||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
||||
);
|
||||
|
||||
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"}`, { cause: migrationErr },
|
||||
);
|
||||
}
|
||||
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "done",
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const counts = countEntities(db);
|
||||
const boardNames = getBoardNames(db);
|
||||
|
||||
setMigrationProgress(null);
|
||||
setAnalysis({ metadata, counts, boardNames, migrations });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
||||
} finally {
|
||||
db.close();
|
||||
dbRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (dbRef.current) {
|
||||
try { dbRef.current.close(); } catch { /* already closed */ }
|
||||
try {
|
||||
dbRef.current.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
dbRef.current = null;
|
||||
}
|
||||
setAnalysis(null);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["src/*"],
|
||||
"~/*": ["./src/*"],
|
||||
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["src/*"],
|
||||
"~/*": ["./src/*"],
|
||||
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
|
||||
@@ -2,5 +2,6 @@ FROM node:24.16.0-trixie AS base
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
COPY pnpm-workspace.yaml .
|
||||
RUN corepack enable pnpm && pnpm install --frozen-lockfile
|
||||
CMD ["sleep", "60s"]
|
||||
@@ -6,12 +6,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=10.34.1"
|
||||
"pnpm": ">=11.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.34.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3"
|
||||
]
|
||||
}
|
||||
"packageManager": "pnpm@11.5.3"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
+68
-26
@@ -81,6 +81,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/audiobookshelf.svg" alt="Audiobookshelf" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Audiobookshelf</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/beszel" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg" alt="Beszel" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Beszel</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/codeberg" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg" alt="Codeberg" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -93,8 +107,8 @@
|
||||
<br/>
|
||||
<p align="center">Coolify</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
|
||||
<br/>
|
||||
@@ -107,8 +121,8 @@
|
||||
<br/>
|
||||
<p align="center">Deluge</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/docker-hub" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg" alt="Docker Hub" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -142,8 +156,8 @@
|
||||
<br/>
|
||||
<p align="center">Gitlab</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg" alt="Glances" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -156,8 +170,8 @@
|
||||
<br/>
|
||||
<p align="center">Home<br/>Assistant</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -191,8 +205,8 @@
|
||||
<br/>
|
||||
<p align="center">Lidarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/linux-server-io" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg" alt="LinuxServer.io" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -200,13 +214,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/navidrome.svg" alt="Navidrome" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Navidrome</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/nextcloud" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg" alt="Nextcloud" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Nextcloud</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/npm" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg" alt="NPM" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -233,8 +254,8 @@
|
||||
<br/>
|
||||
<p align="center">OpenMediaVault</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/opnsense" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg" alt="OPNsense" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -249,13 +270,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/paperless-ngx.svg" alt="Paperless-ngx" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Paperless-ngx</p>
|
||||
</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">
|
||||
</td>
|
||||
<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/>
|
||||
@@ -275,8 +303,8 @@
|
||||
<br/>
|
||||
<p align="center">Prowlarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/proxmox" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg" alt="Proxmox" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -303,8 +331,8 @@
|
||||
<br/>
|
||||
<p align="center">Radarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<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/>
|
||||
@@ -324,8 +352,8 @@
|
||||
<br/>
|
||||
<p align="center">Search.ch</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/seerr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg" alt="Seerr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -352,8 +380,8 @@
|
||||
<br/>
|
||||
<p align="center">Speedtest<br/>Tracker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<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/>
|
||||
@@ -361,13 +389,20 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/technitium-dns" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg" alt="Technitium DNS" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Technitium<br/>DNS</p>
|
||||
</a>
|
||||
</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/>
|
||||
<p align="center">Tracearr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/transmission" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg" alt="Transmission" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -401,6 +436,13 @@
|
||||
<br/>
|
||||
<p align="center">Unraid</p>
|
||||
</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/uptime-kuma.svg" alt="Uptime Kuma" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Uptime<br/>Kuma</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+4
-48
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.64.0",
|
||||
"version": "1.65.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -56,59 +56,15 @@
|
||||
"testcontainers": "catalog:",
|
||||
"turbo": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=10.34.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.34.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tree-sitter-grammars/tree-sitter-yaml",
|
||||
"bcrypt",
|
||||
"better-sqlite3",
|
||||
"cpu-features",
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"ssh2",
|
||||
"tree-sitter",
|
||||
"tree-sitter-json"
|
||||
],
|
||||
"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.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",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
|
||||
"hono@<4.6.5": ">=4.12.23",
|
||||
"linkifyjs@<4.3.2": ">=4.3.3",
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
"proxmox-api>undici": "7.27.1",
|
||||
"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.16"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||
"trpc-to-openapi": "patches/trpc-to-openapi.patch"
|
||||
},
|
||||
"allowUnusedPatches": true,
|
||||
"ignoredBuiltDependencies": [
|
||||
"@scarf/scarf",
|
||||
"@swc/core",
|
||||
"core-js-pure",
|
||||
"protobufjs"
|
||||
]
|
||||
"pnpm": ">=11.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.5.3",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
|
||||
@@ -56,7 +56,14 @@ const CUSTOM_WIDGET_SEEDS: Array<{ id: string; data: Record<string, unknown> }>
|
||||
authType: "none",
|
||||
method: "GET",
|
||||
displayType: "singleValue",
|
||||
displayConfig: { type: "singleValue", jsonPath: "$.data[0].attributes.body", label: "Dog Fact", unit: "", valueSize: "sm", labelPosition: "above" },
|
||||
displayConfig: {
|
||||
type: "singleValue",
|
||||
jsonPath: "$.data[0].attributes.body",
|
||||
label: "Dog Fact",
|
||||
unit: "",
|
||||
valueSize: "sm",
|
||||
labelPosition: "above",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -106,6 +113,26 @@ const CUSTOM_WIDGET_SEEDS: Array<{ id: string; data: Record<string, unknown> }>
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "seed-pokedex",
|
||||
data: {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Pokédex",
|
||||
description: "Browseable Pokémon list",
|
||||
iconUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
|
||||
url: "https://pokeapi.co/api/v2/pokemon?limit=75",
|
||||
authType: "none",
|
||||
headerName: null,
|
||||
method: "GET",
|
||||
requestBody: null,
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
template:
|
||||
'<Stack gap="md" p="xs">\n <Card\n withBorder\n radius="xl"\n p="md"\n shadow="md"\n style={{\n background: "linear-gradient(135deg, rgba(250,82,82,0.22), rgba(253,126,20,0.10), rgba(255,255,255,0.03))",\n border: "1px solid rgba(250,82,82,0.35)",\n overflow: "hidden"\n }}\n >\n <Group justify="space-between" wrap="nowrap">\n <Stack gap={2}>\n <Title order={3}>Pokédex</Title>\n </Stack>\n </Group>\n </Card>\n\n <PaginatedList pageSize={12}>\n <Grid gutter="sm">\n {data.results.map((pokemon, i) =>\n <Grid.Col span={1.5}>\n <Anchor href={pokemon.url} target="_blank" underline="never">\n <Card\n withBorder\n radius="xl"\n p="xs"\n shadow="md"\n style={{\n cursor: "pointer",\n position: "relative",\n overflow: "hidden",\n minHeight: 190,\n background: "linear-gradient(160deg, rgba(255,255,255,0.12), rgba(250,82,82,0.10), rgba(0,0,0,0.04))",\n border: "1px solid rgba(250,82,82,0.28)",\n transition: "transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease, background 180ms ease"\n }}\n >\n <Stack gap="sm" align="center">\n <Group justify="space-between" wrap="nowrap" style={{ width: "100%" }}>\n <Badge size="sm" color="red" variant="filled">\n #{String(i + 1).padStart(3, "0")}\n </Badge>\n <Text fw={800} tt="capitalize" ta="right" truncate style={{ maxWidth: 120 }}>\n {pokemon.name}\n </Text>\n </Group>\n\n <Paper\n radius="xl"\n p="xs"\n withBorder\n style={{\n background: "radial-gradient(circle, rgba(255,255,255,0.95), rgba(250,82,82,0.16))",\n border: "1px solid rgba(255,255,255,0.45)",\n boxShadow: "inset 0 0 20px rgba(255,255,255,0.25)"\n }}\n >\n <Avatar\n src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"}\n alt={pokemon.name}\n size={92}\n radius="xl"\n style={{\n transition: "transform 180ms ease",\n filter: "drop-shadow(0 8px 10px rgba(0,0,0,0.25))"\n }}\n />\n </Paper>\n </Stack>\n </Card>\n </Anchor>\n </Grid.Col>\n )}\n </Grid>\n </PaginatedList>\n</Stack>',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const seedDataAsync = async (db: Database) => {
|
||||
|
||||
@@ -60,8 +60,12 @@ const updateSitemapTypeFileAsync = async (sitemapPathType: string) => {
|
||||
};
|
||||
|
||||
const slugMapPaths = [
|
||||
...Object.values(integrationDocSlugs).filter(Boolean).map((slug) => `/docs/integrations/${slug}`),
|
||||
...Object.values(widgetDocSlugs).filter(Boolean).map((slug) => `/docs/widgets/${slug}`),
|
||||
...Object.values(integrationDocSlugs)
|
||||
.filter(Boolean)
|
||||
.map((slug) => `/docs/integrations/${slug}`),
|
||||
...Object.values(widgetDocSlugs)
|
||||
.filter(Boolean)
|
||||
.map((slug) => `/docs/widgets/${slug}`),
|
||||
];
|
||||
|
||||
const outputPath = path.join(__dirname, "homarr-docs-sitemap.ts");
|
||||
@@ -73,7 +77,10 @@ const main = async () => {
|
||||
const sitemapData = parseXml(sitemapXml);
|
||||
paths = mapSitemapXmlToPaths(sitemapData);
|
||||
} catch {
|
||||
const exists = await fs.access(outputPath).then(() => true, () => false);
|
||||
const exists = await fs.access(outputPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (exists) {
|
||||
console.warn("Could not fetch sitemap from homarr.dev, keeping existing generated file");
|
||||
return;
|
||||
|
||||
@@ -46,6 +46,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/tags/api"
|
||||
| "/docs/tags/apps"
|
||||
| "/docs/tags/background"
|
||||
| "/docs/tags/backup"
|
||||
| "/docs/tags/boards"
|
||||
| "/docs/tags/bookmarks"
|
||||
| "/docs/tags/caddy"
|
||||
@@ -54,6 +55,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/tags/community"
|
||||
| "/docs/tags/configuration"
|
||||
| "/docs/tags/connections"
|
||||
| "/docs/tags/custom"
|
||||
| "/docs/tags/customization"
|
||||
| "/docs/tags/data-sources"
|
||||
| "/docs/tags/database"
|
||||
@@ -90,15 +92,17 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/tags/puid"
|
||||
| "/docs/tags/redis"
|
||||
| "/docs/tags/responsive"
|
||||
| "/docs/tags/restore"
|
||||
| "/docs/tags/roles"
|
||||
| "/docs/tags/search"
|
||||
| "/docs/tags/search-engines"
|
||||
| "/docs/tags/search"
|
||||
| "/docs/tags/secrets"
|
||||
| "/docs/tags/security"
|
||||
| "/docs/tags/self-signed"
|
||||
| "/docs/tags/seo"
|
||||
| "/docs/tags/server"
|
||||
| "/docs/tags/settings"
|
||||
| "/docs/tags/sq-lite"
|
||||
| "/docs/tags/sso"
|
||||
| "/docs/tags/tasks"
|
||||
| "/docs/tags/technical-documentation"
|
||||
@@ -108,6 +112,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/tags/uploads"
|
||||
| "/docs/tags/users"
|
||||
| "/docs/tags/variables"
|
||||
| "/docs/tags/widgets"
|
||||
| "/docs/advanced/command-line"
|
||||
| "/docs/advanced/command-line/fix-usernames"
|
||||
| "/docs/advanced/command-line/integrations"
|
||||
@@ -126,8 +131,8 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/category/community"
|
||||
| "/docs/category/developer-guides"
|
||||
| "/docs/category/getting-started"
|
||||
| "/docs/category/installation"
|
||||
| "/docs/category/installation-1"
|
||||
| "/docs/category/installation"
|
||||
| "/docs/category/integrations"
|
||||
| "/docs/category/management"
|
||||
| "/docs/category/widgets"
|
||||
@@ -156,6 +161,8 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/adguard-home"
|
||||
| "/docs/integrations/anchor"
|
||||
| "/docs/integrations/aria2"
|
||||
| "/docs/integrations/audiobookshelf"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/integrations/codeberg"
|
||||
| "/docs/integrations/coolify"
|
||||
| "/docs/integrations/dash-dot"
|
||||
@@ -175,6 +182,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/kubernetes"
|
||||
| "/docs/integrations/lidarr"
|
||||
| "/docs/integrations/linux-server-io"
|
||||
| "/docs/integrations/navidrome"
|
||||
| "/docs/integrations/nextcloud"
|
||||
| "/docs/integrations/npm"
|
||||
| "/docs/integrations/ntfy"
|
||||
@@ -182,6 +190,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/open-media-vault"
|
||||
| "/docs/integrations/opnsense"
|
||||
| "/docs/integrations/overseerr"
|
||||
| "/docs/integrations/paperless-ngx"
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/pi-hole"
|
||||
| "/docs/integrations/plex"
|
||||
@@ -198,28 +207,38 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/sonarr"
|
||||
| "/docs/integrations/speedtest-tracker"
|
||||
| "/docs/integrations/tdarr"
|
||||
| "/docs/integrations/technitium-dns"
|
||||
| "/docs/integrations/tracearr"
|
||||
| "/docs/integrations/transmission"
|
||||
| "/docs/integrations/truenas"
|
||||
| "/docs/integrations/umami"
|
||||
| "/docs/integrations/unifi-controller"
|
||||
| "/docs/integrations/unraid"
|
||||
| "/docs/integrations/uptime-kuma"
|
||||
| "/docs/management/api"
|
||||
| "/docs/management/apps"
|
||||
| "/docs/management/boards"
|
||||
| "/docs/management/certificates"
|
||||
| "/docs/management/custom-widgets"
|
||||
| "/docs/management/integrations"
|
||||
| "/docs/management/media"
|
||||
| "/docs/management/search-engines"
|
||||
| "/docs/management/settings"
|
||||
| "/docs/management/tasks"
|
||||
| "/docs/management/tools/backup"
|
||||
| "/docs/management/users"
|
||||
| "/docs/widgets/anchor-note"
|
||||
| "/docs/widgets/app"
|
||||
| "/docs/widgets/audio-stats"
|
||||
| "/docs/widgets/beszel-alerts"
|
||||
| "/docs/widgets/beszel-system-grid"
|
||||
| "/docs/widgets/beszel-system-stats"
|
||||
| "/docs/widgets/beszel-system-table"
|
||||
| "/docs/widgets/bookmarks"
|
||||
| "/docs/widgets/calendar"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/coolify"
|
||||
| "/docs/widgets/custom-api"
|
||||
| "/docs/widgets/dns-hole-controls"
|
||||
| "/docs/widgets/dns-hole-summary"
|
||||
| "/docs/widgets/docker-containers"
|
||||
@@ -240,6 +259,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/network-controller-summary"
|
||||
| "/docs/widgets/notebook"
|
||||
| "/docs/widgets/notifications"
|
||||
| "/docs/widgets/paperless-ngx"
|
||||
| "/docs/widgets/releases"
|
||||
| "/docs/widgets/rss-feed"
|
||||
| "/docs/widgets/smart-home-entity-state"
|
||||
@@ -251,6 +271,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/tracearr"
|
||||
| "/docs/widgets/umami"
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/weather"
|
||||
| ""
|
||||
@@ -309,6 +330,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/navidrome"
|
||||
| "/docs/integrations/umami"
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/app"
|
||||
@@ -350,4 +372,9 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
| "/docs/widgets/audio-stats"
|
||||
| "/docs/widgets/umami"
|
||||
| "/docs/widgets/ups";
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/beszel-system-table"
|
||||
| "/docs/widgets/beszel-system-grid"
|
||||
| "/docs/widgets/beszel-alerts"
|
||||
| "/docs/widgets/beszel-system-stats"
|
||||
| "/docs/widgets/custom-api";
|
||||
@@ -55,6 +55,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
navidrome: "navidrome",
|
||||
umami: "umami",
|
||||
peaNut: "peanut",
|
||||
beszel: null,
|
||||
beszel: "beszel",
|
||||
mock: null,
|
||||
};
|
||||
@@ -44,9 +44,9 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
audioStats: "audio-stats",
|
||||
umami: "umami",
|
||||
ups: "ups",
|
||||
beszelSystemTable: null,
|
||||
beszelSystemGrid: null,
|
||||
beszelAlerts: null,
|
||||
beszelSystemStats: null,
|
||||
customApi: null,
|
||||
beszelSystemTable: "beszel-system-table",
|
||||
beszelSystemGrid: "beszel-system-grid",
|
||||
beszelAlerts: "beszel-alerts",
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
customApi: "custom-api",
|
||||
};
|
||||
@@ -479,7 +479,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||
defaultPort: 8090,
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
|
||||
@@ -55,7 +55,8 @@ export class ProxmoxIntegration extends Integration implements IClusterHealthMon
|
||||
host: this.url("/").host,
|
||||
tokenID: `${this.getSecretValue("username")}@${this.getSecretValue("realm")}!${this.getSecretValue("tokenId")}`,
|
||||
tokenSecret: this.getSecretValue("apiKey"),
|
||||
fetch: fetchAsync,
|
||||
// proxmox-api bundles its own undici types which are incompatible with the project's undici version
|
||||
fetch: fetchAsync as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import proxmoxApi from "proxmox-api";
|
||||
import type { fetch as undiciFetch } from "undici";
|
||||
import { Response } from "undici";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
@@ -9,48 +8,32 @@ import { ProxmoxApiErrorHandler } from "../proxmox-error-handler";
|
||||
|
||||
describe("ProxmoxApiErrorHandler handleError should handle the provided error accordingly", () => {
|
||||
test.each([400, 401, 500])("should handle %s error", async (statusCode) => {
|
||||
// Arrange
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const mockedFetch: typeof undiciFetch = async () => {
|
||||
return Promise.resolve(createFakeResponse(statusCode));
|
||||
};
|
||||
const mockedFetch = async () => Promise.resolve(createFakeResponse(statusCode));
|
||||
|
||||
// Act
|
||||
const result = await runWithAsync(mockedFetch);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeInstanceOf(IntegrationResponseError);
|
||||
const error = result as unknown as IntegrationResponseError;
|
||||
expect(error.cause.statusCode).toBe(statusCode);
|
||||
});
|
||||
test("should handle other non successful status codes", async () => {
|
||||
// Arrange
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const mockedFetch: typeof undiciFetch = async () => {
|
||||
return Promise.resolve(createFakeResponse(404));
|
||||
};
|
||||
const mockedFetch = async () => Promise.resolve(createFakeResponse(404));
|
||||
|
||||
// Act
|
||||
const result = await runWithAsync(mockedFetch);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeInstanceOf(IntegrationResponseError);
|
||||
const error = result as unknown as IntegrationResponseError;
|
||||
expect(error.cause.statusCode).toBe(404);
|
||||
});
|
||||
test("should handle request error", async () => {
|
||||
// Arrange
|
||||
const mockedFetch: typeof undiciFetch = () => {
|
||||
const mockedFetch = () => {
|
||||
const errorWithCode = new Error("Inner error") as Error & { code: string };
|
||||
errorWithCode.code = "ECONNREFUSED";
|
||||
throw new TypeError("Outer error", { cause: errorWithCode });
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await runWithAsync(mockedFetch);
|
||||
|
||||
// Assert
|
||||
// In the end in should have the structure IntegrationRequestError -> RequestError -> TypeError -> Error (with code)
|
||||
expect(result).toBeInstanceOf(IntegrationRequestError);
|
||||
const error = result as unknown as IntegrationRequestError;
|
||||
expect(error.cause.cause).toBeInstanceOf(TypeError);
|
||||
@@ -65,13 +48,11 @@ describe("ProxmoxApiErrorHandler handleError should handle the provided error ac
|
||||
const createFakeResponse = (statusCode: number) => {
|
||||
return new Response(JSON.stringify({ data: {} }), {
|
||||
status: statusCode,
|
||||
// It expects a content-type and valid json response
|
||||
// https://github.com/UrielCh/proxmox-api/blob/master/api/src/ProxmoxEngine.ts#L258
|
||||
headers: { "content-type": "application/json;charset=UTF-8" },
|
||||
});
|
||||
};
|
||||
|
||||
const runWithAsync = async (mockedFetch: typeof undiciFetch) => {
|
||||
const runWithAsync = async (mockedFetch: (...args: any[]) => any) => {
|
||||
const integration = { id: "test", name: "test", url: "http://proxmox.example.com" };
|
||||
const client = createProxmoxClient(mockedFetch);
|
||||
const handler = new ProxmoxApiErrorHandler();
|
||||
@@ -79,11 +60,11 @@ const runWithAsync = async (mockedFetch: typeof undiciFetch) => {
|
||||
return await client.nodes.$get().catch((error) => handler.handleError(error, integration));
|
||||
};
|
||||
|
||||
const createProxmoxClient = (fetch: typeof undiciFetch) => {
|
||||
const createProxmoxClient = (fetch: (...args: any[]) => any) => {
|
||||
return proxmoxApi({
|
||||
host: "proxmox.example.com",
|
||||
tokenID: "username@realm!tokenId",
|
||||
tokenSecret: crypto.randomUUID(),
|
||||
fetch,
|
||||
fetch: fetch as any,
|
||||
});
|
||||
};
|
||||
@@ -748,10 +748,7 @@ describe("auth mechanism per version", () => {
|
||||
// ─── testingAsync (session cleanup) ──────────────────────────────────────────
|
||||
|
||||
// Calls the protected testingAsync directly with a custom fetchAsync.
|
||||
function callTestingAsync(
|
||||
integration: TechnitiumDnsIntegration,
|
||||
fetchAsync: typeof fetchWithTrustedCertificatesAsync,
|
||||
) {
|
||||
function callTestingAsync(integration: TechnitiumDnsIntegration, fetchAsync: typeof fetchWithTrustedCertificatesAsync) {
|
||||
return (
|
||||
integration as unknown as {
|
||||
testingAsync: (input: { fetchAsync: typeof fetchWithTrustedCertificatesAsync }) => Promise<unknown>;
|
||||
@@ -760,7 +757,6 @@ function callTestingAsync(
|
||||
}
|
||||
|
||||
describe("testingAsync (session cleanup)", () => {
|
||||
|
||||
test("credentials auth calls logout endpoint after successful test", async () => {
|
||||
sessionData = null;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
@@ -140,6 +140,17 @@ export const localeConfigurations = {
|
||||
return import("dayjs/locale/et").then((module) => module.default);
|
||||
},
|
||||
},
|
||||
fi: {
|
||||
name: "Suomi",
|
||||
translatedName: "Finnish",
|
||||
icon: flagIcon("fi"),
|
||||
importMrtLocalization() {
|
||||
return import("mantine-react-table/locales/fi/index.esm.mjs").then((module) => module.MRT_Localization_FI);
|
||||
},
|
||||
importDayJsLocale() {
|
||||
return import("dayjs/locale/fi").then((module) => module.default);
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
name: "Français",
|
||||
translatedName: "French",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
declare const classes: Record<string, string>;
|
||||
export default classes;
|
||||
@@ -0,0 +1,2 @@
|
||||
declare const classes: Record<string, string>;
|
||||
export default classes;
|
||||
@@ -17,6 +17,8 @@
|
||||
"./custom-api/component": "./src/custom-api/component.tsx",
|
||||
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
|
||||
"./errors": "./src/errors/component.tsx",
|
||||
"./errors/classes": "./src/errors/index.ts",
|
||||
"./widget-integration-select": "./src/widget-integration-select.tsx",
|
||||
"./modals": "./src/modals/index.ts",
|
||||
"./prefetch": "./src/prefetch.ts"
|
||||
},
|
||||
|
||||
Generated
+1944
-1876
File diff suppressed because it is too large.
Load diff
+74
-28
@@ -21,7 +21,7 @@ catalog:
|
||||
"@immich/sdk": ^2.7.5
|
||||
"@jellyfin/sdk": ^0.13.0
|
||||
"@kubernetes/client-node": ^1.4.0
|
||||
"@modelcontextprotocol/sdk": 1.26.0
|
||||
"@modelcontextprotocol/sdk": 1.29.0
|
||||
"@mantine/charts": ^9.3.0
|
||||
"@mantine/colors-generator": ^9.3.0
|
||||
"@mantine/core": ^9.3.0
|
||||
@@ -52,22 +52,22 @@ catalog:
|
||||
"@testcontainers/mysql": ^12.0.1
|
||||
"@testcontainers/postgresql": ^12.0.1
|
||||
"@testcontainers/redis": ^12.0.1
|
||||
"@tiptap/extension-color": 3.25.0
|
||||
"@tiptap/extension-highlight": 3.25.0
|
||||
"@tiptap/extension-image": 3.25.0
|
||||
"@tiptap/extension-link": ^3.25.0
|
||||
"@tiptap/extension-placeholder": ^3.25.0
|
||||
"@tiptap/extension-table": 3.25.0
|
||||
"@tiptap/extension-table-cell": 3.25.0
|
||||
"@tiptap/extension-table-header": 3.25.0
|
||||
"@tiptap/extension-table-row": 3.25.0
|
||||
"@tiptap/extension-task-item": 3.25.0
|
||||
"@tiptap/extension-task-list": 3.25.0
|
||||
"@tiptap/extension-text-align": 3.25.0
|
||||
"@tiptap/extension-text-style": 3.25.0
|
||||
"@tiptap/extension-underline": 3.25.0
|
||||
"@tiptap/react": ^3.25.0
|
||||
"@tiptap/starter-kit": ^3.25.0
|
||||
"@tiptap/extension-color": 3.26.0
|
||||
"@tiptap/extension-highlight": 3.26.0
|
||||
"@tiptap/extension-image": 3.26.0
|
||||
"@tiptap/extension-link": ^3.26.0
|
||||
"@tiptap/extension-placeholder": ^3.26.0
|
||||
"@tiptap/extension-table": 3.26.0
|
||||
"@tiptap/extension-table-cell": 3.26.0
|
||||
"@tiptap/extension-table-header": 3.26.0
|
||||
"@tiptap/extension-table-row": 3.26.0
|
||||
"@tiptap/extension-task-item": 3.26.0
|
||||
"@tiptap/extension-task-list": 3.26.0
|
||||
"@tiptap/extension-text-align": 3.26.0
|
||||
"@tiptap/extension-text-style": 3.26.0
|
||||
"@tiptap/extension-underline": 3.26.0
|
||||
"@tiptap/react": ^3.26.0
|
||||
"@tiptap/starter-kit": ^3.26.0
|
||||
"@trpc/client": ^11.17.0
|
||||
"@trpc/next": ^11.17.0
|
||||
"@trpc/react-query": ^11.17.0
|
||||
@@ -86,14 +86,14 @@ catalog:
|
||||
"@types/node-unifi": ^2.5.1
|
||||
"@types/pg": ^8.20.0
|
||||
"@types/prismjs": ^1.26.6
|
||||
"@types/react": 19.2.16
|
||||
"@types/react": 19.2.17
|
||||
"@types/react-dom": 19.2.3
|
||||
"@scalar/api-reference-react": ^0.9.42
|
||||
"@types/video.js": ^7.3.58
|
||||
"@types/ws": ^8.18.1
|
||||
"@types/xml2js": ^0.4.14
|
||||
"@umami/node": ^0.4.0
|
||||
"@vitejs/plugin-react": ^5.2.0
|
||||
"@vitejs/plugin-react": ^6.0.2
|
||||
"@vitest/coverage-v8": ^4.1.8
|
||||
"@vitest/ui": ^4.1.8
|
||||
"@xterm/addon-canvas": ^0.7.0
|
||||
@@ -101,13 +101,13 @@ catalog:
|
||||
"@xterm/xterm": ^6.0.0
|
||||
adm-zip: 0.5.17
|
||||
jszip: ^3.10.1
|
||||
sql.js: ^1.12.0
|
||||
sql.js: ^1.14.1
|
||||
babel-plugin-react-compiler: ^1.0.0
|
||||
bcrypt: ^6.0.0
|
||||
better-sqlite3: ^12.10.0
|
||||
chroma-js: ^3.2.0
|
||||
clsx: ^2.1.1
|
||||
concurrently: ^9.2.1
|
||||
concurrently: ^10.0.3
|
||||
conventional-changelog-conventionalcommits: ^9.3.1
|
||||
cookie: 1.1.1
|
||||
cookies: ^0.9.1
|
||||
@@ -129,7 +129,7 @@ catalog:
|
||||
flag-icons: ^7.5.0
|
||||
glob: ^13.0.6
|
||||
ical.js: ^2.2.1
|
||||
ioredis: 5.11.0
|
||||
ioredis: 5.11.1
|
||||
isomorphic-dompurify: ^3.16.0
|
||||
jotai: ^2.20.0
|
||||
jsdom: ^29.1.1
|
||||
@@ -138,19 +138,19 @@ catalog:
|
||||
ldapts: 8.1.8
|
||||
lodash.clonedeep: ^4.5.0
|
||||
lucide-react: ^1.17.0
|
||||
"mantine-contextmenu": ^7.17.1
|
||||
"mantine-contextmenu": ^9.2.1
|
||||
mantine-datatable: ^9.2.2
|
||||
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||
mcp-handler: ^1.1.0
|
||||
maria2: ^0.4.1
|
||||
mysql2: 3.22.4
|
||||
mysql2: 3.22.5
|
||||
next: 16.2.7
|
||||
next-auth: 5.0.0-beta.31
|
||||
next-intl: 4.13.0
|
||||
node-loader: ^2.1.0
|
||||
octokit: ^5.0.5
|
||||
oxfmt: ^0.53.0
|
||||
oxlint: ^1.67.0
|
||||
oxfmt: ^0.54.0
|
||||
oxlint: ^1.68.0
|
||||
pg: ^8.21.0
|
||||
postcss-preset-mantine: ^1.18.0
|
||||
prismjs: ^1.30.0
|
||||
@@ -175,8 +175,9 @@ catalog:
|
||||
tsdav: ^2.2.2
|
||||
tsx: 4.20.4
|
||||
turbo: ^2.9.16
|
||||
typescript: ^5.9.3
|
||||
undici: 8.4.0
|
||||
typescript: ^6.0.3
|
||||
vite: ^8.0.16
|
||||
undici: 7.27.1
|
||||
use-deep-compare-effect: ^1.8.1
|
||||
video.js: ^8.23.7
|
||||
vite-tsconfig-paths: ^6.1.1
|
||||
@@ -189,9 +190,54 @@ catalog:
|
||||
zod-form-data: ^3.0.2
|
||||
zod-validation-error: ^5.0.0
|
||||
catalogMode: strict
|
||||
nodeLinker: hoisted
|
||||
strictPeerDependencies: false
|
||||
engineStrict: true
|
||||
verifyDepsBeforeRun: warn
|
||||
minimumReleaseAge: 4320
|
||||
minimumReleaseAgeExclude:
|
||||
- next
|
||||
- "@next/*"
|
||||
# Renovate security update: turbo@2.9.14
|
||||
- turbo@2.9.14
|
||||
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.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"
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.5"
|
||||
"hono@<4.6.5": ">=4.12.23"
|
||||
"linkifyjs@<4.3.2": ">=4.3.3"
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11"
|
||||
"prismjs@<1.30.0": ">=1.30.0"
|
||||
"proxmox-api>undici": "7.27.1"
|
||||
"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.16"
|
||||
patchedDependencies:
|
||||
"@types/node-unifi": "patches/@types__node-unifi.patch"
|
||||
"trpc-to-openapi": "patches/trpc-to-openapi.patch"
|
||||
allowUnusedPatches: true
|
||||
allowBuilds:
|
||||
"@tree-sitter-grammars/tree-sitter-yaml": true
|
||||
bcrypt: true
|
||||
better-sqlite3: true
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
sharp: true
|
||||
ssh2: true
|
||||
tree-sitter: true
|
||||
tree-sitter-json: true
|
||||
"@scarf/scarf": false
|
||||
"@swc/core": false
|
||||
core-js: false
|
||||
core-js-pure: false
|
||||
msw: false
|
||||
protobufjs: false
|
||||
vue-demi: false
|
||||
+11
-5
@@ -1,22 +1,28 @@
|
||||
import fs from "fs/promises";
|
||||
import { join } from "path";
|
||||
import json5 from "json5";
|
||||
import { parse } from "yaml";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
describe("Renovate configuration tests", () => {
|
||||
test("automerge should be disabled for onlyBuiltDependencies", async () => {
|
||||
const packageJson = await import("../package.json");
|
||||
test("automerge should be disabled for allowBuilds dependencies", async () => {
|
||||
const workspaceYaml = await fs.readFile(join(__dirname, "../pnpm-workspace.yaml"), "utf-8").then(parse);
|
||||
const renovateConfig = await fs.readFile(join(__dirname, "../.github/renovate.json5"), "utf-8").then(json5.parse);
|
||||
const onlyBuiltDependencies = packageJson.pnpm.onlyBuiltDependencies;
|
||||
if (!workspaceYaml.allowBuilds || typeof workspaceYaml.allowBuilds !== "object") {
|
||||
throw new Error("pnpm-workspace.yaml must define allowBuilds as an object");
|
||||
}
|
||||
const allowedBuilds = Object.entries(workspaceYaml.allowBuilds)
|
||||
.filter(([, allowed]) => allowed === true)
|
||||
.map(([name]) => name);
|
||||
const automergeDisabledDeps = renovateConfig.packageRules
|
||||
.filter((rule: any) => rule.automerge === false)
|
||||
.flatMap((rule: any) => rule.matchPackageNames || []);
|
||||
|
||||
const missingDeps = onlyBuiltDependencies.filter((dep: string) => !automergeDisabledDeps.includes(dep));
|
||||
const missingDeps = allowedBuilds.filter((dep: string) => !automergeDisabledDeps.includes(dep));
|
||||
|
||||
if (missingDeps.length > 0) {
|
||||
throw new Error(
|
||||
`The following onlyBuiltDependencies are missing automerge disable rules in renovate.json5: ${missingDeps.join(", ")}`,
|
||||
`The following allowBuilds dependencies are missing automerge disable rules in renovate.json5: ${missingDeps.join(", ")}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,10 +19,9 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"strictNullChecks": true,
|
||||
"experimentalDecorators": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@homarr/node-unifi": ["${configDir}/../../node_modules/@types/node-unifi"],
|
||||
"*": ["node_modules/*"]
|
||||
"@homarr/node-unifi": ["${configDir}/../../node_modules/@types/node-unifi"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "build", "dist", ".next"]
|
||||
|
||||
@@ -9,11 +9,6 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
include: ["**/*.spec.ts"],
|
||||
clearMocks: true,
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: false,
|
||||
},
|
||||
},
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["html", "json-summary", "json"],
|
||||
|
||||
Reference in new issue
Block a user