Compare commits

..
Author SHA1 Message Date
Thomas Camlong def3ef8e15 fix: prevent icon selection on mobile when editing dashboard
Add user-select: none and -webkit-touch-callout: none to app icon
to prevent the browser from selecting the image when long-pressing,
which interferes with drag-to-move in edit mode on mobile devices.

Closes #2970
2026-06-14 17:20:46 +02:00
Thomas Camlong 2e709b102f feat(overlay): add integration avatars back to WidgetHoverOverlay 2026-06-14 17:18:44 +02:00
Thomas Camlong 875310f4d9 refactor(overlay): simplify WidgetHoverOverlay to bare minimum 2026-06-14 17:18:04 +02:00
Thomas Camlong 82064b2874 refactor(overlay): strip WidgetHoverOverlay to name-only 2026-06-14 17:17:31 +02:00
Thomas Camlong f1bed389e3 fix(overlay): make WidgetHoverOverlay purely visual with pointer-events none 2026-06-14 17:16:18 +02:00
ajnart b6c817123f style: auto-fix lint and format 2026-06-14 15:07:23 +00:00
Thomas 062ab1ad34 Merge branch 'dev' into feat/various-fixes 2026-06-14 17:06:10 +02:00
Thomas Camlong b7b7105f2d fix(overlay): show WidgetHoverOverlay only during edit mode 2026-06-14 17:05:40 +02:00
Thomas Camlong f0e20eb3da fix(beszel): correct unit scaling for system stats and container charts
Beszel API returns pre-scaled values, not raw bytes as the types claimed:
- Storage (du/mu/mb): GB — remove /BYTES_TO_GB division
- Disk I/O (dr/dw): MB/s — multiply by 1024² for formatters
- Container memory (m): MB — multiply by 1024² for formatters
- Container network (b/ns/nr): bytes/s — passthrough, unchanged

Also fix Recharts width(-1)/height(-1) warnings by adding w=100%
and minHeight:0 to BeszelAreaChart.
2026-06-14 17:05:34 +02:00
85 changed files with 1385 additions and 3389 deletions

No files matched your search

-1
View File
@@ -33,7 +33,6 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.65.0
- 1.64.0
- 1.62.0
- 1.61.0
+1 -1
View File
@@ -1,5 +1,5 @@
{
"js/ts.tsdk.path": "node_modules\\typescript\\lib",
"typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"cSpell.words": [
"ajnart",
@@ -124,8 +124,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
Roles are fetched from group claims. This can also be changed to roles for example added to a azure app registration by using the `AUTH_OIDC_GROUPS_ATTRIBUTE`.
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
<details>
<summary>
Example Setup
@@ -199,7 +197,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
| ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false |
| ``AUTH_OIDC_SCOPE_OVERWRITE`` | Overwrite default scopes (openid, profile, email) | openid email profile groups |
| ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim | groups |
| ``AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT`` | Stop syncing group memberships of OIDC users from the groups claim and manage them locally via the group members page instead | false |
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
@@ -1,33 +0,0 @@
---
title: 'Gotify'
description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time."
hide_title: true
---
import { notificationsWidget } from '@site/docs/widgets/notifications';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { gotifyIntegration } from '.';
<IntegrationHeader
integration={gotifyIntegration}
categories={['Notifications', 'Messaging']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: notificationsWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['username', 'password'],
steps: []
}]} />
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const gotifyIntegration: IntegrationDefinition = {
name: "Gotify",
description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gotify.svg",
path: "../../integrations/gotify",
};
-2
View File
@@ -130,7 +130,6 @@ 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 |
| `apiKeys` | List API keys | GET |
| `beszel` | Systems status, alerts, historical system metrics | GET |
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
| `icon` | Search icons | GET |
@@ -163,4 +162,3 @@ 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
- **"How many users are on my Homarr instance?"** - AI lists all users (admin only)
- **"Toggle Pi-hole blocking for 5 minutes"** - AI interacts with DNS controls
- **"How are my servers doing?"** - AI fetches Beszel system stats including CPU, memory, disk, and alerts
@@ -0,0 +1,5 @@
{
"label": "Tools",
"collapsed": true,
"position": 10
}
@@ -0,0 +1,66 @@
---
title: SQLite Backup & Restore
tags:
- Management
- Backup
- Restore
- SQLite
---
The SQLite Backup & Restore feature allows you to export and import your entire Homarr database as a portable ZIP file. This is useful for creating backups, migrating between instances, or recovering from failures.
:::info SQLite only
This feature is only available when using the SQLite database driver (`DB_DRIVER=better-sqlite3`). MySQL and PostgreSQL users should use their respective database backup tools.
:::
## Accessing Backup & Restore
Navigate to **Management → Tools → Backup** from the sidebar. This page is only visible when running with SQLite.
## Exporting a Backup
Click the **Export** button to download a ZIP archive containing:
- **`db.sqlite`** — A consistent snapshot of your database (created via `VACUUM INTO`)
- **`metadata.json`** — Contains:
- `homarrVersion` — The version of Homarr that created the backup
- `exportedAt` — Timestamp of the export
- `dbDialect` — The database dialect (`sqlite`)
- `encryptionKey` — Your `SECRET_ENCRYPTION_KEY` (needed to decrypt integration secrets)
:::caution
The exported ZIP contains your encryption key and database. Store it securely and do not share it publicly.
:::
A confirmation dialog will ask you to type **"I understand"** before the export proceeds.
## Importing a Backup
1. Click the **Import** button and upload a previously exported ZIP file
2. Homarr analyzes the backup **entirely in your browser** using WebAssembly (sql.js) — no data is sent to the server during preview
3. The preview shows:
- **Entity counts** — How many boards, apps, users, integrations, etc. are in the backup
- **Board names** — A list of all boards contained in the backup
- **Migration status** — If the backup is from an older version, Homarr shows each pending database migration as animated timeline steps
4. Review the preview and type **"I understand"** to confirm
5. Homarr applies any pending migrations, re-encrypts integration secrets if the `SECRET_ENCRYPTION_KEY` differs between instances, and replaces the live database
6. The server automatically restarts to pick up the new database
:::tip
The import process is **backward-compatible** with older backups that used a separate `encryption-key.txt` file instead of embedding the key in `metadata.json`.
:::
## Onboarding Integration
When setting up a fresh Homarr instance with SQLite, the onboarding wizard includes a **"Restore from SQLite backup"** option. This lets you upload a backup ZIP to bootstrap your new instance with data from a previous installation.
## Docker Restart Behavior
After a successful import, only the Next.js process restarts — nginx and Redis remain running. The `run.sh` script automatically restarts Next.js in a loop, so the application comes back online within seconds. Running `docker stop` still works gracefully.
## Best Practices
- **Back up regularly** — Create exports before major updates or configuration changes
- **Test restores** — Periodically verify your backups by importing them on a test instance
- **Secure storage** — Keep backups in an encrypted or access-controlled location since they contain your encryption key
- **Check versions** — Backups from newer versions cannot be imported into older Homarr instances; always import into the same or newer version
@@ -8,19 +8,12 @@ export const beszelSystemStatsWidget: WidgetDefinition = {
path: "../../widgets/beszel-system-stats",
configuration: {
items: [
{
name: "System",
description: "The Beszel system to display stats for. Searchable select populated from the integration.",
values: { type: "string" },
defaultValue: "First available system",
},
{
name: "Time period",
description:
"The time range for chart data. '1 Minute' enables live streaming via PocketBase SSE (requires Beszel agent >= 0.13.0)",
description: "The time range for chart data",
values: {
type: "select",
options: ["1 Minute", "1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"],
options: ["1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"],
},
defaultValue: "1 Hour",
},
@@ -1,17 +1,16 @@
import { WidgetDefinition } from "@site/src/types";
import { IconDeviceGamepad } from "@tabler/icons-react";
import { integrations } from "../dns-hole-summary";
export const dnsHoleControlsWidget: WidgetDefinition = {
icon: IconDeviceGamepad,
name: "DNS Hole Controls",
description: `Control the blocking feature of your ${integrations} from your dashboard`,
description: "Control PiHole or AdGuard from your dashboard",
path: "../../widgets/dns-hole-controls",
configuration: {
items: [
{
name: "Show Toggle All Buttons",
description: "Show or not the 2 buttons that may be redundant when having only one DNS Hole.",
description: "Show or not the 2 buttons that may be redundant when having only one DNS-hole.",
values: { type: "boolean" },
defaultValue: "yes",
},
@@ -1,12 +1,10 @@
import { WidgetDefinition } from "@site/src/types";
import { IconAd } from "@tabler/icons-react";
export const integrations = "Pi-hole, AdGuard Home or Technitium DNS";
export const dnsHoleSummaryWidget: WidgetDefinition = {
icon: IconAd,
name: "DNS Hole Summary",
description: `Displays the summary of your ${integrations} blocks and queries statistics`,
description: "Displays the summary of your DNS Hole",
path: "../../widgets/dns-hole-summary",
configuration: {
items: [
@@ -21,7 +19,7 @@ export const dnsHoleSummaryWidget: WidgetDefinition = {
},
{
name: "Use Pi-Hole colors",
description: `Color the tiles using the background color, that is used by default in ${integrations}.`,
description: "Color the tiles using the background color, that is used by default in PiHole.",
values: { type: "boolean" },
defaultValue: "yes",
},
@@ -13,8 +13,7 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { notificationsWidget } from '.';
import { ntfyIntegration } from '@site/docs/integrations/ntfy';
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud';
import { gotifyIntegration } from '@site/docs/integrations/gotify';
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'
<WidgetHeader
widget={notificationsWidget}
@@ -34,8 +33,7 @@ import notificationsScreenshot from './img/notifications.png';
<WidgetIntegrations
items={[
{ integration: ntfyIntegration },
{ integration: nextcloudIntegration },
{ integration: gotifyIntegration }
{ integration: nextcloudIntegration }
]}
/>
+1 -1
View File
@@ -37,7 +37,7 @@
"dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1",
"leader-line-new": "^1.1.9",
"postcss": "^8.5.15",
"postcss": "^8.5.10",
"posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1",
"random-word-slugs": "^0.1.7",
@@ -57,13 +57,13 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
{definitions.map((def) => (
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Group gap="xs">
{def.iconUrl ? (
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<IconApi size={16} />
)}
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"}>
{def.name}
</Text>
</Group>
@@ -4,7 +4,6 @@ import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
@@ -14,10 +13,9 @@ import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
interface AddGroupMemberProps {
groupId: string;
presentUserIds: string[];
allowedProviders: SupportedAuthProvider[];
}
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps) => {
const tMembersAdd = useScopedI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation();
const { openModal } = useModalAction(UserSelectModal);
@@ -34,13 +32,13 @@ export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: Ad
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
},
presentUserIds,
allowedProviders,
excludeExternalProviders: true,
},
{
title: tMembersAdd("label"),
},
);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
};
@@ -4,12 +4,9 @@ import { IconExclamationCircle } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import {
getGroupMemberManagementType,
getLocallyManageableProviders,
isGroupMembershipManagedLocally,
} from "@homarr/auth/server";
import { isProviderEnabled } from "@homarr/auth/server";
import { everyoneGroup } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, UserAvatar } from "@homarr/ui";
@@ -41,15 +38,16 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
const group = await api.group.getById({ id: params.id });
const isReserved = group.name === everyoneGroup;
const searchTerm = searchParams.search?.trim().toLowerCase();
const filteredMembers = group.members.filter(
(member) => !searchTerm || member.name?.toLowerCase().includes(searchTerm),
);
const filteredMembers = searchParams.search
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
: group.members;
// "local" = every enabled provider managed locally, "external" = none, "mixed" = some.
const managementType = getGroupMemberManagementType();
const canManageMembers = managementType !== "external";
const allowedProviders = getLocallyManageableProviders();
const providerTypes = isProviderEnabled("credentials")
? env.AUTH_PROVIDERS.length > 1
? "mixed"
: "credentials"
: "external";
return (
<Stack>
@@ -58,21 +56,17 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
{isReserved ? (
<ReservedGroupAlert />
) : (
managementType !== "local" && (
providerTypes !== "credentials" && (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t(`group.memberNotice.${managementType}`)}
{t(`group.memberNotice.${providerTypes}`)}
</Alert>
)
)}
<Group justify="space-between">
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
{canManageMembers && !isReserved && (
<AddGroupMember
groupId={group.id}
presentUserIds={group.members.map((member) => member.id)}
allowedProviders={allowedProviders}
/>
{isProviderEnabled("credentials") && !isReserved && (
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
)}
</Group>
{filteredMembers.length === 0 && (
@@ -100,8 +94,6 @@ interface RowProps {
}
const Row = ({ member, groupId, disabled }: RowProps) => {
const canBeRemoved = isGroupMembershipManagedLocally(member.provider);
return (
<TableTr>
<TableTd>
@@ -112,7 +104,9 @@ const Row = ({ member, groupId, disabled }: RowProps) => {
</Anchor>
</Group>
</TableTd>
<TableTd w={100}>{canBeRemoved && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}</TableTd>
<TableTd w={100}>
{member.provider === "credentials" && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}
</TableTd>
</TableTr>
);
};
@@ -148,8 +148,6 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters.
**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`).
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
@@ -5,7 +5,6 @@ import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
@@ -13,7 +12,7 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
excludeExternalProviders?: boolean;
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
confirmLabel?: string;
}
@@ -25,7 +24,7 @@ interface UserSelectFormType {
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: users, isPending } = clientApi.user.selectable.useQuery({
providers: innerProps.allowedProviders,
excludeExternalProviders: innerProps.excludeExternalProviders,
});
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
@@ -46,12 +46,7 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
<Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar
src={getIconUrl(integration.kind)}
size={18}
radius="sm"
className={classes.integrationAvatar}
/>
<Avatar src={getIconUrl(integration.kind)} size={18} radius="sm" className={classes.integrationAvatar} />
</Tooltip>
))}
</Avatar.Group>
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
"pnpm": ">=11.0.0"
},
"packageManager": "pnpm@11.6.0"
"packageManager": "pnpm@11.5.3"
}
+30 -44
View File
@@ -74,13 +74,6 @@
</a>
</td>
<td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png" alt="ArchiveTeam Warrior" width="90" height="90" />
<br/>
<p align="center">ArchiveTeam<br/>Warrior</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/aria2" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg" alt="Aria2" width="90" height="90" />
<br/>
@@ -107,15 +100,15 @@
<br/>
<p align="center">Codeberg</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
<br/>
<p align="center">Coolify</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
<br/>
@@ -156,15 +149,15 @@
<br/>
<p align="center">GitHub<br/>Container<br/>Registry</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/gitlab" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg" alt="Gitlab" width="90" height="90" />
<br/>
<p align="center">Gitlab</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg" alt="Glances" width="90" height="90" />
<br/>
@@ -172,13 +165,6 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/gotify" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg" alt="Gotify" width="90" height="90" />
<br/>
<p align="center">Gotify</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/home-assistant" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
<br/>
@@ -205,8 +191,8 @@
<br/>
<p align="center">Jellyfin</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/jellyseerr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg" alt="Jellyseerr" width="90" height="90" />
<br/>
@@ -219,8 +205,8 @@
<br/>
<p align="center">Lidarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/linux-server-io" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg" alt="LinuxServer.io" width="90" height="90" />
<br/>
@@ -254,8 +240,8 @@
<br/>
<p align="center">ntfy</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/nzbget" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg" alt="NZBGet" width="90" height="90" />
<br/>
@@ -268,8 +254,8 @@
<br/>
<p align="center">OpenMediaVault</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/opnsense" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg" alt="OPNsense" width="90" height="90" />
<br/>
@@ -303,8 +289,8 @@
<br/>
<p align="center">Pi-hole</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
<br/>
@@ -317,8 +303,8 @@
<br/>
<p align="center">Prowlarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/proxmox" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg" alt="Proxmox" width="90" height="90" />
<br/>
@@ -352,8 +338,8 @@
<br/>
<p align="center">Readarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/>
@@ -366,8 +352,8 @@
<br/>
<p align="center">Search.ch</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/seerr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg" alt="Seerr" width="90" height="90" />
<br/>
@@ -401,8 +387,8 @@
<br/>
<p align="center">Tdarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/technitium-dns" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg" alt="Technitium DNS" width="90" height="90" />
<br/>
@@ -415,8 +401,8 @@
<br/>
<p align="center">Tracearr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/transmission" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg" alt="Transmission" width="90" height="90" />
<br/>
@@ -450,8 +436,8 @@
<br/>
<p align="center">Unraid</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg" alt="Uptime Kuma" width="90" height="90" />
<br/>
+2 -2
View File
@@ -62,9 +62,9 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
"pnpm": ">=11.0.0"
},
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@11.5.3",
"dependencies": {
"jsonpath-plus": "catalog:"
}
-2
View File
@@ -9,7 +9,6 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
@@ -27,7 +26,6 @@ export const mcpRouter = createTRPCRouter({
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
beszel: beszelRouter,
calendar: calendarRouter,
dnsHole: dnsHoleRouter,
downloads: downloadsRouter,
+5 -21
View File
@@ -1,12 +1,11 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { canManageGroupMembersLocally, isGroupMembershipManagedLocally } from "@homarr/auth/server";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
import {
@@ -19,6 +18,7 @@ import {
} from "@homarr/validation/group";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const groupRouter = createTRPCRouter({
@@ -315,10 +315,10 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfGroupMembersCannotBeManagedLocally();
throwIfCredentialsDisabled();
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, input.userId),
where: eq(groups.id, input.userId),
});
if (!user) {
@@ -328,13 +328,6 @@ export const groupRouter = createTRPCRouter({
});
}
if (!isGroupMembershipManagedLocally(user.provider)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User's provider is not managed locally",
});
}
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
@@ -346,7 +339,7 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfGroupMembersCannotBeManagedLocally();
throwIfCredentialsDisabled();
await ctx.db
.delete(groupMembers)
@@ -378,15 +371,6 @@ const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => {
}
};
const throwIfGroupMembersCannotBeManagedLocally = () => {
if (!canManageGroupMembersLocally()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Group members cannot be managed locally",
});
}
};
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
const group = await db.query.groups.findFirst({
where: eq(groups.id, id),
+2 -40
View File
@@ -748,45 +748,7 @@ describe("addMember should add member to group", () => {
});
// Assert
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
});
test("with a user from an externally managed provider it should throw FORBIDDEN error", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials", "ldap"] } as never);
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
provider: "ldap",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
// Act
const actAsync = async () =>
await caller.addMember({
groupId,
userId,
});
// Assert
await expect(actAsync()).rejects.toThrow("User's provider is not managed locally");
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
});
});
@@ -910,7 +872,7 @@ describe("removeMember should remove member from group", () => {
});
// Assert
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
});
});
+4 -4
View File
@@ -7,12 +7,12 @@ import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
import { and, eq, handleTransactionsAsync, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
import { credentialsAdminGroup } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import {
@@ -257,7 +257,7 @@ export const userRouter = createTRPCRouter({
}),
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ providers: z.array(z.enum(supportedAuthProviders)).optional() }).optional())
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
.output(
z.array(
selectUserSchema.pick({
@@ -284,7 +284,7 @@ export const userRouter = createTRPCRouter({
image: true,
email: true,
},
where: input?.providers ? inArray(users.provider, input.providers) : undefined,
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
});
}),
search: permissionRequiredProcedure
@@ -1,39 +0,0 @@
import { observable } from "@trpc/server/observable";
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
.query(async ({ ctx }) => {
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
status: data,
updatedAt: timestamp,
};
}),
subscribeStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
.subscription(({ ctx }) => {
return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => {
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
const unsubscribe = handler.subscribe((status) => {
emit.next({
status,
updatedAt: new Date(),
});
});
return () => {
unsubscribe();
};
});
}),
});
+6 -87
View File
@@ -1,9 +1,6 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createIntegrationAsync } from "@homarr/integrations";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
beszelStatsRequestHandler,
@@ -16,21 +13,12 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
@@ -46,11 +34,7 @@ export const beszelRouter = createTRPCRouter({
subscribeSystems: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.subscription(({ ctx }) => {
return observable<{
integrationId: string;
systems: BeszelSystemRow[];
timestamp: Date;
}>((emit) => {
return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
return innerHandler.subscribe((systems) => {
@@ -68,13 +52,6 @@ export const beszelRouter = createTRPCRouter({
}),
getAlerts: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
@@ -119,22 +96,14 @@ export const beszelRouter = createTRPCRouter({
}),
)
.subscription(({ ctx, input }) => {
return observable<{
integrationId: string;
alerts: BeszelAlertsData;
timestamp: Date;
}>((emit) => {
return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
return innerHandler.subscribe((data) => {
emit.next({
integrationId: integration.id,
alerts: data,
timestamp: new Date(),
});
emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() });
});
});
return () => {
@@ -144,26 +113,17 @@ export const beszelRouter = createTRPCRouter({
}),
getSystemStats: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
systemId: z.string(),
timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]).default("1h"),
timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]),
includeDocker: z.boolean().default(true),
}),
)
.query(async ({ ctx, input }) => {
const integration = ctx.integrations[0];
if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
}
if (!integration) return null;
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
@@ -172,45 +132,4 @@ export const beszelRouter = createTRPCRouter({
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { integrationId: integration.id, ...data, updatedAt: timestamp };
}),
subscribeSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
systemId: z.string(),
}),
)
.subscription(({ ctx, input }) => {
return observable<{
stats: BeszelSystemStatsRecord;
containerStats: BeszelContainerStatsRecord | null;
}>((emit) => {
const integration = ctx.integrations[0];
if (!integration) {
emit.error(new Error("No Beszel integration configured"));
return () => {};
}
const abortController = new AbortController();
void (async () => {
try {
const instance = await createIntegrationAsync(integration);
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
} catch (error) {
if (!abortController.signal.aborted) {
if (error instanceof Error) {
emit.error(error);
} else {
emit.error(new Error(String(error)));
}
}
}
})();
return () => {
abortController.abort();
};
});
}),
});
-1
View File
@@ -3,7 +3,6 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
export const widgetRouter = createTRPCRouter({
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
+1 -37
View File
@@ -1,14 +1,7 @@
import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { boards, items } from "@homarr/db/schema";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const optionsRouter = createTRPCRouter({
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
@@ -23,33 +16,4 @@ export const optionsRouter = createTRPCRouter({
},
};
}),
saveItemOptions: protectedProcedure
.input(
z.object({
itemId: z.string(),
boardId: z.string(),
newOptions: z.record(z.string(), z.unknown()),
}),
)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, input.itemId),
});
if (item?.boardId !== input.boardId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Specified item was not found",
});
}
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
Object.assign(options, input.newOptions);
await ctx.db
.update(items)
.set({ options: SuperJSON.stringify(options) })
.where(eq(items.id, input.itemId));
}),
});
-1
View File
@@ -37,7 +37,6 @@ export const env = createEnv({
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: createBooleanSchema(false), // When enabled, group memberships of oidc users are managed locally instead of synced from the groups claim
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
+1 -6
View File
@@ -31,12 +31,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
// Groups from oidc provider are provided from the profile, it's not typed.
if (
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
profile &&
groupsKey in profile &&
Array.isArray(profile[groupsKey])
) {
if (profile && groupsKey in profile && Array.isArray(profile[groupsKey])) {
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
}
-48
View File
@@ -1,57 +1,9 @@
import type { SupportedAuthProvider } from "@homarr/definitions";
import { supportedAuthProviders } from "@homarr/definitions";
import { env } from "../env";
export type GroupMemberManagementType = "local" | "mixed" | "external";
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
// The question mark is placed there because isProviderEnabled is called during static build of about page
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return env.AUTH_PROVIDERS?.includes(provider);
};
/**
* Whether a single provider's group memberships are managed locally (manually via UI/API).
* True for credentials users, and for oidc users when AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT is enabled.
*/
export const isGroupMembershipManagedLocally = (provider: SupportedAuthProvider): boolean => {
switch (provider) {
case "credentials":
return true;
case "oidc":
return env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT;
default:
// ldap + any provider added later: externally managed (synced, not editable
// here) until explicitly given a case above. New providers safe-by-default.
return false;
}
};
export const getEnabledProviders = (): SupportedAuthProvider[] => supportedAuthProviders.filter(isProviderEnabled);
/**
* The enabled providers whose group memberships can be managed locally.
*/
export const getLocallyManageableProviders = (): SupportedAuthProvider[] =>
getEnabledProviders().filter(isGroupMembershipManagedLocally);
/**
* Classifies group-member management across all enabled providers:
* - "local": every enabled provider is managed locally (no member is synced externally)
* - "external": no enabled provider is managed locally (every member is synced externally)
* - "mixed": some are managed locally, some externally (only a subset of members is editable here)
*/
export const getGroupMemberManagementType = (): GroupMemberManagementType => {
const enabled = getEnabledProviders();
const manageable = enabled.filter(isGroupMembershipManagedLocally);
if (manageable.length === enabled.length) return "local";
if (manageable.length === 0) return "external";
return "mixed";
};
/**
* Whether group memberships can be managed locally for at least one enabled provider.
*/
export const canManageGroupMembersLocally = (): boolean => getLocallyManageableProviders().length > 0;
@@ -1,105 +0,0 @@
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 -8
View File
@@ -1,11 +1,4 @@
export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions";
export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions";
export {
isProviderEnabled,
isGroupMembershipManagedLocally,
getLocallyManageableProviders,
getGroupMemberManagementType,
canManageGroupMembersLocally,
} from "./providers/check-provider";
export type { GroupMemberManagementType } from "./providers/check-provider";
export { isProviderEnabled } from "./providers/check-provider";
export { createSessionCallback, createSessionAsync } from "./callbacks";
+3 -29
View File
@@ -12,13 +12,11 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
vi.mock("next-auth", () => ({}));
const mockEnv = vi.hoisted(() => ({
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: false,
}));
vi.mock("../env", () => {
return {
env: mockEnv,
env: {
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
},
};
});
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -200,30 +198,6 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
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([
["ldap" as const, { name: "test-new" }, undefined],
@@ -98,6 +98,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/puid"
| "/docs/tags/redis"
| "/docs/tags/responsive"
| "/docs/tags/restore"
| "/docs/tags/roles"
| "/docs/tags/search-engines"
| "/docs/tags/search"
@@ -179,7 +180,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/github"
| "/docs/integrations/gitlab"
| "/docs/integrations/glances"
| "/docs/integrations/gotify"
| "/docs/integrations/home-assistant"
| "/docs/integrations/ical"
| "/docs/integrations/immich"
@@ -233,6 +233,7 @@ export type HomarrDocumentationPath =
| "/docs/management/search-engines"
| "/docs/management/settings"
| "/docs/management/tasks"
| "/docs/management/tools/backup"
| "/docs/management/users"
| "/docs/widgets/anchor-note"
| "/docs/widgets/app"
@@ -322,7 +323,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/github-containerregistry"
| "/docs/integrations/quay"
| "/docs/integrations/ntfy"
| "/docs/integrations/gotify"
| "/docs/integrations/ical"
| "/docs/integrations/anchor"
| "/docs/integrations/truenas"
@@ -40,7 +40,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
gitHubContainerRegistry: "github-containerregistry",
quay: "quay",
ntfy: "ntfy",
gotify: "gotify",
ical: "ical",
anchor: "anchor",
truenas: "truenas",
@@ -57,6 +56,5 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
umami: "umami",
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
mock: null,
};
@@ -44,7 +44,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
audioStats: "audio-stats",
umami: "umami",
ups: "ups",
archiveTeamWarrior: null,
beszelSystemTable: "beszel-system-table",
beszelSystemGrid: "beszel-system-grid",
beszelAlerts: "beszel-alerts",
-16
View File
@@ -355,13 +355,6 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
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: {
name: "iCal",
secretKinds: [["url"]],
@@ -489,14 +482,6 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
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)
mock: {
name: "Mock",
@@ -604,7 +589,6 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"archiving",
"ups",
"documents",
"mediaLibrary",
-1
View File
@@ -41,7 +41,6 @@ export const widgetKinds = [
"uptimeKuma",
"audioStats",
"umami",
"archiveTeamWarrior",
"ups",
"beszelSystemTable",
"beszelSystemGrid",
@@ -1,386 +0,0 @@
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,
});
}
}
@@ -1,61 +0,0 @@
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>;
@@ -1,37 +0,0 @@
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,7 +3,6 @@ import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
@@ -36,7 +35,6 @@ import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
@@ -123,7 +121,6 @@ export const integrationCreators = {
ical: ICalIntegration,
quay: QuayIntegration,
ntfy: NTFYIntegration,
gotify: GotifyIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
unraid: UnraidIntegration,
@@ -137,7 +134,6 @@ export const integrationCreators = {
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
@@ -1,5 +1,4 @@
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
@@ -13,21 +12,17 @@ import type {
BeszelAlertHistory,
BeszelAuthResponse,
BeszelContainer,
BeszelContainerStats,
BeszelContainerStatsRecord,
BeszelSmartDevice,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemdService,
BeszelSystemStats,
BeszelSystemStatsRecord,
CreateAlertInput,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
const logger = createLogger({ module: "beszel-integration" });
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
interface BeszelSession {
@@ -250,100 +245,6 @@ 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");
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
headers: { Authorization: session.token },
signal,
});
if (!sseResponse.ok || !sseResponse.body) {
throw new ResponseError(sseResponse);
}
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) {
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> {
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
@@ -55,7 +55,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
})),
uptime: stats.uptime.as("seconds"),
rebootRequired: false,
cpuModelName: stats.quicklook?.cpu_name ?? "Unknown",
cpuModelName: stats.quicklook.cpu_name,
loadAverage: null,
smart: [],
cpuTemp: undefined,
@@ -155,11 +155,9 @@ const allSchema = z.object({
return dayjs.duration({ days, hours, minutes, seconds });
}),
quicklook: z
.object({
cpu_name: z.string(),
})
.optional(),
quicklook: z.object({
cpu_name: z.string(),
}),
gpu: z
.array(
z.object({
@@ -1,41 +0,0 @@
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,
}));
}
}
@@ -1,14 +0,0 @@
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),
});
-7
View File
@@ -1,6 +1,5 @@
// General integrations
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 { AnchorIntegration } from "./anchor/anchor-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
@@ -18,7 +17,6 @@ export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
export { GotifyIntegration } from "./gotify/gotify-integration";
export { NTFYIntegration } from "./ntfy/ntfy-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { GlancesIntegration } from "./glances/glances-integration";
@@ -75,11 +73,6 @@ export type {
AnchorNotesListInput,
AnchorNoteSummary,
} 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 { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types";
+1 -35
View File
@@ -330,40 +330,6 @@ 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[]> {
const now = Date.now();
const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"];
@@ -375,7 +341,7 @@ export class BeszelMockService {
name: names[i % names.length] ?? "CPU",
val: rand(60, 95),
created: new Date(now - (i + 1) * 3_600_000).toISOString(),
resolved: (i % 2 === 0 && new Date(now - i * 1_800_000).toISOString()) || null,
resolved: i % 2 === 0 ? new Date(now - i * 1_800_000).toISOString() : null,
}));
}
}
@@ -140,5 +140,4 @@ export class MockIntegration
getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel);
getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel);
getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel);
subscribeRealtimeMetrics = MockIntegration.beszel.subscribeRealtimeMetrics.bind(MockIntegration.beszel);
}
@@ -1,103 +0,0 @@
// @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();
});
});
+152 -211
View File
@@ -2,15 +2,7 @@
import type { StartedTestContainer } 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";
@@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => {
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")
>();
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi
.fn()
.mockImplementation(() => Promise.resolve([])),
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
};
});
@@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium";
const makeInput = (
baseUrl: string,
auth:
| { kind: "credentials"; password?: string }
| { kind: "apiKey"; value: string },
auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
): IntegrationInput => ({
id: INTEGRATION_ID,
name: "Technitium DNS",
@@ -109,10 +94,7 @@ const makeInput = (
// Fetch an API token appropriate for the server version.
// 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") {
// v15 exposes a dedicated endpoint for non-expiring API tokens.
const res = await fetch(
@@ -122,9 +104,7 @@ async function fetchApiToken(
}
// 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.
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;
}
@@ -144,16 +124,12 @@ const pool: Record<string, ContainerEntry> = {};
beforeAll(async () => {
await Promise.all(
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)
// Explicitly set the admin password so all versions use the same credentials.
// Without this, older versions may generate a random password on first run.
.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();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
const apiToken = await fetchApiToken(baseUrl, apiVersion);
@@ -163,238 +139,203 @@ beforeAll(async () => {
}, 120_000);
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 ────────────────────────────────────────────
describe.each(VERSIONS)(
"Technitium DNS $tag ($apiVersion)",
({ tag, apiVersion }) => {
let baseUrl: string;
let apiToken: string;
describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
let baseUrl: string;
let apiToken: string;
beforeAll(() => {
const entry = pool[tag];
if (!entry)
throw new Error(`Container pool entry for ${tag} was not initialized`);
({ baseUrl, apiToken } = entry);
});
beforeAll(() => {
const entry = pool[tag];
if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
({ baseUrl, apiToken } = entry);
});
beforeEach(() => {
sessionState[INTEGRATION_ID] = null;
});
beforeEach(() => {
sessionState[INTEGRATION_ID] = null;
});
// ── factory ────────────────────────────────────────────────────────────────
// ── factory ────────────────────────────────────────────────────────────────
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
test("factory creates a working integration (auto-detects version)", async () => {
const integration = await createTechnitiumDnsIntegrationAsync(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
test("factory creates a working integration (auto-detects version)", async () => {
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.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
});
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
});
// ── getSummaryAsync ────────────────────────────────────────────────────────
// ── getSummaryAsync ────────────────────────────────────────────────────────
describe("getSummaryAsync", () => {
test("returns valid summary with credentials", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("getSummaryAsync", () => {
test("returns valid summary with credentials", async () => {
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.adsBlockedToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
expect(result.status).toBe("enabled");
}, 20_000);
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
expect(result.status).toBe("enabled");
}, 20_000);
test("returns valid summary with API key", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
);
test("returns valid summary with API key", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
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(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
}, 20_000);
});
expect(typeof result.domainsBeingBlocked).toBe("number");
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
}, 20_000);
});
// ── session caching ────────────────────────────────────────────────────────
// ── session caching ────────────────────────────────────────────────────────
describe("session caching", () => {
test("login is performed once and token is cached for subsequent calls", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("session caching", () => {
test("login is performed once and token is cached for subsequent calls", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.getSummaryAsync();
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterFirst).toBeDefined();
await integration.getSummaryAsync();
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterFirst).toBeDefined();
await integration.getSummaryAsync();
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
await integration.getSummaryAsync();
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterSecond).toBe(tokenAfterFirst);
}, 20_000);
expect(tokenAfterSecond).toBe(tokenAfterFirst);
}, 20_000);
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
sessionState[INTEGRATION_ID] = {
token: "deliberately-invalid-token",
version: apiVersion,
};
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
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(sessionState[INTEGRATION_ID]).not.toBeNull();
expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
"deliberately-invalid-token",
);
}, 20_000);
});
expect(result.status).toBe("enabled");
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
}, 20_000);
});
// ── blocking state changes ─────────────────────────────────────────────────
// ── blocking state changes ─────────────────────────────────────────────────
describe("blocking state changes", () => {
test("disableAsync permanently disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("blocking state changes", () => {
test("disableAsync permanently disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("enableAsync re-enables blocking after permanent disable", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("enableAsync re-enables blocking after permanent disable", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync();
await integration.enableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync();
await integration.enableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
}, 20_000);
expect(result.status).toBe("enabled");
}, 20_000);
test("disableAsync with duration temporarily disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("disableAsync with duration temporarily disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync(120); // 120s → 2 min
const result = await integration.getSummaryAsync();
await integration.disableAsync(120); // 120s → 2 min
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync(1);
const result = await integration.getSummaryAsync();
await integration.disableAsync(1);
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("enableAsync cancels an active timed disable immediately", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("enableAsync cancels an active timed disable immediately", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync(600); // 10 minutes
await integration.enableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync(600); // 10 minutes
await integration.enableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
}, 20_000);
});
expect(result.status).toBe("enabled");
}, 20_000);
});
// ── testConnectionAsync ────────────────────────────────────────────────────
// ── testConnectionAsync ────────────────────────────────────────────────────
describe("testConnectionAsync", () => {
test("succeeds with valid credentials", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
describe("testConnectionAsync", () => {
test("succeeds with valid credentials", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("succeeds with valid API key", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("succeeds with valid API key", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("fails with wrong password — returns authorization error", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, {
kind: "credentials",
password: "wrong-password",
}),
);
test("fails with wrong password — returns authorization error", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials", password: "wrong-password" }),
);
const result = await integration.testConnectionAsync();
const result = await integration.testConnectionAsync();
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
test("fails with invalid API key — returns authorization error", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
apiVersion,
).testConnectionAsync();
test("fails with invalid API key — returns authorization error", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
});
},
);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
});
});
@@ -1,20 +0,0 @@
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",
});
+8 -14
View File
@@ -90,13 +90,12 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSeconds: number }> = {
"1m": { type: "1m", perPage: 60, cacheSeconds: 5 },
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 },
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
"1h": { type: "1m", perPage: 60 },
"12h": { type: "10m", perPage: 72 },
"24h": { type: "20m", perPage: 72 },
"1w": { type: "120m", perPage: 84 },
"30d": { type: "480m", perPage: 90 },
};
export interface BeszelStatsData {
@@ -110,7 +109,7 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
async requestAsync(integration, input) {
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker
@@ -118,11 +117,6 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
: [];
return { systemStats, containerStats };
},
cacheDuration: dayjs.duration(60, "seconds"),
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "beszelStats",
cacheDurationForInput(input) {
const config = timePeriodConfig[input.timePeriod];
if (!config) return undefined;
return dayjs.duration(config.cacheSeconds, "seconds");
},
});
@@ -14,10 +14,10 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
};
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
// Unique key for this request handler
queryKey: string;
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheDuration: Duration;
cacheDurationForInput?: (input: TInput) => Duration | undefined;
}
export const createCachedIntegrationRequestHandler = <
@@ -34,9 +34,9 @@ export const createCachedIntegrationRequestHandler = <
requestAsync: async (input: { options: TInput; integration: IntegrationOfKind<TKind> }) => {
return await options.requestAsync(input.integration, input.options);
},
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
createRedisChannel(input, handlerOptions) {
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
cacheDuration: options.cacheDuration,
createRedisChannel(input, options) {
return createIntegrationOptionsChannel<TData>(input.integration.id, options.queryKey, input.options);
},
}).handler({ options: itemOptions, integration }),
};
+256 -256
View File
@@ -856,37 +856,37 @@
"title": "Vorschau",
"test": "Test",
"hint": "Klicken Sie auf Test, um eine Vorschau der API Antwort mit Ihrer aktuellen Konfiguration zu erhalten.",
"rawResponse": "Rohe JSON-Antwort"
"rawResponse": ""
},
"notification": {
"created": "Widget \"{name}\" erfolgreich erstellt.",
"updated": "Widget \"{name}\" erfolgreich aktualisiert.",
"deleted": "Widget \"{name}\" gelöscht.",
"imported": "Widget erfolgreich importiert.",
"aiPromptCopied": "KI-Prompt in Zwischenablage kopiert",
"aiPromptDescription": "Kopieren Sie eine vorgefertigte Eingabeaufforderung, um sie in jeden AI-Chatbot einzufügen (ChatGPT, Claude, Gemini...). Es enthält das komplette Widget-Schema und Ihre API-Antwort, so dass die KI eine fertige JSON-Konfiguration für Sie generieren kann.",
"aiPromptCopy": "In die Zwischenablage kopieren",
"aiPromptNoResponse": "Tipp: Führen Sie zuerst einen Test aus, um Ihre API Antwort in die Eingabeaufforderung einzubinden, um bessere Ergebnisse zu erzielen.",
"aiPromptCopyError": "Fehler beim Kopieren der Eingabeaufforderung. Möglicherweise unterstützt Ihr Browser diese Funktion nicht.",
"createError": "Fehler beim Erstellen des Widgets. Überprüfen Sie, ob Ihr API Endpunkt erreichbar ist.",
"updateError": "Fehler beim Aktualisieren des Widgets. Ihre Änderungen wurden nicht gespeichert.",
"importError": "Fehler beim Importieren des Widgets. Stellen Sie sicher, dass die Datei ein gültiger JSON Export ist.",
"exportError": "Fehler beim Exportieren des Widgets.",
"duplicated": "Widget \"{name}\" dupliziert.",
"duplicateError": "Fehler beim Duplizieren des Widgets.",
"toggleError": "Fehler beim Umschalten des Widgets Status.",
"deleteError": "Fehler beim Löschen des Widgets.",
"previewError": "Fehler beim Abrufen der Vorschau. Überprüfen Sie die URL und versuchen Sie es erneut.",
"executed": "Aktion erfolgreich ausgeführt.",
"executeError": "Aktion fehlgeschlagen: {error}"
"created": "",
"updated": "",
"deleted": "",
"imported": "",
"aiPromptCopied": "",
"aiPromptDescription": "",
"aiPromptCopy": "",
"aiPromptNoResponse": "",
"aiPromptCopyError": "",
"createError": "",
"updateError": "",
"importError": "",
"exportError": "",
"duplicated": "",
"duplicateError": "",
"toggleError": "",
"deleteError": "",
"previewError": "",
"executed": "",
"executeError": ""
},
"table": {
"name": "Name",
"url": "URL",
"display": "Anzeige"
"display": ""
},
"widget": {
"disabled": "Dieses Widget ist deaktiviert"
"disabled": ""
}
},
"integration": {
@@ -983,20 +983,20 @@
"placeholder": "Benutzerdefinierte App URL"
},
"apiKeySettings": {
"label": "API-Schlüssel holen"
"label": ""
}
},
"action": {
"create": "Neue Integration"
},
"grid": {
"widgetCount": "{count} Widgets",
"noWidgets": "Keine Widgets",
"detected": "Über Docker erkannt"
"widgetCount": "",
"noWidgets": "",
"detected": ""
},
"category": {
"dnsHole": "DNS",
"mediaService": "Medien",
"mediaService": "",
"calendar": "Kalender",
"mediaSearch": "Suche",
"mediaRelease": "Veröffentlichungen",
@@ -1014,15 +1014,15 @@
"releasesProvider": "Veröffentlichungen",
"notifications": "Benachrichtigungen",
"firewall": "Firewall",
"timetable": "Zeitplan",
"timetable": "",
"photoService": "Fotos",
"documents": "Dokumente",
"notes": "Notizen",
"mediaMonitoring": "Überwachung",
"speedtest": "Geschwindigkeitstest",
"analytics": "Nutzungsdaten",
"mediaLibrary": "Medienbibliothek",
"uptimeMonitoring": "Überwachung der Betriebszeit"
"documents": "",
"notes": "",
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
"testConnection": {
"action": {
@@ -1297,8 +1297,8 @@
"newLabel": "Neuer privater Schlüssel"
},
"slug": {
"label": "Status Page Slug",
"newLabel": "Neuer Status Page Slug"
"label": "",
"newLabel": ""
}
}
},
@@ -1308,28 +1308,28 @@
"full": "Voller Zugriff auf Integrationen"
},
"audiobookshelf": {
"name": "Hörbuchregal",
"description": "Selbstgehosteter Audio- und Podcast-Server"
"name": "",
"description": ""
},
"navidrome": {
"name": "Navidrome",
"description": "Selbstgehosteter Musik-Streaming-Server mit Subsonic API Kompatibilität"
"name": "",
"description": ""
},
"uptimeKuma": {
"name": "Uptime Kuma",
"description": "Selbstgehostete Uptime Überwachung mit öffentlichen Statusseiten",
"name": "",
"description": "",
"secret": {
"slug": {
"description": "Der Slug der öffentlichen Statusseite. Standardwert ist \"Standard\", wenn nicht anderswertig gesetzt."
"description": ""
},
"apiKey": {
"description": "Optionaler API Schlüssel für Authentifizeriungs /Metriken Zugriff"
"description": ""
}
}
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente"
"name": "",
"description": ""
}
},
"media": {
@@ -1373,7 +1373,7 @@
}
},
"noResults": {
"title": "Keine Medien hochgeladen"
"title": ""
}
},
"common": {
@@ -1384,7 +1384,7 @@
"add": "Hinzufügen",
"apply": "Übernehmen",
"backToOverview": "Zurück zur Übersicht",
"skip": "Überspringen",
"skip": "",
"change": "Verändern",
"create": "Erstellen",
"createAnother": "Erstellen und neu starten",
@@ -1625,7 +1625,7 @@
"app": "App bearbeiten"
},
"app": {
"propagationNotice": "Änderungen an dieser App werden überall dort übernommen, wo sie über alle Dashboards hinweg verwendet wird."
"propagationNotice": ""
},
"advancedOptions": {
"label": "Erweiterte Einstellungen",
@@ -1731,7 +1731,7 @@
"label": "In neuem Tab öffnen"
},
"withBorder": {
"label": "Rahmen anzeigen"
"label": ""
},
"items": {
"label": "Lesezeichen",
@@ -1874,13 +1874,13 @@
}
},
"timetable": {
"name": "Zeitplan",
"description": "Zeigt Abfahrtszeiten für eine Station an",
"title": "{station} Abfahrt",
"noStation": "Keine Station ausgewählt",
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": "Station"
"label": ""
}
}
},
@@ -1955,32 +1955,32 @@
}
},
"anchorNote": {
"name": "Anchor Notiz",
"description": "Zeigt eine ausgewählte Notiz von Anchor an",
"name": "",
"description": "",
"option": {
"noteId": {
"label": "Notiz"
"label": ""
},
"showTitle": {
"label": "Titel anzeigen"
"label": ""
},
"showUpdatedAt": {
"label": "Letzte Aktualisierungen anzeigen"
"label": ""
}
},
"empty": "Notiz zum Anzeigen auswählen",
"emptyContent": "Diese Notiz hat keinen Inhalt",
"notFound": "Notiz wurde nicht gefunden",
"noNotes": "Keine Notizen gefunden",
"integrationRequired": "Wählen Sie zuerst eine Anchor Integration aus",
"updatedAt": "Aktualisiert {date}",
"untitled": "Unbenannt",
"edit": "Bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"readOnlyViewer": "Sie haben Leserechte auf diese Notiz. Die Bearbeitung ist deaktiviert.",
"saveForbidden": "Du hast keine Berechtigung diese Notiz zu bearbeiten.",
"saveFailed": "Fehler beim Speichern dieser Notiz. Bitte versuchen Sie es erneut."
"empty": "",
"emptyContent": "",
"notFound": "",
"noNotes": "",
"integrationRequired": "",
"updatedAt": "",
"untitled": "",
"edit": "",
"save": "",
"cancel": "",
"readOnlyViewer": "",
"saveForbidden": "",
"saveFailed": ""
},
"iframe": {
"name": "iFrame",
@@ -2014,7 +2014,7 @@
"label": "Geolokalisierung zulassen"
},
"allowModals": {
"label": "Modale erlauben"
"label": ""
}
},
"error": {
@@ -2249,7 +2249,7 @@
"label": "Speicher-Info anzeigen"
},
"gpu": {
"label": "GPU Info anzeigen"
"label": ""
},
"showUptime": {
"label": "Laufzeit anzeigen"
@@ -2390,187 +2390,187 @@
}
},
"ups": {
"name": "UPS",
"description": "Überwachen Sie den Status Ihrer UPS Geräte über einen NUT Server",
"name": "",
"description": "",
"option": {
"showBattery": {
"label": "Batterie anzeigen"
"label": ""
},
"showLoad": {
"label": "Laden anzeigen"
"label": ""
},
"showVoltage": {
"label": "Spannung anzeigen"
"label": ""
}
},
"status": {
"online": "Online",
"onBattery": "Im Akkubetrieb",
"lowBattery": "Niedriger Akkustand",
"charging": "Lade",
"unknown": "Unbekannt"
"online": "",
"onBattery": "",
"lowBattery": "",
"charging": "",
"unknown": ""
},
"field": {
"runtime": "Laufzeit",
"load": "Laden",
"input": "Eingabe",
"output": "Ausgabe"
"runtime": "",
"load": "",
"input": "",
"output": ""
},
"error": {
"internalServerError": "Fehler beim Abrufen der UPS Daten"
"internalServerError": ""
}
},
"tracearr": {
"name": "Tracearr",
"description": "Verbesserte Überwachung von Medienservern mit detaillierten Stream- und Transcodierungsdaten",
"name": "",
"description": "",
"option": {
"showStreams": {
"label": "Aktive Streams anzeigen"
},
"showStats": {
"label": "Statistikübersicht anzeigen"
"label": ""
},
"showRecentActivity": {
"label": "Letzte Aktivitäten anzeigen"
"label": ""
},
"showViolations": {
"label": "Verstöße anzeigen"
"label": ""
}
},
"stats": {
"activeStreams": "Aktive Streams",
"totalUsers": "Anzahl der Benutzer",
"transcodes": "Transkodierungen",
"bandwidth": "Bandbreite"
"totalUsers": "",
"transcodes": "",
"bandwidth": ""
},
"streams": {
"empty": "Keine aktiven Streams",
"user": "Benutzer",
"media": "Medien",
"state": "Status",
"quality": "Qualität",
"device": "Gerät",
"progress": "Fortschritt",
"empty": "",
"user": "",
"media": "",
"state": "",
"quality": "",
"device": "",
"progress": "",
"videoDecision": {
"directPlay": "Direkte Wiedergabe",
"transcode": "Transkodieren",
"directStream": "Direkter Stream"
"directPlay": "",
"transcode": "",
"directStream": ""
}
},
"recentActivity": {
"title": "Kürzliche Aktivitäten",
"empty": "Keine kürzliche Aktivitäten",
"watched": "Gesehen",
"partial": "Teilweise"
"title": "",
"empty": "",
"watched": "",
"partial": ""
},
"violations": {
"title": "Verstöße",
"empty": "Keine kürzlichen Verstöße",
"rule": "Regel",
"user": "Benutzer"
"title": "",
"empty": "",
"rule": "",
"user": ""
},
"noSectionsEnabled": "Keine Abschnitte aktiviert",
"noSectionsEnabled": "",
"error": {
"noIntegration": "Keine Tracearr Integration konfiguriert",
"internalServerError": "Fehler beim Abrufen der Tracearr Daten"
"noIntegration": "",
"internalServerError": ""
}
},
"umami": {
"name": "Umami Ergebnisse",
"description": "Besucherstatistiken aus Ihrer Umami Ergebnisinstanz anzeigen",
"visitors": "Besucher",
"active": "Aktiv",
"pageviews": "Seitenaufrufe",
"visits": "Besuche",
"bounceRate": "abprallen",
"avgDuration": "Durchschnitt",
"name": "",
"description": "",
"visitors": "",
"active": "",
"pageviews": "",
"visits": "",
"bounceRate": "",
"avgDuration": "",
"option": {
"websiteId": {
"label": "Webseite",
"description": "Wählen Sie eine Website, die von Ihrer Umami Instanz verfolgt wird",
"placeholder": "Wähle eine Website",
"loading": "Website wird geladen...",
"noWebsites": "Keine Webseiten gefunden",
"configureIntegrationFirst": "Verbinde zuerst eine Umami Integration"
"label": "",
"description": "",
"placeholder": "",
"loading": "",
"noWebsites": "",
"configureIntegrationFirst": ""
},
"timeFrame": {
"label": "Zeitfenster",
"description": "Der Zeitraum, in dem Besucherdaten angezeigt werden sollen",
"label": "",
"description": "",
"option": {
"today": "Heute",
"24h": "Letzte 24 Stunden",
"7d": "Letzte 7 Tage",
"30d": "Letzte 30 Tage",
"month": "Dieser Monat",
"lastMonth": "Letzter Monat"
"today": "",
"24h": "",
"7d": "",
"30d": "",
"month": "",
"lastMonth": ""
}
},
"eventName": {
"label": "Veranstaltung",
"description": "Gesamte Vorkommnisse eines bestimmten Ereignisses anzeigen",
"placeholder": "Wählen Sie ein Ereignis aus",
"loading": "Ereignisse werden geladen...",
"noEvents": "Keine Ereignisse gefunden",
"none": "Kein Ereignis",
"configureWebsiteFirst": "Wählen Sie zuerst eine Webseite aus, um Ereignisse zu laden"
"label": "",
"description": "",
"placeholder": "",
"loading": "",
"noEvents": "",
"none": "",
"configureWebsiteFirst": ""
},
"chartType": {
"label": "Diagrammtyp",
"description": "Stil für das Diagramm anzeigen",
"label": "",
"description": "",
"option": {
"bar": "Balkendiagramm",
"sparkline": "Sparkline"
"bar": "",
"sparkline": ""
}
},
"chartStyle": {
"label": "Diagrammstil",
"description": "Wie die Ereignisserie im Diagramm angezeigt wird",
"label": "",
"description": "",
"option": {
"grouped": "Seite an Seite",
"overlay": "Overlay (subset)"
"grouped": "",
"overlay": ""
}
},
"viewMode": {
"label": "Ansicht",
"description": "Was im Widget Körper angezeigt werden soll",
"label": "",
"description": "",
"option": {
"chart": "Diagramm",
"events": "Ereignis Diagramm",
"topPages": "Top Seiten",
"topReferrers": "Top Referrer"
"chart": "",
"events": "",
"topPages": "",
"topReferrers": ""
}
},
"eventNames": {
"label": "Ereignisse",
"description": "Wählen Sie eine oder mehrere Ereignisse zum Diagramm",
"placeholder": "Ereignisse auswählen...",
"loading": "Ereignisse werden geladen...",
"noEvents": "Keine Ereignisse gefunden",
"configureWebsiteFirst": "Wählen Sie zuerst eine Webseite aus, um Ereignisse zu laden"
"label": "",
"description": "",
"placeholder": "",
"loading": "",
"noEvents": "",
"configureWebsiteFirst": ""
},
"topCount": {
"label": "Elemente anzeigen",
"description": "Anzahl der anzuzeigenden Top Elemente (max. 500)"
"label": "",
"description": ""
}
},
"event": {
"occurrences": "Ereignisse",
"label": "Ereignisse"
"occurrences": "",
"label": ""
},
"topPages": {
"direct": "Direkt"
"direct": ""
},
"topReferrers": {
"direct": "Direkt"
"direct": ""
},
"events": {
"selectPrompt": "Wählen Sie Ereignisse zum Diagramm"
"selectPrompt": ""
},
"error": {
"noIntegration": "Keine Umami Integration konfiguriert",
"noWebsite": "Keine Webseite konfiguriert",
"noData": "Keine Daten verfügbar",
"internalServerError": "Fehler beim Abrufen der Umami-Daten"
"noIntegration": "",
"noWebsite": "",
"noData": "",
"internalServerError": ""
}
},
"common": {
@@ -2669,95 +2669,95 @@
}
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "Übersicht des Paperless-ngx Dokumentenarchivs",
"documentsTotal": "Dokumente insgesamt",
"documentsInbox": "Posteingang",
"correspondents": "Beteiligte",
"tags": "Tags",
"documentTypes": "Dokumententypen",
"inboxRatio": "Eingangs-Verhältnis",
"inbox": "Eingang",
"total": "insgesamt",
"name": "",
"description": "",
"documentsTotal": "",
"documentsInbox": "",
"correspondents": "",
"tags": "",
"documentTypes": "",
"inboxRatio": "",
"inbox": "",
"total": "",
"option": {
"showInboxRatio": {
"label": "Zeige Eingangs Verhältnis",
"description": "Zeigt das Dokumentenverhältnis zwischen Eingang und Gesamtsumme im Heldenbereich an"
"label": "",
"description": ""
},
"showInboxRing": {
"label": "Inbox Ring anzeigen",
"description": "Fortschrittsring neben dem Eingangs-Verhältnis anzeigen"
"label": "",
"description": ""
},
"showDocumentsTotal": {
"label": "Gesamte Dokumente anzeigen",
"description": "Zeigt die Gesamtzahl der Dokumente im Archiv an"
"label": "",
"description": ""
},
"showDocumentsInbox": {
"label": "Eingangszähler anzeigen",
"description": "Zeige die Anzahl der nicht verarbeiteten Dokumente im Posteingang"
"label": "",
"description": ""
},
"showCorrespondents": {
"label": "Korrespondenten anzeigen",
"description": "Gesamtzahl der Korrespondenten anzeigen"
"label": "",
"description": ""
},
"showTags": {
"label": "Tags anzeigen",
"description": "Gesamtzahl der Tags anzeigen"
"label": "",
"description": ""
},
"showDocumentTypes": {
"label": "Dokumententypen anzeigen",
"description": "Gesamtzahl der Dokumenttypen anzeigen"
"label": "",
"description": ""
}
},
"error": {
"internalServerError": "Fehler beim Abrufen der Paperless-ngx-Daten"
"internalServerError": ""
}
},
"immich-serverStats": {
"name": "Immich Serverstatistik einblenden",
"description": "Informationen über Ihre Immich Instanz",
"name": "",
"description": "",
"option": {
"showUsers": {
"label": "Benutzer anzeigen",
"description": "Anzahl der Benutzer Ihrer Instanz anzeigen"
"description": ""
},
"showPhotos": {
"label": "Fotos anzeigen",
"description": "Anzahl der Fotos in deiner Instanz anzeigen"
"label": "",
"description": ""
},
"showVideos": {
"label": "Videos anzeigen",
"description": "Anzahl der Videos in deiner Instanz anzeigen"
"label": "",
"description": ""
},
"showStorage": {
"label": "Speicher anzeigen",
"description": "Gesamtanzahl des verwendeten Speichers anzeigen"
"label": "",
"description": ""
}
},
"users": "Benutzer",
"photos": "Fotos",
"videos": "Videos",
"storage": "Verwendeter Speicher"
"users": "",
"photos": "",
"videos": "",
"storage": ""
},
"immich-albumCarousel": {
"name": "Immich Album",
"name": "",
"option": {
"albumId": {
"label": "Album ID",
"description": "Dies ist eine eindeutige ID, die sich auf Ihr Immich Album bezieht. Sie können diese ID finden, indem Sie ein Album öffnen und die einzigartige Zeichenkette von der URL kopieren, zum Beispiel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
"label": "",
"description": ""
},
"rotationIntervalSeconds": {
"label": "Intervall in Sekunden",
"description": "Rotieren Sie alle X Sekunden zwischen den Bildern im Album. Muss mindestens 1 sein und unter 3600 liegen."
"label": "",
"description": ""
},
"showPhotoInfo": {
"label": "Foto Infos anzeigen",
"description": "Datum des Bildes und des aktuellen Indexes am unteren Rand der Diashow anzeigen"
"label": "",
"description": ""
}
},
"description": "Diashow der Bilder in Ihrem Immich Album",
"noAlbumSelected": "Kein Album ausgewählt, Widget bearbeiten und Album auswählen",
"noPhotos": "Keine Fotos in diesem Album"
"description": "",
"noAlbumSelected": "",
"noPhotos": ""
},
"downloads": {
"name": "Download Client",
@@ -3068,7 +3068,7 @@
"label": "Beschreibung ausblenden"
},
"showPosterImage": {
"label": "Poster Bild anzeigen"
"label": ""
}
}
},
@@ -3283,46 +3283,46 @@
"option": {}
},
"speedtestTracker": {
"name": "Speedtest Tracker",
"description": "Zeigt die Ergebnisse des Speedtest Trackers an.",
"name": "",
"description": "",
"option": {
"showLatestResult": {
"label": "Letztes Ergebnis anzeigen"
"label": ""
},
"showStats": {
"label": "Durchschnitt anzeigen"
"label": ""
},
"showRecentResults": {
"label": "Letzte Ergebnisse anzeigen"
"label": ""
},
"showPingGraph": {
"label": "Ping Grafik anzeigen"
"label": ""
}
},
"latestResult": "Letztes Ergebnis",
"averages": "Durchschnitt",
"tests": "{count} Versuche",
"recentResults": "Neueste Ergebnisse",
"recentResultsSpeed": "Geschwindigkeit",
"recentResultsPing": "Ping",
"download": "Download",
"upload": "Upload",
"ping": "Ping",
"status": "Status",
"avgDownload": "Durchschnittlicher Download",
"avgUpload": "Durchschnittlicher Upload",
"avgPing": "Durchschnittlicher Ping",
"healthy": "Gesund",
"unhealthy": "Fehlerhaft",
"ok": "Ok",
"fail": "Fehlgeschlagen",
"noSectionsEnabled": "Aktivieren Sie mindestens einen Abschnitt in den Widget Optionen.",
"latestResult": "",
"averages": "",
"tests": "",
"recentResults": "",
"recentResultsSpeed": "",
"recentResultsPing": "",
"download": "",
"upload": "",
"ping": "",
"status": "",
"avgDownload": "",
"avgUpload": "",
"avgPing": "",
"healthy": "",
"unhealthy": "",
"ok": "",
"fail": "",
"noSectionsEnabled": "",
"error": {
"internalServerError": "Fehler beim Abrufen der Speedtest Daten"
"internalServerError": ""
}
},
"uptimeKuma": {
"name": "Uptime Kuma",
"name": "",
"description": "",
"option": {
"showAverageUptime": {
-35
View File
@@ -1652,13 +1652,6 @@
}
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "Connect an integration first",
"loading": "Loading...",
"placeholder": "Select an option",
"noResults": "No results found",
"loadError": "Failed to load options"
},
"app": {
"name": "App",
"description": "Embeds an app into the board.",
@@ -1746,15 +1739,6 @@
}
}
},
"archiveTeamWarrior": {
"name": "ArchiveTeam Warrior",
"description": "Monitor ArchiveTeam Warrior status, projects, and active archival jobs.",
"option": {
"showBroadcastMessage": {
"label": "Show broadcast messages"
}
}
},
"dnsHoleSummary": {
"name": "DNS Hole Summary",
"description": "Displays the summary of your DNS Hole",
@@ -3577,9 +3561,6 @@
}
},
"noRecords": "No systems found",
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
},
"column": {
"system": "System",
"cpu": "CPU",
@@ -3636,9 +3617,6 @@
"label": "Show agent version"
}
},
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
},
"metric": {
"cpu": "CPU",
"memory": "Memory",
@@ -3665,9 +3643,6 @@
}
},
"empty": "No alerts configured",
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
},
"history": "History",
"resolved": "Resolved",
"status": {
@@ -3679,10 +3654,6 @@
"name": "Beszel System Stats",
"description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics",
"option": {
"systemId": {
"label": "System",
"description": "Select a Beszel system to display stats for"
},
"timePeriod": {
"label": "Time period"
},
@@ -3712,12 +3683,6 @@
}
},
"selectSystem": "Select system",
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
"switchToHistorical": "Switch to historical view",
"systemNotFound": "Selected system was not found. Choose a different system."
},
"chart": {
"cpu": {
"title": "CPU Usage",
+50 -50
View File
@@ -8,9 +8,9 @@
"action": {
"scratch": "Почати з нуля",
"importOldmarr": "Імпорт з Homarr пре-1.0",
"restoreSqlite": "Відновлення з резервної копії SQLite",
"restoreSqlite": "",
"restoreSqliteDescription": "Завантажте повну резервну копію бази даних SQLite з попереднього екземпляра Homarr. Це відновить усі дошки, користувачів, програми, інтеграції та налаштування.",
"backToOptions": "Назад до параметрів налаштування"
"backToOptions": ""
}
},
"import": {
@@ -94,45 +94,45 @@
"subtitle": "Налаштування сервера."
},
"integrations": {
"title": "Інтеграції",
"subtitle": "Виберіть сервіси, якими ви користуєтеся.",
"description": "Виберіть інтеграції, які ви хочете під'єднати. Ми автоматично налаштуємо вашу інформаційну панель з відповідними віджетами.",
"title": "",
"subtitle": "",
"description": "",
"action": {
"continue": "Продовжити",
"skip": "Пропустити поки що"
"continue": "",
"skip": ""
},
"configure": {
"progress": "{поточний} з {загальний}",
"skip": "Пропустити цю інтеграцію",
"finishing": "Налаштування панелі інструментів..",
"progress": "",
"skip": "",
"finishing": "",
"error": {
"title": "Не вдалося налаштувати",
"message": "Під час налаштування вашої інформаційної панелі сталася помилка. Спробуйте ще раз."
"title": "",
"message": ""
},
"helpers": {
"tooltip": "Помічники URL-адрес",
"description": "Ці помічники намагаються вгадати URL-адресу ваших послуг. Якщо це не працює, просто введіть адресу вручну."
"description": ""
}
},
"urlTemplate": {
"label": "Базовий URL",
"placeholder": "homelab.local чи 192.168.1.1",
"hint": "Введіть свій базовий домен або IP-адресу для автоматичного заповнення URL-адрес інтеграції",
"warning": "Якщо встановити базову URL-адресу зараз, усі URL-адреси інтеграції будуть автоматично заповнені, що заощадить ваш час під час налаштування",
"preview": "напр. {example}",
"label": "",
"placeholder": "",
"hint": "",
"warning": "",
"preview": "",
"mode": {
"subdomain": "Субдомен",
"hostPort": "Хост:Порт"
"subdomain": "",
"hostPort": ""
}
},
"docker": {
"detected": "Docker виявив {count} служб",
"detectedBreakdown": "{integrations} інтеграцій - {apps} програм",
"summary": "Homarr виявив {apps} програми, {integrations} з яких мають вбудовану інтеграцію",
"appBadge": "Додаток",
"appSectionLabel": "Додатки для додавання на інформаційну панель (вибрано {count})",
"scanning": "Сканування Docker...",
"scanningDescription": "Пошук запущених контейнерів у вашій системі"
"detected": "",
"detectedBreakdown": "",
"summary": "",
"appBadge": "",
"appSectionLabel": "",
"scanning": "",
"scanningDescription": ""
}
},
"finish": {
@@ -177,7 +177,7 @@
},
"password": {
"label": "Пароль",
"suggestionsTitle": "Пропозиції паролів",
"suggestionsTitle": "",
"suggestion": {
"length": "Включає принаймні 8 символів",
"lowercase": "Включає малу літеру",
@@ -205,7 +205,7 @@
"label": "Відкрити результати пошуку в новій вкладці"
},
"ddgBangs": {
"label": "Увімкніть чубчик DuckDuckGo"
"label": ""
}
},
"error": {
@@ -298,10 +298,10 @@
"changeDdgBangs": {
"notification": {
"success": {
"message": "Чубчик DuckDuckGo успішно змінено"
"message": ""
},
"error": {
"message": "Неможливо перемкнути чубчик DuckDuckGo"
"message": ""
}
}
},
@@ -367,11 +367,11 @@
"members": "Учасники",
"homeBoard": {
"label": "Домашня дошка",
"description": "Можна вибрати лише дошки, доступні групі"
"description": ""
},
"mobileBoard": {
"label": "Мобільна дошка",
"description": "Можна вибрати лише дошки, доступні групі"
"description": ""
}
},
"permission": {
@@ -492,8 +492,8 @@
}
},
"memberNotice": {
"mixed": "Деякі учасники належать до зовнішніх постачальників, і ними не можна керувати тут",
"external": "Усі учасники належать до зовнішніх постачальників, і ними не можна керувати тут"
"mixed": "",
"external": ""
},
"reservedNotice": {
"message": "Ця група зарезервована для використання системою та обмежує деякі дії. <checkoutDocs></checkoutDocs>"
@@ -573,8 +573,8 @@
"board": {
"notification": {
"success": {
"title": "Налаштування збережено",
"message": "Налаштування дошки успішно збережено"
"title": "",
"message": ""
},
"error": {
"title": "Не вдалося зберегти налаштування",
@@ -586,17 +586,17 @@
"changePosition": {
"notification": {
"success": {
"message": "Позицію успішно змінено"
"message": ""
},
"error": {
"message": "Неможливо змінити позицію"
"message": ""
}
}
}
},
"defaultGroup": {
"name": "Група за замовчуванням",
"description": "{name} – усі авторизовані користувачі"
"description": ""
}
},
"app": {
@@ -662,8 +662,8 @@
},
"useDifferentUrlForPing": {
"checkbox": {
"label": "Використовуйте іншу URL-адресу для ping",
"description": "Корисно, якщо Homarr може отримати прямий доступ за допомогою внутрішньої назви хоста або мережі, щоб уникнути використання пропускної здатності провайдера"
"label": "",
"description": ""
}
}
},
@@ -671,17 +671,17 @@
"select": {
"label": "Оберіть додаток",
"notFound": "Додатків не знайдено",
"search": "Пошук програми",
"noResults": "Результатів немає",
"action": "Оберіть {app}",
"title": "Оберіть додаток, щоб додати на цю дошку"
"search": "",
"noResults": "",
"action": "",
"title": ""
},
"create": {
"title": "Створити новий додаток",
"description": "Створити новий додаток",
"action": "Відкрийте створення додатку"
"title": "",
"description": "",
"action": ""
},
"add": "Додати додаток"
"add": ""
}
},
"customWidget": {
-2
View File
@@ -16,7 +16,6 @@ import { WidgetTextInput } from "./widget-text-input";
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
const mapping = {
anchorNote: WidgetAnchorNoteInput,
@@ -36,7 +35,6 @@ const mapping = {
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
integrationSelect: WidgetIntegrationSelectInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
@@ -1,55 +0,0 @@
"use client";
import { Select, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationSelect">) => {
const t = useIntegrationSelectTranslation();
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds as string[];
const { data: selectData = [], isPending, isError } = options.useOptions(integrationIds);
const currentValue = form.getInputProps(`options.${property}`).value as string;
const hasCurrentInData = !currentValue || selectData.some((opt) => opt.value === currentValue);
const data = hasCurrentInData ? selectData : [...selectData, { value: currentValue, label: currentValue }];
if (integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureIntegrationFirst")}
</Text>
);
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : t("placeholder");
const error = isError ? t("loadError") : undefined;
return (
<Select
label={tInput("label")}
description={description}
placeholder={placeholder}
clearable={options.clearable}
searchable={options.searchable}
nothingFoundMessage={t("noResults")}
data={data}
disabled={isError}
error={error}
{...form.getInputProps(`options.${property}`)}
/>
);
};
@@ -1,100 +0,0 @@
"use client";
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const integrationId = integrationIds[0];
if (!integrationId) {
return null;
}
return <ArchiveTeamWarriorWidgetContent integrationId={integrationId} options={options} />;
}
const ArchiveTeamWarriorWidgetContent = ({
integrationId,
options,
}: {
integrationId: string;
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
}) => {
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({
integrationId,
});
const utils = clientApi.useUtils();
clientApi.widget.archiveTeamWarrior.subscribeStatus.useSubscription(
{ integrationId },
{
onData: (next) => {
utils.widget.archiveTeamWarrior.getStatus.setData({ integrationId }, next);
},
},
);
const status = data.status;
const projectName = status.project?.title ?? status.selectedProject ?? "No project selected";
return (
<Stack p="xs" gap="xs" h="100%">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}>
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
<Text size="sm" c="dimmed" lineClamp={1}>
{projectName}
</Text>
</Group>
<Badge size="sm" color={status.status === "Running" ? "green" : "gray"}>
{status.status}
</Badge>
</Group>
{options.showBroadcastMessage && status.broadcastMessage && (
<Card withBorder p="xs">
<Text size="xs" lineClamp={3}>
{status.broadcastMessage}
</Text>
</Card>
)}
<SimpleGrid cols={2} spacing="xs">
<Metric label="Running" value={status.counts.running} />
<Metric label="Done" value={status.counts.completed} />
<Metric label="Failed" value={status.counts.failed} />
<Metric label="Canceled" value={status.counts.canceled} />
</SimpleGrid>
{status.bandwidth && (
<SimpleGrid cols={2} spacing="xs">
<Metric label="Download" value={formatBandwidth(status.bandwidth.receiving)} />
<Metric label="Upload" value={formatBandwidth(status.bandwidth.sending)} />
</SimpleGrid>
)}
</Stack>
);
};
const Metric = ({ label, value }: { label: string; value: number | string }) => (
<Stack gap={0} miw={0}>
<Text size="md" fw={700} lineClamp={1}>
{value}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{label}
</Text>
</Stack>
);
const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s");
@@ -1,16 +0,0 @@
import { IconArchive } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
createOptions() {
return optionsBuilder.from((factory) => ({
showBroadcastMessage: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: ["archiveTeamWarrior"],
}).withDynamicImport(() => import("./component"));
@@ -36,35 +36,35 @@ export default function BeszelAlertsWidget({
isEditMode,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems],
const { data: results = [] } = clientApi.widget.beszel.getAlerts.useQuery(
{
integrationIds,
includeHistory: options.showHistory,
maxHistoryItems: options.maxHistoryItems,
},
{ staleTime: 30 * 1000 },
);
const { data: results = [] } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
staleTime: 15_000,
refetchInterval: 15_000,
retry: false,
});
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeAlerts.useSubscription(alertsInput, {
enabled: !isEditMode,
onData(data) {
utils.widget.beszel.getAlerts.setData(alertsInput, (prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId
? {
...r,
alerts: data.alerts.alerts,
history: data.alerts.history,
updatedAt: data.timestamp,
}
: r,
clientApi.widget.beszel.subscribeAlerts.useSubscription(
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
{
enabled: !isEditMode,
onData(data) {
utils.widget.beszel.getAlerts.setData(
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
(prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId
? { ...r, alerts: data.alerts.alerts, history: data.alerts.history, updatedAt: data.timestamp }
: r,
);
},
);
});
},
},
});
);
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
+2 -11
View File
@@ -1,4 +1,4 @@
import { IconBell, IconServerOff } from "@tabler/icons-react";
import { IconBell } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
@@ -11,16 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelAle
createOptions() {
return optionsBuilder.from((factory) => ({
showHistory: factory.switch({ defaultValue: true }),
maxHistoryItems: factory.number({
defaultValue: 10,
validate: z.number().min(1).max(100),
}),
maxHistoryItems: factory.number({ defaultValue: 10, validate: z.number().min(1).max(100) }),
}));
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelAlerts.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -1,7 +1,6 @@
"use client";
import type { MantineSize } from "@mantine/core";
import { Badge, Box, Card, Group, Progress, Text, Stack, useComputedColorScheme } from "@mantine/core";
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
import {
Activity,
Battery,
@@ -17,7 +16,6 @@ import {
} from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -153,8 +151,6 @@ interface SystemCardProps {
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
size: SizeConfig;
maxMetrics: number;
backgroundColor: string;
itemRadius: MantineSize;
}
const metricRenderers = [
@@ -311,21 +307,16 @@ const metricRenderers = [
},
] as const;
const SystemCard = ({ system, options, t, size, maxMetrics, backgroundColor, itemRadius }: SystemCardProps) => {
const SystemCard = ({ system, options, t, size, maxMetrics }: SystemCardProps) => {
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
return (
<Card
padding={size.cardPadding}
radius={itemRadius}
bg={backgroundColor}
radius="sm"
withBorder
h="100%"
style={{
overflow: "hidden",
display: "flex",
flexDirection: "column" as const,
border: "0.0625rem solid var(--border-color)",
}}
style={{ overflow: "hidden", display: "flex", flexDirection: "column" as const }}
>
<Group gap="xs" mb={2}>
<Badge
@@ -353,15 +344,9 @@ export default function BeszelSystemGridWidget({
height,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard();
const colorScheme = useComputedColorScheme("light");
const opacity = board.opacity / 100;
const bgBase: Record<"light" | "dark", string> = { dark: "57, 57, 57", light: "246, 247, 248" };
const backgroundColor = `rgba(${bgBase[colorScheme]}, ${opacity})`;
const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
{ staleTime: 30 * 1000 },
);
useBeszelSystemsSubscription(integrationIds, !isEditMode);
@@ -389,16 +374,7 @@ export default function BeszelSystemGridWidget({
}}
>
{filteredSystems.map((system) => (
<SystemCard
key={system._key}
system={system}
options={options}
t={t}
size={size}
maxMetrics={maxMetrics}
backgroundColor={backgroundColor}
itemRadius={board.itemRadius}
/>
<SystemCard key={system._key} system={system} options={options} t={t} size={size} maxMetrics={maxMetrics} />
))}
</Box>
);
@@ -1,4 +1,4 @@
import { IconLayoutGrid, IconServerOff } from "@tabler/icons-react";
import { IconLayoutGrid } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
@@ -10,10 +10,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({
defaultValue: "all",
options: statusOptions,
}),
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
@@ -27,10 +24,4 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showAgent: factory.switch({ defaultValue: true }),
}));
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelSystemGrid.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -1,6 +0,0 @@
.beszelStatsSystemToggle {
position: absolute;
top: 6px;
left: 6px;
z-index: 1;
}
@@ -1,23 +1,16 @@
"use client";
import { useCallback, useMemo } from "react";
import { Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react";
import { useCallback, useMemo, useState } from "react";
import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
BeszelAreaChart,
ChartPanel,
useContainerNames,
useDockerChartData,
useSystemChartData,
@@ -30,7 +23,6 @@ import {
formatStorageBytes,
} from "../beszel/_shared/format";
import { makeTooltipProps } from "../beszel/_shared/tooltip";
import { useLiveStats } from "../beszel/_shared/use-live-stats";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
@@ -39,29 +31,15 @@ const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const gridColumns = [1, 2] as const;
function statsWhenShown<T>(show: boolean | undefined, data: T | undefined): T | undefined {
if (!show || !data) return undefined;
return data;
}
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
boardId,
itemId,
setOptions,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation();
const { data: systemsResult = [], isPending: systemsPending } = clientApi.widget.beszel.getSystems.useQuery(
const { data: systemsResult = [] } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 30 * 1000 },
);
@@ -71,305 +49,200 @@ export default function BeszelSystemStatsWidget({
[systemsResult],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label;
const systemExists = systems.some((s) => s.value === selectedSystem);
const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
const handleSelectSystem = useCallback(
(value: string) => {
setOptions({ newOptions: { systemId: value } });
if (hasChangeAccess && boardId && itemId) {
saveItemOptions({ boardId, itemId, newOptions: { systemId: value } });
}
},
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
const [selectedSystem, setSelectedSystem] = useState<string | null>(systems[0]?.value ?? null);
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const isLive = options.timePeriod === "1m";
const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
systemId: selectedSystem,
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
systemId: selectedSystem ?? systems[0]?.value ?? "",
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{ staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "" },
{ staleTime: 30 * 1000 },
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
// BeszelSystemStats fields: storage values (m, mu, mb, d, du, s, su) are already in GB.
// Disk I/O (dr, dw) is in MB/s — scale to bytes/s for display formatters.
// Network (ns, nr, b) is in bytes/s. Container memory (m) is in MB.
let activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] }
| null
| undefined = statsResult;
if (isLive) {
activeStats = liveData;
}
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]);
const memoryMap = useCallback(
(s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
[t],
);
const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du }), [t]);
const diskIOMap = useCallback(
(s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * (1024 * 1024),
[t("chart.diskIO.write")]: (s.dw ?? 0) * (1024 * 1024),
}),
[t],
);
// b = public interface bandwidth (preferred), ns/nr = all-interface traffic (fallback)
const networkMap = useCallback(
(s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const cpuData = useSystemChartData(statsResult?.systemStats, cpuMap);
const memoryData = useSystemChartData(statsResult?.systemStats, memoryMap);
const diskData = useSystemChartData(statsResult?.systemStats, diskMap);
const diskIOData = useSystemChartData(statsResult?.systemStats, diskIOMap);
const networkData = useSystemChartData(statsResult?.systemStats, networkMap);
const systemStats = activeStats?.systemStats;
const containerStatsRaw = activeStats?.containerStats;
const containerNames = useContainerNames(statsResult?.containerStats);
const dockerCpuData = useDockerChartData(statsResult?.containerStats, containerNames, "cpu");
const dockerMemData = useDockerChartData(statsResult?.containerStats, containerNames, "memory");
const dockerNetData = useDockerChartData(statsResult?.containerStats, containerNames, "network");
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, isLive);
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, isLive);
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, isLive);
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, isLive);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, isLive);
const containerSeries = containerNames.map((name, i) => ({
name,
color: containerColors[i % containerColors.length] as string,
}));
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
const dockerCpuData = useDockerChartData(
statsWhenShown(options.showDockerCpu, containerStatsRaw),
containerNames,
"cpu",
isLive,
);
const dockerMemData = useDockerChartData(
statsWhenShown(options.showDockerMemory, containerStatsRaw),
containerNames,
"memory",
isLive,
);
const dockerNetData = useDockerChartData(
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
containerNames,
"network",
isLive,
);
const containerSeries = useMemo(
() => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
[containerNames],
);
const cols = gridColumns[Number(width > 600)];
if (!systemsPending && !systemExists && systems.length > 0) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconQuestionMark size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.systemNotFound")}
</Text>
<Select
size="xs"
data={systems}
value={selectedSystem}
placeholder={t("selectSystem")}
onChange={(value) => value && handleSelectSystem(value)}
/>
</Stack>
</Center>
);
}
if (isLive && liveError && !liveData) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
<Button size="xs" variant="light" onClick={() => setOptions({ newOptions: { timePeriod: "1h" } })}>
{t("error.switchToHistorical")}
</Button>
</Stack>
</Center>
);
}
const cols = width > 600 ? 2 : 1;
return (
<ScrollArea h="100%" style={{ pointerEvents: (isEditMode && "none") || undefined }}>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer size={14} />}
className={classes.beszelStatsSystemToggle}
>
{selectedLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((s) => (
<Menu.Item
key={s.value}
fz="xs"
fw={s.value === selectedSystem ? 600 : 400}
c={s.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(s.value)}
>
{s.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
<Select
size="xs"
data={systems}
value={selectedSystem}
onChange={setSelectedSystem}
placeholder={t("selectSystem")}
/>
{activeStats && (
{statsResult && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
<ChartPanel title={t("chart.cpu.title")} subtitle={t("chart.cpu.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={cpuData}
series={[{ name: t("chart.cpu.series"), color: "teal.6" }]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.percent}
yAxisDomain={[0, "auto"]}
tooltipProps={tooltipPercent}
/>
</ChartPanel>
)}
{options.showMemory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
<ChartPanel title={t("chart.memory.title")} subtitle={t("chart.memory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={memoryData}
series={[
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
withLegend
/>
</ChartPanel>
)}
{options.showDisk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
<ChartPanel title={t("chart.disk.title")} subtitle={t("chart.disk.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskData}
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
/>
</ChartPanel>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
<ChartPanel title={t("chart.diskIO.title")} subtitle={t("chart.diskIO.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskIOData}
series={[
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
]}
fillOpacity={0.3}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
)}
{options.showNetwork && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
<ChartPanel title={t("chart.network.title")} subtitle={t("chart.network.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={networkData}
series={[
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
]}
fillOpacity={0.3}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
<ChartPanel title={t("chart.dockerCpu.title")} subtitle={t("chart.dockerCpu.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerCpuData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.percent}
tooltipProps={tooltipPercentTotal}
/>
</ChartPanel>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
<ChartPanel title={t("chart.dockerMemory.title")} subtitle={t("chart.dockerMemory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerMemData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.bytes}
tooltipProps={tooltipBytesTotal}
/>
</ChartPanel>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
<ChartPanel title={t("chart.dockerNetwork.title")} subtitle={t("chart.dockerNetwork.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerNetData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
/>
</ChartPanel>
)}
</>
)}
@@ -1,12 +1,9 @@
import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { IconChartAreaLine } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const timePeriodOptions = [
{ value: "1m", label: "Live" },
{ value: "1h", label: "1 Hour" },
{ value: "12h", label: "12 Hours" },
{ value: "24h", label: "24 Hours" },
@@ -20,26 +17,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
systemId: factory.integrationSelect({
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const {
data = [],
isPending,
isError,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ enabled: integrationIds.length > 0, staleTime: 30_000 },
);
const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name })));
return { data: selectData, isPending, isError };
},
}),
timePeriod: factory.select({
defaultValue: "1h",
options: timePeriodOptions,
}),
timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
@@ -50,10 +28,4 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showDockerNetwork: factory.switch({ defaultValue: true }),
}));
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelSystemStats.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -34,7 +34,7 @@ type SystemRowWithKey = BeszelSystemRow & { _key: string };
interface SizeConfig {
iconSize: number;
fontSize: "xs" | "sm";
fontSize: "xs" | "10px";
progressSize: "xs" | "sm";
cellPadding: number;
valueMiw: number;
@@ -42,21 +42,9 @@ interface SizeConfig {
const getSizeConfig = (width: number): SizeConfig => {
if (width < 400) {
return {
iconSize: 10,
fontSize: "xs",
progressSize: "xs",
cellPadding: 2,
valueMiw: 30,
};
return { iconSize: 10, fontSize: "10px", progressSize: "xs", cellPadding: 2, valueMiw: 30 };
}
return {
iconSize: 14,
fontSize: "sm",
progressSize: "sm",
cellPadding: 4,
valueMiw: 38,
};
return { iconSize: 14, fontSize: "xs", progressSize: "sm", cellPadding: 4, valueMiw: 38 };
};
export default function BeszelSystemTableWidget({
@@ -68,7 +56,7 @@ export default function BeszelSystemTableWidget({
const t = useScopedI18n("widget.beszelSystemTable");
const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
{ staleTime: 30 * 1000 },
);
const size = getSizeConfig(width);
@@ -1,4 +1,4 @@
import { IconServerOff, IconTable } from "@tabler/icons-react";
import { IconTable } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
@@ -29,15 +29,9 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({
defaultValue: "all",
options: statusOptions,
}),
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
sortBy: factory.select({ defaultValue: "name", options: sortOptions }),
sortDirection: factory.select({
defaultValue: "asc",
options: sortDirectionOptions,
}),
sortDirection: factory.select({ defaultValue: "asc", options: sortDirectionOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
@@ -51,10 +45,4 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showAgent: factory.switch({ defaultValue: true }),
}));
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelSystemTable.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
+35 -85
View File
@@ -1,6 +1,6 @@
"use client";
import { memo, useMemo, useRef } from "react";
import { useMemo } from "react";
import { Group, Stack, Text } from "@mantine/core";
import type { AreaChartProps } from "@mantine/charts";
import { AreaChart } from "@mantine/charts";
@@ -9,28 +9,17 @@ import dayjs from "dayjs";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss");
function prepareRecords<T>(records: T[], live: boolean) {
if (live) {
return { fmt: formatTimeLive, ordered: records };
}
return { fmt: formatTime, ordered: [...records].toReversed() };
}
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const chartStyle = { minWidth: 0, minHeight: 0 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
export const CPU_Y_AXIS_DOMAIN: [number, string] = [0, "auto"];
interface BeszelChartPanelProps {
interface ChartPanelProps {
title: string;
subtitle?: string;
chartProps: BeszelAreaChartProps;
children: React.ReactNode;
}
export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => (
<Stack gap={4} style={panelStyle}>
export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
<Stack gap={4} style={{ minWidth: 0, overflow: "hidden" }}>
<Group gap="xs">
<Text size="sm" fw={600}>
{title}
@@ -41,93 +30,57 @@ export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelCha
</Text>
)}
</Group>
<BeszelAreaChart {...chartProps} />
{children}
</Stack>
));
);
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis"> & {
yAxisFormatter: (value: number) => string;
yAxisDomain?: [number, string];
};
const BeszelAreaChart = memo(
({
yAxisFormatter,
yAxisDomain,
yAxisProps: yAxisPropsOverride,
type = "default",
...props
}: BeszelAreaChartProps) => {
const mergedYAxis = useMemo(() => {
const base = {
...yAxisBase,
width: 48,
tickMargin: 2,
tickFormatter: yAxisFormatter,
...yAxisPropsOverride,
};
if (yAxisDomain) {
return { ...base, domain: yAxisDomain };
}
return base;
}, [yAxisFormatter, yAxisDomain, yAxisPropsOverride]);
return (
<AreaChart
dataKey="time"
curveType="monotone"
withGradient={false}
connectNulls
withDots={false}
type={type}
strokeWidth={1}
fillOpacity={0.2}
withXAxis
withYAxis
w="100%"
style={chartStyle}
yAxisProps={mergedYAxis}
{...props}
/>
);
},
export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style, ...props }: BeszelAreaChartProps) => (
<AreaChart
dataKey="time"
curveType="monotone"
withDots={false}
withXAxis
withYAxis
w="100%"
style={{ minWidth: 0, minHeight: 0, ...style }}
yAxisProps={{
...yAxisBase,
...(yAxisDomain ? { domain: yAxisDomain } : {}),
tickFormatter: yAxisFormatter,
...yAxisProps,
}}
{...props}
/>
);
export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
live = false,
) =>
useMemo(() => {
if (!systemStats) return [];
const { fmt, ordered } = prepareRecords(systemStats, live);
return ordered.map((r) => ({
time: fmt(r.created),
return [...systemStats].toReversed().map((r) => ({
time: formatTime(r.created),
...mapFn(r.stats),
}));
}, [systemStats, mapFn, live]);
}, [systemStats, mapFn]);
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
const prevRef = useRef<string[]>([]);
return useMemo(() => {
if (!containerStats?.length) {
if (prevRef.current.length === 0) return prevRef.current;
prevRef.current = [];
return prevRef.current;
}
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) =>
useMemo(() => {
if (!containerStats?.length) return [];
const names = new Set<string>();
for (const record of containerStats) {
for (const c of record.stats) {
names.add(c.n);
}
}
const next = [...names].slice(0, max);
const prev = prevRef.current;
if (prev.length === next.length && prev.every((n, i) => n === next[i])) return prev;
prevRef.current = next;
return next;
return [...names].slice(0, max);
}, [containerStats, max]);
};
type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;
@@ -144,19 +97,16 @@ export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
containerNames: string[],
metric: "cpu" | "memory" | "network",
live = false,
) =>
useMemo(() => {
if (!containerStats?.length) return [];
const extract = defaultContainerExtractors[metric];
if (!extract) return [];
const { fmt, ordered } = prepareRecords(containerStats, live);
return ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created) };
const byName = new Map(record.stats.map((c) => [c.n, c]));
return [...containerStats].toReversed().map((record) => {
const point: Record<string, unknown> = { time: formatTime(record.created) };
for (const name of containerNames) {
point[name] = extract(byName.get(name));
point[name] = extract(record.stats.find((c) => c.n === name));
}
return point;
});
}, [containerStats, containerNames, metric, live]);
}, [containerStats, containerNames, metric]);
+12 -9
View File
@@ -21,16 +21,19 @@ export const statusColorMap: Record<BeszelSystemStatus, string> = {
};
export const containerColors = [
"red.6",
"orange.6",
"yellow.6",
"lime.6",
"green.6",
"teal.6",
"cyan.6",
"blue.6",
"indigo.6",
"violet.6",
"teal.6",
"orange.6",
"grape.6",
"cyan.6",
"pink.6",
"lime.6",
"violet.6",
"indigo.6",
"yellow.6",
"red.6",
"green.6",
"blue.4",
"teal.4",
"orange.4",
];
@@ -13,11 +13,13 @@ const styles: Record<string, CSSProperties> = {
fontSize: 12,
lineHeight: 1.5,
pointerEvents: "none",
maxHeight: 320,
overflowY: "auto",
overflowX: "hidden",
},
header: { marginBottom: 4, fontWeight: 550, color: "var(--mantine-color-gray-2)" },
header: { marginBottom: 4, fontWeight: 500, color: "var(--mantine-color-gray-2)" },
row: { display: "flex", alignItems: "center", gap: 6, padding: "1px 0" },
indicator: { width: 5, height: 14, borderRadius: 5, flexShrink: 0 },
indicator: { width: 3, height: 14, borderRadius: 1, flexShrink: 0 },
name: {
flex: 1,
color: "var(--mantine-color-gray-3)",
@@ -76,11 +78,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
if (!isActive) return null;
const deduped = new Map<string, TooltipPayloadItem>();
for (const item of (payload ?? []).filter((entry) => entry.value > 0)) {
if (!deduped.has(item.dataKey)) deduped.set(item.dataKey, item);
}
const sorted = [...deduped.values()].toSorted((a, b) => b.value - a.value);
const sorted = [...(payload ?? [])].filter((p) => p.value > 0).toSorted((a, b) => b.value - a.value);
if (!sorted.length) return null;
@@ -1,92 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
const MAX_BUFFER = 30;
const FLUSH_INTERVAL_MS = 3000;
interface LiveStatsData {
systemStats: BeszelSystemStatsRecord[];
containerStats: BeszelContainerStatsRecord[];
}
function appendToBuffer(
prev: LiveStatsData | null,
incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null },
): LiveStatsData {
const systemStats = [...(prev?.systemStats ?? []), incoming.stats].slice(-MAX_BUFFER);
const prevContainer = prev?.containerStats ?? [];
if (!incoming.containerStats) {
return { systemStats, containerStats: prevContainer };
}
return { systemStats, containerStats: [...prevContainer, incoming.containerStats].slice(-MAX_BUFFER) };
}
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [data, setData] = useState<LiveStatsData | null>(null);
const [error, setError] = useState<Error | null>(null);
const bufferRef = useRef<LiveStatsData | null>(null);
const dirtyRef = useRef(false);
const generationRef = useRef(0);
const integrationKey = [...integrationIds].sort().join(",");
useEffect(() => {
generationRef.current += 1;
setData(null);
setError(null);
bufferRef.current = null;
dirtyRef.current = false;
}, [systemId, enabled, integrationKey]);
const onData = useCallback(
(incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => {
const gen = generationRef.current;
queueMicrotask(() => {
if (generationRef.current !== gen) return;
setError(null);
const isFirst = !bufferRef.current;
bufferRef.current = appendToBuffer(bufferRef.current, incoming);
dirtyRef.current = true;
if (isFirst) {
setData(bufferRef.current);
}
});
},
[],
);
const onError = useCallback((err: unknown) => {
if (err instanceof Error) {
setError(err);
return;
}
setError(new Error(String(err)));
}, []);
const subscriptionInput = useMemo(() => ({ integrationIds, systemId }), [integrationKey, systemId]);
const subscriptionEnabled = enabled && systemId !== "";
useEffect(() => {
if (!subscriptionEnabled) return;
const gen = generationRef.current;
const id = setInterval(() => {
if (generationRef.current !== gen) return;
if (!dirtyRef.current || !bufferRef.current) return;
dirtyRef.current = false;
setData(bufferRef.current);
}, FLUSH_INTERVAL_MS);
return () => clearInterval(id);
}, [subscriptionEnabled, integrationKey, systemId]);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(subscriptionInput, {
enabled: subscriptionEnabled,
onData,
onError,
});
return { data, error };
};
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import JsxParser from "react-jsx-parser";
import { Alert, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
@@ -19,11 +19,6 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
const template = String(data.template ?? "");
const apiData = data.data;
const [parseErrors, setParseErrors] = useState<string[]>([]);
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
useEffect(() => {
setParseErrors([]);
}, [template, bindings]);
const handleError = useCallback((error: Error) => {
setParseErrors((prev) => appendParseError(prev, error.message));
@@ -44,8 +39,7 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
<JsxParser
jsx={template}
components={WHITELISTED_COMPONENTS as never}
bindings={bindings}
disableKeyGeneration
bindings={SAFE_BINDINGS(apiData)}
componentsOnly
allowUnknownElements={false}
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
@@ -69,13 +69,9 @@ function isTabPanel(child: ReactNode): child is React.ReactElement<TabPanelProps
export function TabsContainer({ children, defaultTab }: TabsContainerProps) {
const panels = Children.toArray(children).filter(isTabPanel);
const firstPanel = panels[0];
const panelValues = new Set(panels.map((p) => p.props.value));
const resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab;
const initialTab = resolvedDefault || firstPanel?.props.value || null;
const [activeTab, setActiveTab] = useState<string | null>(initialTab);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs defaultValue={defaultTab ?? firstPanel?.props.value}>
<Tabs.List>
{panels.map((panel) => (
<Tabs.Tab key={panel.props.value} value={panel.props.value}>
-2
View File
@@ -10,7 +10,6 @@ import type { SettingsContextProps } from "@homarr/settings/creator";
import * as anchorNote from "./anchor-note";
import * as audioStats from "./audio-stats";
import * as app from "./app";
import * as archiveTeamWarrior from "./archive-team-warrior";
import * as bookmarks from "./bookmarks";
import * as calendar from "./calendar";
import * as clock from "./clock";
@@ -68,7 +67,6 @@ export const widgetImports = {
clock,
weather,
app,
archiveTeamWarrior,
anchorNote,
notebook,
iframe,
-18
View File
@@ -60,16 +60,6 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
};
}
interface IntegrationSelectInput extends CommonInput<string> {
clearable?: boolean;
searchable?: boolean;
useOptions: (integrationIds: string[]) => {
data: { value: string; label: string }[];
isPending: boolean;
isError: boolean;
};
}
interface NumberInput extends CommonInput<number> {
validate: z.ZodNumber;
step?: number;
@@ -185,14 +175,6 @@ const optionsFactory = {
defaultValue: "",
withDescription: true,
}),
integrationSelect: (input: IntegrationSelectInput) => ({
type: "integrationSelect" as const,
defaultValue: input.defaultValue ?? "",
withDescription: input.withDescription ?? false,
clearable: input.clearable ?? false,
searchable: input.searchable ?? true,
useOptions: input.useOptions,
}),
customWidgetSelect: (input?: CommonInput<string>) => ({
type: "customWidgetSelect" as const,
defaultValue: input?.defaultValue ?? "",
+469 -548
View File
File diff suppressed because it is too large. Load diff
+22 -24
View File
@@ -22,18 +22,18 @@ catalog:
"@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0
"@modelcontextprotocol/sdk": 1.29.0
"@mantine/charts": ^9.3.1
"@mantine/colors-generator": ^9.3.1
"@mantine/core": ^9.3.1
"@mantine/dates": ^9.3.1
"@mantine/dropzone": ^9.3.1
"@mantine/form": ^9.3.1
"@mantine/hooks": ^9.3.1
"@mantine/modals": ^9.3.1
"@mantine/notifications": ^9.3.1
"@mantine/spotlight": ^9.3.1
"@mantine/tiptap": ^9.3.1
"@next/eslint-plugin-next": 16.2.9
"@mantine/charts": ^9.3.0
"@mantine/colors-generator": ^9.3.0
"@mantine/core": ^9.3.0
"@mantine/dates": ^9.3.0
"@mantine/dropzone": ^9.3.0
"@mantine/form": ^9.3.0
"@mantine/hooks": ^9.3.0
"@mantine/modals": ^9.3.0
"@mantine/notifications": ^9.3.0
"@mantine/spotlight": ^9.3.0
"@mantine/tiptap": ^9.3.0
"@next/eslint-plugin-next": 16.2.7
"@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0
"@playwright/test": ^1.60.0
@@ -73,7 +73,7 @@ catalog:
"@trpc/react-query": ^11.17.0
"@trpc/server": ^11.17.0
"@trpc/tanstack-react-query": ^11.17.0
"@turbo/gen": ^2.9.18
"@turbo/gen": ^2.9.16
"@types/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13
@@ -82,13 +82,13 @@ catalog:
"@types/crypto-js": ^4.2.2
"@types/css-modules": ^1.0.5
"@types/dockerode": ^4.0.1
"@types/node": ^24.13.2
"@types/node": ^24.13.1
"@types/node-unifi": ^2.5.1
"@types/pg": ^8.20.0
"@types/prismjs": ^1.26.6
"@types/react": 19.2.17
"@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.45
"@scalar/api-reference-react": ^0.9.42
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
@@ -144,13 +144,13 @@ catalog:
mcp-handler: ^1.1.0
maria2: ^0.4.1
mysql2: 3.22.5
next: 16.2.9
next: 16.2.7
next-auth: 5.0.0-beta.31
next-intl: 4.13.0
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.54.0
oxlint: ^1.69.0
oxlint: ^1.68.0
pg: ^8.21.0
postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0
@@ -164,7 +164,7 @@ catalog:
react-simple-code-editor: ^0.14.1
recharts: ^3.8.1
sass: ^1.100.0
semantic-release: ^25.0.5
semantic-release: ^25.0.3
semver-parser: ^4.2.1
superjson: 2.2.6
svgson: ^5.3.1
@@ -174,10 +174,10 @@ catalog:
trpc-to-openapi: ^3.3.0
tsdav: ^2.2.2
tsx: 4.20.4
turbo: ^2.9.18
turbo: ^2.9.16
typescript: ^6.0.3
vite: ^8.0.16
undici: 7.27.2
undici: 7.27.1
use-deep-compare-effect: ^1.8.1
video.js: ^8.23.7
vite-tsconfig-paths: ^6.1.1
@@ -200,8 +200,6 @@ minimumReleaseAgeExclude:
- "@next/*"
# Renovate security update: turbo@2.9.14
- turbo@2.9.14
# Renovate security update: form-data@4.0.6
- form-data@4.0.6
overrides:
"@babel/helpers@<7.26.10": ">=7.29.7"
"@babel/runtime@<7.26.10": ">=7.29.7"
@@ -209,8 +207,8 @@ overrides:
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6"
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6"
"esbuild@<=0.24.2": ">=0.28.0"
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
"hono@<4.6.5": ">=4.12.25"
"form-data@>=4.0.0 <4.0.4": ">=4.0.5"
"hono@<4.6.5": ">=4.12.23"
"linkifyjs@<4.3.2": ">=4.3.3"
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11"
"prismjs@<1.30.0": ">=1.30.0"
+1 -1
View File
@@ -1 +1 @@
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontentLine truncated
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"alotlikebeans","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontLine truncated
+1 -1
View File
@@ -1 +1 @@
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/aaa1f083e04c572f953fde4563171e4a.jpeg"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"https://crowLine truncated
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/249b02e3368752cb175446082015737c_default.png"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"httpsLine truncated