Compare commits
138
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80b8aae9fc | ||
|
|
58cb65cb3a | ||
|
|
89380f81dd | ||
|
|
e01571b221 | ||
|
|
49c266374d | ||
|
|
577950d20e | ||
|
|
82bf71b073 | ||
|
|
e26883acfa | ||
|
|
8ee747cde8 | ||
|
|
faa7e55b1b | ||
|
|
ce021a89ac | ||
|
|
55fb227305 | ||
|
|
39e424563a | ||
|
|
e29c79a902 | ||
|
|
a19c9d88ca | ||
|
|
1fb97ee9c6 | ||
|
|
f69e545d64 | ||
|
|
52df56b19d | ||
|
|
0f66bfd6c1 | ||
|
|
842c5a0b4c | ||
|
|
ce9134dee2 | ||
|
|
9ce9be6b7e | ||
|
|
7ec22411a6 | ||
|
|
d3ae06e515 | ||
|
|
d09a4f8468 | ||
|
|
e1a245ff2d | ||
|
|
bf4396917e | ||
|
|
d059023b51 | ||
|
|
d9cbd5427d | ||
|
|
c56d9d6894 | ||
|
|
3ea49f0391 | ||
|
|
e2f8f5f081 | ||
|
|
f57037b568 | ||
|
|
84803aeefb | ||
|
|
5f47298f3d | ||
|
|
de858011fc | ||
|
|
12f8e2b83a | ||
|
|
78b6e520ac | ||
|
|
bcaa93f90b | ||
|
|
15e6612697 | ||
|
|
eef06dd5a3 | ||
|
|
84e044f427 | ||
|
|
609ab9976a | ||
|
|
28f570aa79 | ||
|
|
5b4f03da35 | ||
|
|
6960ac15e9 | ||
|
|
f1ee76c99c | ||
|
|
9a9ee5a9d7 | ||
|
|
7debe34e24 | ||
|
|
0d67c5a451 | ||
|
|
2db0fe15fb | ||
|
|
4b85afbb87 | ||
|
|
536f82af04 | ||
|
|
445e191490 | ||
|
|
52c624addb | ||
|
|
91f9329773 | ||
|
|
4f1fc4d616 | ||
|
|
8bee0174e9 | ||
|
|
45dfe3b9ab | ||
|
|
5078c300f8 | ||
|
|
065b78fa3e | ||
|
|
39900090b3 | ||
|
|
2af2d59b8f | ||
|
|
d0450b5153 | ||
|
|
5f588ae287 | ||
|
|
066fb038b5 | ||
|
|
ece436a1cc | ||
|
|
4da2feda35 | ||
|
|
0a05bbed8d | ||
|
|
420c0c30e4 | ||
|
|
8600c63bab | ||
|
|
2a5398bec0 | ||
|
|
c751feedf7 | ||
|
|
cbb5e301dc | ||
|
|
ea8f07e2bd | ||
|
|
fb18b975b2 | ||
|
|
4692e7ce3a | ||
|
|
dd57ee2102 | ||
|
|
2ae83458e6 | ||
|
|
5a1967bdd4 | ||
|
|
a4f0eda4a0 | ||
|
|
1e07ce5c96 | ||
|
|
176222f56b | ||
|
|
d6ce320eb2 | ||
|
|
094f23778c | ||
|
|
ec9f3cc5ad | ||
|
|
7ff115465a | ||
|
|
58058f2ea6 | ||
|
|
6881b8355c | ||
|
|
7837b6fa6b | ||
|
|
f263388f71 | ||
|
|
9aecb2ae13 | ||
|
|
1d691c0994 | ||
|
|
baf2b09327 | ||
|
|
3d93ae1246 | ||
|
|
df66cf25c7 | ||
|
|
15ed1e51fd | ||
|
|
9abce89a14 | ||
|
|
7ffb90abd2 | ||
|
|
4a65d2ae87 | ||
|
|
4857b72804 | ||
|
|
8134b168e1 | ||
|
|
583b1d2935 | ||
|
|
6189daf6a4 | ||
|
|
569293275d | ||
|
|
d6ced037df | ||
|
|
8d2b2ee74a | ||
|
|
c5b4cf9a0e | ||
|
|
2316f6e79d | ||
|
|
16094876fa | ||
|
|
6cb9024781 | ||
|
|
a68eee24eb | ||
|
|
2d4832dc96 | ||
|
|
e29c46cc5a | ||
|
|
85e74691f2 | ||
|
|
ed333e1cbb | ||
|
|
e0e493e2f9 | ||
|
|
d13effdda0 | ||
|
|
7e46c71187 | ||
|
|
4e32e81c54 | ||
|
|
c5266a7a42 | ||
|
|
680cbc69d8 | ||
|
|
e22eee271c | ||
|
|
441cc692f9 | ||
|
|
c9769bf530 | ||
|
|
5035c26dbd | ||
|
|
4d8beac7d3 | ||
|
|
824f31d6aa | ||
|
|
90ceaf5fe8 | ||
|
|
82eac2dcc1 | ||
|
|
ab41b76a66 | ||
|
|
1f474c9ee7 | ||
|
|
0c15dc13fa | ||
|
|
992c48fdde | ||
|
|
7ccaabbd97 | ||
|
|
030b84674f | ||
|
|
5fe43eb363 | ||
|
|
db8440bddf |
No files matched your search
@@ -33,6 +33,10 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
|
- 1.67.0
|
||||||
|
- 1.66.1
|
||||||
|
- 1.66.0
|
||||||
|
- 1.65.0
|
||||||
- 1.64.0
|
- 1.64.0
|
||||||
- 1.62.0
|
- 1.62.0
|
||||||
- 1.61.0
|
- 1.61.0
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name: "[Deployment] Automatic Weekly Release"
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||||
|
timezone: Europe/Zurich
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
send-notifications:
|
send-notifications:
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
"js/ts.tsdk.path": "node_modules\\typescript\\lib",
|
||||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"ajnart",
|
"ajnart",
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
## [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)
|
## [1.65.0](https://github.com/homarr-labs/homarr/compare/v1.64.0...v1.65.0) (2026-06-12)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+14
@@ -20,6 +20,17 @@ ARG DISABLE_REDIS_LOGS='true'
|
|||||||
|
|
||||||
RUN corepack enable pnpm && pnpm build
|
RUN corepack enable pnpm && pnpm build
|
||||||
|
|
||||||
|
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
|
||||||
|
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
|
||||||
|
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
|
||||||
|
# See https://github.com/homarr-labs/homarr/issues/5891
|
||||||
|
RUN cd apps/nextjs/.next/server && \
|
||||||
|
find . -name '*.map' | while IFS= read -r map; do \
|
||||||
|
dest="../standalone/apps/nextjs/.next/server/$map"; \
|
||||||
|
mkdir -p "$(dirname "$dest")"; \
|
||||||
|
cp "$map" "$dest"; \
|
||||||
|
done
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -64,6 +75,9 @@ ENV DB_DRIVER='better-sqlite3'
|
|||||||
ENV AUTH_PROVIDERS='credentials'
|
ENV AUTH_PROVIDERS='credentials'
|
||||||
ENV REDIS_IS_EXTERNAL='false'
|
ENV REDIS_IS_EXTERNAL='false'
|
||||||
ENV NODE_ENV='production'
|
ENV NODE_ENV='production'
|
||||||
|
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
|
||||||
|
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
|
||||||
|
ENV NODE_OPTIONS='--enable-source-maps'
|
||||||
|
|
||||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||||
CMD ["sh", "run.sh"]
|
CMD ["sh", "run.sh"]
|
||||||
@@ -23,6 +23,7 @@ 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_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_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_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>
|
<Tabs>
|
||||||
<TabItem value="credentials" label="Credentials Provider" default>
|
<TabItem value="credentials" label="Credentials Provider" default>
|
||||||
@@ -124,6 +125,8 @@ 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`.
|
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>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
Example Setup
|
Example Setup
|
||||||
@@ -197,6 +200,7 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
|||||||
| ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false |
|
| ``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_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_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_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_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 |
|
| ``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 |
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ 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_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_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_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_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_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
|
||||||
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
|
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
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: []
|
||||||
|
}]} />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
@@ -130,6 +130,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
|
|||||||
| ------------------ | -------------------------------------------------------- | ---------- |
|
| ------------------ | -------------------------------------------------------- | ---------- |
|
||||||
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
|
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
|
||||||
| `apiKeys` | List API keys | GET |
|
| `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 |
|
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
|
||||||
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
|
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
|
||||||
| `icon` | Search icons | GET |
|
| `icon` | Search icons | GET |
|
||||||
@@ -162,3 +163,4 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
|
|||||||
- **"Create a new app pointing to my Grafana instance"** - AI creates an app with the correct icon and URL
|
- **"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)
|
- **"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
|
- **"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
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"label": "Tools",
|
|
||||||
"collapsed": true,
|
|
||||||
"position": 10
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
title: SQLite Backup & Restore
|
|
||||||
tags:
|
|
||||||
- Management
|
|
||||||
- Backup
|
|
||||||
- Restore
|
|
||||||
- SQLite
|
|
||||||
---
|
|
||||||
|
|
||||||
The SQLite Backup & Restore feature allows you to export and import your entire Homarr database as a portable ZIP file. This is useful for creating backups, migrating between instances, or recovering from failures.
|
|
||||||
|
|
||||||
:::info SQLite only
|
|
||||||
This feature is only available when using the SQLite database driver (`DB_DRIVER=better-sqlite3`). MySQL and PostgreSQL users should use their respective database backup tools.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Accessing Backup & Restore
|
|
||||||
|
|
||||||
Navigate to **Management → Tools → Backup** from the sidebar. This page is only visible when running with SQLite.
|
|
||||||
|
|
||||||
## Exporting a Backup
|
|
||||||
|
|
||||||
Click the **Export** button to download a ZIP archive containing:
|
|
||||||
|
|
||||||
- **`db.sqlite`** — A consistent snapshot of your database (created via `VACUUM INTO`)
|
|
||||||
- **`metadata.json`** — Contains:
|
|
||||||
- `homarrVersion` — The version of Homarr that created the backup
|
|
||||||
- `exportedAt` — Timestamp of the export
|
|
||||||
- `dbDialect` — The database dialect (`sqlite`)
|
|
||||||
- `encryptionKey` — Your `SECRET_ENCRYPTION_KEY` (needed to decrypt integration secrets)
|
|
||||||
|
|
||||||
:::caution
|
|
||||||
The exported ZIP contains your encryption key and database. Store it securely and do not share it publicly.
|
|
||||||
:::
|
|
||||||
|
|
||||||
A confirmation dialog will ask you to type **"I understand"** before the export proceeds.
|
|
||||||
|
|
||||||
## Importing a Backup
|
|
||||||
|
|
||||||
1. Click the **Import** button and upload a previously exported ZIP file
|
|
||||||
2. Homarr analyzes the backup **entirely in your browser** using WebAssembly (sql.js) — no data is sent to the server during preview
|
|
||||||
3. The preview shows:
|
|
||||||
- **Entity counts** — How many boards, apps, users, integrations, etc. are in the backup
|
|
||||||
- **Board names** — A list of all boards contained in the backup
|
|
||||||
- **Migration status** — If the backup is from an older version, Homarr shows each pending database migration as animated timeline steps
|
|
||||||
4. Review the preview and type **"I understand"** to confirm
|
|
||||||
5. Homarr applies any pending migrations, re-encrypts integration secrets if the `SECRET_ENCRYPTION_KEY` differs between instances, and replaces the live database
|
|
||||||
6. The server automatically restarts to pick up the new database
|
|
||||||
|
|
||||||
:::tip
|
|
||||||
The import process is **backward-compatible** with older backups that used a separate `encryption-key.txt` file instead of embedding the key in `metadata.json`.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Onboarding Integration
|
|
||||||
|
|
||||||
When setting up a fresh Homarr instance with SQLite, the onboarding wizard includes a **"Restore from SQLite backup"** option. This lets you upload a backup ZIP to bootstrap your new instance with data from a previous installation.
|
|
||||||
|
|
||||||
## Docker Restart Behavior
|
|
||||||
|
|
||||||
After a successful import, only the Next.js process restarts — nginx and Redis remain running. The `run.sh` script automatically restarts Next.js in a loop, so the application comes back online within seconds. Running `docker stop` still works gracefully.
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
- **Back up regularly** — Create exports before major updates or configuration changes
|
|
||||||
- **Test restores** — Periodically verify your backups by importing them on a test instance
|
|
||||||
- **Secure storage** — Keep backups in an encrypted or access-controlled location since they contain your encryption key
|
|
||||||
- **Check versions** — Backups from newer versions cannot be imported into older Homarr instances; always import into the same or newer version
|
|
||||||
@@ -8,12 +8,19 @@ export const beszelSystemStatsWidget: WidgetDefinition = {
|
|||||||
path: "../../widgets/beszel-system-stats",
|
path: "../../widgets/beszel-system-stats",
|
||||||
configuration: {
|
configuration: {
|
||||||
items: [
|
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",
|
name: "Time period",
|
||||||
description: "The time range for chart data",
|
description:
|
||||||
|
"The time range for chart data. '1 Minute' enables live streaming via PocketBase SSE (requires Beszel agent >= 0.13.0)",
|
||||||
values: {
|
values: {
|
||||||
type: "select",
|
type: "select",
|
||||||
options: ["1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"],
|
options: ["1 Minute", "1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"],
|
||||||
},
|
},
|
||||||
defaultValue: "1 Hour",
|
defaultValue: "1 Hour",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { WidgetDefinition } from "@site/src/types";
|
import { WidgetDefinition } from "@site/src/types";
|
||||||
import { IconDeviceGamepad } from "@tabler/icons-react";
|
import { IconDeviceGamepad } from "@tabler/icons-react";
|
||||||
|
import { integrations } from "../dns-hole-summary";
|
||||||
|
|
||||||
export const dnsHoleControlsWidget: WidgetDefinition = {
|
export const dnsHoleControlsWidget: WidgetDefinition = {
|
||||||
icon: IconDeviceGamepad,
|
icon: IconDeviceGamepad,
|
||||||
name: "DNS Hole Controls",
|
name: "DNS Hole Controls",
|
||||||
description: "Control PiHole or AdGuard from your dashboard",
|
description: `Control the blocking feature of your ${integrations} from your dashboard`,
|
||||||
path: "../../widgets/dns-hole-controls",
|
path: "../../widgets/dns-hole-controls",
|
||||||
configuration: {
|
configuration: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: "Show Toggle All Buttons",
|
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" },
|
values: { type: "boolean" },
|
||||||
defaultValue: "yes",
|
defaultValue: "yes",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { WidgetDefinition } from "@site/src/types";
|
import { WidgetDefinition } from "@site/src/types";
|
||||||
import { IconAd } from "@tabler/icons-react";
|
import { IconAd } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export const integrations = "Pi-hole, AdGuard Home or Technitium DNS";
|
||||||
|
|
||||||
export const dnsHoleSummaryWidget: WidgetDefinition = {
|
export const dnsHoleSummaryWidget: WidgetDefinition = {
|
||||||
icon: IconAd,
|
icon: IconAd,
|
||||||
name: "DNS Hole Summary",
|
name: "DNS Hole Summary",
|
||||||
description: "Displays the summary of your DNS Hole",
|
description: `Displays the summary of your ${integrations} blocks and queries statistics`,
|
||||||
path: "../../widgets/dns-hole-summary",
|
path: "../../widgets/dns-hole-summary",
|
||||||
configuration: {
|
configuration: {
|
||||||
items: [
|
items: [
|
||||||
@@ -19,7 +21,7 @@ export const dnsHoleSummaryWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Use Pi-Hole colors",
|
name: "Use Pi-Hole colors",
|
||||||
description: "Color the tiles using the background color, that is used by default in PiHole.",
|
description: `Color the tiles using the background color, that is used by default in ${integrations}.`,
|
||||||
values: { type: "boolean" },
|
values: { type: "boolean" },
|
||||||
defaultValue: "yes",
|
defaultValue: "yes",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import Tabs from '@theme/Tabs';
|
|||||||
import TabItem from '@theme/TabItem';
|
import TabItem from '@theme/TabItem';
|
||||||
import { notificationsWidget } from '.';
|
import { notificationsWidget } from '.';
|
||||||
import { ntfyIntegration } from '@site/docs/integrations/ntfy';
|
import { ntfyIntegration } from '@site/docs/integrations/ntfy';
|
||||||
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'
|
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud';
|
||||||
|
import { gotifyIntegration } from '@site/docs/integrations/gotify';
|
||||||
|
|
||||||
<WidgetHeader
|
<WidgetHeader
|
||||||
widget={notificationsWidget}
|
widget={notificationsWidget}
|
||||||
@@ -33,7 +34,8 @@ import notificationsScreenshot from './img/notifications.png';
|
|||||||
<WidgetIntegrations
|
<WidgetIntegrations
|
||||||
items={[
|
items={[
|
||||||
{ integration: ntfyIntegration },
|
{ integration: ntfyIntegration },
|
||||||
{ integration: nextcloudIntegration }
|
{ integration: nextcloudIntegration },
|
||||||
|
{ integration: gotifyIntegration }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -32,19 +32,19 @@
|
|||||||
"@scalar/api-reference-react": "catalog:",
|
"@scalar/api-reference-react": "catalog:",
|
||||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||||
"leader-line-new": "^1.1.9",
|
"leader-line-new": "^1.1.9",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.15",
|
||||||
"posthog-docusaurus": "^2.0.5",
|
"posthog-docusaurus": "^2.0.5",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"random-word-slugs": "^0.1.7",
|
"random-word-slugs": "^0.1.7",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"swiper": "^12.2.0",
|
"swiper": "^12.2.0",
|
||||||
"tailwindcss": "^4.3.0"
|
"tailwindcss": "^4.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
|
||||||
|
productionBrowserSourceMaps: true,
|
||||||
// react compiler breaks mantine-react-table, so disabled for now
|
// react compiler breaks mantine-react-table, so disabled for now
|
||||||
//reactCompiler: true,
|
//reactCompiler: true,
|
||||||
/** We already do typechecking as separate tasks in CI */
|
/** We already do typechecking as separate tasks in CI */
|
||||||
@@ -41,6 +43,8 @@ const nextConfig: NextConfig = {
|
|||||||
*/
|
*/
|
||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||||
experimental: {
|
experimental: {
|
||||||
|
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
|
||||||
|
serverSourceMaps: true,
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
preloadEntriesOnStart: false,
|
preloadEntriesOnStart: false,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
"mcp-handler": "catalog:",
|
"mcp-handler": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"postcss-preset-mantine": "catalog:",
|
"postcss-preset-mantine": "catalog:",
|
||||||
|
"posthog-js": "catalog:",
|
||||||
"prismjs": "catalog:",
|
"prismjs": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
|
|||||||
@@ -139,11 +139,11 @@ export default async function Layout(props: {
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<Analytics />
|
|
||||||
<SearchEngineOptimization />
|
<SearchEngineOptimization />
|
||||||
<CrowdinLiveTranslation locale={locale} />
|
<CrowdinLiveTranslation locale={locale} />
|
||||||
</head>
|
</head>
|
||||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||||
|
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||||
<StackedProvider>
|
<StackedProvider>
|
||||||
<Notifications pauseResetOnHover="notification" />
|
<Notifications pauseResetOnHover="notification" />
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
|||||||
{definitions.map((def) => (
|
{definitions.map((def) => (
|
||||||
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
|
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs">
|
<Group gap="xs" wrap="nowrap">
|
||||||
{def.iconUrl ? (
|
{def.iconUrl ? (
|
||||||
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||||
) : (
|
) : (
|
||||||
<IconApi size={16} />
|
<IconApi size={16} />
|
||||||
)}
|
)}
|
||||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"}>
|
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
|
||||||
{def.name}
|
{def.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
+2
@@ -131,6 +131,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
|||||||
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
|
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
|
||||||
error.data.reason === "hostnameMismatch" ? (
|
error.data.reason === "hostnameMismatch" ? (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||||
@@ -140,6 +141,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
|||||||
) : null}
|
) : null}
|
||||||
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ interface AnalyticsSettingsProps {
|
|||||||
|
|
||||||
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||||
const t = useScopedI18n("management.page.settings.section.analytics");
|
const t = useScopedI18n("management.page.settings.section.analytics");
|
||||||
|
const { instanceId: _, ...formDefaults } = initialData;
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: initialData,
|
initialValues: formDefaults,
|
||||||
onValuesChange: (updatedValues, _) => {
|
onValuesChange: (updatedValues, _prev) => {
|
||||||
if (!form.isValid()) {
|
if (!form.isValid()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,16 +149,20 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
interface DockerTableProps {
|
||||||
|
initialData?: RouterOutputs["docker"]["getContainers"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockerTable({ initialData }: DockerTableProps) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tDocker = useScopedI18n("docker");
|
const tDocker = useScopedI18n("docker");
|
||||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||||
initialData: { containers, timestamp },
|
initialData,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
const relativeTime = useTimeAgo(data.timestamp);
|
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
@@ -176,8 +180,11 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const containers = data?.containers ?? [];
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data: data.containers,
|
data: containers,
|
||||||
|
state: { isLoading: isFetching },
|
||||||
enableDensityToggle: false,
|
enableDensityToggle: false,
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
enableColumnFilters: false,
|
enableColumnFilters: false,
|
||||||
@@ -190,7 +197,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
|||||||
enableBottomToolbar: false,
|
enableBottomToolbar: false,
|
||||||
positionGlobalFilter: "right",
|
positionGlobalFilter: "right",
|
||||||
mantineSearchTextInputProps: {
|
mantineSearchTextInputProps: {
|
||||||
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
|
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
||||||
style: { minWidth: 300 },
|
style: { minWidth: 300 },
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default async function DockerPage() {
|
|||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tDocker("title")}</Title>
|
<Title order={1}>{tDocker("title")}</Title>
|
||||||
<DockerTable containers={containers} timestamp={timestamp} />
|
<DockerTable initialData={{ containers, timestamp }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback } from "react";
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -13,9 +14,10 @@ import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
|||||||
interface AddGroupMemberProps {
|
interface AddGroupMemberProps {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
presentUserIds: string[];
|
presentUserIds: string[];
|
||||||
|
allowedProviders: SupportedAuthProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps) => {
|
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
|
||||||
const tMembersAdd = useScopedI18n("group.action.addMember");
|
const tMembersAdd = useScopedI18n("group.action.addMember");
|
||||||
const { mutateAsync } = clientApi.group.addMember.useMutation();
|
const { mutateAsync } = clientApi.group.addMember.useMutation();
|
||||||
const { openModal } = useModalAction(UserSelectModal);
|
const { openModal } = useModalAction(UserSelectModal);
|
||||||
@@ -32,13 +34,13 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps)
|
|||||||
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
|
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
|
||||||
},
|
},
|
||||||
presentUserIds,
|
presentUserIds,
|
||||||
excludeExternalProviders: true,
|
allowedProviders,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: tMembersAdd("label"),
|
title: tMembersAdd("label"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
|
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]);
|
||||||
|
|
||||||
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
|
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
|
||||||
};
|
};
|
||||||
@@ -4,9 +4,12 @@ import { IconExclamationCircle } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { env } from "@homarr/auth/env";
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import {
|
||||||
|
getGroupMemberManagementType,
|
||||||
|
getLocallyManageableProviders,
|
||||||
|
isGroupMembershipManagedLocally,
|
||||||
|
} from "@homarr/auth/server";
|
||||||
import { everyoneGroup } from "@homarr/definitions";
|
import { everyoneGroup } from "@homarr/definitions";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { Link, SearchInput, UserAvatar } from "@homarr/ui";
|
import { Link, SearchInput, UserAvatar } from "@homarr/ui";
|
||||||
@@ -38,16 +41,15 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
|||||||
const group = await api.group.getById({ id: params.id });
|
const group = await api.group.getById({ id: params.id });
|
||||||
const isReserved = group.name === everyoneGroup;
|
const isReserved = group.name === everyoneGroup;
|
||||||
|
|
||||||
const filteredMembers = searchParams.search
|
const searchTerm = searchParams.search?.trim().toLowerCase();
|
||||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
const filteredMembers = group.members.filter(
|
||||||
group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
|
(member) => !searchTerm || member.name?.toLowerCase().includes(searchTerm),
|
||||||
: group.members;
|
);
|
||||||
|
|
||||||
const providerTypes = isProviderEnabled("credentials")
|
// "local" = every enabled provider managed locally, "external" = none, "mixed" = some.
|
||||||
? env.AUTH_PROVIDERS.length > 1
|
const managementType = getGroupMemberManagementType();
|
||||||
? "mixed"
|
const canManageMembers = managementType !== "external";
|
||||||
: "credentials"
|
const allowedProviders = getLocallyManageableProviders();
|
||||||
: "external";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -56,17 +58,21 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
|||||||
{isReserved ? (
|
{isReserved ? (
|
||||||
<ReservedGroupAlert />
|
<ReservedGroupAlert />
|
||||||
) : (
|
) : (
|
||||||
providerTypes !== "credentials" && (
|
managementType !== "local" && (
|
||||||
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||||
{t(`group.memberNotice.${providerTypes}`)}
|
{t(`group.memberNotice.${managementType}`)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
|
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
|
||||||
{isProviderEnabled("credentials") && !isReserved && (
|
{canManageMembers && !isReserved && (
|
||||||
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
|
<AddGroupMember
|
||||||
|
groupId={group.id}
|
||||||
|
presentUserIds={group.members.map((member) => member.id)}
|
||||||
|
allowedProviders={allowedProviders}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{filteredMembers.length === 0 && (
|
{filteredMembers.length === 0 && (
|
||||||
@@ -94,6 +100,8 @@ interface RowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Row = ({ member, groupId, disabled }: RowProps) => {
|
const Row = ({ member, groupId, disabled }: RowProps) => {
|
||||||
|
const canBeRemoved = isGroupMembershipManagedLocally(member.provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
@@ -104,9 +112,7 @@ const Row = ({ member, groupId, disabled }: RowProps) => {
|
|||||||
</Anchor>
|
</Anchor>
|
||||||
</Group>
|
</Group>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd w={100}>
|
<TableTd w={100}>{canBeRemoved && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}</TableTd>
|
||||||
{member.provider === "credentials" && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}
|
|
||||||
</TableTd>
|
|
||||||
</TableTr>
|
</TableTr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -148,6 +148,8 @@ 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 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.
|
**"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.
|
**"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,6 +5,7 @@ import { IconCheck } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
import { useForm } from "@homarr/form";
|
import { useForm } from "@homarr/form";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -12,7 +13,7 @@ import { UserAvatar } from "@homarr/ui";
|
|||||||
|
|
||||||
interface InnerProps {
|
interface InnerProps {
|
||||||
presentUserIds: string[];
|
presentUserIds: string[];
|
||||||
excludeExternalProviders?: boolean;
|
allowedProviders?: SupportedAuthProvider[];
|
||||||
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
|
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
}
|
}
|
||||||
@@ -24,7 +25,7 @@ interface UserSelectFormType {
|
|||||||
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { data: users, isPending } = clientApi.user.selectable.useQuery({
|
const { data: users, isPending } = clientApi.user.selectable.useQuery({
|
||||||
excludeExternalProviders: innerProps.excludeExternalProviders,
|
providers: innerProps.allowedProviders,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const form = useForm<UserSelectFormType>();
|
const form = useForm<UserSelectFormType>();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
bottom: calc(100% - 10px);
|
bottom: calc(100% - 10px);
|
||||||
margin-bottom: 1px;
|
margin-bottom: 1px;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
pointer-events: auto;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(6px);
|
transform: translateY(6px);
|
||||||
transition:
|
transition:
|
||||||
@@ -13,8 +13,10 @@
|
|||||||
transform 150ms ease;
|
transform 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
|
||||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
.wrapper,
|
||||||
|
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
|
||||||
|
.wrapper {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,8 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadataItem {
|
.metadataItem {
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
|
import { Avatar, Text, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { getIconUrl } from "@homarr/definitions";
|
import { getIconUrl } from "@homarr/definitions";
|
||||||
import { useSettings } from "@homarr/settings";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
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";
|
import classes from "./widget-hover-overlay.module.css";
|
||||||
|
|
||||||
interface WidgetHoverOverlayProps {
|
interface WidgetHoverOverlayProps {
|
||||||
@@ -21,27 +18,17 @@ interface WidgetHoverOverlayProps {
|
|||||||
|
|
||||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const settings = useSettings();
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { definition } = widgetImports[item.kind];
|
const { definition } = widgetImports[item.kind];
|
||||||
const WidgetIcon = definition.icon;
|
const WidgetIcon = definition.icon;
|
||||||
|
|
||||||
const translate = ((key: string) => t(key as never)) as Translator;
|
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||||
const displayName = resolveWidgetDisplayName(item, settings, translate);
|
|
||||||
|
|
||||||
const connectedIntegrations = (integrations ?? []).filter((integration) =>
|
const connectedIntegrations = (integrations ?? []).filter((integration) =>
|
||||||
item.integrationIds.includes(integration.id),
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +42,13 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasIntegrations && (
|
{connectedIntegrations.length > 0 && (
|
||||||
<Avatar.Group spacing="xs" className={classes.integrations}>
|
<Avatar.Group spacing="xs" className={classes.integrations}>
|
||||||
{connectedIntegrations.map((integration) => (
|
{connectedIntegrations.map((integration) => (
|
||||||
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
|
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={getIconUrl(integration.kind)}
|
src={getIconUrl(integration.kind)}
|
||||||
size={20}
|
size={18}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
className={classes.integrationAvatar}
|
className={classes.integrationAvatar}
|
||||||
/>
|
/>
|
||||||
@@ -69,17 +56,6 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
|||||||
))}
|
))}
|
||||||
</Avatar.Group>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import Script from "next/script";
|
"use client";
|
||||||
|
|
||||||
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
|
import { useEffect } from "react";
|
||||||
import { db } from "@homarr/db";
|
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
|
||||||
|
|
||||||
export const Analytics = async () => {
|
const POSTHOG_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
|
||||||
// For static pages it will not find any analytics data so we do not include the script on them
|
const POSTHOG_HOST = "https://hog.homarr.dev";
|
||||||
const analytics = await getServerSettingByKeyAsync(db, "analytics").catch(() => null);
|
|
||||||
|
|
||||||
if (analytics?.enableGeneral) {
|
export const Analytics = ({ enabled }: { enabled: boolean }) => {
|
||||||
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
|
useEffect(() => {
|
||||||
}
|
if (!enabled) return;
|
||||||
|
|
||||||
return <></>;
|
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;
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createModal } from "@homarr/modals";
|
||||||
|
|
||||||
|
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
|
||||||
|
|
||||||
|
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
|
||||||
|
defaultTitle: (t) => t("docker.title"),
|
||||||
|
size: 1400,
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core";
|
|||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { env } from "@homarr/docker/env";
|
||||||
|
|
||||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||||
@@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update";
|
|||||||
export const UserButton = async () => {
|
export const UserButton = async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const isAdmin = session?.user.permissions.includes("admin");
|
const isAdmin = session?.user.permissions.includes("admin");
|
||||||
|
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||||
return (
|
return (
|
||||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
|
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||||
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
import {
|
||||||
|
IconBrandDocker,
|
||||||
|
IconCheck,
|
||||||
|
IconHome,
|
||||||
|
IconLogin,
|
||||||
|
IconLogout,
|
||||||
|
IconSettings,
|
||||||
|
IconTool,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { signOut, useSession } from "@homarr/auth/client";
|
import { signOut, useSession } from "@homarr/auth/client";
|
||||||
@@ -17,14 +25,16 @@ import { Link } from "@homarr/ui";
|
|||||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||||
|
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||||
|
|
||||||
interface UserAvatarMenuProps {
|
interface UserAvatarMenuProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||||
|
isDockerEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
|
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||||
const t = useScopedI18n("common.userAvatar.menu");
|
const t = useScopedI18n("common.userAvatar.menu");
|
||||||
const { toggleColorScheme } = useMantineColorScheme();
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||||
@@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
|||||||
|
|
||||||
const { logoutUrl } = useAuthContext();
|
const { logoutUrl } = useAuthContext();
|
||||||
const { openModal } = useModalAction(LogoutModal);
|
const { openModal } = useModalAction(LogoutModal);
|
||||||
|
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||||
|
|
||||||
const handleSignout = useCallback(async () => {
|
const handleSignout = useCallback(async () => {
|
||||||
await signOut({
|
await signOut({
|
||||||
@@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
|||||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||||
{t("management")}
|
{t("management")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
{isDockerEnabled && (
|
||||||
|
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||||
|
{t("docker")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0",
|
"node": ">=24.16.0",
|
||||||
"pnpm": ">=11.0.0"
|
"pnpm": ">=11.7.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.5.3"
|
"packageManager": "pnpm@11.7.0"
|
||||||
}
|
}
|
||||||
+44
-30
@@ -74,6 +74,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<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">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg" alt="Aria2" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -100,15 +107,15 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Codeberg</p>
|
<p align="center">Codeberg</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Coolify</p>
|
<p align="center">Coolify</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -149,15 +156,15 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">GitHub<br/>Container<br/>Registry</p>
|
<p align="center">GitHub<br/>Container<br/>Registry</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/gitlab" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg" alt="Gitlab" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
<p align="center">Gitlab</p>
|
<p align="center">Gitlab</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg" alt="Glances" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg" alt="Glances" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -165,6 +172,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<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">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -191,8 +205,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Jellyfin</p>
|
<p align="center">Jellyfin</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/jellyseerr" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg" alt="Jellyseerr" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -205,8 +219,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Lidarr</p>
|
<p align="center">Lidarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/linux-server-io" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/linux-server-io" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg" alt="LinuxServer.io" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg" alt="LinuxServer.io" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -240,8 +254,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">ntfy</p>
|
<p align="center">ntfy</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/nzbget" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg" alt="NZBGet" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -254,8 +268,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">OpenMediaVault</p>
|
<p align="center">OpenMediaVault</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/opnsense" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/opnsense" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg" alt="OPNsense" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg" alt="OPNsense" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -289,8 +303,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Pi-hole</p>
|
<p align="center">Pi-hole</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -303,8 +317,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Prowlarr</p>
|
<p align="center">Prowlarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/proxmox" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/proxmox" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg" alt="Proxmox" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg" alt="Proxmox" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -338,8 +352,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Readarr</p>
|
<p align="center">Readarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -352,8 +366,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Search.ch</p>
|
<p align="center">Search.ch</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/seerr" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/seerr" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg" alt="Seerr" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg" alt="Seerr" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -387,8 +401,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Tdarr</p>
|
<p align="center">Tdarr</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/technitium-dns" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg" alt="Technitium DNS" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -401,8 +415,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Tracearr</p>
|
<p align="center">Tracearr</p>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr><td align="center">
|
<td align="center">
|
||||||
<a href="https://homarr.dev/docs/integrations/transmission" target="_blank" rel="noreferrer noopener">
|
<a href="https://homarr.dev/docs/integrations/transmission" target="_blank" rel="noreferrer noopener">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg" alt="Transmission" width="90" height="90" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg" alt="Transmission" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
@@ -436,8 +450,8 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<p align="center">Unraid</p>
|
<p align="center">Unraid</p>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
<td align="center">
|
<tr><td align="center">
|
||||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
<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" />
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg" alt="Uptime Kuma" width="90" height="90" />
|
||||||
<br/>
|
<br/>
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.65.0",
|
"version": "1.67.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
@@ -62,9 +62,9 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0",
|
"node": ">=24.16.0",
|
||||||
"pnpm": ">=11.0.0"
|
"pnpm": ">=11.7.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.5.3",
|
"packageManager": "pnpm@11.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jsonpath-plus": "catalog:"
|
"jsonpath-plus": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,7 @@
|
|||||||
"@homarr/core": "workspace:^0.1.0",
|
"@homarr/core": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/docker": "workspace:^0.1.0",
|
"@homarr/docker": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"posthog-node": "catalog:"
|
||||||
"@umami/node": "catalog:",
|
|
||||||
"superjson": "catalog:"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
|
|
||||||
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from "./constants";
|
export { trackEvent } from "./client";
|
||||||
export * from "./send-server-analytics";
|
export { sendServerAnalyticsAsync } from "./send-server-analytics";
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import type { UmamiEventData } from "@umami/node";
|
|
||||||
import { Umami } from "@umami/node";
|
|
||||||
|
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { Stopwatch } from "@homarr/common";
|
import { createId, Stopwatch } from "@homarr/common";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { count, db } from "@homarr/db";
|
import { count, db } from "@homarr/db";
|
||||||
import { isMysql, isPostgresql } from "@homarr/db/collection";
|
import { isMysql, isPostgresql } from "@homarr/db/collection";
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import {
|
import {
|
||||||
|
accounts,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
apps,
|
apps,
|
||||||
boards,
|
boards,
|
||||||
cronJobConfigurations,
|
cronJobConfigurations,
|
||||||
|
customWidgetDefinitions,
|
||||||
groups,
|
groups,
|
||||||
iconRepositories,
|
iconRepositories,
|
||||||
icons,
|
icons,
|
||||||
@@ -24,86 +23,160 @@ import {
|
|||||||
searchEngines,
|
searchEngines,
|
||||||
sectionLayouts,
|
sectionLayouts,
|
||||||
sections,
|
sections,
|
||||||
|
sessions,
|
||||||
|
trustedCertificateHostnames,
|
||||||
users,
|
users,
|
||||||
} from "@homarr/db/schema";
|
} from "@homarr/db/schema";
|
||||||
import { env as dockerEnv } from "@homarr/docker/env";
|
import { env as dockerEnv } from "@homarr/docker/env";
|
||||||
|
|
||||||
import packageJson from "../../../package.json";
|
import packageJson from "../../../package.json";
|
||||||
import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
|
import { getPostHogClient } from "./client";
|
||||||
|
|
||||||
const logger = createLogger({ module: "analytics" });
|
const logger = createLogger({ module: "analytics" });
|
||||||
|
|
||||||
const getDatabaseType = (): "mysql" | "postgresql" | "sqlite" => {
|
type AnalyticsResult = "sent" | "disabled" | "failed";
|
||||||
if (isMysql()) return "mysql";
|
|
||||||
if (isPostgresql()) return "postgresql";
|
// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins
|
||||||
return "sqlite";
|
// 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 getEnabledAuthProviders = (): string[] => {
|
const sumGroupedCounts = (rows: { count: number }[]): number => rows.reduce((sum, row) => sum + row.count, 0);
|
||||||
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 () => {
|
export const sendServerAnalyticsAsync = async (): Promise<AnalyticsResult> => {
|
||||||
const stopWatch = new Stopwatch();
|
const stopWatch = new Stopwatch();
|
||||||
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
|
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
|
||||||
|
|
||||||
if (!analyticsSettings.enableGeneral) {
|
if (!analyticsSettings.enableGeneral) {
|
||||||
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
|
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
|
||||||
return;
|
return "disabled";
|
||||||
}
|
}
|
||||||
|
|
||||||
const umamiInstance = new Umami();
|
const client = getPostHogClient();
|
||||||
umamiInstance.init({
|
|
||||||
hostUrl: UMAMI_HOST_URL,
|
|
||||||
websiteId: UMAMI_WEBSITE_ID,
|
|
||||||
});
|
|
||||||
|
|
||||||
const enabledAuthProviders = getEnabledAuthProviders();
|
try {
|
||||||
|
const instanceId = await getOrCreateInstanceId(analyticsSettings);
|
||||||
|
|
||||||
const analyticsData: UmamiEventData = {
|
const [
|
||||||
homarrVersion: packageJson.version,
|
cultureSettings,
|
||||||
databaseType: getDatabaseType(),
|
countBoards,
|
||||||
dockerEnabled: dockerEnv.ENABLE_DOCKER ? 1 : 0,
|
countGroups,
|
||||||
kubernetesEnabled: dockerEnv.ENABLE_KUBERNETES ? 1 : 0,
|
countApps,
|
||||||
authCredentials: enabledAuthProviders.includes("credentials") ? 1 : 0,
|
countSections,
|
||||||
authOidc: enabledAuthProviders.includes("oidc") ? 1 : 0,
|
countSearchEngines,
|
||||||
authLdap: enabledAuthProviders.includes("ldap") ? 1 : 0,
|
countIconRepositories,
|
||||||
countUsers: await db.$count(users),
|
countIcons,
|
||||||
countBoards: await db.$count(boards),
|
countLayouts,
|
||||||
countGroups: await db.$count(groups),
|
countItemLayouts,
|
||||||
countApps: await db.$count(apps),
|
countSectionLayouts,
|
||||||
countWidgets: await db.$count(items),
|
countCronJobConfigs,
|
||||||
countSections: await db.$count(sections),
|
countCustomWidgets,
|
||||||
countIntegrations: await db.$count(integrations),
|
countTrustedCertificates,
|
||||||
countSearchEngines: await db.$count(searchEngines),
|
countUsers,
|
||||||
countIconRepositories: await db.$count(iconRepositories),
|
countApiKeys,
|
||||||
countIcons: await db.$count(icons),
|
countInvites,
|
||||||
countApiKeys: await db.$count(apiKeys),
|
countMedias,
|
||||||
countInvites: await db.$count(invites),
|
countSessions,
|
||||||
countMedias: await db.$count(medias),
|
countAccounts,
|
||||||
countLayouts: await db.$count(layouts),
|
integrationKinds,
|
||||||
countItemLayouts: await db.$count(itemLayouts),
|
widgetKinds,
|
||||||
countSectionLayouts: await db.$count(sectionLayouts),
|
] = await Promise.all([
|
||||||
countCronJobConfigs: await db.$count(cronJobConfigurations),
|
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 integrationKinds = await db
|
const enabledAuthProviders = (["credentials", "oidc", "ldap"] as const).filter(isProviderEnabled);
|
||||||
.select({ kind: integrations.kind, count: count(integrations.id) })
|
|
||||||
.from(integrations)
|
|
||||||
.groupBy(integrations.kind);
|
|
||||||
|
|
||||||
integrationKinds.forEach((integrationKind) => {
|
const properties: Record<string, unknown> = {
|
||||||
analyticsData[`integration_${integrationKind.kind}`] = integrationKind.count;
|
homarrVersion: packageJson.version,
|
||||||
});
|
databaseType: isMysql() ? "mysql" : isPostgresql() ? "postgresql" : "sqlite",
|
||||||
|
dockerEnabled: Boolean(dockerEnv.ENABLE_DOCKER),
|
||||||
|
kubernetesEnabled: Boolean(dockerEnv.ENABLE_KUBERNETES),
|
||||||
|
authProviders: enabledAuthProviders,
|
||||||
|
|
||||||
const response = await umamiInstance.track("server-analytics-v2", analyticsData);
|
osPlatform: process.platform,
|
||||||
if (!response.ok) {
|
osArch: process.arch,
|
||||||
logger.warn("Unable to send analytics to Umami instance");
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Sent analytics in ${stopWatch.getElapsedInHumanWords()}`);
|
|
||||||
};
|
};
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/core": "workspace:^0.1.0",
|
"@homarr/core": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { iconsRouter } from "./router/icons";
|
|||||||
import { infoRouter } from "./router/info";
|
import { infoRouter } from "./router/info";
|
||||||
import { integrationRouter } from "./router/integration/integration-router";
|
import { integrationRouter } from "./router/integration/integration-router";
|
||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
|
import { beszelRouter } from "./router/widgets/beszel";
|
||||||
import { calendarRouter } from "./router/widgets/calendar";
|
import { calendarRouter } from "./router/widgets/calendar";
|
||||||
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
||||||
import { downloadsRouter } from "./router/widgets/downloads";
|
import { downloadsRouter } from "./router/widgets/downloads";
|
||||||
@@ -26,6 +27,7 @@ export const mcpRouter = createTRPCRouter({
|
|||||||
info: infoRouter,
|
info: infoRouter,
|
||||||
integration: integrationRouter,
|
integration: integrationRouter,
|
||||||
invite: inviteRouter,
|
invite: inviteRouter,
|
||||||
|
beszel: beszelRouter,
|
||||||
calendar: calendarRouter,
|
calendar: calendarRouter,
|
||||||
dnsHole: dnsHoleRouter,
|
dnsHole: dnsHoleRouter,
|
||||||
downloads: downloadsRouter,
|
downloads: downloadsRouter,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
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)),
|
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
|
||||||
customWidget: lazy(() => import("./router/custom-widget/custom-widget-router").then((mod) => mod.customWidgetRouter)),
|
customWidget: lazy(() => import("./router/custom-widget/custom-widget-router").then((mod) => mod.customWidgetRouter)),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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,11 +1,12 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { canManageGroupMembersLocally, isGroupMembershipManagedLocally } from "@homarr/auth/server";
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
|
||||||
import { everyoneGroup } from "@homarr/definitions";
|
import { everyoneGroup } from "@homarr/definitions";
|
||||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
} from "@homarr/validation/group";
|
} from "@homarr/validation/group";
|
||||||
|
|
||||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
|
||||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||||
|
|
||||||
export const groupRouter = createTRPCRouter({
|
export const groupRouter = createTRPCRouter({
|
||||||
@@ -315,10 +315,10 @@ export const groupRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||||
throwIfCredentialsDisabled();
|
throwIfGroupMembersCannotBeManagedLocally();
|
||||||
|
|
||||||
const user = await ctx.db.query.users.findFirst({
|
const user = await ctx.db.query.users.findFirst({
|
||||||
where: eq(groups.id, input.userId),
|
where: eq(users.id, input.userId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -328,6 +328,13 @@ 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({
|
await ctx.db.insert(groupMembers).values({
|
||||||
groupId: input.groupId,
|
groupId: input.groupId,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
@@ -339,7 +346,7 @@ export const groupRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||||
throwIfCredentialsDisabled();
|
throwIfGroupMembersCannotBeManagedLocally();
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.delete(groupMembers)
|
.delete(groupMembers)
|
||||||
@@ -371,6 +378,15 @@ 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 throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
|
||||||
const group = await db.query.groups.findFirst({
|
const group = await db.query.groups.findFirst({
|
||||||
where: eq(groups.id, id),
|
where: eq(groups.id, id),
|
||||||
|
|||||||
@@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.input(iconsFindSchema)
|
.input(iconsFindSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
const term = input.searchText?.toLowerCase().trim() ?? "";
|
||||||
|
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
|
||||||
|
|
||||||
|
let whereCondition = undefined;
|
||||||
|
if (term.length > 0) {
|
||||||
|
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icons: await ctx.db.query.iconRepositories.findMany({
|
icons: await ctx.db.query.iconRepositories.findMany({
|
||||||
with: {
|
with: {
|
||||||
icons: {
|
icons: {
|
||||||
columns: {
|
columns: { id: true, name: true, url: true },
|
||||||
id: true,
|
where: whereCondition,
|
||||||
name: true,
|
orderBy: (table, { asc, sql }) => {
|
||||||
url: true,
|
// ponytail: SVG first, then alphabetical; with a search term,
|
||||||
|
// prepend a relevance tier (exact > prefix > substring).
|
||||||
|
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
||||||
|
const nameAsc = asc(table.name);
|
||||||
|
if (term.length === 0) {
|
||||||
|
return [svgFirst, nameAsc];
|
||||||
|
}
|
||||||
|
const tier = sql`CASE
|
||||||
|
WHEN LOWER(${table.name}) = ${term} THEN 0
|
||||||
|
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
|
||||||
|
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END`;
|
||||||
|
return [tier, svgFirst, nameAsc];
|
||||||
},
|
},
|
||||||
where:
|
|
||||||
(input.searchText?.length ?? 0) > 0
|
|
||||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
|
|
||||||
: undefined,
|
|
||||||
limit: input.limitPerGroup,
|
limit: input.limitPerGroup,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
integrationSecretKindObject,
|
integrationSecretKindObject,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
|
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
|
||||||
import { byIdSchema } from "@homarr/validation/common";
|
import { byIdSchema } from "@homarr/validation/common";
|
||||||
import {
|
import {
|
||||||
integrationCreateSchema,
|
integrationCreateSchema,
|
||||||
@@ -469,6 +470,10 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
kind: integration.kind,
|
kind: integration.kind,
|
||||||
url: input.url,
|
url: input.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate all cached data for this integration so that widgets pick up the
|
||||||
|
// new configuration immediately instead of serving stale (or errored) data.
|
||||||
|
await invalidateIntegrationCacheAsync(input.id);
|
||||||
}),
|
}),
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -490,6 +495,9 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
|
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
|
||||||
|
|
||||||
|
// Clean up any cached data left behind by the deleted integration.
|
||||||
|
await invalidateIntegrationCacheAsync(input.id);
|
||||||
}),
|
}),
|
||||||
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
|
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||||
|
|||||||
@@ -24,17 +24,18 @@ export const serverSettingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await updateServerSettingByKeyAsync(
|
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||||
ctx.db,
|
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||||
input.settingsKey,
|
...current,
|
||||||
input.value as ServerSettings[keyof ServerSettings],
|
...input.value,
|
||||||
);
|
} as ServerSettings[keyof ServerSettings]);
|
||||||
}),
|
}),
|
||||||
initSettings: onboardingProcedure
|
initSettings: onboardingProcedure
|
||||||
.requiresStep("settings")
|
.requiresStep("settings")
|
||||||
.input(settingsInitSchema)
|
.input(settingsInitSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
|
const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
|
||||||
|
await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics });
|
||||||
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
|
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
|
||||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -748,7 +748,45 @@ describe("addMember should add member to group", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -872,7 +910,7 @@ describe("removeMember should remove member from group", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
|
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -75,9 +75,6 @@ describe("saveSettings", () => {
|
|||||||
settingsKey: "analytics",
|
settingsKey: "analytics",
|
||||||
value: {
|
value: {
|
||||||
enableGeneral: true,
|
enableGeneral: true,
|
||||||
enableWidgetData: true,
|
|
||||||
enableIntegrationData: true,
|
|
||||||
enableUserData: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,9 +84,7 @@ describe("saveSettings", () => {
|
|||||||
settingKey: "analytics",
|
settingKey: "analytics",
|
||||||
value: SuperJSON.stringify({
|
value: SuperJSON.stringify({
|
||||||
enableGeneral: true,
|
enableGeneral: true,
|
||||||
enableWidgetData: true,
|
instanceId: null,
|
||||||
enableIntegrationData: true,
|
|
||||||
enableUserData: true,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
|
|||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, eq, handleTransactionsAsync, like } from "@homarr/db";
|
import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
|
||||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||||
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
|
||||||
import { byIdSchema } from "@homarr/validation/common";
|
import { byIdSchema } from "@homarr/validation/common";
|
||||||
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
||||||
import {
|
import {
|
||||||
@@ -257,7 +257,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
// Is protected because also used in board access / integration access forms
|
// Is protected because also used in board access / integration access forms
|
||||||
selectable: protectedProcedure
|
selectable: protectedProcedure
|
||||||
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
|
.input(z.object({ providers: z.array(z.enum(supportedAuthProviders)).optional() }).optional())
|
||||||
.output(
|
.output(
|
||||||
z.array(
|
z.array(
|
||||||
selectUserSchema.pick({
|
selectUserSchema.pick({
|
||||||
@@ -284,7 +284,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
image: true,
|
image: true,
|
||||||
email: true,
|
email: true,
|
||||||
},
|
},
|
||||||
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
|
where: input?.providers ? inArray(users.provider, input.providers) : undefined,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
search: permissionRequiredProcedure
|
search: permissionRequiredProcedure
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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,6 +1,10 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
|
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||||
import {
|
import {
|
||||||
beszelAlertsRequestHandler,
|
beszelAlertsRequestHandler,
|
||||||
beszelStatsRequestHandler,
|
beszelStatsRequestHandler,
|
||||||
@@ -11,14 +15,27 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/
|
|||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "beszelRouter" });
|
||||||
|
|
||||||
export const beszelRouter = createTRPCRouter({
|
export const beszelRouter = createTRPCRouter({
|
||||||
getSystems: publicProcedure
|
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"))
|
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||||
|
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||||
|
forceUpdate: false,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
@@ -28,13 +45,40 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const results = settled.map((result, index) => {
|
||||||
|
if (result.status === "fulfilled") return result.value;
|
||||||
|
const integration = ctx.integrations[index];
|
||||||
|
logger.warn("getSystems integration failed", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationId: integration?.id,
|
||||||
|
error: result.reason,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
integrationId: integration?.id ?? "unknown",
|
||||||
|
integrationName: integration?.name ?? "unknown",
|
||||||
|
integrationUrl: integration?.url ?? "",
|
||||||
|
systems: [],
|
||||||
|
updatedAt: new Date(0),
|
||||||
|
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
logger.debug("getSystems completed", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationIds,
|
||||||
|
resultCount: results.length,
|
||||||
|
errorCount: results.filter((r) => "error" in r).length,
|
||||||
|
});
|
||||||
return results;
|
return results;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
subscribeSystems: publicProcedure
|
subscribeSystems: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||||
.subscription(({ ctx }) => {
|
.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 unsubscribes = ctx.integrations.map((integration) => {
|
||||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||||
return innerHandler.subscribe((systems) => {
|
return innerHandler.subscribe((systems) => {
|
||||||
@@ -52,6 +96,13 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getAlerts: publicProcedure
|
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"))
|
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -60,7 +111,14 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const results = await Promise.all(
|
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||||
|
logger.debug("getAlerts called", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationIds,
|
||||||
|
includeHistory: input.includeHistory,
|
||||||
|
maxHistoryItems: input.maxHistoryItems,
|
||||||
|
});
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||||
includeHistory: input.includeHistory,
|
includeHistory: input.includeHistory,
|
||||||
@@ -77,6 +135,7 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
|
integrationName: integration.name,
|
||||||
alerts: alertsResult.data.alerts,
|
alerts: alertsResult.data.alerts,
|
||||||
history: alertsResult.data.history,
|
history: alertsResult.data.history,
|
||||||
systemNameMap,
|
systemNameMap,
|
||||||
@@ -84,6 +143,30 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const results = settled.map((result, index) => {
|
||||||
|
if (result.status === "fulfilled") return result.value;
|
||||||
|
const integration = ctx.integrations[index];
|
||||||
|
logger.warn("getAlerts integration failed", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationId: integration?.id,
|
||||||
|
error: result.reason,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
integrationId: integration?.id ?? "unknown",
|
||||||
|
integrationName: integration?.name ?? "unknown",
|
||||||
|
alerts: [],
|
||||||
|
history: [],
|
||||||
|
systemNameMap: {},
|
||||||
|
updatedAt: new Date(0),
|
||||||
|
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
logger.debug("getAlerts completed", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationIds,
|
||||||
|
resultCount: results.length,
|
||||||
|
errorCount: results.filter((r) => "error" in r).length,
|
||||||
|
});
|
||||||
return results;
|
return results;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -96,14 +179,22 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.subscription(({ ctx, input }) => {
|
.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 unsubscribes = ctx.integrations.map((integration) => {
|
||||||
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
|
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||||
includeHistory: input.includeHistory,
|
includeHistory: input.includeHistory,
|
||||||
maxHistoryItems: input.maxHistoryItems,
|
maxHistoryItems: input.maxHistoryItems,
|
||||||
});
|
});
|
||||||
return innerHandler.subscribe((data) => {
|
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 () => {
|
return () => {
|
||||||
@@ -113,23 +204,117 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getSystemStats: publicProcedure
|
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"))
|
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
systemId: z.string(),
|
systemId: z.string(),
|
||||||
timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]),
|
timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]).default("1h"),
|
||||||
includeDocker: z.boolean().default(true),
|
includeDocker: z.boolean().default(true),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const integration = ctx.integrations[0];
|
const integration = ctx.integrations[0];
|
||||||
if (!integration) return null;
|
if (!integration) {
|
||||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
||||||
|
}
|
||||||
|
logger.debug("getSystemStats called", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationId: integration.id,
|
||||||
systemId: input.systemId,
|
systemId: input.systemId,
|
||||||
timePeriod: input.timePeriod,
|
timePeriod: input.timePeriod,
|
||||||
includeDocker: input.includeDocker,
|
includeDocker: input.includeDocker,
|
||||||
});
|
});
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
try {
|
||||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||||
|
systemId: input.systemId,
|
||||||
|
timePeriod: input.timePeriod,
|
||||||
|
includeDocker: input.includeDocker,
|
||||||
|
});
|
||||||
|
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
|
logger.debug("getSystemStats completed", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationId: integration.id,
|
||||||
|
systemId: input.systemId,
|
||||||
|
});
|
||||||
|
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("getSystemStats failed", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationId: integration.id,
|
||||||
|
systemId: input.systemId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
integrationId: integration.id,
|
||||||
|
systemStats: [],
|
||||||
|
containerStats: [],
|
||||||
|
updatedAt: new Date(0),
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
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 () => {};
|
||||||
|
}
|
||||||
|
logger.debug("subscribeSystemStats opened", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationId: integration.id,
|
||||||
|
systemId: input.systemId,
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
logger.warn("subscribeSystemStats error", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationId: integration.id,
|
||||||
|
systemId: input.systemId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
if (error instanceof Error) {
|
||||||
|
emit.error(error);
|
||||||
|
} else {
|
||||||
|
emit.error(new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
logger.debug("subscribeSystemStats closed", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationId: integration.id,
|
||||||
|
systemId: input.systemId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
|
|||||||
import { createTRPCRouter } from "../../trpc";
|
import { createTRPCRouter } from "../../trpc";
|
||||||
|
|
||||||
export const widgetRouter = createTRPCRouter({
|
export const widgetRouter = createTRPCRouter({
|
||||||
|
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
|
||||||
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
|
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
|
||||||
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
||||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
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 { getServerSettingsAsync } from "@homarr/db/queries";
|
||||||
|
import { boards, items } from "@homarr/db/schema";
|
||||||
|
|
||||||
import type { WidgetOptionsSettings } from "../../../../widgets/src";
|
import type { WidgetOptionsSettings } from "../../../../widgets/src";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
|
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||||
|
|
||||||
export const optionsRouter = createTRPCRouter({
|
export const optionsRouter = createTRPCRouter({
|
||||||
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
|
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
|
||||||
@@ -16,4 +23,33 @@ 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,6 +20,24 @@ import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from ".
|
|||||||
|
|
||||||
const logger = createLogger({ module: "authConfiguration" });
|
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
|
// See why it's unknown in the [...nextauth]/route.ts file
|
||||||
export const createConfiguration = (
|
export const createConfiguration = (
|
||||||
provider: SupportedAuthProvider | "unknown",
|
provider: SupportedAuthProvider | "unknown",
|
||||||
@@ -41,11 +59,7 @@ export const createConfiguration = (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
cookies: {
|
cookies: createCookies(useSecureCookies),
|
||||||
sessionToken: {
|
|
||||||
name: sessionTokenCookieName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
adapter,
|
adapter,
|
||||||
providers: filterProviders([
|
providers: filterProviders([
|
||||||
Credentials(createCredentialsConfiguration(db)),
|
Credentials(createCredentialsConfiguration(db)),
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export const env = createEnv({
|
|||||||
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
|
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
|
||||||
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
|
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
|
||||||
AUTH_PROVIDERS: authProvidersSchema,
|
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")
|
...(authProviders.includes("oidc")
|
||||||
? {
|
? {
|
||||||
AUTH_OIDC_ISSUER: z.string().url(),
|
AUTH_OIDC_ISSUER: z.string().url(),
|
||||||
@@ -37,6 +42,7 @@ export const env = createEnv({
|
|||||||
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
|
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
|
||||||
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
|
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_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_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
|
||||||
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
|
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
|
||||||
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
|
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
|||||||
|
|
||||||
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
|
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
|
||||||
// Groups from oidc provider are provided from the profile, it's not typed.
|
// Groups from oidc provider are provided from the profile, it's not typed.
|
||||||
if (profile && groupsKey in profile && Array.isArray(profile[groupsKey])) {
|
if (
|
||||||
|
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
|
||||||
|
profile &&
|
||||||
|
groupsKey in profile &&
|
||||||
|
Array.isArray(profile[groupsKey])
|
||||||
|
) {
|
||||||
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
|
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
|
||||||
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
|
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,57 @@
|
|||||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
|
import { supportedAuthProviders } from "@homarr/definitions";
|
||||||
|
|
||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
|
|
||||||
|
export type GroupMemberManagementType = "local" | "mixed" | "external";
|
||||||
|
|
||||||
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
|
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
|
||||||
// The question mark is placed there because isProviderEnabled is called during static build of about page
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
return env.AUTH_PROVIDERS?.includes(provider);
|
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;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import * as env from "../../env";
|
||||||
|
import {
|
||||||
|
canManageGroupMembersLocally,
|
||||||
|
getGroupMemberManagementType,
|
||||||
|
getLocallyManageableProviders,
|
||||||
|
isGroupMembershipManagedLocally,
|
||||||
|
} from "../check-provider";
|
||||||
|
|
||||||
|
const mockEnv = (providers: SupportedAuthProvider[], oidcLocalManagement = false) => {
|
||||||
|
vi.spyOn(env, "env", "get").mockReturnValue({
|
||||||
|
AUTH_PROVIDERS: providers,
|
||||||
|
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: oidcLocalManagement,
|
||||||
|
} as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isGroupMembershipManagedLocally", () => {
|
||||||
|
test("credentials is always managed locally", () => {
|
||||||
|
mockEnv(["credentials"]);
|
||||||
|
expect(isGroupMembershipManagedLocally("credentials")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oidc follows AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT", () => {
|
||||||
|
mockEnv(["oidc"], true);
|
||||||
|
expect(isGroupMembershipManagedLocally("oidc")).toBe(true);
|
||||||
|
|
||||||
|
mockEnv(["oidc"], false);
|
||||||
|
expect(isGroupMembershipManagedLocally("oidc")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ldap is never managed locally", () => {
|
||||||
|
mockEnv(["ldap"]);
|
||||||
|
expect(isGroupMembershipManagedLocally("ldap")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getGroupMemberManagementType", () => {
|
||||||
|
test("credentials only is local", () => {
|
||||||
|
mockEnv(["credentials"]);
|
||||||
|
expect(getGroupMemberManagementType()).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("credentials + oidc with local management is local", () => {
|
||||||
|
mockEnv(["credentials", "oidc"], true);
|
||||||
|
expect(getGroupMemberManagementType()).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("credentials + oidc without local management is mixed", () => {
|
||||||
|
mockEnv(["credentials", "oidc"], false);
|
||||||
|
expect(getGroupMemberManagementType()).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("credentials + ldap is mixed", () => {
|
||||||
|
mockEnv(["credentials", "ldap"]);
|
||||||
|
expect(getGroupMemberManagementType()).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ldap only is external", () => {
|
||||||
|
mockEnv(["ldap"]);
|
||||||
|
expect(getGroupMemberManagementType()).toBe("external");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oidc only without local management is external", () => {
|
||||||
|
mockEnv(["oidc"], false);
|
||||||
|
expect(getGroupMemberManagementType()).toBe("external");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getLocallyManageableProviders", () => {
|
||||||
|
test("returns only the enabled, locally managed providers", () => {
|
||||||
|
mockEnv(["credentials", "oidc", "ldap"], true);
|
||||||
|
expect(getLocallyManageableProviders()).toEqual(["credentials", "oidc"]);
|
||||||
|
|
||||||
|
mockEnv(["credentials", "oidc", "ldap"], false);
|
||||||
|
expect(getLocallyManageableProviders()).toEqual(["credentials"]);
|
||||||
|
|
||||||
|
mockEnv(["ldap"]);
|
||||||
|
expect(getLocallyManageableProviders()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canManageGroupMembersLocally", () => {
|
||||||
|
test("is true when at least one enabled provider is managed locally", () => {
|
||||||
|
mockEnv(["credentials", "ldap"]);
|
||||||
|
expect(canManageGroupMembersLocally()).toBe(true);
|
||||||
|
|
||||||
|
mockEnv(["oidc"], true);
|
||||||
|
expect(canManageGroupMembersLocally()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is false when every enabled provider is external", () => {
|
||||||
|
mockEnv(["ldap"]);
|
||||||
|
expect(canManageGroupMembersLocally()).toBe(false);
|
||||||
|
|
||||||
|
mockEnv(["oidc"], false);
|
||||||
|
expect(canManageGroupMembersLocally()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions";
|
export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions";
|
||||||
export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions";
|
export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions";
|
||||||
export { isProviderEnabled } from "./providers/check-provider";
|
export {
|
||||||
|
isProviderEnabled,
|
||||||
|
isGroupMembershipManagedLocally,
|
||||||
|
getLocallyManageableProviders,
|
||||||
|
getGroupMemberManagementType,
|
||||||
|
canManageGroupMembersLocally,
|
||||||
|
} from "./providers/check-provider";
|
||||||
|
export type { GroupMemberManagementType } from "./providers/check-provider";
|
||||||
export { createSessionCallback, createSessionAsync } from "./callbacks";
|
export { createSessionCallback, createSessionAsync } from "./callbacks";
|
||||||
@@ -4,9 +4,11 @@ import { generateSecureRandomToken } from "@homarr/common/server";
|
|||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
|
|
||||||
import { getCurrentUserPermissionsAsync } from "./callbacks";
|
import { getCurrentUserPermissionsAsync } from "./callbacks";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
// Default of authjs
|
// Prefixed (AUTH_COOKIE_PREFIX, default "homarr") to avoid cookie collisions
|
||||||
export const sessionTokenCookieName = "authjs.session-token";
|
// with other Auth.js apps on the same host. See https://github.com/homarr-labs/homarr/issues/5773
|
||||||
|
export const sessionTokenCookieName = `${env.AUTH_COOKIE_PREFIX}.session-token`;
|
||||||
|
|
||||||
export const expireDateAfter = (seconds: number) => {
|
export const expireDateAfter = (seconds: number) => {
|
||||||
return new Date(Date.now() + seconds * 1000);
|
return new Date(Date.now() + seconds * 1000);
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
|||||||
import { createSignInEventHandler } from "../events";
|
import { createSignInEventHandler } from "../events";
|
||||||
|
|
||||||
vi.mock("next-auth", () => ({}));
|
vi.mock("next-auth", () => ({}));
|
||||||
|
const mockEnv = vi.hoisted(() => ({
|
||||||
|
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
|
||||||
|
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: false,
|
||||||
|
}));
|
||||||
vi.mock("../env", () => {
|
vi.mock("../env", () => {
|
||||||
return {
|
return {
|
||||||
env: {
|
env: mockEnv,
|
||||||
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
@@ -198,6 +200,30 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
|||||||
});
|
});
|
||||||
expect(dbGroupMembers?.groupId).toBe("1");
|
expect(dbGroupMembers?.groupId).toBe("1");
|
||||||
});
|
});
|
||||||
|
test("should not synchronize groups when AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT is enabled", async () => {
|
||||||
|
// Arrange
|
||||||
|
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true;
|
||||||
|
const db = createDb();
|
||||||
|
await createUserAsync(db);
|
||||||
|
await createGroupAsync(db);
|
||||||
|
const eventHandler = createSignInEventHandler(db);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await eventHandler?.({
|
||||||
|
user: { id: "1", name: "test" },
|
||||||
|
profile: { preferred_username: "test", someRandomGroupsKey: ["test"] },
|
||||||
|
account: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const dbGroupMembers = await db.query.groupMembers.findFirst({
|
||||||
|
where: eq(groupMembers.userId, "1"),
|
||||||
|
});
|
||||||
|
expect(dbGroupMembers).toBeUndefined();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
test.each([
|
test.each([
|
||||||
["ldap" as const, { name: "test-new" }, undefined],
|
["ldap" as const, { name: "test-new" }, undefined],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface CreateCronJobOptions {
|
|||||||
runOnStart?: boolean;
|
runOnStart?: boolean;
|
||||||
preventManualExecution?: boolean;
|
preventManualExecution?: boolean;
|
||||||
preventCustomInterval?: boolean;
|
preventCustomInterval?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
expectedMaximumDurationInMillis?: number;
|
expectedMaximumDurationInMillis?: number;
|
||||||
beforeStart?: () => MaybePromise<void>;
|
beforeStart?: () => MaybePromise<void>;
|
||||||
}
|
}
|
||||||
@@ -121,6 +122,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
|||||||
},
|
},
|
||||||
preventManualExecution: options.preventManualExecution ?? false,
|
preventManualExecution: options.preventManualExecution ?? false,
|
||||||
preventCustomInterval: options.preventCustomInterval ?? false,
|
preventCustomInterval: options.preventCustomInterval ?? false,
|
||||||
|
hidden: options.hidden ?? false,
|
||||||
timezone: creatorOptions.timezone,
|
timezone: creatorOptions.timezone,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -122,15 +122,17 @@ export class JobManager {
|
|||||||
> {
|
> {
|
||||||
const configurations = await this.db.query.cronJobConfigurations.findMany();
|
const configurations = await this.db.query.cronJobConfigurations.findMany();
|
||||||
|
|
||||||
return [...this.jobGroup.getJobRegistry().entries()].map(([name, job]) => {
|
return [...this.jobGroup.getJobRegistry().entries()]
|
||||||
const config = configurations.find((config) => config.name === name);
|
.filter(([_, job]) => !job.hidden)
|
||||||
return {
|
.map(([name, job]) => {
|
||||||
name,
|
const config = configurations.find((config) => config.name === name);
|
||||||
cron: config?.cronExpression ?? job.cronExpression,
|
return {
|
||||||
preventManualExecution: job.preventManualExecution,
|
name,
|
||||||
preventCustomInterval: job.preventCustomInterval,
|
cron: config?.cronExpression ?? job.cronExpression,
|
||||||
isEnabled: config?.isEnabled ?? true,
|
preventManualExecution: job.preventManualExecution,
|
||||||
};
|
preventCustomInterval: job.preventCustomInterval,
|
||||||
});
|
isEnabled: config?.isEnabled ?? true,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { sendServerAnalyticsAsync } from "@homarr/analytics";
|
import { sendServerAnalyticsAsync } from "@homarr/analytics";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db } from "@homarr/db";
|
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
|
||||||
|
|
||||||
import { createCronJob } from "../lib";
|
import { createCronJob } from "../lib";
|
||||||
|
|
||||||
@@ -10,13 +8,8 @@ export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
|
|||||||
runOnStart: true,
|
runOnStart: true,
|
||||||
preventManualExecution: true,
|
preventManualExecution: true,
|
||||||
preventCustomInterval: true,
|
preventCustomInterval: true,
|
||||||
|
hidden: true,
|
||||||
}).withCallback(async () => {
|
}).withCallback(async () => {
|
||||||
if (env.NO_EXTERNAL_CONNECTION) return;
|
if (env.NO_EXTERNAL_CONNECTION) return;
|
||||||
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
|
|
||||||
|
|
||||||
if (!analyticSetting.enableGeneral) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendServerAnalyticsAsync();
|
await sendServerAnalyticsAsync();
|
||||||
});
|
});
|
||||||
@@ -647,9 +647,13 @@ const seedDemoUserAsync = async (db: Database) => {
|
|||||||
|
|
||||||
const seedDefaultCustomWidgetsAsync = async (db: Database) => {
|
const seedDefaultCustomWidgetsAsync = async (db: Database) => {
|
||||||
const seedIds = CUSTOM_WIDGET_SEEDS.map((s) => s.id);
|
const seedIds = CUSTOM_WIDGET_SEEDS.map((s) => s.id);
|
||||||
const beforeCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
|
const existing = await db.query.customWidgetDefinitions.findMany({
|
||||||
|
columns: { id: true },
|
||||||
|
where: inArray(customWidgetDefinitions.id, seedIds),
|
||||||
|
});
|
||||||
|
const existingIds = new Set(existing.map((row) => row.id));
|
||||||
|
|
||||||
const seedValues = CUSTOM_WIDGET_SEEDS.map((seed) => {
|
const seedValues = CUSTOM_WIDGET_SEEDS.filter((seed) => !existingIds.has(seed.id)).map((seed) => {
|
||||||
const parsed = customWidgetImportSchema.parse(seed.data);
|
const parsed = customWidgetImportSchema.parse(seed.data);
|
||||||
return {
|
return {
|
||||||
id: seed.id,
|
id: seed.id,
|
||||||
@@ -668,22 +672,14 @@ const seedDefaultCustomWidgetsAsync = async (db: Database) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (seedValues.length === 0) return;
|
if (seedValues.length === 0) {
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(customWidgetDefinitions)
|
|
||||||
.values(seedValues)
|
|
||||||
.onConflictDoNothing({ target: customWidgetDefinitions.id });
|
|
||||||
|
|
||||||
const afterCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
|
|
||||||
const createdCount = afterCount - beforeCount;
|
|
||||||
|
|
||||||
if (createdCount === 0) {
|
|
||||||
console.log("Skipping seeding of default custom widgets as they already exist");
|
console.log("Skipping seeding of default custom widgets as they already exist");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Created ${createdCount} default custom widgets through seeding process`);
|
await db.insert(customWidgetDefinitions).values(seedValues);
|
||||||
|
|
||||||
|
console.log(`Created ${seedValues.length} default custom widgets through seeding process`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seedBoardWidgetsAsync = async (db: Database) => {
|
const seedBoardWidgetsAsync = async (db: Database) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// it uses the sitemap.xml to generate the HomarrDocumentationPath type
|
// it uses the sitemap.xml to generate the HomarrDocumentationPath type
|
||||||
export type HomarrDocumentationPath =
|
export type HomarrDocumentationPath =
|
||||||
| "/about-us"
|
| "/about-us"
|
||||||
|
| "/api-reference"
|
||||||
| "/blog"
|
| "/blog"
|
||||||
| "/blog/2023/01/11/version0.11"
|
| "/blog/2023/01/11/version0.11"
|
||||||
| "/blog/2023/04/16/version0.12-more-widgets"
|
| "/blog/2023/04/16/version0.12-more-widgets"
|
||||||
@@ -42,9 +43,11 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/tags/active-directory"
|
| "/docs/tags/active-directory"
|
||||||
| "/docs/tags/administration"
|
| "/docs/tags/administration"
|
||||||
| "/docs/tags/advanced"
|
| "/docs/tags/advanced"
|
||||||
|
| "/docs/tags/ai"
|
||||||
| "/docs/tags/analytics"
|
| "/docs/tags/analytics"
|
||||||
| "/docs/tags/api"
|
| "/docs/tags/api"
|
||||||
| "/docs/tags/apps"
|
| "/docs/tags/apps"
|
||||||
|
| "/docs/tags/automation"
|
||||||
| "/docs/tags/background"
|
| "/docs/tags/background"
|
||||||
| "/docs/tags/backup"
|
| "/docs/tags/backup"
|
||||||
| "/docs/tags/boards"
|
| "/docs/tags/boards"
|
||||||
@@ -55,9 +58,10 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/tags/community"
|
| "/docs/tags/community"
|
||||||
| "/docs/tags/configuration"
|
| "/docs/tags/configuration"
|
||||||
| "/docs/tags/connections"
|
| "/docs/tags/connections"
|
||||||
| "/docs/tags/custom"
|
| "/docs/tags/custom-widgets"
|
||||||
| "/docs/tags/customization"
|
| "/docs/tags/customization"
|
||||||
| "/docs/tags/data-sources"
|
| "/docs/tags/data-sources"
|
||||||
|
| "/docs/tags/data"
|
||||||
| "/docs/tags/database"
|
| "/docs/tags/database"
|
||||||
| "/docs/tags/developer"
|
| "/docs/tags/developer"
|
||||||
| "/docs/tags/development"
|
| "/docs/tags/development"
|
||||||
@@ -78,9 +82,11 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/tags/integrations"
|
| "/docs/tags/integrations"
|
||||||
| "/docs/tags/interface"
|
| "/docs/tags/interface"
|
||||||
| "/docs/tags/jobs"
|
| "/docs/tags/jobs"
|
||||||
|
| "/docs/tags/jsx"
|
||||||
| "/docs/tags/layout"
|
| "/docs/tags/layout"
|
||||||
| "/docs/tags/ldap"
|
| "/docs/tags/ldap"
|
||||||
| "/docs/tags/management"
|
| "/docs/tags/management"
|
||||||
|
| "/docs/tags/mcp"
|
||||||
| "/docs/tags/media"
|
| "/docs/tags/media"
|
||||||
| "/docs/tags/oidc"
|
| "/docs/tags/oidc"
|
||||||
| "/docs/tags/open-collective"
|
| "/docs/tags/open-collective"
|
||||||
@@ -92,7 +98,6 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/tags/puid"
|
| "/docs/tags/puid"
|
||||||
| "/docs/tags/redis"
|
| "/docs/tags/redis"
|
||||||
| "/docs/tags/responsive"
|
| "/docs/tags/responsive"
|
||||||
| "/docs/tags/restore"
|
|
||||||
| "/docs/tags/roles"
|
| "/docs/tags/roles"
|
||||||
| "/docs/tags/search-engines"
|
| "/docs/tags/search-engines"
|
||||||
| "/docs/tags/search"
|
| "/docs/tags/search"
|
||||||
@@ -174,6 +179,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/github"
|
| "/docs/integrations/github"
|
||||||
| "/docs/integrations/gitlab"
|
| "/docs/integrations/gitlab"
|
||||||
| "/docs/integrations/glances"
|
| "/docs/integrations/glances"
|
||||||
|
| "/docs/integrations/gotify"
|
||||||
| "/docs/integrations/home-assistant"
|
| "/docs/integrations/home-assistant"
|
||||||
| "/docs/integrations/ical"
|
| "/docs/integrations/ical"
|
||||||
| "/docs/integrations/immich"
|
| "/docs/integrations/immich"
|
||||||
@@ -217,15 +223,16 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/uptime-kuma"
|
| "/docs/integrations/uptime-kuma"
|
||||||
| "/docs/management/api"
|
| "/docs/management/api"
|
||||||
| "/docs/management/apps"
|
| "/docs/management/apps"
|
||||||
|
| "/docs/management/backup"
|
||||||
| "/docs/management/boards"
|
| "/docs/management/boards"
|
||||||
| "/docs/management/certificates"
|
| "/docs/management/certificates"
|
||||||
| "/docs/management/custom-widgets"
|
| "/docs/management/custom-widgets"
|
||||||
| "/docs/management/integrations"
|
| "/docs/management/integrations"
|
||||||
|
| "/docs/management/mcp"
|
||||||
| "/docs/management/media"
|
| "/docs/management/media"
|
||||||
| "/docs/management/search-engines"
|
| "/docs/management/search-engines"
|
||||||
| "/docs/management/settings"
|
| "/docs/management/settings"
|
||||||
| "/docs/management/tasks"
|
| "/docs/management/tasks"
|
||||||
| "/docs/management/tools/backup"
|
|
||||||
| "/docs/management/users"
|
| "/docs/management/users"
|
||||||
| "/docs/widgets/anchor-note"
|
| "/docs/widgets/anchor-note"
|
||||||
| "/docs/widgets/app"
|
| "/docs/widgets/app"
|
||||||
@@ -315,6 +322,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/github-containerregistry"
|
| "/docs/integrations/github-containerregistry"
|
||||||
| "/docs/integrations/quay"
|
| "/docs/integrations/quay"
|
||||||
| "/docs/integrations/ntfy"
|
| "/docs/integrations/ntfy"
|
||||||
|
| "/docs/integrations/gotify"
|
||||||
| "/docs/integrations/ical"
|
| "/docs/integrations/ical"
|
||||||
| "/docs/integrations/anchor"
|
| "/docs/integrations/anchor"
|
||||||
| "/docs/integrations/truenas"
|
| "/docs/integrations/truenas"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
|||||||
gitHubContainerRegistry: "github-containerregistry",
|
gitHubContainerRegistry: "github-containerregistry",
|
||||||
quay: "quay",
|
quay: "quay",
|
||||||
ntfy: "ntfy",
|
ntfy: "ntfy",
|
||||||
|
gotify: "gotify",
|
||||||
ical: "ical",
|
ical: "ical",
|
||||||
anchor: "anchor",
|
anchor: "anchor",
|
||||||
truenas: "truenas",
|
truenas: "truenas",
|
||||||
@@ -56,5 +57,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
|||||||
umami: "umami",
|
umami: "umami",
|
||||||
peaNut: "peanut",
|
peaNut: "peanut",
|
||||||
beszel: "beszel",
|
beszel: "beszel",
|
||||||
|
archiveTeamWarrior: null,
|
||||||
mock: null,
|
mock: null,
|
||||||
};
|
};
|
||||||
@@ -44,6 +44,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
|||||||
audioStats: "audio-stats",
|
audioStats: "audio-stats",
|
||||||
umami: "umami",
|
umami: "umami",
|
||||||
ups: "ups",
|
ups: "ups",
|
||||||
|
archiveTeamWarrior: null,
|
||||||
beszelSystemTable: "beszel-system-table",
|
beszelSystemTable: "beszel-system-table",
|
||||||
beszelSystemGrid: "beszel-system-grid",
|
beszelSystemGrid: "beszel-system-grid",
|
||||||
beszelAlerts: "beszel-alerts",
|
beszelAlerts: "beszel-alerts",
|
||||||
|
|||||||
@@ -355,6 +355,13 @@ export const integrationDefs = {
|
|||||||
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
|
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
|
||||||
defaultPort: 80,
|
defaultPort: 80,
|
||||||
},
|
},
|
||||||
|
gotify: {
|
||||||
|
name: "Gotify",
|
||||||
|
secretKinds: [["username", "password"]],
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg",
|
||||||
|
category: ["notifications"],
|
||||||
|
documentationUrl: createDocumentationLink("/docs/integrations/gotify"),
|
||||||
|
},
|
||||||
ical: {
|
ical: {
|
||||||
name: "iCal",
|
name: "iCal",
|
||||||
secretKinds: [["url"]],
|
secretKinds: [["url"]],
|
||||||
@@ -482,6 +489,14 @@ export const integrationDefs = {
|
|||||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||||
defaultPort: 8090,
|
defaultPort: 8090,
|
||||||
},
|
},
|
||||||
|
archiveTeamWarrior: {
|
||||||
|
name: "ArchiveTeam Warrior",
|
||||||
|
secretKinds: [[], ["username", "password"]],
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||||
|
category: ["archiving"],
|
||||||
|
defaultUrl: "http://localhost:8001",
|
||||||
|
documentationUrl: null,
|
||||||
|
},
|
||||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||||
mock: {
|
mock: {
|
||||||
name: "Mock",
|
name: "Mock",
|
||||||
@@ -589,6 +604,7 @@ export const integrationCategories = [
|
|||||||
"mediaMonitoring",
|
"mediaMonitoring",
|
||||||
"speedtest",
|
"speedtest",
|
||||||
"analytics",
|
"analytics",
|
||||||
|
"archiving",
|
||||||
"ups",
|
"ups",
|
||||||
"documents",
|
"documents",
|
||||||
"mediaLibrary",
|
"mediaLibrary",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const widgetKinds = [
|
|||||||
"uptimeKuma",
|
"uptimeKuma",
|
||||||
"audioStats",
|
"audioStats",
|
||||||
"umami",
|
"umami",
|
||||||
|
"archiveTeamWarrior",
|
||||||
"ups",
|
"ups",
|
||||||
"beszelSystemTable",
|
"beszelSystemTable",
|
||||||
"beszelSystemGrid",
|
"beszelSystemGrid",
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import type { FocusEventHandler } from "react";
|
import type { FocusEventHandler } from "react";
|
||||||
import { startTransition } from "react";
|
import { startTransition, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Combobox,
|
Combobox,
|
||||||
Flex,
|
Flex,
|
||||||
@@ -18,7 +21,7 @@ import {
|
|||||||
useCombobox,
|
useCombobox,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||||
import { IconUpload } from "@tabler/icons-react";
|
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -34,7 +37,6 @@ interface IconPickerProps {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
onFocus?: FocusEventHandler;
|
onFocus?: FocusEventHandler;
|
||||||
onBlur?: FocusEventHandler;
|
onBlur?: FocusEventHandler;
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
@@ -50,29 +52,30 @@ export const IconPicker = ({
|
|||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: IconPickerProps) => {
|
}: IconPickerProps) => {
|
||||||
const [value, setValue] = useUncontrolled({
|
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
||||||
value: propsValue,
|
const [query, setQuery] = useState("");
|
||||||
onChange,
|
|
||||||
});
|
// ponytail: reset the typed query when the committed value changes
|
||||||
const [search, setSearch] = useUncontrolled({
|
// externally (form reset, parent re-render) so the input reverts to the URL.
|
||||||
value,
|
useEffect(() => {
|
||||||
onChange: (value) => {
|
setQuery("");
|
||||||
setValue(value);
|
}, [value]);
|
||||||
},
|
|
||||||
});
|
|
||||||
const [previewUrl, setPreviewUrl] = useUncontrolled({
|
|
||||||
value: propsValue ?? null,
|
|
||||||
});
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 100);
|
||||||
|
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 100);
|
// ponytail: only search when the typed query differs from the committed
|
||||||
|
// value. On mount the input shows the existing URL (edit mode), which
|
||||||
|
// would never match the bare filenames in the icon table.
|
||||||
|
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
|
||||||
|
|
||||||
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
// ponytail: not useSuspenseQuery — an above Suspense boundary would
|
||||||
const { data } = clientApi.icon.findIcons.useQuery({
|
// trigger on every keystroke and the dropdown flash is bad UX.
|
||||||
searchText: debouncedSearch,
|
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
||||||
});
|
{ searchText },
|
||||||
|
{ placeholderData: (prev) => prev },
|
||||||
|
);
|
||||||
|
|
||||||
const combobox = useCombobox({
|
const combobox = useCombobox({
|
||||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
@@ -90,25 +93,15 @@ export const IconPicker = ({
|
|||||||
>
|
>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const value = item.url;
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setPreviewUrl(value);
|
setValue(item.url);
|
||||||
setSearch(value);
|
setQuery("");
|
||||||
setValue(value);
|
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||||
<Card
|
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
||||||
p="sm"
|
|
||||||
pos="relative"
|
|
||||||
style={{
|
|
||||||
overflow: "visible",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
className={classes.iconCard}
|
|
||||||
>
|
|
||||||
<Box w={25} h={25}>
|
<Box w={25} h={25}>
|
||||||
<Image src={item.url} w={25} h={25} />
|
<Image src={item.url} w={25} h={25} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -130,6 +123,48 @@ export const IconPicker = ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ponytail: show the typed query if non-empty, else fall back to the URL.
|
||||||
|
const inputValue = query || value || "";
|
||||||
|
// ponytail: show the existing icon as a left-section preview.
|
||||||
|
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
||||||
|
// ponytail: prop override -> real count -> loading text.
|
||||||
|
const placeholderText =
|
||||||
|
placeholder ||
|
||||||
|
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
||||||
|
tCommon("iconPicker.headerLoading");
|
||||||
|
|
||||||
|
const renderDropdown = () => {
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap="xs" p="md">
|
||||||
|
<IconSearchOff size={32} stroke={1.5} />
|
||||||
|
<Text size="sm">{tCommon("iconPicker.error")}</Text>
|
||||||
|
<Button size="xs" variant="default" onClick={() => refetch()}>
|
||||||
|
{tCommon("action.tryAgain")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isLoading) {
|
||||||
|
return Array(15)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, index) => (
|
||||||
|
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||||
|
<Skeleton height={25} visible />
|
||||||
|
</Combobox.Option>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (totalOptions > 0) {
|
||||||
|
return <Stack gap={4}>{groups}</Stack>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap="xs" p="md">
|
||||||
|
<IconMoodEmpty size={32} stroke={1.5} />
|
||||||
|
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox store={combobox} withinPortal>
|
<Combobox store={combobox} withinPortal>
|
||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
@@ -137,16 +172,11 @@ export const IconPicker = ({
|
|||||||
<InputBase
|
<InputBase
|
||||||
flex={1}
|
flex={1}
|
||||||
rightSection={<Combobox.Chevron />}
|
rightSection={<Combobox.Chevron />}
|
||||||
leftSection={
|
leftSection={leftSection}
|
||||||
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
|
value={inputValue}
|
||||||
}
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
combobox.openDropdown();
|
combobox.openDropdown();
|
||||||
combobox.updateSelectedOptionIndex();
|
setQuery(event.currentTarget.value);
|
||||||
setSearch(event.currentTarget.value);
|
|
||||||
setValue(event.currentTarget.value);
|
|
||||||
setPreviewUrl(null);
|
|
||||||
}}
|
}}
|
||||||
onClick={() => combobox.openDropdown()}
|
onClick={() => combobox.openDropdown()}
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
@@ -156,25 +186,21 @@ export const IconPicker = ({
|
|||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
onBlur?.(event);
|
onBlur?.(event);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
setPreviewUrl(value);
|
setQuery("");
|
||||||
setSearch(value || "");
|
|
||||||
}}
|
}}
|
||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents="none"
|
||||||
withAsterisk={withAsterisk}
|
withAsterisk={withAsterisk}
|
||||||
error={error}
|
error={error}
|
||||||
label={label ?? tCommon("iconPicker.label")}
|
label={label ?? tCommon("iconPicker.label")}
|
||||||
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
placeholder={placeholderText}
|
||||||
/>
|
/>
|
||||||
{session?.user.permissions.includes("media-upload") && (
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
<UploadMedia
|
<UploadMedia
|
||||||
onSuccess={(medias) => {
|
onSuccess={(medias) => {
|
||||||
const first = medias.at(0);
|
const first = medias.at(0);
|
||||||
if (!first) return;
|
if (!first) return;
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setValue(first.url);
|
setValue(first.url);
|
||||||
setPreviewUrl(first.url);
|
|
||||||
setSearch(first.url);
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -190,17 +216,7 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||||
{totalOptions > 0 ? (
|
{renderDropdown()}
|
||||||
<Stack gap={4}>{groups}</Stack>
|
|
||||||
) : (
|
|
||||||
Array(15)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, index: number) => (
|
|
||||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
|
||||||
<Skeleton height={25} visible />
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
</Combobox.Dropdown>
|
</Combobox.Dropdown>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
@@ -209,8 +225,8 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
||||||
// See https://github.com/homarr-labs/homarr/issues/3070
|
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
|
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
||||||
const shouldShowPreview = (value: string | null): value is string => {
|
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
return !localizationPathRegex.test(value);
|
return !localizationPathRegex.test(value);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import type { RawData } from "ws";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
|
import { Integration } from "../base/integration";
|
||||||
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
|
import {
|
||||||
|
bandwidthMessageSchema,
|
||||||
|
itemStatusMessageSchema,
|
||||||
|
pipelineStartItemMessageSchema,
|
||||||
|
projectRefreshMessageSchema,
|
||||||
|
seesawFrameSchema,
|
||||||
|
warriorBroadcastMessageSchema,
|
||||||
|
warriorProjectSelectedMessageSchema,
|
||||||
|
warriorStatusMessageSchema,
|
||||||
|
} from "./archive-team-warrior-schemas";
|
||||||
|
import type { SeesawFrame, WarriorItem, WarriorProject } from "./archive-team-warrior-schemas";
|
||||||
|
import type { ArchiveTeamWarriorItem, ArchiveTeamWarriorStatus } from "./archive-team-warrior-types";
|
||||||
|
|
||||||
|
const SOCKJS_SNAPSHOT_TIMEOUT_MS = 2500;
|
||||||
|
|
||||||
|
interface WarriorSnapshotState {
|
||||||
|
status: string;
|
||||||
|
runnerStatus?: string;
|
||||||
|
selectedProject: string | null;
|
||||||
|
broadcastMessage: string | null;
|
||||||
|
project: ArchiveTeamWarriorStatus["project"];
|
||||||
|
bandwidth: ArchiveTeamWarriorStatus["bandwidth"];
|
||||||
|
items: Map<string, ArchiveTeamWarriorItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ArchiveTeamWarriorIntegration extends Integration {
|
||||||
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
|
const response = await input.fetchAsync(this.url("/index.html"), {
|
||||||
|
headers: this.createAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getStatusAsync(): Promise<ArchiveTeamWarriorStatus> {
|
||||||
|
const baseUrl = this.url("/").toString().replace(/\/$/, "");
|
||||||
|
|
||||||
|
await fetchWithTrustedCertificatesAsync(`${baseUrl}/index.html`, {
|
||||||
|
headers: this.createAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.readSockJsSnapshotAsync(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readSockJsSnapshotAsync(baseUrl: string): Promise<ArchiveTeamWarriorStatus> {
|
||||||
|
const snapshot: WarriorSnapshotState = {
|
||||||
|
status: "Unknown",
|
||||||
|
selectedProject: null,
|
||||||
|
broadcastMessage: null,
|
||||||
|
project: null,
|
||||||
|
bandwidth: null,
|
||||||
|
items: new Map<string, ArchiveTeamWarriorItem>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let isSettled = false;
|
||||||
|
|
||||||
|
const socket = new WebSocket(this.createSockJsWebSocketUrl(baseUrl), {
|
||||||
|
headers: this.createAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (isSettled) return;
|
||||||
|
|
||||||
|
isSettled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fail = () => {
|
||||||
|
if (isSettled) return;
|
||||||
|
|
||||||
|
isSettled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error("Failed to connect to ArchiveTeam Warrior websocket"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = setTimeout(finish, SOCKJS_SNAPSHOT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
socket.on("message", (data: RawData) => {
|
||||||
|
const frames = this.parseSockJsMessage(this.decodeWebSocketData(data));
|
||||||
|
|
||||||
|
for (const frame of frames) {
|
||||||
|
this.applyFrameToSnapshot(frame, snapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", fail);
|
||||||
|
socket.on("close", finish);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.createStatusFromSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyFrameToSnapshot(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||||
|
switch (frame.event_name) {
|
||||||
|
case "warrior.status":
|
||||||
|
this.applyWarriorStatusFrame(frame, snapshot);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "warrior.project_selected":
|
||||||
|
this.applyProjectSelectedFrame(frame, snapshot);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "warrior.broadcast_message":
|
||||||
|
this.applyBroadcastMessageFrame(frame, snapshot);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "runner.status":
|
||||||
|
this.applyRunnerStatusFrame(frame, snapshot);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "project.refresh":
|
||||||
|
this.applyProjectRefreshFrame(frame, snapshot);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pipeline.start_item":
|
||||||
|
this.applyPipelineStartItemFrame(frame, snapshot);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "item.complete":
|
||||||
|
this.applyItemStatusFrame(frame, snapshot, "completed");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "item.fail":
|
||||||
|
this.applyItemStatusFrame(frame, snapshot, "failed");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "item.cancel":
|
||||||
|
this.applyItemStatusFrame(frame, snapshot, "canceled");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "bandwidth":
|
||||||
|
this.applyBandwidthFrame(frame, snapshot);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyWarriorStatusFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||||
|
const message = warriorStatusMessageSchema.safeParse(frame.message);
|
||||||
|
if (!message.success) return;
|
||||||
|
|
||||||
|
snapshot.status = this.normalizeDisplayStatus(message.data.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyProjectSelectedFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||||
|
const message = warriorProjectSelectedMessageSchema.safeParse(frame.message);
|
||||||
|
if (!message.success) return;
|
||||||
|
|
||||||
|
snapshot.selectedProject = message.data.project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyBroadcastMessageFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||||
|
const message = warriorBroadcastMessageSchema.safeParse(frame.message);
|
||||||
|
if (!message.success) return;
|
||||||
|
|
||||||
|
snapshot.broadcastMessage = message.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRunnerStatusFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||||
|
const message = warriorStatusMessageSchema.safeParse(frame.message);
|
||||||
|
if (!message.success) return;
|
||||||
|
|
||||||
|
snapshot.runnerStatus = this.normalizeDisplayStatus(message.data.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyProjectRefreshFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||||
|
const message = projectRefreshMessageSchema.safeParse(frame.message);
|
||||||
|
if (!message.success) return;
|
||||||
|
|
||||||
|
snapshot.project = this.mapProject(message.data?.project);
|
||||||
|
snapshot.runnerStatus = message.data?.status
|
||||||
|
? this.normalizeDisplayStatus(message.data.status)
|
||||||
|
: snapshot.runnerStatus;
|
||||||
|
|
||||||
|
for (const item of message.data?.items ?? []) {
|
||||||
|
snapshot.items.set(item.id, this.mapItem(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPipelineStartItemFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||||
|
const message = pipelineStartItemMessageSchema.safeParse(frame.message);
|
||||||
|
if (!message.success) return;
|
||||||
|
|
||||||
|
snapshot.items.set(message.data.item.id, this.mapItem(message.data.item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyItemStatusFrame(
|
||||||
|
frame: SeesawFrame,
|
||||||
|
snapshot: WarriorSnapshotState,
|
||||||
|
status: ArchiveTeamWarriorItem["status"],
|
||||||
|
) {
|
||||||
|
const message = itemStatusMessageSchema.safeParse(frame.message);
|
||||||
|
if (!message.success) return;
|
||||||
|
|
||||||
|
this.updateItemStatus(snapshot.items, message.data.item_id, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyBandwidthFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||||
|
const message = bandwidthMessageSchema.safeParse(frame.message);
|
||||||
|
if (!message.success) return;
|
||||||
|
|
||||||
|
snapshot.bandwidth = message.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createStatusFromSnapshot(snapshot: WarriorSnapshotState): ArchiveTeamWarriorStatus {
|
||||||
|
const itemList = Array.from(snapshot.items.values());
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: snapshot.status,
|
||||||
|
runnerStatus: snapshot.runnerStatus,
|
||||||
|
project: snapshot.project,
|
||||||
|
selectedProject: snapshot.selectedProject,
|
||||||
|
broadcastMessage: snapshot.broadcastMessage,
|
||||||
|
bandwidth: snapshot.bandwidth,
|
||||||
|
items: itemList,
|
||||||
|
counts: {
|
||||||
|
running: itemList.filter((item) => item.status === "running").length,
|
||||||
|
completed: itemList.filter((item) => item.status === "completed").length,
|
||||||
|
failed: itemList.filter((item) => item.status === "failed").length,
|
||||||
|
canceled: itemList.filter((item) => item.status === "canceled").length,
|
||||||
|
},
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAuthHeaders() {
|
||||||
|
const authorization = this.createBasicAuthHeader();
|
||||||
|
|
||||||
|
return authorization ? { Authorization: authorization } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBasicAuthHeader() {
|
||||||
|
if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) return undefined;
|
||||||
|
|
||||||
|
const username = this.getSecretValue("username");
|
||||||
|
const password = this.getSecretValue("password");
|
||||||
|
const credentials = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
||||||
|
|
||||||
|
return `Basic ${credentials}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeWebSocketData(data: RawData) {
|
||||||
|
if (typeof data === "string") return data;
|
||||||
|
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
||||||
|
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
||||||
|
|
||||||
|
return Buffer.from(data).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSockJsMessage(raw: string): SeesawFrame[] {
|
||||||
|
if (raw === "o" || raw === "h" || raw.length === 0) return [];
|
||||||
|
if (!raw.startsWith("a")) return [];
|
||||||
|
|
||||||
|
const rawPayload = this.parseJsonSafely(raw.slice(1));
|
||||||
|
const payload = z.array(z.string()).safeParse(rawPayload);
|
||||||
|
if (!payload.success) return [];
|
||||||
|
|
||||||
|
return payload.data.flatMap((frame) => {
|
||||||
|
const rawFrame = this.parseJsonSafely(frame);
|
||||||
|
const result = seesawFrameSchema.safeParse(rawFrame);
|
||||||
|
|
||||||
|
return result.success ? [result.data] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseJsonSafely(value: string) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSockJsWebSocketUrl(baseUrl: string) {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
url.pathname = `/${Date.now()}/${randomUUID()}/websocket`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapProject(project?: WarriorProject): ArchiveTeamWarriorStatus["project"] {
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: project.project_id?.toString(),
|
||||||
|
title: project.title ?? "Unknown Project",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapItem(item: WarriorItem): ArchiveTeamWarriorItem {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: this.formatItemName(item.name),
|
||||||
|
status: this.normalizeItemStatus(item.status),
|
||||||
|
project: item.project,
|
||||||
|
startTime: item.start_time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDisplayStatus(status?: string | null) {
|
||||||
|
if (!status) return "Unknown";
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "RUNNING_PROJECT":
|
||||||
|
return "Running";
|
||||||
|
case "STOPPED":
|
||||||
|
return "Stopped";
|
||||||
|
case "IDLE":
|
||||||
|
return "Idle";
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatItemName(name: string) {
|
||||||
|
const cleaned = name.replace(/^Item\s+/i, "");
|
||||||
|
const parts = cleaned.split("\u0000").filter(Boolean);
|
||||||
|
|
||||||
|
if (parts.length === 0) return "Item";
|
||||||
|
|
||||||
|
const first = parts[0];
|
||||||
|
|
||||||
|
if (!first) return "Item";
|
||||||
|
|
||||||
|
const label = parts.length === 1 ? first : `${first} + ${parts.length - 1} more`;
|
||||||
|
|
||||||
|
return label.length <= 80 ? label : `${label.slice(0, 77)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeItemStatus(status?: string | null): ArchiveTeamWarriorItem["status"] {
|
||||||
|
if (!status) return "running";
|
||||||
|
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case "running":
|
||||||
|
case "running_project":
|
||||||
|
return "running";
|
||||||
|
case "completed":
|
||||||
|
return "completed";
|
||||||
|
case "failed":
|
||||||
|
case "fail":
|
||||||
|
return "failed";
|
||||||
|
case "canceled":
|
||||||
|
case "cancel":
|
||||||
|
return "canceled";
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateItemStatus(
|
||||||
|
items: Map<string, ArchiveTeamWarriorItem>,
|
||||||
|
itemId: string,
|
||||||
|
status: ArchiveTeamWarriorItem["status"],
|
||||||
|
) {
|
||||||
|
const item = items.get(itemId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
items.set(itemId, {
|
||||||
|
...item,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const seesawFrameSchema = z.object({
|
||||||
|
event_name: z.string(),
|
||||||
|
message: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const warriorStatusMessageSchema = z.object({
|
||||||
|
status: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const warriorBroadcastMessageSchema = z.object({
|
||||||
|
message: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const warriorProjectSelectedMessageSchema = z.object({
|
||||||
|
project: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const warriorProjectSchema = z.object({
|
||||||
|
project_id: z.number().optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
project_html: z.string().optional(),
|
||||||
|
utc_deadline: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const warriorItemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
status: z.string().nullable().optional(),
|
||||||
|
project: z.string().nullable().optional(),
|
||||||
|
start_time: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectRefreshMessageSchema = z
|
||||||
|
.object({
|
||||||
|
project: warriorProjectSchema.optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
items: z.array(warriorItemSchema).optional(),
|
||||||
|
})
|
||||||
|
.nullable();
|
||||||
|
|
||||||
|
export const itemStatusMessageSchema = z.object({
|
||||||
|
item_id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pipelineStartItemMessageSchema = z.object({
|
||||||
|
item: warriorItemSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bandwidthMessageSchema = z.object({
|
||||||
|
received: z.number().optional(),
|
||||||
|
sent: z.number().optional(),
|
||||||
|
receiving: z.number().optional(),
|
||||||
|
sending: z.number().optional(),
|
||||||
|
session_id: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WarriorProject = z.infer<typeof warriorProjectSchema>;
|
||||||
|
export type WarriorItem = z.infer<typeof warriorItemSchema>;
|
||||||
|
export type SeesawFrame = z.infer<typeof seesawFrameSchema>;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export interface ArchiveTeamWarriorItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: "running" | "completed" | "failed" | "canceled" | "unknown";
|
||||||
|
project?: string | null;
|
||||||
|
startTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveTeamWarriorBandwidth {
|
||||||
|
received?: number;
|
||||||
|
sent?: number;
|
||||||
|
receiving?: number;
|
||||||
|
sending?: number;
|
||||||
|
session_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveTeamWarriorProject {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveTeamWarriorStatus {
|
||||||
|
status: string;
|
||||||
|
runnerStatus?: string;
|
||||||
|
project?: ArchiveTeamWarriorProject | null;
|
||||||
|
selectedProject?: string | null;
|
||||||
|
broadcastMessage?: string | null;
|
||||||
|
bandwidth?: ArchiveTeamWarriorBandwidth | null;
|
||||||
|
items: ArchiveTeamWarriorItem[];
|
||||||
|
counts: {
|
||||||
|
running: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
canceled: number;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { IntegrationKind } from "@homarr/definitions";
|
|||||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||||
import { BeszelIntegration } from "../beszel/beszel-integration";
|
import { BeszelIntegration } from "../beszel/beszel-integration";
|
||||||
import { AnchorIntegration } from "../anchor/anchor-integration";
|
import { AnchorIntegration } from "../anchor/anchor-integration";
|
||||||
|
import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
|
||||||
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
|
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
|
||||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||||
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
||||||
@@ -35,6 +36,7 @@ import { MockIntegration } from "../mock/mock-integration";
|
|||||||
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
|
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
|
||||||
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
||||||
import { NPMIntegration } from "../npm/npm-integration";
|
import { NPMIntegration } from "../npm/npm-integration";
|
||||||
|
import { GotifyIntegration } from "../gotify/gotify-integration";
|
||||||
import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
||||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||||
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
||||||
@@ -121,6 +123,7 @@ export const integrationCreators = {
|
|||||||
ical: ICalIntegration,
|
ical: ICalIntegration,
|
||||||
quay: QuayIntegration,
|
quay: QuayIntegration,
|
||||||
ntfy: NTFYIntegration,
|
ntfy: NTFYIntegration,
|
||||||
|
gotify: GotifyIntegration,
|
||||||
mock: MockIntegration,
|
mock: MockIntegration,
|
||||||
truenas: TrueNasIntegration,
|
truenas: TrueNasIntegration,
|
||||||
unraid: UnraidIntegration,
|
unraid: UnraidIntegration,
|
||||||
@@ -134,6 +137,7 @@ export const integrationCreators = {
|
|||||||
audiobookshelf: AudiobookshelfIntegration,
|
audiobookshelf: AudiobookshelfIntegration,
|
||||||
navidrome: NavidromeIntegration,
|
navidrome: NavidromeIntegration,
|
||||||
umami: UmamiIntegration,
|
umami: UmamiIntegration,
|
||||||
|
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
||||||
uptimeKuma: UptimeKumaIntegration,
|
uptimeKuma: UptimeKumaIntegration,
|
||||||
peaNut: PeaNutIntegration,
|
peaNut: PeaNutIntegration,
|
||||||
beszel: BeszelIntegration,
|
beszel: BeszelIntegration,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||||
@@ -12,17 +13,21 @@ import type {
|
|||||||
BeszelAlertHistory,
|
BeszelAlertHistory,
|
||||||
BeszelAuthResponse,
|
BeszelAuthResponse,
|
||||||
BeszelContainer,
|
BeszelContainer,
|
||||||
|
BeszelContainerStats,
|
||||||
BeszelContainerStatsRecord,
|
BeszelContainerStatsRecord,
|
||||||
BeszelSmartDevice,
|
BeszelSmartDevice,
|
||||||
BeszelSystem,
|
BeszelSystem,
|
||||||
BeszelSystemDetails,
|
BeszelSystemDetails,
|
||||||
BeszelSystemdService,
|
BeszelSystemdService,
|
||||||
|
BeszelSystemStats,
|
||||||
BeszelSystemStatsRecord,
|
BeszelSystemStatsRecord,
|
||||||
CreateAlertInput,
|
CreateAlertInput,
|
||||||
PocketBaseListResponse,
|
PocketBaseListResponse,
|
||||||
UpdateAlertInput,
|
UpdateAlertInput,
|
||||||
} from "./beszel-types";
|
} from "./beszel-types";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "beszel-integration" });
|
||||||
|
|
||||||
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||||
|
|
||||||
interface BeszelSession {
|
interface BeszelSession {
|
||||||
@@ -41,10 +46,13 @@ export class BeszelIntegration extends Integration {
|
|||||||
private async authenticateAsync(): Promise<BeszelSession> {
|
private async authenticateAsync(): Promise<BeszelSession> {
|
||||||
const existingSession = await this.sessionStore.getAsync();
|
const existingSession = await this.sessionStore.getAsync();
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
|
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
|
||||||
return existingSession;
|
return existingSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
|
const authUrl = this.url("/api/collections/users/auth-with-password");
|
||||||
|
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -52,23 +60,28 @@ export class BeszelIntegration extends Integration {
|
|||||||
password: this.getSecretValue("password"),
|
password: this.getSecretValue("password"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
logger.debug("Beszel auth response received", { integrationId: this.integration.id, status: response.status });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
logger.warn("Beszel auth failed", { integrationId: this.integration.id, status: response.status });
|
||||||
throw new ResponseError(response);
|
throw new ResponseError(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as BeszelAuthResponse;
|
const data = (await response.json()) as BeszelAuthResponse;
|
||||||
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
||||||
await this.sessionStore.setAsync(session);
|
await this.sessionStore.setAsync(session);
|
||||||
|
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
|
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
|
||||||
|
const method = options.method ?? "GET";
|
||||||
|
const start = performance.now();
|
||||||
let session = await this.authenticateAsync();
|
let session = await this.authenticateAsync();
|
||||||
|
|
||||||
const doFetch = (token: string) =>
|
const doFetch = (token: string) =>
|
||||||
fetchWithTrustedCertificatesAsync(url, {
|
fetchWithTrustedCertificatesAsync(url, {
|
||||||
method: options.method ?? "GET",
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token,
|
Authorization: token,
|
||||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||||
@@ -77,14 +90,42 @@ export class BeszelIntegration extends Integration {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let response = await doFetch(session.token);
|
let response = await doFetch(session.token);
|
||||||
|
logger.debug("Beszel response", {
|
||||||
|
integrationId: this.integration.id,
|
||||||
|
method,
|
||||||
|
path: url.pathname,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
attempt: 1,
|
||||||
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
logger.warn("Beszel 401, clearing session and retrying", {
|
||||||
|
integrationId: this.integration.id,
|
||||||
|
method,
|
||||||
|
path: url.pathname,
|
||||||
|
});
|
||||||
await this.sessionStore.clearAsync();
|
await this.sessionStore.clearAsync();
|
||||||
session = await this.authenticateAsync();
|
session = await this.authenticateAsync();
|
||||||
response = await doFetch(session.token);
|
response = await doFetch(session.token);
|
||||||
|
logger.debug("Beszel response (after re-auth)", {
|
||||||
|
integrationId: this.integration.id,
|
||||||
|
method,
|
||||||
|
path: url.pathname,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
attempt: 2,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
logger.warn("Beszel request failed", {
|
||||||
|
integrationId: this.integration.id,
|
||||||
|
method,
|
||||||
|
path: url.pathname,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
});
|
||||||
throw new ResponseError(response);
|
throw new ResponseError(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +286,111 @@ export class BeszelIntegration extends Integration {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async subscribeRealtimeMetrics(
|
||||||
|
systemId: string,
|
||||||
|
onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await this.authenticateAsync();
|
||||||
|
const sseUrl = this.url("/api/realtime");
|
||||||
|
logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId });
|
||||||
|
|
||||||
|
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
|
||||||
|
headers: { Authorization: session.token },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sseResponse.ok || !sseResponse.body) {
|
||||||
|
logger.warn("Beszel SSE connection failed", {
|
||||||
|
integrationId: this.integration.id,
|
||||||
|
systemId,
|
||||||
|
status: sseResponse.status,
|
||||||
|
});
|
||||||
|
throw new ResponseError(sseResponse);
|
||||||
|
}
|
||||||
|
logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId });
|
||||||
|
|
||||||
|
const reader = sseResponse.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let clientId: string | null = null;
|
||||||
|
|
||||||
|
const processLine = (line: string) => {
|
||||||
|
if (!line.startsWith("data:")) return;
|
||||||
|
const jsonStr = line.slice(5).trim();
|
||||||
|
if (!jsonStr) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!clientId && typeof parsed.clientId === "string") {
|
||||||
|
clientId = parsed.clientId;
|
||||||
|
this.subscribeTopic(clientId, systemId, session.token).catch((err) => {
|
||||||
|
logger.warn("Failed to subscribe to rt_metrics topic, aborting SSE", { error: err });
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("stats" in parsed) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const envelope = { id: "", system: systemId, type: "1m" as const, created: now, updated: now };
|
||||||
|
const stats: BeszelSystemStatsRecord = { ...envelope, stats: parsed.stats as BeszelSystemStats };
|
||||||
|
|
||||||
|
let containerStats: BeszelContainerStatsRecord | null = null;
|
||||||
|
const hasContainers =
|
||||||
|
parsed.container &&
|
||||||
|
Array.isArray(parsed.container) &&
|
||||||
|
(parsed.container as BeszelContainerStats[]).length > 0;
|
||||||
|
if (hasContainers) {
|
||||||
|
containerStats = { ...envelope, stats: parsed.container as BeszelContainerStats[] };
|
||||||
|
}
|
||||||
|
onMessage({ stats, containerStats });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.debug("Failed to parse SSE message", { line });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
processLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!signal.aborted) {
|
||||||
|
logger.warn("Beszel SSE stream ended unexpectedly", {
|
||||||
|
integrationId: this.integration.id,
|
||||||
|
systemId,
|
||||||
|
});
|
||||||
|
throw new Error("Beszel SSE stream ended unexpectedly");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async subscribeTopic(clientId: string, systemId: string, token: string): Promise<void> {
|
||||||
|
const topic = `rt_metrics?options=${JSON.stringify({ query: { system: systemId } })}`;
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/realtime"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ clientId, subscriptions: [topic] }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ResponseError(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
|
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ export interface BeszelSystemDetails {
|
|||||||
*
|
*
|
||||||
* Units:
|
* Units:
|
||||||
* - CPU/GPU/memory/disk percentages: 0–100
|
* - CPU/GPU/memory/disk percentages: 0–100
|
||||||
* - Memory/disk absolute values (mu, du, etc.): bytes
|
* - Memory/disk absolute values (mu, du, m, d, etc.): GB
|
||||||
* - Disk I/O (dr, dw): bytes/s
|
* - Disk I/O (dr, dw): MB/s
|
||||||
* - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges)
|
* - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges)
|
||||||
* - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts
|
* - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts
|
||||||
*/
|
*/
|
||||||
@@ -110,35 +110,35 @@ export interface BeszelSystemStats {
|
|||||||
cpus?: number[];
|
cpus?: number[];
|
||||||
/** load average [1m, 5m, 15m] */
|
/** load average [1m, 5m, 15m] */
|
||||||
la?: [number, number, number];
|
la?: [number, number, number];
|
||||||
/** total memory (bytes) */
|
/** total memory (GB) */
|
||||||
m: number;
|
m: number;
|
||||||
/** memory used (bytes) */
|
/** memory used (GB) */
|
||||||
mu: number;
|
mu: number;
|
||||||
/** memory usage (%) */
|
/** memory usage (%) */
|
||||||
mp: number;
|
mp: number;
|
||||||
/** memory buffer/cache (bytes) */
|
/** memory buffer/cache (GB) */
|
||||||
mb: number;
|
mb: number;
|
||||||
/** memory max used (bytes) */
|
/** memory max used (GB) */
|
||||||
mm?: number;
|
mm?: number;
|
||||||
/** memory zfs arc (bytes) */
|
/** memory zfs arc (GB) */
|
||||||
mz?: number;
|
mz?: number;
|
||||||
/** total swap (bytes) */
|
/** total swap (GB) */
|
||||||
s: number;
|
s: number;
|
||||||
/** swap used (bytes) */
|
/** swap used (GB) */
|
||||||
su: number;
|
su: number;
|
||||||
/** total disk (bytes) */
|
/** total disk (GB) */
|
||||||
d: number;
|
d: number;
|
||||||
/** disk used (bytes) */
|
/** disk used (GB) */
|
||||||
du: number;
|
du: number;
|
||||||
/** disk usage (%) */
|
/** disk usage (%) */
|
||||||
dp: number;
|
dp: number;
|
||||||
/** disk read (bytes/s) */
|
/** disk read (MB/s) */
|
||||||
dr: number;
|
dr: number;
|
||||||
/** disk write (bytes/s) */
|
/** disk write (MB/s) */
|
||||||
dw: number;
|
dw: number;
|
||||||
/** disk read max (bytes/s) */
|
/** disk read max (MB/s) */
|
||||||
drm?: number;
|
drm?: number;
|
||||||
/** disk write max (bytes/s) */
|
/** disk write max (MB/s) */
|
||||||
dwm?: number;
|
dwm?: number;
|
||||||
/** disk IOPS [read, write] */
|
/** disk IOPS [read, write] */
|
||||||
dio?: [number, number];
|
dio?: [number, number];
|
||||||
@@ -236,13 +236,14 @@ export interface BeszelContainer {
|
|||||||
* One entry per container in a BeszelContainerStatsRecord.
|
* One entry per container in a BeszelContainerStatsRecord.
|
||||||
*
|
*
|
||||||
* Network fields: b (bandwidth) is preferred when available; fall back to ns/nr.
|
* Network fields: b (bandwidth) is preferred when available; fall back to ns/nr.
|
||||||
|
* Units: m = MB, c = %, b/ns/nr = bytes/s
|
||||||
*/
|
*/
|
||||||
export interface BeszelContainerStats {
|
export interface BeszelContainerStats {
|
||||||
/** container name */
|
/** container name */
|
||||||
n: string;
|
n: string;
|
||||||
/** CPU usage (%) */
|
/** CPU usage (%) */
|
||||||
c: number;
|
c: number;
|
||||||
/** memory usage (bytes) */
|
/** memory usage (MB) */
|
||||||
m: number;
|
m: number;
|
||||||
/** network sent (bytes/s) — legacy, use b when available */
|
/** network sent (bytes/s) — legacy, use b when available */
|
||||||
ns?: number;
|
ns?: number;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const itemSchema = z.object({
|
|||||||
ServerId: z.string(),
|
ServerId: z.string(),
|
||||||
Name: z.string(),
|
Name: z.string(),
|
||||||
Taglines: z.array(z.string()),
|
Taglines: z.array(z.string()),
|
||||||
Studios: z.array(z.object({ Name: z.string() })),
|
Studios: z.array(z.object({ Name: z.string() })).optional(),
|
||||||
Overview: z.string().optional(),
|
Overview: z.string().optional(),
|
||||||
PremiereDate: z
|
PremiereDate: z
|
||||||
.string()
|
.string()
|
||||||
@@ -172,7 +172,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||||
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||||
},
|
},
|
||||||
producer: item.Studios.at(0)?.Name,
|
producer: item.Studios?.at(0)?.Name,
|
||||||
rating: item.CommunityRating?.toFixed(1),
|
rating: item.CommunityRating?.toFixed(1),
|
||||||
tags: item.Genres,
|
tags: item.Genres,
|
||||||
href: super.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
href: super.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
|
|||||||
})),
|
})),
|
||||||
uptime: stats.uptime.as("seconds"),
|
uptime: stats.uptime.as("seconds"),
|
||||||
rebootRequired: false,
|
rebootRequired: false,
|
||||||
cpuModelName: stats.quicklook.cpu_name,
|
cpuModelName: stats.quicklook?.cpu_name ?? "Unknown",
|
||||||
loadAverage: null,
|
loadAverage: null,
|
||||||
smart: [],
|
smart: [],
|
||||||
cpuTemp: undefined,
|
cpuTemp: undefined,
|
||||||
@@ -155,9 +155,11 @@ const allSchema = z.object({
|
|||||||
|
|
||||||
return dayjs.duration({ days, hours, minutes, seconds });
|
return dayjs.duration({ days, hours, minutes, seconds });
|
||||||
}),
|
}),
|
||||||
quicklook: z.object({
|
quicklook: z
|
||||||
cpu_name: z.string(),
|
.object({
|
||||||
}),
|
cpu_name: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
gpu: z
|
gpu: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ResponseError } from "@homarr/common/server";
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import { Integration } from "../base/integration";
|
||||||
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
|
import type { Notification } from "../interfaces/notifications/notification-types";
|
||||||
|
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
|
||||||
|
import { gotifyMessagesResponseSchema } from "./gotify-schema";
|
||||||
|
|
||||||
|
export class GotifyIntegration extends Integration implements INotificationsIntegration {
|
||||||
|
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
|
await input.fetchAsync(this.url("/health"), { headers: this.getHeaders() });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHeaders() {
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${super.getSecretValue("username")}:${super.getSecretValue("password")}`,
|
||||||
|
).toString("base64");
|
||||||
|
return { Authorization: `Basic ${credentials}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getNotificationsAsync(): Promise<Notification[]> {
|
||||||
|
const url = this.url("/message", { limit: 100 });
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(url, { headers: this.getHeaders() });
|
||||||
|
|
||||||
|
if (!response.ok) throw new ResponseError(response);
|
||||||
|
|
||||||
|
const result = await gotifyMessagesResponseSchema.safeParseAsync(await response.json());
|
||||||
|
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
|
||||||
|
const parsed = result.data;
|
||||||
|
|
||||||
|
return parsed.messages.map((message): Notification => ({
|
||||||
|
id: String(message.id),
|
||||||
|
time: new Date(message.date),
|
||||||
|
title: message.title,
|
||||||
|
body: message.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
// See: https://gotify.net/api-docs#/message/getMessages
|
||||||
|
export const gotifyMessageSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
date: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gotifyMessagesResponseSchema = z.object({
|
||||||
|
messages: z.array(gotifyMessageSchema),
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// General integrations
|
// General integrations
|
||||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||||
|
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
|
||||||
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
||||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||||
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
||||||
@@ -17,6 +18,7 @@ export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
|
|||||||
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
|
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
|
||||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||||
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
|
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
|
||||||
|
export { GotifyIntegration } from "./gotify/gotify-integration";
|
||||||
export { NTFYIntegration } from "./ntfy/ntfy-integration";
|
export { NTFYIntegration } from "./ntfy/ntfy-integration";
|
||||||
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
||||||
export { GlancesIntegration } from "./glances/glances-integration";
|
export { GlancesIntegration } from "./glances/glances-integration";
|
||||||
@@ -73,6 +75,11 @@ export type {
|
|||||||
AnchorNotesListInput,
|
AnchorNotesListInput,
|
||||||
AnchorNoteSummary,
|
AnchorNoteSummary,
|
||||||
} from "./anchor/anchor-types";
|
} from "./anchor/anchor-types";
|
||||||
|
export type {
|
||||||
|
ArchiveTeamWarriorBandwidth,
|
||||||
|
ArchiveTeamWarriorItem,
|
||||||
|
ArchiveTeamWarriorStatus,
|
||||||
|
} from "./archive-team-warrior/archive-team-warrior-types";
|
||||||
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
|
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
|
||||||
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
|
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
|
||||||
export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types";
|
export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types";
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
} from "../../beszel/beszel-types";
|
} from "../../beszel/beszel-types";
|
||||||
|
|
||||||
const GB = 1024 * 1024 * 1024;
|
const GB = 1024 * 1024 * 1024;
|
||||||
const MB = 1024 * 1024;
|
|
||||||
const KB = 1024;
|
const KB = 1024;
|
||||||
|
|
||||||
interface MockSystemDef {
|
interface MockSystemDef {
|
||||||
@@ -144,26 +143,27 @@ function generateSystemStatsSeries(count: number, sys: MockSystemDef): BeszelSys
|
|||||||
const netSendWalk = smoothWalk(500, 200, count, 10, 8000);
|
const netSendWalk = smoothWalk(500, 200, count, 10, 8000);
|
||||||
const netRecvWalk = smoothWalk(800, 300, count, 10, 12000);
|
const netRecvWalk = smoothWalk(800, 300, count, 10, 12000);
|
||||||
|
|
||||||
const memTotal = sys.mem;
|
|
||||||
const diskTotal = rand(200, 2000) * GB;
|
const diskTotal = rand(200, 2000) * GB;
|
||||||
|
|
||||||
return cpuWalk.map((cpu, i) => {
|
return cpuWalk.map((cpu, i) => {
|
||||||
const memPct = memPctWalk[i] ?? sys.baseMem;
|
const memPct = memPctWalk[i] ?? sys.baseMem;
|
||||||
const diskPct = diskPctWalk[i] ?? sys.baseDisk;
|
const diskPct = diskPctWalk[i] ?? sys.baseDisk;
|
||||||
|
const memTotalGB = sys.mem / GB;
|
||||||
|
const diskTotalGB = diskTotal / GB;
|
||||||
return {
|
return {
|
||||||
cpu,
|
cpu,
|
||||||
la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number],
|
la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number],
|
||||||
m: memTotal,
|
m: memTotalGB,
|
||||||
mu: (memTotal * memPct) / 100,
|
mu: (memTotalGB * memPct) / 100,
|
||||||
mp: memPct,
|
mp: memPct,
|
||||||
mb: rand(0.5, 4) * GB,
|
mb: rand(0.5, 4),
|
||||||
s: 4 * GB,
|
s: 4,
|
||||||
su: rand(0, 2) * GB,
|
su: rand(0, 2),
|
||||||
d: diskTotal,
|
d: diskTotalGB,
|
||||||
du: (diskTotal * diskPct) / 100,
|
du: (diskTotalGB * diskPct) / 100,
|
||||||
dp: diskPct,
|
dp: diskPct,
|
||||||
dr: (diskReadWalk[i] ?? 5) * MB,
|
dr: diskReadWalk[i] ?? 5,
|
||||||
dw: (diskWriteWalk[i] ?? 3) * MB,
|
dw: diskWriteWalk[i] ?? 3,
|
||||||
ns: (netSendWalk[i] ?? 500) * KB,
|
ns: (netSendWalk[i] ?? 500) * KB,
|
||||||
nr: (netRecvWalk[i] ?? 800) * KB,
|
nr: (netRecvWalk[i] ?? 800) * KB,
|
||||||
b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number],
|
b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number],
|
||||||
@@ -183,7 +183,7 @@ function generateContainerStatsSeries(count: number, profiles: typeof containerP
|
|||||||
walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({
|
walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({
|
||||||
n: profile.name,
|
n: profile.name,
|
||||||
c: cpuWalk[i] ?? profile.cpuBase,
|
c: cpuWalk[i] ?? profile.cpuBase,
|
||||||
m: (memWalk[i] ?? profile.memMB) * MB,
|
m: memWalk[i] ?? profile.memMB,
|
||||||
b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number],
|
b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -264,13 +264,15 @@ export class BeszelMockService {
|
|||||||
const sys = resolveSystem(systemId);
|
const sys = resolveSystem(systemId);
|
||||||
const intervalMinutes = typeToMinutes[type] ?? 1;
|
const intervalMinutes = typeToMinutes[type] ?? 1;
|
||||||
const statsSeries = generateSystemStatsSeries(perPage, sys);
|
const statsSeries = generateSystemStatsSeries(perPage, sys);
|
||||||
return buildTimeSeries(
|
const result = buildTimeSeries(
|
||||||
systemId,
|
systemId,
|
||||||
perPage,
|
perPage,
|
||||||
intervalMinutes,
|
intervalMinutes,
|
||||||
type,
|
type,
|
||||||
statsSeries.map((stats) => ({ stats })),
|
statsSeries.map((stats) => ({ stats })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getContainerStatsAsync(
|
public async getContainerStatsAsync(
|
||||||
@@ -284,13 +286,15 @@ export class BeszelMockService {
|
|||||||
.map((i) => containerProfiles[i])
|
.map((i) => containerProfiles[i])
|
||||||
.filter(Boolean) as typeof containerProfiles;
|
.filter(Boolean) as typeof containerProfiles;
|
||||||
const series = generateContainerStatsSeries(perPage, selectedProfiles);
|
const series = generateContainerStatsSeries(perPage, selectedProfiles);
|
||||||
return buildTimeSeries(
|
const result = buildTimeSeries(
|
||||||
systemId,
|
systemId,
|
||||||
perPage,
|
perPage,
|
||||||
intervalMinutes,
|
intervalMinutes,
|
||||||
type,
|
type,
|
||||||
series.map((stats) => ({ stats })),
|
series.map((stats) => ({ stats })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAlertsAsync(): Promise<BeszelAlert[]> {
|
public async getAlertsAsync(): Promise<BeszelAlert[]> {
|
||||||
@@ -326,6 +330,40 @@ export class BeszelMockService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async subscribeRealtimeMetrics(
|
||||||
|
systemId: string,
|
||||||
|
onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const sys = resolveSystem(systemId);
|
||||||
|
const emit = () => {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
const [stats] = generateSystemStatsSeries(1, sys);
|
||||||
|
if (!stats) return;
|
||||||
|
const record: BeszelSystemStatsRecord = {
|
||||||
|
id: `mock-rt-${Date.now()}`,
|
||||||
|
system: systemId,
|
||||||
|
stats,
|
||||||
|
type: "1m",
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
onMessage({ stats: record, containerStats: null });
|
||||||
|
};
|
||||||
|
if (signal.aborted) return;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const interval = setInterval(emit, 1000);
|
||||||
|
signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise<BeszelAlertHistory[]> {
|
public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise<BeszelAlertHistory[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"];
|
const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"];
|
||||||
@@ -337,7 +375,7 @@ export class BeszelMockService {
|
|||||||
name: names[i % names.length] ?? "CPU",
|
name: names[i % names.length] ?? "CPU",
|
||||||
val: rand(60, 95),
|
val: rand(60, 95),
|
||||||
created: new Date(now - (i + 1) * 3_600_000).toISOString(),
|
created: new Date(now - (i + 1) * 3_600_000).toISOString(),
|
||||||
resolved: i % 2 === 0 ? new Date(now - i * 1_800_000).toISOString() : null,
|
resolved: (i % 2 === 0 && new Date(now - i * 1_800_000).toISOString()) || null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,4 +140,5 @@ export class MockIntegration
|
|||||||
getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel);
|
getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel);
|
||||||
getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel);
|
getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel);
|
||||||
getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel);
|
getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel);
|
||||||
|
subscribeRealtimeMetrics = MockIntegration.beszel.subscribeRealtimeMetrics.bind(MockIntegration.beszel);
|
||||||
}
|
}
|
||||||
@@ -132,8 +132,9 @@ const happyResponder = (method: string) => {
|
|||||||
aggregations: emptyAggregations(),
|
aggregations: emptyAggregations(),
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 1,
|
end: 1,
|
||||||
legend: [],
|
// The "memory" graph reports available (free) memory: legend is ["time", "available"].
|
||||||
data: [[1000, 8_000_000_000]],
|
legend: ["time", "available"],
|
||||||
|
data: [[1000, 6_000_000_000]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cputemp",
|
name: "cputemp",
|
||||||
@@ -194,15 +195,17 @@ describe("TrueNasIntegration", () => {
|
|||||||
version: "TrueNAS-SCALE-24.10",
|
version: "TrueNAS-SCALE-24.10",
|
||||||
cpuModelName: "Intel Xeon",
|
cpuModelName: "Intel Xeon",
|
||||||
cpuTemp: 45,
|
cpuTemp: 45,
|
||||||
memAvailableInBytes: 16_000_000_000,
|
// physmem (16 GB) minus the reported available 6 GB leaves 10 GB used.
|
||||||
memUsedInBytes: 8_000_000_000,
|
memAvailableInBytes: 6_000_000_000,
|
||||||
|
memUsedInBytes: 10_000_000_000,
|
||||||
uptime: 3600,
|
uptime: 3600,
|
||||||
rebootRequired: false,
|
rebootRequired: false,
|
||||||
availablePkgUpdates: 0,
|
availablePkgUpdates: 0,
|
||||||
loadAverage: null,
|
loadAverage: null,
|
||||||
gpu: [],
|
gpu: [],
|
||||||
network: { up: 20_000, down: 10_000 },
|
network: { up: 20_000, down: 10_000 },
|
||||||
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
|
// `available` is the free space left on the pool (free = 500), not its total size.
|
||||||
|
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
||||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,14 +37,19 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
|
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
|
||||||
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
|
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
|
||||||
|
|
||||||
|
// The "memory" reporting graph reports available (free) memory, its legend is ["time", "available"].
|
||||||
|
// Used memory is therefore the physical total minus what is still available (clamped against timing skew).
|
||||||
|
const memAvailableInBytes = memoryData[1] ?? 0; // Index 0 is the UNIX timestamp, Index 1 is available bytes
|
||||||
|
const memUsedInBytes = Math.max(systemInformation.physmem - memAvailableInBytes, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
||||||
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
|
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
|
||||||
memAvailableInBytes: systemInformation.physmem,
|
memAvailableInBytes,
|
||||||
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
memUsedInBytes,
|
||||||
fileSystem: datasets.map((dataset) => ({
|
fileSystem: datasets.map((dataset) => ({
|
||||||
deviceName: dataset.name,
|
deviceName: dataset.name,
|
||||||
available: `${dataset.size}`, // TODO: can we use number instead of string here?
|
available: `${dataset.free}`, // free space left on the pool
|
||||||
used: `${dataset.allocated}`,
|
used: `${dataset.allocated}`,
|
||||||
percentage: (dataset.allocated / dataset.size) * 100,
|
percentage: (dataset.allocated / dataset.size) * 100,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
|||||||
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
|
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
|
||||||
fileSystem: systemInfo.array.disks.map((disk) => ({
|
fileSystem: systemInfo.array.disks.map((disk) => ({
|
||||||
deviceName: disk.name,
|
deviceName: disk.name,
|
||||||
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), covert to bytes
|
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
|
||||||
available: `${disk.size * 1024}`, // API is in KiB (kibibytes), covert to bytes
|
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
|
||||||
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
||||||
})),
|
})),
|
||||||
smart: systemInfo.array.disks.map((disk) => ({
|
smart: systemInfo.array.disks.map((disk) => ({
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createDb } from "@homarr/db/test";
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import { GlancesIntegration } from "../src/glances/glances-integration";
|
||||||
|
|
||||||
|
// ─── module mocks ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock("@homarr/db", async (importActual) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
|
const actual = await importActual<typeof import("@homarr/db")>();
|
||||||
|
return { ...actual, db: createDb() };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
|
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||||
|
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../src/base/session-store", () => ({
|
||||||
|
createSessionStore: () => ({
|
||||||
|
async getAsync() {
|
||||||
|
return { version: "4.0.0" };
|
||||||
|
},
|
||||||
|
async setAsync() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
async clearAsync() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const baseStats = {
|
||||||
|
cpu: { total: 10 },
|
||||||
|
mem: { total: 8_000_000_000, used: 4_000_000_000, free: 4_000_000_000 },
|
||||||
|
network: [{ bytes_sent_rate_per_sec: 1000, bytes_recv_rate_per_sec: 2000 }],
|
||||||
|
fs: [{ device_name: "/dev/sda1", used: 10_000, free: 90_000, percent: 10 }],
|
||||||
|
uptime: "1 day, 2:03:04",
|
||||||
|
gpu: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||||
|
|
||||||
|
// Cast lives here so call sites don't need `as never`.
|
||||||
|
function makeResponse(body: unknown) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrationInput = {
|
||||||
|
id: "test-glances",
|
||||||
|
name: "Glances",
|
||||||
|
url: "http://localhost:61208",
|
||||||
|
decryptedSecrets: [],
|
||||||
|
externalUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("GlancesIntegration schema", () => {
|
||||||
|
test("parses stats when quicklook is present", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeResponse({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }));
|
||||||
|
|
||||||
|
const integration = new GlancesIntegration(integrationInput);
|
||||||
|
const result = await integration.getSystemInfoAsync();
|
||||||
|
|
||||||
|
expect(result.cpuModelName).toBe("Apple M1");
|
||||||
|
expect(result.cpuUtilization).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
|
||||||
|
|
||||||
|
const integration = new GlancesIntegration(integrationInput);
|
||||||
|
const result = await integration.getSystemInfoAsync();
|
||||||
|
|
||||||
|
expect(result.cpuModelName).toBe("Unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not throw a Zod parse error when quicklook is missing", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
|
||||||
|
|
||||||
|
const integration = new GlancesIntegration(integrationInput);
|
||||||
|
await expect(integration.getSystemInfoAsync()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
import type { StartedTestContainer } from "testcontainers";
|
import type { StartedTestContainer } from "testcontainers";
|
||||||
import { GenericContainer, Wait } from "testcontainers";
|
import { GenericContainer, Wait } from "testcontainers";
|
||||||
import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
|
import {
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
afterAll,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
@@ -23,10 +31,15 @@ vi.mock("@homarr/db", async (importActual) => {
|
|||||||
|
|
||||||
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
const actual =
|
||||||
|
await importActual<
|
||||||
|
typeof import("@homarr/core/infrastructure/certificates")
|
||||||
|
>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
|
getTrustedCertificateHostnamesAsync: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => Promise.resolve([])),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,7 +90,9 @@ const INTEGRATION_ID = "test-technitium";
|
|||||||
|
|
||||||
const makeInput = (
|
const makeInput = (
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
|
auth:
|
||||||
|
| { kind: "credentials"; password?: string }
|
||||||
|
| { kind: "apiKey"; value: string },
|
||||||
): IntegrationInput => ({
|
): IntegrationInput => ({
|
||||||
id: INTEGRATION_ID,
|
id: INTEGRATION_ID,
|
||||||
name: "Technitium DNS",
|
name: "Technitium DNS",
|
||||||
@@ -94,7 +109,10 @@ const makeInput = (
|
|||||||
|
|
||||||
// Fetch an API token appropriate for the server version.
|
// Fetch an API token appropriate for the server version.
|
||||||
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
|
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
|
||||||
async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise<string> {
|
async function fetchApiToken(
|
||||||
|
baseUrl: string,
|
||||||
|
apiVersion: TechnitiumVersion,
|
||||||
|
): Promise<string> {
|
||||||
if (apiVersion === "v15") {
|
if (apiVersion === "v15") {
|
||||||
// v15 exposes a dedicated endpoint for non-expiring API tokens.
|
// v15 exposes a dedicated endpoint for non-expiring API tokens.
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -104,7 +122,9 @@ async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Pr
|
|||||||
}
|
}
|
||||||
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
|
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
|
||||||
// token that can be used as a long-lived API key via the ?token= query parameter.
|
// token that can be used as a long-lived API key via the ?token= query parameter.
|
||||||
const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
|
const res = await fetch(
|
||||||
|
`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
|
||||||
|
);
|
||||||
return ((await res.json()) as { token: string }).token;
|
return ((await res.json()) as { token: string }).token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,12 +144,16 @@ const pool: Record<string, ContainerEntry> = {};
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
VERSIONS.map(async ({ tag, apiVersion }) => {
|
VERSIONS.map(async ({ tag, apiVersion }) => {
|
||||||
const container = await new GenericContainer(`technitium/dns-server:${tag}`)
|
const container = await new GenericContainer(
|
||||||
|
`technitium/dns-server:${tag}`,
|
||||||
|
)
|
||||||
.withExposedPorts(5380)
|
.withExposedPorts(5380)
|
||||||
// Explicitly set the admin password so all versions use the same credentials.
|
// Explicitly set the admin password so all versions use the same credentials.
|
||||||
// Without this, older versions may generate a random password on first run.
|
// Without this, older versions may generate a random password on first run.
|
||||||
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
|
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
|
||||||
.withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully."))
|
.withWaitStrategy(
|
||||||
|
Wait.forLogMessage("Technitium DNS Server was started successfully."),
|
||||||
|
)
|
||||||
.start();
|
.start();
|
||||||
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
|
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
|
||||||
const apiToken = await fetchApiToken(baseUrl, apiVersion);
|
const apiToken = await fetchApiToken(baseUrl, apiVersion);
|
||||||
@@ -139,203 +163,238 @@ beforeAll(async () => {
|
|||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
|
await Promise.all(
|
||||||
|
Object.values(pool).map(({ container }) => container.stop()),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── version-parameterised suites ────────────────────────────────────────────
|
// ─── version-parameterised suites ────────────────────────────────────────────
|
||||||
|
|
||||||
describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
|
describe.each(VERSIONS)(
|
||||||
let baseUrl: string;
|
"Technitium DNS $tag ($apiVersion)",
|
||||||
let apiToken: string;
|
({ tag, apiVersion }) => {
|
||||||
|
let baseUrl: string;
|
||||||
|
let apiToken: string;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const entry = pool[tag];
|
const entry = pool[tag];
|
||||||
if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
|
if (!entry)
|
||||||
({ baseUrl, apiToken } = entry);
|
throw new Error(`Container pool entry for ${tag} was not initialized`);
|
||||||
});
|
({ baseUrl, apiToken } = entry);
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionState[INTEGRATION_ID] = null;
|
sessionState[INTEGRATION_ID] = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── factory ────────────────────────────────────────────────────────────────
|
// ── factory ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
||||||
test("factory creates a working integration (auto-detects version)", async () => {
|
test("factory creates a working integration (auto-detects version)", async () => {
|
||||||
const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
|
const integration = await createTechnitiumDnsIntegrationAsync(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("getSummaryAsync", () => {
|
describe("getSummaryAsync", () => {
|
||||||
test("returns valid summary with credentials", async () => {
|
test("returns valid summary with credentials", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
const integration = new TechnitiumDnsIntegration(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("returns valid summary with API key", async () => {
|
test("returns valid summary with API key", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||||
apiVersion,
|
apiVersion,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
const integration = new TechnitiumDnsIntegration(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(typeof result.domainsBeingBlocked).toBe("number");
|
expect(typeof result.domainsBeingBlocked).toBe("number");
|
||||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── session caching ────────────────────────────────────────────────────────
|
// ── session caching ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("session caching", () => {
|
describe("session caching", () => {
|
||||||
test("login is performed once and token is cached for subsequent calls", async () => {
|
test("login is performed once and token is cached for subsequent calls", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
const integration = new TechnitiumDnsIntegration(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
);
|
||||||
|
|
||||||
await integration.getSummaryAsync();
|
await integration.getSummaryAsync();
|
||||||
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
||||||
expect(tokenAfterFirst).toBeDefined();
|
expect(tokenAfterFirst).toBeDefined();
|
||||||
|
|
||||||
await integration.getSummaryAsync();
|
await integration.getSummaryAsync();
|
||||||
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
||||||
|
|
||||||
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("recovers from an expired/invalid stored token by re-logging in", async () => {
|
test("recovers from an expired/invalid stored token by re-logging in", async () => {
|
||||||
// Plant a bad token with the correct version so it hits the right API path before failing
|
// Plant a bad token with the correct version so it hits the right API path before failing
|
||||||
sessionState[INTEGRATION_ID] = { token: "deliberately-invalid-token", version: apiVersion };
|
sessionState[INTEGRATION_ID] = {
|
||||||
|
token: "deliberately-invalid-token",
|
||||||
|
version: apiVersion,
|
||||||
|
};
|
||||||
|
|
||||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
|
const integration = new TechnitiumDnsIntegration(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
apiVersion,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
||||||
expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
|
expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
|
||||||
}, 20_000);
|
"deliberately-invalid-token",
|
||||||
});
|
);
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
|
|
||||||
// ── blocking state changes ─────────────────────────────────────────────────
|
// ── blocking state changes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("blocking state changes", () => {
|
describe("blocking state changes", () => {
|
||||||
test("disableAsync permanently disables blocking", async () => {
|
test("disableAsync permanently disables blocking", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
const integration = new TechnitiumDnsIntegration(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
);
|
||||||
|
|
||||||
await integration.disableAsync();
|
await integration.disableAsync();
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("disabled");
|
expect(result.status).toBe("disabled");
|
||||||
|
|
||||||
await integration.enableAsync(); // restore
|
await integration.enableAsync(); // restore
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("enableAsync re-enables blocking after permanent disable", async () => {
|
test("enableAsync re-enables blocking after permanent disable", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
const integration = new TechnitiumDnsIntegration(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
);
|
||||||
|
|
||||||
await integration.disableAsync();
|
await integration.disableAsync();
|
||||||
await integration.enableAsync();
|
await integration.enableAsync();
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("disableAsync with duration temporarily disables blocking", async () => {
|
test("disableAsync with duration temporarily disables blocking", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
const integration = new TechnitiumDnsIntegration(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
);
|
||||||
|
|
||||||
await integration.disableAsync(120); // 120s → 2 min
|
await integration.disableAsync(120); // 120s → 2 min
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("disabled");
|
expect(result.status).toBe("disabled");
|
||||||
|
|
||||||
await integration.enableAsync(); // restore
|
await integration.enableAsync(); // restore
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
const integration = new TechnitiumDnsIntegration(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
);
|
||||||
|
|
||||||
await integration.disableAsync(1);
|
await integration.disableAsync(1);
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("disabled");
|
expect(result.status).toBe("disabled");
|
||||||
|
|
||||||
await integration.enableAsync(); // restore
|
await integration.enableAsync(); // restore
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("enableAsync cancels an active timed disable immediately", async () => {
|
test("enableAsync cancels an active timed disable immediately", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
const integration = new TechnitiumDnsIntegration(
|
||||||
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
|
);
|
||||||
|
|
||||||
await integration.disableAsync(600); // 10 minutes
|
await integration.disableAsync(600); // 10 minutes
|
||||||
await integration.enableAsync();
|
await integration.enableAsync();
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── testConnectionAsync ────────────────────────────────────────────────────
|
// ── testConnectionAsync ────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("testConnectionAsync", () => {
|
describe("testConnectionAsync", () => {
|
||||||
test("succeeds with valid credentials", async () => {
|
test("succeeds with valid credentials", async () => {
|
||||||
const result = await new TechnitiumDnsIntegration(
|
const result = await new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
).testConnectionAsync();
|
).testConnectionAsync();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("succeeds with valid API key", async () => {
|
test("succeeds with valid API key", async () => {
|
||||||
const result = await new TechnitiumDnsIntegration(
|
const result = await new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||||
apiVersion,
|
apiVersion,
|
||||||
).testConnectionAsync();
|
).testConnectionAsync();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("fails with wrong password — returns authorization error", async () => {
|
test("fails with wrong password — returns authorization error", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, { kind: "credentials", password: "wrong-password" }),
|
makeInput(baseUrl, {
|
||||||
);
|
kind: "credentials",
|
||||||
|
password: "wrong-password",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const result = await integration.testConnectionAsync();
|
const result = await integration.testConnectionAsync();
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (result.success) return;
|
if (result.success) return;
|
||||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||||
expect(result.error.type).toBe("authorization");
|
expect(result.error.type).toBe("authorization");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("fails with invalid API key — returns authorization error", async () => {
|
test("fails with invalid API key — returns authorization error", async () => {
|
||||||
const result = await new TechnitiumDnsIntegration(
|
const result = await new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
||||||
apiVersion,
|
apiVersion,
|
||||||
).testConnectionAsync();
|
).testConnectionAsync();
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (result.success) return;
|
if (result.success) return;
|
||||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||||
expect(result.error.type).toBe("authorization");
|
expect(result.error.type).toBe("authorization");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
createGetSetChannel,
|
createGetSetChannel,
|
||||||
createQueryCacheChannel,
|
createQueryCacheChannel,
|
||||||
getAllQueryCacheAsync,
|
getAllQueryCacheAsync,
|
||||||
|
invalidateIntegrationCacheAsync,
|
||||||
} from "./lib/channel";
|
} from "./lib/channel";
|
||||||
|
|
||||||
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
|
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
|
||||||
|
|||||||
@@ -261,6 +261,36 @@ export const createIntegrationOptionsChannel = <TData>(
|
|||||||
return createChannelWithLatestAndEvents<TData>(channelName);
|
return createChannelWithLatestAndEvents<TData>(channelName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates all cached data for a given integration by deleting every Redis key
|
||||||
|
* that starts with `integration:${integrationId}:` as well as the integration's
|
||||||
|
* session store entry. This ensures that stale data from a misconfigured or
|
||||||
|
* updated integration is never served from cache.
|
||||||
|
*/
|
||||||
|
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise<void> => {
|
||||||
|
const client = getSetClient as typeof getSetClient | null;
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = [`integration:${integrationId}:*`, `session-store:${integrationId}`];
|
||||||
|
let deletedCount = 0;
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let cursor = "0";
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200);
|
||||||
|
cursor = nextCursor;
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await client.del(...keys);
|
||||||
|
deletedCount += keys.length;
|
||||||
|
}
|
||||||
|
} while (cursor !== "0");
|
||||||
|
}
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
logger.info("Invalidated integration cache", { integrationId, deletedKeys: deletedCount });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const createWidgetOptionsChannel = <TData>(
|
export const createWidgetOptionsChannel = <TData>(
|
||||||
widgetKind: WidgetKind,
|
widgetKind: WidgetKind,
|
||||||
queryKey: string,
|
queryKey: string,
|
||||||
@@ -385,8 +415,18 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
publishAndUpdateLastStateAsync: async (data: TData) => {
|
publishAndUpdateLastStateAsync: async (data: TData) => {
|
||||||
await publisher.publish(channelName, superjson.stringify(data));
|
// Set first, then publish. If publish fails the storage is still consistent
|
||||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
// and subscribers will catch up on the next event.
|
||||||
|
const payload = superjson.stringify({ data, timestamp: new Date() });
|
||||||
|
await getSetClient.set(channelName, payload);
|
||||||
|
try {
|
||||||
|
await publisher.publish(channelName, superjson.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", {
|
||||||
|
channel: channelName,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setAsync: async (data: TData) => {
|
setAsync: async (data: TData) => {
|
||||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
|
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
|
||||||
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
|
|
||||||
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
|
export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler<
|
||||||
|
ArchiveTeamWarriorStatus,
|
||||||
|
IntegrationKindByCategory<"archiving">,
|
||||||
|
Record<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration) {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.getStatusAsync();
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(5, "minute"),
|
||||||
|
queryKey: "archiveTeamWarriorStatus",
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type {
|
import type {
|
||||||
BeszelAlert,
|
BeszelAlert,
|
||||||
@@ -13,6 +14,8 @@ import type {
|
|||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "beszelRequestHandler" });
|
||||||
|
|
||||||
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +59,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration) {
|
async requestAsync(integration) {
|
||||||
|
const start = performance.now();
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const systems = await instance.getSystemsAsync();
|
const systems = await instance.getSystemsAsync();
|
||||||
const enriched = await Promise.all(
|
const enriched = await Promise.all(
|
||||||
@@ -64,9 +68,16 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
|||||||
return mapToSystemRow(system, details);
|
return mapToSystemRow(system, details);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
logger.debug("beszelSystems fetch completed", {
|
||||||
|
integrationId: integration.id,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
count: enriched.length,
|
||||||
|
});
|
||||||
return enriched;
|
return enriched;
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
|
fallbackToStaleOnError: true,
|
||||||
|
retry: { attempts: 2, delayMs: 500 },
|
||||||
queryKey: "beszelSystems",
|
queryKey: "beszelSystems",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,21 +92,31 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
{ includeHistory: boolean; maxHistoryItems: number }
|
{ includeHistory: boolean; maxHistoryItems: number }
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, input) {
|
async requestAsync(integration, input) {
|
||||||
|
const start = performance.now();
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const alerts = await instance.getAlertsAsync();
|
const alerts = await instance.getAlertsAsync();
|
||||||
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
||||||
|
logger.debug("beszelAlerts fetch completed", {
|
||||||
|
integrationId: integration.id,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
alertsCount: alerts.length,
|
||||||
|
historyCount: history.length,
|
||||||
|
});
|
||||||
return { alerts, history };
|
return { alerts, history };
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(15, "seconds"),
|
cacheDuration: dayjs.duration(15, "seconds"),
|
||||||
|
fallbackToStaleOnError: true,
|
||||||
|
retry: { attempts: 2, delayMs: 500 },
|
||||||
queryKey: "beszelAlerts",
|
queryKey: "beszelAlerts",
|
||||||
});
|
});
|
||||||
|
|
||||||
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
|
const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSeconds: number }> = {
|
||||||
"1h": { type: "1m", perPage: 60 },
|
"1m": { type: "1m", perPage: 60, cacheSeconds: 5 },
|
||||||
"12h": { type: "10m", perPage: 72 },
|
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
|
||||||
"24h": { type: "20m", perPage: 72 },
|
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
|
||||||
"1w": { type: "120m", perPage: 84 },
|
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
|
||||||
"30d": { type: "480m", perPage: 90 },
|
"1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
|
||||||
|
"30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BeszelStatsData {
|
export interface BeszelStatsData {
|
||||||
@@ -109,14 +130,30 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, input) {
|
async requestAsync(integration, input) {
|
||||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
|
const start = performance.now();
|
||||||
|
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||||
const containerStats = input.includeDocker
|
const containerStats = input.includeDocker
|
||||||
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
||||||
: [];
|
: [];
|
||||||
|
logger.debug("beszelStats fetch completed", {
|
||||||
|
integrationId: integration.id,
|
||||||
|
systemId: input.systemId,
|
||||||
|
timePeriod: input.timePeriod,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
systemStatsCount: systemStats.length,
|
||||||
|
containerStatsCount: containerStats.length,
|
||||||
|
});
|
||||||
return { systemStats, containerStats };
|
return { systemStats, containerStats };
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(30, "seconds"),
|
cacheDuration: dayjs.duration(60, "seconds"),
|
||||||
|
fallbackToStaleOnError: true,
|
||||||
|
retry: { attempts: 2, delayMs: 500 },
|
||||||
queryKey: "beszelStats",
|
queryKey: "beszelStats",
|
||||||
|
cacheDurationForInput(input) {
|
||||||
|
const config = timePeriodConfig[input.timePeriod];
|
||||||
|
if (!config) return undefined;
|
||||||
|
return dayjs.duration(config.cacheSeconds, "seconds");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -14,10 +14,13 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||||
// Unique key for this request handler
|
|
||||||
queryKey: string;
|
queryKey: string;
|
||||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||||
cacheDuration: Duration;
|
cacheDuration: Duration;
|
||||||
|
cacheDurationForInput?: (input: TInput) => Duration | undefined;
|
||||||
|
fallbackToStaleOnError?: boolean;
|
||||||
|
retry?: { attempts?: number; delayMs?: number };
|
||||||
|
isValid?: (data: TData) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createCachedIntegrationRequestHandler = <
|
export const createCachedIntegrationRequestHandler = <
|
||||||
@@ -34,9 +37,12 @@ export const createCachedIntegrationRequestHandler = <
|
|||||||
requestAsync: async (input: { options: TInput; integration: IntegrationOfKind<TKind> }) => {
|
requestAsync: async (input: { options: TInput; integration: IntegrationOfKind<TKind> }) => {
|
||||||
return await options.requestAsync(input.integration, input.options);
|
return await options.requestAsync(input.integration, input.options);
|
||||||
},
|
},
|
||||||
cacheDuration: options.cacheDuration,
|
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
|
||||||
createRedisChannel(input, options) {
|
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||||
return createIntegrationOptionsChannel<TData>(input.integration.id, options.queryKey, input.options);
|
retry: options.retry,
|
||||||
|
isValid: options.isValid,
|
||||||
|
createRedisChannel(input, handlerOptions) {
|
||||||
|
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
|
||||||
},
|
},
|
||||||
}).handler({ options: itemOptions, integration }),
|
}).handler({ options: itemOptions, integration }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import type { createChannelWithLatestAndEvents } from "@homarr/redis";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "cachedRequestHandler" });
|
const logger = createLogger({ module: "cachedRequestHandler" });
|
||||||
|
|
||||||
|
type FetchResult<TData> = { data: TData; timestamp: Date };
|
||||||
|
type InFlightEntry = Promise<FetchResult<unknown>>;
|
||||||
|
|
||||||
|
const inFlightFetches = new Map<string, InFlightEntry>();
|
||||||
|
|
||||||
|
const isAbortedError = (error: unknown): boolean => {
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
return error.name === "AbortError" || error.name === "TimeoutError";
|
||||||
|
};
|
||||||
|
|
||||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||||
// Unique key for this request handler
|
// Unique key for this request handler
|
||||||
queryKey: string;
|
queryKey: string;
|
||||||
@@ -17,28 +27,70 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
|||||||
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
||||||
cacheDuration: Duration;
|
cacheDuration: Duration;
|
||||||
fallbackToStaleOnError?: boolean;
|
fallbackToStaleOnError?: boolean;
|
||||||
|
// Retry the upstream call on failure. attempts=1 means no retry (default).
|
||||||
|
// AbortError / TimeoutError are never retried.
|
||||||
|
retry?: { attempts?: number; delayMs?: number };
|
||||||
|
// Predicate to decide whether a fetched response is worth caching.
|
||||||
|
// Return false to skip the cache write (next call will refetch).
|
||||||
|
isValid?: (data: TData) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultRetry = { attempts: 1, delayMs: 0 };
|
||||||
|
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||||
options: Options<TData, TInput>,
|
options: Options<TData, TInput>,
|
||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
handler: (input: TInput) => {
|
handler: (input: TInput) => {
|
||||||
const channel = options.createRedisChannel(input, options);
|
const channel = options.createRedisChannel(input, options);
|
||||||
|
const retryConfig = { ...defaultRetry, ...options.retry };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
||||||
const channelData = await channel.getAsync();
|
const channelData = await channel.getAsync();
|
||||||
|
|
||||||
const requestNewDataAsync = async () => {
|
const requestNewDataAsync = (): Promise<FetchResult<TData>> => {
|
||||||
try {
|
const inflight = inFlightFetches.get(channel.name);
|
||||||
const data = await options.requestAsync(input);
|
if (inflight) {
|
||||||
await channel.publishAndUpdateLastStateAsync(data);
|
logger.debug("Cached request handler joining in-flight fetch", {
|
||||||
return {
|
channel: channel.name,
|
||||||
data,
|
queryKey: options.queryKey,
|
||||||
timestamp: new Date(),
|
});
|
||||||
};
|
return inflight as Promise<FetchResult<TData>>;
|
||||||
} catch (error) {
|
}
|
||||||
|
|
||||||
|
const promise = (async (): Promise<FetchResult<TData>> => {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const data = await options.requestAsync(input);
|
||||||
|
if (options.isValid && !options.isValid(data)) {
|
||||||
|
logger.warn("Cached request handler received invalid data, not caching", {
|
||||||
|
channel: channel.name,
|
||||||
|
queryKey: options.queryKey,
|
||||||
|
attempt,
|
||||||
|
});
|
||||||
|
return { data, timestamp: new Date() };
|
||||||
|
}
|
||||||
|
await channel.publishAndUpdateLastStateAsync(data);
|
||||||
|
return { data, timestamp: new Date() };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (isAbortedError(error) || attempt === retryConfig.attempts) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
logger.warn("Cached request handler fetch failed, retrying", {
|
||||||
|
channel: channel.name,
|
||||||
|
queryKey: options.queryKey,
|
||||||
|
attempt,
|
||||||
|
nextDelayMs: retryConfig.delayMs,
|
||||||
|
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
||||||
|
});
|
||||||
|
await sleep(retryConfig.delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (options.fallbackToStaleOnError && channelData) {
|
if (options.fallbackToStaleOnError && channelData) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
new ErrorWithMetadata(
|
new ErrorWithMetadata(
|
||||||
@@ -46,15 +98,21 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
|
|||||||
{
|
{
|
||||||
channel: channel.name,
|
channel: channel.name,
|
||||||
queryKey: options.queryKey,
|
queryKey: options.queryKey,
|
||||||
|
attempts: retryConfig.attempts,
|
||||||
},
|
},
|
||||||
{ cause: error },
|
{ cause: lastError },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return channelData;
|
return channelData;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw lastError;
|
||||||
}
|
})().finally(() => {
|
||||||
|
inFlightFetches.delete(channel.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
inFlightFetches.set(channel.name, promise as InFlightEntry);
|
||||||
|
return promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (forceUpdate) {
|
if (forceUpdate) {
|
||||||
|
|||||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user