Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b8c18cd2 | ||
|
|
ab46d822cc | ||
|
|
93bcc5945f | ||
|
|
d5776e7d2a | ||
|
|
0156fa0413 | ||
|
|
38ce6edd17 | ||
|
|
f1fb760c69 |
No files matched your search
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: Revert Demo MCP
|
||||
overview: Revert the demo-specific MCP accommodations (API key bypass, read-only filtering, demo UI) since demo MCP is an edge case with poor ROI. Replace with a clear "MCP not available in demo mode" message. Keep the core MCP feature intact for real instances.
|
||||
todos:
|
||||
- id: revert-backend
|
||||
content: Revert demoSafe procedure, apiKeys demo bypass, isDemoMode export from mcp.ts
|
||||
status: pending
|
||||
- id: revert-mcp-route
|
||||
content: Remove isDemoMode filtering and demo SERVER_INSTRUCTIONS from MCP route
|
||||
status: pending
|
||||
- id: revert-ui
|
||||
content: Remove isDemoMode prop plumbing; add demo-not-supported Alert via info.isDemoMode
|
||||
status: pending
|
||||
- id: verify
|
||||
content: Run lint + typecheck; smoke-test demo vs non-demo MCP tab
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Revert Demo MCP Accommodations
|
||||
|
||||
## Why Docker works but integrations do not
|
||||
|
||||
Demo mode has **two different mock mechanisms**:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph dockerPath [Docker - works in demo]
|
||||
MCP1[MCP docker_getContainers]
|
||||
Handler[docker.ts request handler]
|
||||
MockContainers[Hardcoded mockContainers]
|
||||
MCP1 --> Handler
|
||||
Handler -->|"if DEMO_MODE"| MockContainers
|
||||
end
|
||||
|
||||
subgraph integrationPath [Integrations - confusing in demo]
|
||||
MCP2[MCP widget tools e.g. calendar_findAllEvents]
|
||||
TRPC[tRPC procedure]
|
||||
RH[cached-integration-request-handler]
|
||||
Kind{integration.kind}
|
||||
Mock[MockIntegration fake data]
|
||||
Real[Sonarr/Radarr HTTP - fails]
|
||||
MCP2 --> TRPC --> RH --> Kind
|
||||
Kind -->|mock| Mock
|
||||
Kind -->|sonarr/radarr| Real
|
||||
end
|
||||
```
|
||||
|
||||
- **Docker**:
|
||||
[`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
|
||||
checks `DEMO_MODE` directly and returns inline fake containers. No integration
|
||||
record needed.
|
||||
- **Everything else**: Demo seed creates **one** integration with `kind: "mock"`
|
||||
([`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts)), not
|
||||
Sonarr/Radarr/Overseerr.
|
||||
[`MockIntegration`](packages/integrations/src/mock/mock-integration.ts) can
|
||||
serve calendar, downloads, health, DNS, media server, etc. **if** callers pass
|
||||
that integration ID.
|
||||
- **Why it felt broken**: MCP tool descriptions tell AI clients to look for
|
||||
"Overseerr/Sonarr IDs". Demo has none. Tools like
|
||||
`integration_searchMediaRequests` hard-filter to `mediaSearch` kinds
|
||||
(`overseerr`, `jellyseerr`, `seerr`) — `mock` is excluded from that category
|
||||
in
|
||||
[`packages/definitions/src/integration.ts`](packages/definitions/src/integration.ts).
|
||||
|
||||
Fixing this properly means extending the mock integration system (new
|
||||
`searchAsync`, category changes, middleware relaxations, instruction rewrites)
|
||||
for a feature almost nobody will use on the public demo.
|
||||
|
||||
## Option comparison
|
||||
|
||||
| | **Revert (chosen)** | **Minimal fix** | **Full fix** |
|
||||
| -------------------- | --------------------------- | ---------------------------------------------- | --------------------------------------------------- |
|
||||
| **Effort** | ~1 hour, ~100 lines removed | ~2 hours | 1-2 days |
|
||||
| **Files touched** | 8 MCP/demo-specific files | 8 + route instructions | 15+ across integrations, definitions, seed, routers |
|
||||
| **Risk** | Low | Low-medium | Medium (mock/search semantics) |
|
||||
| **Demo MCP outcome** | Clearly unsupported | Partial (widget reads only, no search/request) | Near-full read coverage |
|
||||
| **Maintenance** | None ongoing | Instructions drift with tool changes | Mock parity with real integrations |
|
||||
|
||||
**Recommendation: Revert.** The demo accommodation code added special-case
|
||||
plumbing (`demoSafePermissionRequiredProcedure`, dual mutation guards, hardcoded
|
||||
UI strings) for a workflow that still cannot deliver a convincing "homelab AI"
|
||||
experience without substantial integration-layer work.
|
||||
|
||||
## What to revert (demo MCP accommodations only)
|
||||
|
||||
These changes were added specifically for demo + MCP. **Do not revert** the core
|
||||
MCP server, OAuth, Scalar docs, tool descriptions, or `normalizeSchema`.
|
||||
|
||||
### Backend (3 files)
|
||||
|
||||
1. [`packages/api/src/trpc.ts`](packages/api/src/trpc.ts)
|
||||
- Remove `demoSafePermissionRequiredProcedure` export (~12 lines)
|
||||
- Keep existing `enforceDemoModeReadOnly` (pre-dates MCP; used by whole app)
|
||||
|
||||
2. [`packages/api/src/router/apiKeys.ts`](packages/api/src/router/apiKeys.ts)
|
||||
- Change `create` back to `permissionRequiredProcedure` (mutations blocked in
|
||||
demo again)
|
||||
- Remove `isDemoMode` guard on `delete` (global demo middleware already
|
||||
blocks delete mutations)
|
||||
- Remove `TRPCError` / `isDemoMode` imports if unused
|
||||
|
||||
3. [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts)
|
||||
- Stop exporting `isDemoMode`; keep only `createTRPCContext` and `mcpRouter`
|
||||
|
||||
### MCP route (1 file)
|
||||
|
||||
4. [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
|
||||
- Remove `isDemoMode` import and all demo branches:
|
||||
- `ListToolsRequestSchema` mutation filter
|
||||
- `CallToolRequestSchema` mutation rejection
|
||||
- `SERVER_INSTRUCTIONS` demo section
|
||||
- Keep `getToolType` / `buildToolTypeMap` only if still needed elsewhere;
|
||||
otherwise remove dead code from demo-only filtering
|
||||
|
||||
### UI (4 files)
|
||||
|
||||
5. [`apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx)
|
||||
- Remove `isDemoMode` import from `@homarr/api/mcp`
|
||||
- Stop passing `isDemoMode` to child components
|
||||
|
||||
6. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx)
|
||||
- Remove `isDemoMode` prop from interface and `McpInstructions` call
|
||||
|
||||
7. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
|
||||
- Remove `isDemoMode` prop
|
||||
- Replace current demo banner with a single **Alert**: MCP requires a
|
||||
self-hosted instance; demo mode does not support MCP (API key creation and
|
||||
write tools are disabled)
|
||||
- Use `clientApi.info.isDemoMode.useQuery()` or pass `isDemoMode` from server
|
||||
via existing `api.info.isDemoMode()` tRPC query (already exists in
|
||||
[`packages/api/src/router/info.ts`](packages/api/src/router/info.ts)) —
|
||||
avoids re-exporting from `@homarr/api/mcp`
|
||||
|
||||
8. [`apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx)
|
||||
- Remove `isDemoMode` prop and conditional delete-column spread
|
||||
- Delete button hidden automatically in demo because `apiKeys.delete`
|
||||
mutation is blocked by global `enforceDemoModeReadOnly`
|
||||
|
||||
## What to add instead
|
||||
|
||||
- **MCP tab**: When `info.isDemoMode` is true, show a prominent notice at the
|
||||
top of
|
||||
[`mcp-instructions.tsx`](apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx)
|
||||
explaining MCP is for self-hosted instances only.
|
||||
- **Optional**: Disable/hide the "Create API Key" button on the MCP tab in demo
|
||||
(mutations will fail anyway).
|
||||
- **No changes** to integrations, definitions, seed, or mock services.
|
||||
|
||||
## Files NOT impacted by revert
|
||||
|
||||
Core MCP feature stays as-is:
|
||||
|
||||
- [`apps/nextjs/src/app/api/mcp/[transport]/route.ts`](apps/nextjs/src/app/api/mcp/[transport]/route.ts)
|
||||
— auth, rate limiting, `normalizeSchema`, `SERVER_INSTRUCTIONS` (minus demo
|
||||
section)
|
||||
- OAuth routes under `apps/nextjs/src/app/api/mcp/oauth/`
|
||||
- All MCP-enabled routers in `packages/api/src/router/`
|
||||
- [`packages/api/src/mcp.ts`](packages/api/src/mcp.ts) — eager router (minus
|
||||
`isDemoMode` export)
|
||||
- Scalar UI, dynamic tools table,
|
||||
[`.cursor/rules/mcp-integration.mdc`](.cursor/rules/mcp-integration.mdc)
|
||||
|
||||
Pre-existing demo infrastructure (unchanged):
|
||||
|
||||
- [`packages/request-handler/src/docker.ts`](packages/request-handler/src/docker.ts)
|
||||
— Docker mock containers
|
||||
- [`packages/db/migrations/seed.ts`](packages/db/migrations/seed.ts) — mock
|
||||
integration + demo board
|
||||
- [`packages/integrations/src/mock/`](packages/integrations/src/mock/) — mock
|
||||
services for widgets on demo board UI
|
||||
|
||||
## Verification
|
||||
|
||||
1. `pnpm lint` and `pnpm typecheck` pass
|
||||
2. Demo mode (`DEMO_MODE=true`):
|
||||
- MCP tab shows "not supported" notice
|
||||
- API key create fails with existing "Mutations are disabled in demo mode"
|
||||
- `docker_getContainers` still works on demo board UI (unchanged; separate
|
||||
code path)
|
||||
3. Non-demo mode: MCP unchanged (all tools listed, mutations work with
|
||||
permissions)
|
||||
|
||||
## Commit strategy
|
||||
|
||||
Single focused commit on `feat/add-mcp-server`:
|
||||
|
||||
```
|
||||
revert(mcp): drop demo-mode accommodations for MCP
|
||||
|
||||
MCP is intended for self-hosted instances. Remove demo-specific API key
|
||||
bypass, read-only tool filtering, and demo UI plumbing. Show a clear
|
||||
notice on the MCP tab when demo mode is active.
|
||||
```
|
||||
|
||||
Then push to the existing PR (no force-push needed unless you want another
|
||||
squash).
|
||||
@@ -33,9 +33,6 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.66.1
|
||||
- 1.66.0
|
||||
- 1.65.0
|
||||
- 1.64.0
|
||||
- 1.62.0
|
||||
- 1.61.0
|
||||
|
||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"js/ts.tsdk.path": "node_modules\\typescript\\lib",
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||
"cSpell.words": [
|
||||
"ajnart",
|
||||
|
||||
@@ -1,44 +1,3 @@
|
||||
## [1.67.0](https://github.com/homarr-labs/homarr/compare/v1.66.1...v1.67.0) (2026-06-19)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** migrate from Umami to PostHog server-side analytics ([#5983](https://github.com/homarr-labs/homarr/issues/5983)) ([d059023](https://github.com/homarr-labs/homarr/commit/d059023b51a323df027c1cf033454440bd0d7e57))
|
||||
* Calculate and display total CPU and memory usage ([#5911](https://github.com/homarr-labs/homarr/issues/5911)) ([d9cbd54](https://github.com/homarr-labs/homarr/commit/d9cbd5427d0e5be3e5dbecca8d6069dc8074d810))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** isolate session cookies from other Auth.js apps on same host ([#5996](https://github.com/homarr-labs/homarr/issues/5996)) ([e1a245f](https://github.com/homarr-labs/homarr/commit/e1a245ff2d8520d08957c4a7dbe01d039607a973))
|
||||
* **deps:** update tailwindcss monorepo to ^4.3.1 ([#5988](https://github.com/homarr-labs/homarr/issues/5988)) ([78b6e52](https://github.com/homarr-labs/homarr/commit/78b6e520ac095129398d3da8f2441df408e93e9f))
|
||||
* **downloads:** sort stalled torrents last when sorting by finish time ([#6011](https://github.com/homarr-labs/homarr/issues/6011)) ([c56d9d6](https://github.com/homarr-labs/homarr/commit/c56d9d6894c717b5c70ab75d6a100d4d1071c2d3)), closes [#5416](https://github.com/homarr-labs/homarr/issues/5416)
|
||||
|
||||
## [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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
|
||||
};
|
||||
@@ -23,7 +23,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
| AUTH_PROVIDERS | Select Which provider to use between *credentials*, *ldap* and *oidc*. <br /> Multiple providers can be enabled with by separating them with `,`, (ex. `AUTH_PROVIDERS=credentials,oidc`, it is highly recommended to just enable one provider). | credentials |
|
||||
| AUTH_LOGOUT_REDIRECT_URL | URL to redirect to after clicking logging out. | --- |
|
||||
| AUTH_SESSION_EXPIRY_TIME | Time for the session to time out. Can be set as pure number, which will automatically be used in seconds, or followed by s, m, h or d for seconds, minutes, hours or days. (ex: "30m") | "30d" |
|
||||
| AUTH_COOKIE_PREFIX | Prefix used for all authentication cookies (session, csrf, callback, pkce, state, nonce). Change this if you run another Auth.js/NextAuth app on the same hostname as Homarr (browsers scope cookies by host, not by port) to avoid cookie name collisions. Only letters, numbers, hyphens and underscores are allowed. | `homarr` |
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="credentials" label="Credentials Provider" default>
|
||||
@@ -125,8 +124,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
|
||||
Roles are fetched from group claims. This can also be changed to roles for example added to a azure app registration by using the `AUTH_OIDC_GROUPS_ATTRIBUTE`.
|
||||
|
||||
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
Example Setup
|
||||
@@ -200,7 +197,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
| ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false |
|
||||
| ``AUTH_OIDC_SCOPE_OVERWRITE`` | Overwrite default scopes (openid, profile, email) | openid email profile groups |
|
||||
| ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim | groups |
|
||||
| ``AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT`` | Stop syncing group memberships of OIDC users from the groups claim and manage them locally via the group members page instead | false |
|
||||
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
|
||||
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
|
||||
|
||||
@@ -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](/docs/community/get-in-touch).
|
||||
You can get into contact with us [here](./get-in-touch).
|
||||
@@ -21,7 +21,7 @@ import manageImportDecryptionToken from "./img/old-homarr-import-decryption-toke
|
||||
|
||||
:::tip
|
||||
|
||||
Read our [glossary](/docs/getting-started/glossary) if you are new to Homarr.
|
||||
Read our [glossary](./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](/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/)
|
||||
- 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)
|
||||
- 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](/docs/management/api/)
|
||||
- Integrate with Homarr with your own application using [the API](../management/api/)
|
||||
|
||||
:::danger
|
||||
|
||||
|
||||
@@ -395,7 +395,6 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) |
|
||||
| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field |
|
||||
| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. |
|
||||
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
|
||||
| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login |
|
||||
| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
|
||||
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 948 KiB |
@@ -1,54 +0,0 @@
|
||||
---
|
||||
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.
|
||||
:::
|
||||
@@ -1,9 +0,0 @@
|
||||
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:
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
title: 'Gotify'
|
||||
description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { notificationsWidget } from '@site/docs/widgets/notifications';
|
||||
import { AddingIntegration } from '@site/src/components/integrations/adding';
|
||||
import { IntegrationHeader } from '@site/src/components/integrations/header';
|
||||
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
|
||||
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
|
||||
import { gotifyIntegration } from '.';
|
||||
|
||||
<IntegrationHeader
|
||||
integration={gotifyIntegration}
|
||||
categories={['Notifications', 'Messaging']}
|
||||
/>
|
||||
|
||||
### Widgets & Capabilities
|
||||
<IntegrationCapabilites
|
||||
items={[{
|
||||
widget: notificationsWidget
|
||||
}]}
|
||||
/>
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
<IntegrationSecrets secrets={[{
|
||||
credentials: ['username', 'password'],
|
||||
steps: []
|
||||
}]} />
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const gotifyIntegration: IntegrationDefinition = {
|
||||
name: "Gotify",
|
||||
description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gotify.svg",
|
||||
path: "../../integrations/gotify",
|
||||
};
|
||||
@@ -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.
|
Before Width: | Height: | Size: 533 KiB |
@@ -6,6 +6,8 @@ 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
|
||||
@@ -13,25 +15,20 @@ 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 [Yaak](https://yaak.app/).
|
||||
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/).
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
@@ -39,35 +36,49 @@ 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
|
||||
- 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,
|
||||
}}
|
||||
/>
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,208 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,166 +0,0 @@
|
||||
---
|
||||
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 |
|
||||
| `beszel` | Systems status, alerts, historical system metrics | 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
|
||||
- **"How are my servers doing?"** - AI fetches Beszel system stats including CPU, memory, disk, and alerts
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 119 KiB |
@@ -1,39 +0,0 @@
|
||||
---
|
||||
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} />
|
||||
@@ -1,25 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 107 KiB |
@@ -1,39 +0,0 @@
|
||||
---
|
||||
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} />
|
||||
@@ -1,88 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 688 KiB |
@@ -1,39 +0,0 @@
|
||||
---
|
||||
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} />
|
||||
@@ -1,77 +0,0 @@
|
||||
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: "System",
|
||||
description: "The Beszel system to display stats for. Searchable select populated from the integration.",
|
||||
values: { type: "string" },
|
||||
defaultValue: "First available system",
|
||||
},
|
||||
{
|
||||
name: "Time period",
|
||||
description:
|
||||
"The time range for chart data. '1 Minute' enables live streaming via PocketBase SSE (requires Beszel agent >= 0.13.0)",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["1 Minute", "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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
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} />
|
||||
@@ -1,106 +0,0 @@
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
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.
|
||||
:::
|
||||
@@ -1,9 +0,0 @@
|
||||
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",
|
||||
};
|
||||
@@ -1,17 +1,16 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconDeviceGamepad } from "@tabler/icons-react";
|
||||
import { integrations } from "../dns-hole-summary";
|
||||
|
||||
export const dnsHoleControlsWidget: WidgetDefinition = {
|
||||
icon: IconDeviceGamepad,
|
||||
name: "DNS Hole Controls",
|
||||
description: `Control the blocking feature of your ${integrations} from your dashboard`,
|
||||
description: "Control PiHole or AdGuard from your dashboard",
|
||||
path: "../../widgets/dns-hole-controls",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show Toggle All Buttons",
|
||||
description: "Show or not the 2 buttons that may be redundant when having only one DNS Hole.",
|
||||
description: "Show or not the 2 buttons that may be redundant when having only one DNS-hole.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
|
||||
export const integrations = "Pi-hole, AdGuard Home or Technitium DNS";
|
||||
|
||||
export const dnsHoleSummaryWidget: WidgetDefinition = {
|
||||
icon: IconAd,
|
||||
name: "DNS Hole Summary",
|
||||
description: `Displays the summary of your ${integrations} blocks and queries statistics`,
|
||||
description: "Displays the summary of your DNS Hole",
|
||||
path: "../../widgets/dns-hole-summary",
|
||||
configuration: {
|
||||
items: [
|
||||
@@ -21,7 +19,7 @@ export const dnsHoleSummaryWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Use Pi-Hole colors",
|
||||
description: `Color the tiles using the background color, that is used by default in ${integrations}.`,
|
||||
description: "Color the tiles using the background color, that is used by default in PiHole.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import { notificationsWidget } from '.';
|
||||
import { ntfyIntegration } from '@site/docs/integrations/ntfy';
|
||||
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud';
|
||||
import { gotifyIntegration } from '@site/docs/integrations/gotify';
|
||||
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'
|
||||
|
||||
<WidgetHeader
|
||||
widget={notificationsWidget}
|
||||
@@ -34,8 +33,7 @@ import notificationsScreenshot from './img/notifications.png';
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{ integration: ntfyIntegration },
|
||||
{ integration: nextcloudIntegration },
|
||||
{ integration: gotifyIntegration }
|
||||
{ integration: nextcloudIntegration }
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,7 +8,6 @@ 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",
|
||||
@@ -17,6 +16,7 @@ const config: Config = {
|
||||
locales: ["en"],
|
||||
},
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenMarkdownLinks: "throw",
|
||||
onBrokenAnchors: "throw",
|
||||
onDuplicateRoutes: "throw",
|
||||
|
||||
@@ -28,17 +28,11 @@ const config: Config = {
|
||||
fasterByDefault: true,
|
||||
mdx1CompatDisabledByDefault: false,
|
||||
},
|
||||
faster: {
|
||||
swcHtmlMinimizer: false,
|
||||
},
|
||||
},
|
||||
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
format: "detect",
|
||||
hooks: {
|
||||
onBrokenMarkdownLinks: "throw",
|
||||
},
|
||||
},
|
||||
|
||||
themes: ["@docusaurus/theme-mermaid"],
|
||||
@@ -62,7 +56,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: [],
|
||||
exclude: ["**/custom-widget.mdx"],
|
||||
showLastUpdateAuthor: false,
|
||||
showLastUpdateTime: false,
|
||||
},
|
||||
|
||||
@@ -32,19 +32,19 @@
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
"leader-line-new": "^1.1.9",
|
||||
"postcss": "^8.5.15",
|
||||
"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": "^4.3.1"
|
||||
"tailwindcss": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||
|
||||
@@ -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/10 rounded-md">
|
||||
<div className="w-10 h-10 flex justify-center items-center bg-red-500 bg-opacity-10 rounded-md">
|
||||
<capability.icon size={24} stroke={1.5} className="stroke-red-500" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"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/10 rounded-md">
|
||||
<div className="w-12 h-12 flex justify-center items-center bg-red-500 bg-opacity-10 rounded-md">
|
||||
<props.widget.icon size={32} stroke={1.5} className="stroke-red-500" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+35
-28
@@ -1,29 +1,36 @@
|
||||
{
|
||||
"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"],
|
||||
}
|
||||
"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",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.jsx",
|
||||
"**/*.js",
|
||||
"**/*.tsx",
|
||||
"**/*.md",
|
||||
"**/*.mdx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -89,7 +89,6 @@
|
||||
"mcp-handler": "catalog:",
|
||||
"next": "catalog:",
|
||||
"postcss-preset-mantine": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"prismjs": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
@@ -16,7 +16,11 @@ 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">
|
||||
|
||||
@@ -139,11 +139,11 @@ export default async function Layout(props: {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<Analytics />
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
|
||||
@@ -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/categories/custom-widgets";
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/5916";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
return (
|
||||
|
||||
@@ -150,13 +150,7 @@ 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",
|
||||
@@ -266,34 +260,15 @@ 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() })),
|
||||
@@ -306,13 +281,7 @@ 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];
|
||||
@@ -393,31 +362,12 @@ 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,
|
||||
}),
|
||||
@@ -439,11 +389,7 @@ 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,
|
||||
@@ -469,10 +415,7 @@ 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)) {
|
||||
@@ -484,10 +427,7 @@ 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) {
|
||||
@@ -511,13 +451,7 @@ 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;
|
||||
@@ -587,10 +521,7 @@ 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 {
|
||||
@@ -604,10 +535,7 @@ 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 });
|
||||
@@ -619,10 +547,7 @@ 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) });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -641,10 +566,7 @@ 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]);
|
||||
@@ -827,18 +749,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
w={480}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
position: "sticky",
|
||||
top: 80,
|
||||
alignSelf: "start",
|
||||
maxHeight: "calc(100vh - 100px)",
|
||||
overflow: "auto",
|
||||
}}
|
||||
visibleFrom="md"
|
||||
>
|
||||
<Box w={340} style={{ flexShrink: 0, position: "sticky", top: 100, alignSelf: "start" }} visibleFrom="md">
|
||||
<Stack gap="sm">
|
||||
<Button type="submit" fullWidth loading={createMutation.isPending || updateMutation.isPending}>
|
||||
{mode === "create" ? t("action.create") : t("action.save")}
|
||||
@@ -906,19 +817,13 @@ 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}
|
||||
/>
|
||||
@@ -933,10 +838,7 @@ 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}
|
||||
/>
|
||||
@@ -1056,10 +958,7 @@ 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}
|
||||
/>
|
||||
@@ -1128,10 +1027,7 @@ 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}
|
||||
/>
|
||||
@@ -1207,19 +1103,13 @@ 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}
|
||||
/>
|
||||
@@ -1281,10 +1171,7 @@ 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}
|
||||
/>
|
||||
|
||||
@@ -57,13 +57,13 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
{definitions.map((def) => (
|
||||
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap="xs">
|
||||
{def.iconUrl ? (
|
||||
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<IconApi size={16} />
|
||||
)}
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -109,7 +109,7 @@ export function CustomWidgetPreview({
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mah={320} style={{ overflow: "auto" }}>
|
||||
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mih={120}>
|
||||
<PreviewDisplay data={displayData} />
|
||||
</Card>
|
||||
|
||||
@@ -119,9 +119,7 @@ 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 fluid>
|
||||
<Container size="xl">
|
||||
<Stack>
|
||||
<Title>{definition.name}</Title>
|
||||
<CustomWidgetBetaBanner />
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function NewCustomWidgetPage() {
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Container fluid>
|
||||
<Container size="xl">
|
||||
<Stack>
|
||||
<div>
|
||||
<Title>{t("page.create.title")}</Title>
|
||||
|
||||
@@ -17,10 +17,9 @@ interface AnalyticsSettingsProps {
|
||||
|
||||
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.analytics");
|
||||
const { instanceId: _, ...formDefaults } = initialData;
|
||||
const form = useForm({
|
||||
initialValues: formDefaults,
|
||||
onValuesChange: (updatedValues, _prev) => {
|
||||
initialValues: initialData,
|
||||
onValuesChange: (updatedValues, _) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ 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;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -14,10 +13,9 @@ import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
interface AddGroupMemberProps {
|
||||
groupId: string;
|
||||
presentUserIds: string[];
|
||||
allowedProviders: SupportedAuthProvider[];
|
||||
}
|
||||
|
||||
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
|
||||
export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps) => {
|
||||
const tMembersAdd = useScopedI18n("group.action.addMember");
|
||||
const { mutateAsync } = clientApi.group.addMember.useMutation();
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
@@ -34,13 +32,13 @@ export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: Ad
|
||||
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
|
||||
},
|
||||
presentUserIds,
|
||||
allowedProviders,
|
||||
excludeExternalProviders: true,
|
||||
},
|
||||
{
|
||||
title: tMembersAdd("label"),
|
||||
},
|
||||
);
|
||||
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]);
|
||||
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
|
||||
|
||||
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
|
||||
};
|
||||
@@ -4,12 +4,9 @@ import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import {
|
||||
getGroupMemberManagementType,
|
||||
getLocallyManageableProviders,
|
||||
isGroupMembershipManagedLocally,
|
||||
} from "@homarr/auth/server";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, UserAvatar } from "@homarr/ui";
|
||||
@@ -41,15 +38,16 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const isReserved = group.name === everyoneGroup;
|
||||
|
||||
const searchTerm = searchParams.search?.trim().toLowerCase();
|
||||
const filteredMembers = group.members.filter(
|
||||
(member) => !searchTerm || member.name?.toLowerCase().includes(searchTerm),
|
||||
);
|
||||
const filteredMembers = searchParams.search
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
|
||||
: group.members;
|
||||
|
||||
// "local" = every enabled provider managed locally, "external" = none, "mixed" = some.
|
||||
const managementType = getGroupMemberManagementType();
|
||||
const canManageMembers = managementType !== "external";
|
||||
const allowedProviders = getLocallyManageableProviders();
|
||||
const providerTypes = isProviderEnabled("credentials")
|
||||
? env.AUTH_PROVIDERS.length > 1
|
||||
? "mixed"
|
||||
: "credentials"
|
||||
: "external";
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -58,21 +56,17 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
{isReserved ? (
|
||||
<ReservedGroupAlert />
|
||||
) : (
|
||||
managementType !== "local" && (
|
||||
providerTypes !== "credentials" && (
|
||||
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||
{t(`group.memberNotice.${managementType}`)}
|
||||
{t(`group.memberNotice.${providerTypes}`)}
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
|
||||
{canManageMembers && !isReserved && (
|
||||
<AddGroupMember
|
||||
groupId={group.id}
|
||||
presentUserIds={group.members.map((member) => member.id)}
|
||||
allowedProviders={allowedProviders}
|
||||
/>
|
||||
{isProviderEnabled("credentials") && !isReserved && (
|
||||
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
|
||||
)}
|
||||
</Group>
|
||||
{filteredMembers.length === 0 && (
|
||||
@@ -100,8 +94,6 @@ interface RowProps {
|
||||
}
|
||||
|
||||
const Row = ({ member, groupId, disabled }: RowProps) => {
|
||||
const canBeRemoved = isGroupMembershipManagedLocally(member.provider);
|
||||
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd>
|
||||
@@ -112,7 +104,9 @@ const Row = ({ member, groupId, disabled }: RowProps) => {
|
||||
</Anchor>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd w={100}>{canBeRemoved && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}</TableTd>
|
||||
<TableTd w={100}>
|
||||
{member.provider === "credentials" && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
@@ -39,9 +39,7 @@ 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(".");
|
||||
@@ -150,13 +148,14 @@ 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,4 +8,5 @@ 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,10 +80,7 @@ describe("SQLite backup", () => {
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
const readBack = new AdmZip(zipPath);
|
||||
const entryNames = readBack
|
||||
.getEntries()
|
||||
.map((e) => e.entryName)
|
||||
.toSorted();
|
||||
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
|
||||
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
|
||||
|
||||
const dbEntry = readBack.getEntry("db.sqlite");
|
||||
@@ -100,9 +97,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");
|
||||
@@ -127,10 +124,7 @@ 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"];
|
||||
@@ -179,11 +173,9 @@ 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" = ?',
|
||||
@@ -261,9 +253,7 @@ 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");
|
||||
@@ -291,9 +281,7 @@ 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");
|
||||
|
||||
|
||||
@@ -148,8 +148,6 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
|
||||
|
||||
**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters.
|
||||
|
||||
**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`).
|
||||
|
||||
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
|
||||
|
||||
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -13,7 +12,7 @@ import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
interface InnerProps {
|
||||
presentUserIds: string[];
|
||||
allowedProviders?: SupportedAuthProvider[];
|
||||
excludeExternalProviders?: boolean;
|
||||
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
@@ -25,7 +24,7 @@ interface UserSelectFormType {
|
||||
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: users, isPending } = clientApi.user.selectable.useQuery({
|
||||
providers: innerProps.allowedProviders,
|
||||
excludeExternalProviders: innerProps.excludeExternalProviders,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<UserSelectFormType>();
|
||||
|
||||
@@ -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,13 +63,9 @@ 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,9 +49,7 @@ 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>
|
||||
@@ -78,7 +76,12 @@ 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,19 +115,13 @@ 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>
|
||||
@@ -149,12 +143,8 @@ 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}>
|
||||
@@ -162,14 +152,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>
|
||||
)}
|
||||
|
||||
@@ -183,7 +173,10 @@ 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>
|
||||
@@ -197,7 +190,10 @@ 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,9 +91,7 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
return () => { cancelled = true; };
|
||||
}, [active, onComplete, steps]);
|
||||
|
||||
if (!active && currentStepIdx === -1) return null;
|
||||
@@ -109,7 +107,12 @@ 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;
|
||||
@@ -135,16 +138,7 @@ 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,124 +64,117 @@ 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);
|
||||
const analyzeFile = useCallback(async (file: File) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAnalysis(null);
|
||||
setMigrationProgress(null);
|
||||
|
||||
try {
|
||||
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
|
||||
import("jszip").then((m) => m.default),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
import("sql.js").then((m: any) => m.default),
|
||||
fetchMigrations(),
|
||||
fetch("/api/backup/sql-wasm").then((r) => {
|
||||
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
|
||||
return r.arrayBuffer();
|
||||
}),
|
||||
]);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const metadataFile = zip.file("metadata.json");
|
||||
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
|
||||
const dbFile = zip.file("db.sqlite");
|
||||
if (!dbFile) throw new Error(t("invalidMissingDb"));
|
||||
|
||||
const metadata = JSON.parse(await metadataFile.async("string"));
|
||||
const dbBuffer = await dbFile.async("uint8array");
|
||||
|
||||
const SQL = await initSqlJs({ wasmBinary });
|
||||
|
||||
const db = new SQL.Database(dbBuffer);
|
||||
dbRef.current = db;
|
||||
|
||||
try {
|
||||
const [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 appliedCount = getAppliedMigrationCount(db);
|
||||
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
const migrations: MigrationStatus = {
|
||||
applied: appliedCount,
|
||||
pending: pendingMigrations,
|
||||
total: allMigrations.length,
|
||||
};
|
||||
|
||||
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"));
|
||||
if (pendingMigrations.length > 0) {
|
||||
for (const [i, migration] of pendingMigrations.entries()) {
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "applying",
|
||||
});
|
||||
|
||||
const metadata = JSON.parse(await metadataFile.async("string"));
|
||||
const dbBuffer = await dbFile.async("uint8array");
|
||||
await delay(100);
|
||||
|
||||
const SQL = await initSqlJs({ wasmBinary });
|
||||
try {
|
||||
const statements = migration.sql
|
||||
.split("--> statement-breakpoint")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const db = new SQL.Database(dbBuffer);
|
||||
dbRef.current = db;
|
||||
|
||||
try {
|
||||
const appliedCount = getAppliedMigrationCount(db);
|
||||
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
|
||||
|
||||
const migrations: MigrationStatus = {
|
||||
applied: appliedCount,
|
||||
pending: pendingMigrations,
|
||||
total: allMigrations.length,
|
||||
};
|
||||
|
||||
if (pendingMigrations.length > 0) {
|
||||
for (const [i, migration] of pendingMigrations.entries()) {
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "applying",
|
||||
});
|
||||
|
||||
await delay(100);
|
||||
|
||||
try {
|
||||
const statements = migration.sql
|
||||
.split("--> statement-breakpoint")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const stmt of statements) {
|
||||
db.run(stmt);
|
||||
}
|
||||
|
||||
db.run(`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`, [
|
||||
migration.tag,
|
||||
Date.now(),
|
||||
]);
|
||||
} catch (migrationErr) {
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "error",
|
||||
});
|
||||
throw new Error(
|
||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr },
|
||||
);
|
||||
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: "done",
|
||||
phase: "error",
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
throw new Error(
|
||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "done",
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
}
|
||||
|
||||
const counts = countEntities(db);
|
||||
const boardNames = getBoardNames(db);
|
||||
|
||||
setMigrationProgress(null);
|
||||
setAnalysis({ metadata, counts, boardNames, migrations });
|
||||
} finally {
|
||||
db.close();
|
||||
dbRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
||||
|
||||
const counts = countEntities(db);
|
||||
const boardNames = getBoardNames(db);
|
||||
|
||||
setMigrationProgress(null);
|
||||
setAnalysis({ metadata, counts, boardNames, migrations });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
db.close();
|
||||
dbRef.current = null;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (dbRef.current) {
|
||||
try {
|
||||
dbRef.current.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
try { dbRef.current.close(); } catch { /* already closed */ }
|
||||
dbRef.current = null;
|
||||
}
|
||||
setAnalysis(null);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
bottom: calc(100% - 10px);
|
||||
margin-bottom: 1px;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition:
|
||||
@@ -13,10 +13,8 @@
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
|
||||
.wrapper,
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
|
||||
.wrapper {
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -79,8 +77,6 @@
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metadataItem {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Translator } from "./widget-hover-metadata";
|
||||
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
|
||||
import classes from "./widget-hover-overlay.module.css";
|
||||
|
||||
interface WidgetHoverOverlayProps {
|
||||
@@ -18,17 +21,27 @@ interface WidgetHoverOverlayProps {
|
||||
|
||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const WidgetIcon = definition.icon;
|
||||
|
||||
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
const translate = ((key: string) => t(key as never)) as Translator;
|
||||
const displayName = resolveWidgetDisplayName(item, settings, translate);
|
||||
|
||||
const connectedIntegrations = (integrations ?? []).filter((integration) =>
|
||||
item.integrationIds.includes(integration.id),
|
||||
);
|
||||
const metadataItems = collectWidgetHoverMetadata(
|
||||
item,
|
||||
settings,
|
||||
connectedIntegrations.map((integration) => integration.kind),
|
||||
translate,
|
||||
);
|
||||
const hasIntegrations = connectedIntegrations.length > 0;
|
||||
const hasMetadata = metadataItems.length > 0;
|
||||
|
||||
if (!isEditMode) {
|
||||
if (isEditMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -42,13 +55,13 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{connectedIntegrations.length > 0 && (
|
||||
{hasIntegrations && (
|
||||
<Avatar.Group spacing="xs" className={classes.integrations}>
|
||||
{connectedIntegrations.map((integration) => (
|
||||
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
|
||||
<Avatar
|
||||
src={getIconUrl(integration.kind)}
|
||||
size={18}
|
||||
size={20}
|
||||
radius="sm"
|
||||
className={classes.integrationAvatar}
|
||||
/>
|
||||
@@ -56,6 +69,17 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
||||
))}
|
||||
</Avatar.Group>
|
||||
)}
|
||||
|
||||
{hasMetadata && (
|
||||
<Group className={classes.metadata} gap={4}>
|
||||
{metadataItems.map((metadataItem) => (
|
||||
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
|
||||
<span className={classes.metadataLabel}>{metadataItem.label}</span>
|
||||
<span className={classes.metadataValue}>{metadataItem.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
"use client";
|
||||
import Script from "next/script";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
const POSTHOG_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
|
||||
const POSTHOG_HOST = "https://hog.homarr.dev";
|
||||
export const Analytics = async () => {
|
||||
// For static pages it will not find any analytics data so we do not include the script on them
|
||||
const analytics = await getServerSettingByKeyAsync(db, "analytics").catch(() => null);
|
||||
|
||||
export const Analytics = ({ enabled }: { enabled: boolean }) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (analytics?.enableGeneral) {
|
||||
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
|
||||
}
|
||||
|
||||
void import("posthog-js").then(({ default: posthog }) => {
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
autocapture: false,
|
||||
persistence: "memory",
|
||||
});
|
||||
});
|
||||
}, [enabled]);
|
||||
|
||||
return null;
|
||||
return <></>;
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"pnpm": ">=11.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0"
|
||||
"packageManager": "pnpm@11.5.3"
|
||||
}
|
||||
+24
-80
@@ -74,13 +74,6 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png" alt="ArchiveTeam Warrior" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">ArchiveTeam<br/>Warrior</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/aria2" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg" alt="Aria2" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -88,27 +81,13 @@
|
||||
</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/>
|
||||
<p align="center">Codeberg</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -128,8 +107,8 @@
|
||||
<br/>
|
||||
<p align="center">Deluge</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><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/>
|
||||
@@ -156,8 +135,8 @@
|
||||
<br/>
|
||||
<p align="center">GitHub<br/>Container<br/>Registry</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/gitlab" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg" alt="Gitlab" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -172,20 +151,13 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/gotify" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg" alt="Gotify" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Gotify</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/home-assistant" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Home<br/>Assistant</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><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/>
|
||||
@@ -205,8 +177,8 @@
|
||||
<br/>
|
||||
<p align="center">Jellyfin</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/jellyseerr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg" alt="Jellyseerr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -228,20 +200,13 @@
|
||||
</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>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><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/>
|
||||
@@ -254,8 +219,8 @@
|
||||
<br/>
|
||||
<p align="center">ntfy</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/nzbget" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg" alt="NZBGet" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -284,27 +249,20 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/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>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Pi-hole</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -345,15 +303,15 @@
|
||||
<br/>
|
||||
<p align="center">Radarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Readarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -394,20 +352,13 @@
|
||||
<br/>
|
||||
<p align="center">Speedtest<br/>Tracker</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Tdarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<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">
|
||||
@@ -451,13 +402,6 @@
|
||||
<p align="center">Unraid</p>
|
||||
</a>
|
||||
</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/uptime-kuma.svg" alt="Uptime Kuma" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Uptime<br/>Kuma</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.67.0",
|
||||
"version": "1.64.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -62,9 +62,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"pnpm": ">=11.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.7.0",
|
||||
"packageManager": "pnpm@11.5.3",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"posthog-node": "catalog:"
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@umami/node": "catalog:",
|
||||
"superjson": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
export const POSTHOG_API_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
|
||||
export const POSTHOG_HOST = "https://hog.homarr.dev";
|
||||
|
||||
let instance: PostHog | undefined;
|
||||
|
||||
export const getPostHogClient = (): PostHog => {
|
||||
instance ??= new PostHog(POSTHOG_API_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 10,
|
||||
flushInterval: 30_000,
|
||||
});
|
||||
return instance;
|
||||
};
|
||||
|
||||
export const trackEvent = (instanceId: string, event: string, properties?: Record<string, unknown>) => {
|
||||
getPostHogClient().capture({
|
||||
distinctId: instanceId,
|
||||
event,
|
||||
properties,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
|
||||
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
|
||||
@@ -1,2 +1,2 @@
|
||||
export { trackEvent } from "./client";
|
||||
export { sendServerAnalyticsAsync } from "./send-server-analytics";
|
||||
export * from "./constants";
|
||||
export * from "./send-server-analytics";
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { UmamiEventData } from "@umami/node";
|
||||
import { Umami } from "@umami/node";
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createId, Stopwatch } from "@homarr/common";
|
||||
import { Stopwatch } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { count, db } from "@homarr/db";
|
||||
import { isMysql, isPostgresql } from "@homarr/db/collection";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
accounts,
|
||||
apiKeys,
|
||||
apps,
|
||||
boards,
|
||||
cronJobConfigurations,
|
||||
customWidgetDefinitions,
|
||||
groups,
|
||||
iconRepositories,
|
||||
icons,
|
||||
@@ -23,160 +24,86 @@ import {
|
||||
searchEngines,
|
||||
sectionLayouts,
|
||||
sections,
|
||||
sessions,
|
||||
trustedCertificateHostnames,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { env as dockerEnv } from "@homarr/docker/env";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
import { getPostHogClient } from "./client";
|
||||
import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
|
||||
|
||||
const logger = createLogger({ module: "analytics" });
|
||||
|
||||
type AnalyticsResult = "sent" | "disabled" | "failed";
|
||||
|
||||
// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins
|
||||
// and the ID stabilizes after the first successful run. Upgrade path: use DB upsert with WHERE instanceId IS NULL.
|
||||
const getOrCreateInstanceId = async (
|
||||
analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>,
|
||||
): Promise<string> => {
|
||||
if (analyticsSettings.instanceId) return analyticsSettings.instanceId;
|
||||
|
||||
const instanceId = createId();
|
||||
await updateServerSettingByKeyAsync(db, "analytics", { ...analyticsSettings, instanceId });
|
||||
|
||||
const verified = await getServerSettingByKeyAsync(db, "analytics");
|
||||
return verified.instanceId ?? instanceId;
|
||||
const getDatabaseType = (): "mysql" | "postgresql" | "sqlite" => {
|
||||
if (isMysql()) return "mysql";
|
||||
if (isPostgresql()) return "postgresql";
|
||||
return "sqlite";
|
||||
};
|
||||
|
||||
const sumGroupedCounts = (rows: { count: number }[]): number => rows.reduce((sum, row) => sum + row.count, 0);
|
||||
const getEnabledAuthProviders = (): string[] => {
|
||||
const providers: string[] = [];
|
||||
if (isProviderEnabled("credentials")) providers.push("credentials");
|
||||
if (isProviderEnabled("oidc")) providers.push("oidc");
|
||||
if (isProviderEnabled("ldap")) providers.push("ldap");
|
||||
return providers;
|
||||
};
|
||||
|
||||
export const sendServerAnalyticsAsync = async (): Promise<AnalyticsResult> => {
|
||||
export const sendServerAnalyticsAsync = async () => {
|
||||
const stopWatch = new Stopwatch();
|
||||
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
if (!analyticsSettings.enableGeneral) {
|
||||
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
|
||||
return "disabled";
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getPostHogClient();
|
||||
const umamiInstance = new Umami();
|
||||
umamiInstance.init({
|
||||
hostUrl: UMAMI_HOST_URL,
|
||||
websiteId: UMAMI_WEBSITE_ID,
|
||||
});
|
||||
|
||||
try {
|
||||
const instanceId = await getOrCreateInstanceId(analyticsSettings);
|
||||
const enabledAuthProviders = getEnabledAuthProviders();
|
||||
|
||||
const [
|
||||
cultureSettings,
|
||||
countBoards,
|
||||
countGroups,
|
||||
countApps,
|
||||
countSections,
|
||||
countSearchEngines,
|
||||
countIconRepositories,
|
||||
countIcons,
|
||||
countLayouts,
|
||||
countItemLayouts,
|
||||
countSectionLayouts,
|
||||
countCronJobConfigs,
|
||||
countCustomWidgets,
|
||||
countTrustedCertificates,
|
||||
countUsers,
|
||||
countApiKeys,
|
||||
countInvites,
|
||||
countMedias,
|
||||
countSessions,
|
||||
countAccounts,
|
||||
integrationKinds,
|
||||
widgetKinds,
|
||||
] = await Promise.all([
|
||||
getServerSettingByKeyAsync(db, "culture"),
|
||||
db.$count(boards),
|
||||
db.$count(groups),
|
||||
db.$count(apps),
|
||||
db.$count(sections),
|
||||
db.$count(searchEngines),
|
||||
db.$count(iconRepositories),
|
||||
db.$count(icons),
|
||||
db.$count(layouts),
|
||||
db.$count(itemLayouts),
|
||||
db.$count(sectionLayouts),
|
||||
db.$count(cronJobConfigurations),
|
||||
db.$count(customWidgetDefinitions),
|
||||
db.$count(trustedCertificateHostnames),
|
||||
db.$count(users),
|
||||
db.$count(apiKeys),
|
||||
db.$count(invites),
|
||||
db.$count(medias),
|
||||
db.$count(sessions),
|
||||
db.$count(accounts),
|
||||
db
|
||||
.select({ kind: integrations.kind, count: count(integrations.id) })
|
||||
.from(integrations)
|
||||
.groupBy(integrations.kind),
|
||||
db
|
||||
.select({ kind: items.kind, count: count(items.id) })
|
||||
.from(items)
|
||||
.groupBy(items.kind),
|
||||
]);
|
||||
const analyticsData: UmamiEventData = {
|
||||
homarrVersion: packageJson.version,
|
||||
databaseType: getDatabaseType(),
|
||||
dockerEnabled: dockerEnv.ENABLE_DOCKER ? 1 : 0,
|
||||
kubernetesEnabled: dockerEnv.ENABLE_KUBERNETES ? 1 : 0,
|
||||
authCredentials: enabledAuthProviders.includes("credentials") ? 1 : 0,
|
||||
authOidc: enabledAuthProviders.includes("oidc") ? 1 : 0,
|
||||
authLdap: enabledAuthProviders.includes("ldap") ? 1 : 0,
|
||||
countUsers: await db.$count(users),
|
||||
countBoards: await db.$count(boards),
|
||||
countGroups: await db.$count(groups),
|
||||
countApps: await db.$count(apps),
|
||||
countWidgets: await db.$count(items),
|
||||
countSections: await db.$count(sections),
|
||||
countIntegrations: await db.$count(integrations),
|
||||
countSearchEngines: await db.$count(searchEngines),
|
||||
countIconRepositories: await db.$count(iconRepositories),
|
||||
countIcons: await db.$count(icons),
|
||||
countApiKeys: await db.$count(apiKeys),
|
||||
countInvites: await db.$count(invites),
|
||||
countMedias: await db.$count(medias),
|
||||
countLayouts: await db.$count(layouts),
|
||||
countItemLayouts: await db.$count(itemLayouts),
|
||||
countSectionLayouts: await db.$count(sectionLayouts),
|
||||
countCronJobConfigs: await db.$count(cronJobConfigurations),
|
||||
};
|
||||
|
||||
const enabledAuthProviders = (["credentials", "oidc", "ldap"] as const).filter(isProviderEnabled);
|
||||
const integrationKinds = await db
|
||||
.select({ kind: integrations.kind, count: count(integrations.id) })
|
||||
.from(integrations)
|
||||
.groupBy(integrations.kind);
|
||||
|
||||
const properties: Record<string, unknown> = {
|
||||
homarrVersion: packageJson.version,
|
||||
databaseType: isMysql() ? "mysql" : isPostgresql() ? "postgresql" : "sqlite",
|
||||
dockerEnabled: Boolean(dockerEnv.ENABLE_DOCKER),
|
||||
kubernetesEnabled: Boolean(dockerEnv.ENABLE_KUBERNETES),
|
||||
authProviders: enabledAuthProviders,
|
||||
integrationKinds.forEach((integrationKind) => {
|
||||
analyticsData[`integration_${integrationKind.kind}`] = integrationKind.count;
|
||||
});
|
||||
|
||||
osPlatform: process.platform,
|
||||
osArch: process.arch,
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
defaultLocale: cultureSettings.defaultLocale,
|
||||
|
||||
countBoards,
|
||||
countGroups,
|
||||
countApps,
|
||||
countSections,
|
||||
countSearchEngines,
|
||||
countIconRepositories,
|
||||
countIcons,
|
||||
countLayouts,
|
||||
countItemLayouts,
|
||||
countSectionLayouts,
|
||||
countCronJobConfigs,
|
||||
countCustomWidgets,
|
||||
countTrustedCertificates,
|
||||
|
||||
countUsers,
|
||||
countApiKeys,
|
||||
countInvites,
|
||||
countMedias,
|
||||
countSessions,
|
||||
countAccounts,
|
||||
|
||||
countIntegrations: sumGroupedCounts(integrationKinds),
|
||||
countWidgets: sumGroupedCounts(widgetKinds),
|
||||
};
|
||||
|
||||
for (const row of integrationKinds) {
|
||||
properties[`integration_${row.kind}`] = row.count;
|
||||
}
|
||||
for (const row of widgetKinds) {
|
||||
properties[`widget_${row.kind}`] = row.count;
|
||||
}
|
||||
|
||||
client.capture({
|
||||
distinctId: instanceId,
|
||||
event: "server-analytics",
|
||||
properties,
|
||||
});
|
||||
|
||||
await client.flush();
|
||||
logger.info(`Sent analytics to PostHog in ${stopWatch.getElapsedInHumanWords()}`);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
logger.warn("Failed to send analytics to PostHog", { error });
|
||||
return "failed";
|
||||
const response = await umamiInstance.track("server-analytics-v2", analyticsData);
|
||||
if (!response.ok) {
|
||||
logger.warn("Unable to send analytics to Umami instance");
|
||||
}
|
||||
|
||||
logger.info(`Sent analytics in ${stopWatch.getElapsedInHumanWords()}`);
|
||||
};
|
||||
@@ -24,7 +24,6 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
|
||||
@@ -9,7 +9,6 @@ import { iconsRouter } from "./router/icons";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
import { calendarRouter } from "./router/widgets/calendar";
|
||||
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
||||
import { downloadsRouter } from "./router/widgets/downloads";
|
||||
@@ -27,7 +26,6 @@ export const mcpRouter = createTRPCRouter({
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
downloads: downloadsRouter,
|
||||
|
||||
@@ -28,7 +28,6 @@ export const appRouter = createTRPCRouter({
|
||||
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
analytics: lazy(() => import("./router/analytics").then((mod) => mod.analyticsRouter)),
|
||||
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
|
||||
customWidget: lazy(() => import("./router/custom-widget/custom-widget-router").then((mod) => mod.customWidgetRouter)),
|
||||
});
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import z from "zod/v4";
|
||||
|
||||
import { sendServerAnalyticsAsync, trackEvent } from "@homarr/analytics";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
const getInstanceId = async (): Promise<string | null> => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
const settings = await getServerSettingByKeyAsync(db, "analytics");
|
||||
if (!settings.enableGeneral) return null;
|
||||
return settings.instanceId;
|
||||
};
|
||||
|
||||
export const analyticsRouter = createTRPCRouter({
|
||||
sendAnalytics: permissionRequiredProcedure.requiresPermission("admin").mutation(async () => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "External connections are disabled" });
|
||||
}
|
||||
return { status: await sendServerAnalyticsAsync() };
|
||||
}),
|
||||
|
||||
trackFeature: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
feature: z.string().max(128),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const instanceId = await getInstanceId();
|
||||
if (!instanceId) return;
|
||||
|
||||
trackEvent(instanceId, `feature:${input.feature}`, {
|
||||
...input.properties,
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
}),
|
||||
|
||||
heartbeat: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const instanceId = await getInstanceId();
|
||||
if (!instanceId) return;
|
||||
|
||||
trackEvent(instanceId, "user-active", {
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { canManageGroupMembersLocally, isGroupMembershipManagedLocally } from "@homarr/auth/server";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
|
||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
} from "@homarr/validation/group";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
@@ -315,10 +315,10 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||
throwIfGroupMembersCannotBeManagedLocally();
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
where: eq(groups.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -328,13 +328,6 @@ export const groupRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (!isGroupMembershipManagedLocally(user.provider)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User's provider is not managed locally",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId: input.groupId,
|
||||
userId: input.userId,
|
||||
@@ -346,7 +339,7 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||
throwIfGroupMembersCannotBeManagedLocally();
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
await ctx.db
|
||||
.delete(groupMembers)
|
||||
@@ -378,15 +371,6 @@ const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const throwIfGroupMembersCannotBeManagedLocally = () => {
|
||||
if (!canManageGroupMembersLocally()) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Group members cannot be managed locally",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
|
||||
const group = await db.query.groups.findFirst({
|
||||
where: eq(groups.id, id),
|
||||
|
||||
@@ -24,18 +24,17 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||
...current,
|
||||
...input.value,
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
await updateServerSettingByKeyAsync(
|
||||
ctx.db,
|
||||
input.settingsKey,
|
||||
input.value as ServerSettings[keyof ServerSettings],
|
||||
);
|
||||
}),
|
||||
initSettings: onboardingProcedure
|
||||
.requiresStep("settings")
|
||||
.input(settingsInitSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
|
||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics });
|
||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
|
||||
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
|
||||
@@ -748,45 +748,7 @@ describe("addMember should add member to group", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
|
||||
});
|
||||
|
||||
test("with a user from an externally managed provider it should throw FORBIDDEN error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials", "ldap"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
await db.insert(users).values([
|
||||
{
|
||||
id: userId,
|
||||
name: "User",
|
||||
provider: "ldap",
|
||||
},
|
||||
{
|
||||
id: defaultOwnerId,
|
||||
name: "Creator",
|
||||
},
|
||||
]);
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.addMember({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("User's provider is not managed locally");
|
||||
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -910,7 +872,7 @@ describe("removeMember should remove member from group", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
|
||||
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ describe("saveSettings", () => {
|
||||
settingsKey: "analytics",
|
||||
value: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,7 +87,9 @@ describe("saveSettings", () => {
|
||||
settingKey: "analytics",
|
||||
value: SuperJSON.stringify({
|
||||
enableGeneral: true,
|
||||
instanceId: null,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -7,12 +7,12 @@ import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
||||
import {
|
||||
@@ -257,7 +257,7 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
// Is protected because also used in board access / integration access forms
|
||||
selectable: protectedProcedure
|
||||
.input(z.object({ providers: z.array(z.enum(supportedAuthProviders)).optional() }).optional())
|
||||
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
|
||||
.output(
|
||||
z.array(
|
||||
selectUserSchema.pick({
|
||||
@@ -284,7 +284,7 @@ export const userRouter = createTRPCRouter({
|
||||
image: true,
|
||||
email: true,
|
||||
},
|
||||
where: input?.providers ? inArray(users.provider, input.providers) : undefined,
|
||||
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
|
||||
});
|
||||
}),
|
||||
search: permissionRequiredProcedure
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
|
||||
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const archiveTeamWarriorRouter = createTRPCRouter({
|
||||
getStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
||||
const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
status: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
|
||||
subscribeStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => {
|
||||
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
||||
const unsubscribe = handler.subscribe((status) => {
|
||||
emit.next({
|
||||
status,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import {
|
||||
beszelAlertsRequestHandler,
|
||||
beszelStatsRequestHandler,
|
||||
@@ -16,21 +13,12 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const beszelRouter = createTRPCRouter({
|
||||
getSystems: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
@@ -46,11 +34,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
subscribeSystems: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
systems: BeszelSystemRow[];
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((systems) => {
|
||||
@@ -68,13 +52,6 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
getAlerts: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
@@ -119,22 +96,14 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
alerts: BeszelAlertsData;
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
return innerHandler.subscribe((data) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
alerts: data,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() });
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
@@ -144,26 +113,17 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
getSystemStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]).default("1h"),
|
||||
timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]),
|
||||
includeDocker: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = ctx.integrations[0];
|
||||
if (!integration) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
||||
}
|
||||
if (!integration) return null;
|
||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
@@ -172,45 +132,4 @@ export const beszelRouter = createTRPCRouter({
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{
|
||||
stats: BeszelSystemStatsRecord;
|
||||
containerStats: BeszelContainerStatsRecord | null;
|
||||
}>((emit) => {
|
||||
const integration = ctx.integrations[0];
|
||||
if (!integration) {
|
||||
emit.error(new Error("No Beszel integration configured"));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
if (error instanceof Error) {
|
||||
emit.error(error);
|
||||
} else {
|
||||
emit.error(new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { lazy } from "@trpc/server";
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
|
||||
export const widgetRouter = createTRPCRouter({
|
||||
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
|
||||
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
|
||||
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import SuperJSON from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { boards, items } from "@homarr/db/schema";
|
||||
|
||||
import type { WidgetOptionsSettings } from "../../../../widgets/src";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const optionsRouter = createTRPCRouter({
|
||||
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
|
||||
@@ -23,33 +16,4 @@ export const optionsRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
}),
|
||||
saveItemOptions: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
itemId: z.string(),
|
||||
boardId: z.string(),
|
||||
newOptions: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: eq(items.id, input.itemId),
|
||||
});
|
||||
|
||||
if (item?.boardId !== input.boardId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Specified item was not found",
|
||||
});
|
||||
}
|
||||
|
||||
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
|
||||
Object.assign(options, input.newOptions);
|
||||
await ctx.db
|
||||
.update(items)
|
||||
.set({ options: SuperJSON.stringify(options) })
|
||||
.where(eq(items.id, input.itemId));
|
||||
}),
|
||||
});
|
||||
@@ -20,24 +20,6 @@ import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from ".
|
||||
|
||||
const logger = createLogger({ module: "authConfiguration" });
|
||||
|
||||
// All Auth.js cookies are prefixed with AUTH_COOKIE_PREFIX (default "homarr") so
|
||||
// Homarr's cookie jar is isolated from any other Auth.js/NextAuth app on the
|
||||
// same hostname (browsers scope cookies by host, not by port).
|
||||
// See https://github.com/homarr-labs/homarr/issues/5773
|
||||
const createCookies = (useSecureCookies: boolean) => {
|
||||
const prefix = env.AUTH_COOKIE_PREFIX;
|
||||
const securePrefix = useSecureCookies ? "__Secure-" : "";
|
||||
const hostPrefix = useSecureCookies ? "__Host-" : "";
|
||||
return {
|
||||
sessionToken: { name: sessionTokenCookieName },
|
||||
csrfToken: { name: `${hostPrefix}${prefix}.csrf-token` },
|
||||
callbackUrl: { name: `${securePrefix}${prefix}.callback-url` },
|
||||
pkceCodeVerifier: { name: `${securePrefix}${prefix}.pkce.cooldown` },
|
||||
state: { name: `${securePrefix}${prefix}.state` },
|
||||
nonce: { name: `${securePrefix}${prefix}.nonce` },
|
||||
};
|
||||
};
|
||||
|
||||
// See why it's unknown in the [...nextauth]/route.ts file
|
||||
export const createConfiguration = (
|
||||
provider: SupportedAuthProvider | "unknown",
|
||||
@@ -59,7 +41,11 @@ export const createConfiguration = (
|
||||
},
|
||||
},
|
||||
trustHost: true,
|
||||
cookies: createCookies(useSecureCookies),
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: sessionTokenCookieName,
|
||||
},
|
||||
},
|
||||
adapter,
|
||||
providers: filterProviders([
|
||||
Credentials(createCredentialsConfiguration(db)),
|
||||
|
||||
@@ -28,11 +28,6 @@ export const env = createEnv({
|
||||
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
|
||||
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
|
||||
AUTH_PROVIDERS: authProvidersSchema,
|
||||
AUTH_COOKIE_PREFIX: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-zA-Z0-9-_]+$/, "AUTH_COOKIE_PREFIX must only contain letters, numbers, hyphens and underscores")
|
||||
.default("homarr"),
|
||||
...(authProviders.includes("oidc")
|
||||
? {
|
||||
AUTH_OIDC_ISSUER: z.string().url(),
|
||||
@@ -42,7 +37,6 @@ export const env = createEnv({
|
||||
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
|
||||
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: createBooleanSchema(false), // When enabled, group memberships of oidc users are managed locally instead of synced from the groups claim
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
|
||||
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
|
||||
|
||||
@@ -31,12 +31,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
|
||||
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
|
||||
// Groups from oidc provider are provided from the profile, it's not typed.
|
||||
if (
|
||||
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
|
||||
profile &&
|
||||
groupsKey in profile &&
|
||||
Array.isArray(profile[groupsKey])
|
||||
) {
|
||||
if (profile && groupsKey in profile && Array.isArray(profile[groupsKey])) {
|
||||
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
|
||||
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,9 @@
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { supportedAuthProviders } from "@homarr/definitions";
|
||||
|
||||
import { env } from "../env";
|
||||
|
||||
export type GroupMemberManagementType = "local" | "mixed" | "external";
|
||||
|
||||
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
|
||||
// The question mark is placed there because isProviderEnabled is called during static build of about page
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return env.AUTH_PROVIDERS?.includes(provider);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a single provider's group memberships are managed locally (manually via UI/API).
|
||||
* True for credentials users, and for oidc users when AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT is enabled.
|
||||
*/
|
||||
export const isGroupMembershipManagedLocally = (provider: SupportedAuthProvider): boolean => {
|
||||
switch (provider) {
|
||||
case "credentials":
|
||||
return true;
|
||||
case "oidc":
|
||||
return env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT;
|
||||
default:
|
||||
// ldap + any provider added later: externally managed (synced, not editable
|
||||
// here) until explicitly given a case above. New providers safe-by-default.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getEnabledProviders = (): SupportedAuthProvider[] => supportedAuthProviders.filter(isProviderEnabled);
|
||||
|
||||
/**
|
||||
* The enabled providers whose group memberships can be managed locally.
|
||||
*/
|
||||
export const getLocallyManageableProviders = (): SupportedAuthProvider[] =>
|
||||
getEnabledProviders().filter(isGroupMembershipManagedLocally);
|
||||
|
||||
/**
|
||||
* Classifies group-member management across all enabled providers:
|
||||
* - "local": every enabled provider is managed locally (no member is synced externally)
|
||||
* - "external": no enabled provider is managed locally (every member is synced externally)
|
||||
* - "mixed": some are managed locally, some externally (only a subset of members is editable here)
|
||||
*/
|
||||
export const getGroupMemberManagementType = (): GroupMemberManagementType => {
|
||||
const enabled = getEnabledProviders();
|
||||
const manageable = enabled.filter(isGroupMembershipManagedLocally);
|
||||
|
||||
if (manageable.length === enabled.length) return "local";
|
||||
if (manageable.length === 0) return "external";
|
||||
return "mixed";
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether group memberships can be managed locally for at least one enabled provider.
|
||||
*/
|
||||
export const canManageGroupMembersLocally = (): boolean => getLocallyManageableProviders().length > 0;
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user