Compare commits

..
Author SHA1 Message Date
Releases Homarr 46ae756d15 chore(release): 1.68.1 [skip ci] 2026-06-30 12:02:44 +00:00
Thomas 8b63799f1b Merge pull request #6124 from homarr-labs/fix/source-maps-memory-regression
fix: make production source maps opt-in to restore v1.67.0 memory baseline
2026-06-30 14:01:42 +02:00
ajnart 60a69e31ef fix: revert production source maps to restore v1.67.0 memory baseline
CodeRabbit review addressed:
- Removed the env-var gating (simpler: just remove source maps entirely)
- CI health check now fails fast if container never becomes healthy

Changes vs origin/main:
- next.config.ts: remove productionBrowserSourceMaps and serverSourceMaps
  (they default to false, restoring pre-v1.68.0 behavior)
- Dockerfile: remove .map copy step and NODE_OPTIONS=--enable-source-maps
- CI: add memory-regression job (<700MB guardrail)

Closes #6123
2026-06-29 15:13:08 +02:00
ajnart 57ef0af4a2 fix: make source maps opt-in to restore v1.67.0 memory baseline
Production source maps (--enable-source-maps) caused a ~50% memory increase
(600MB to 1000MB) in v1.68.0. Node.js loads all .map files into memory when
this flag is enabled, and Next.js server chunks generate hundreds of map files.

Changes:
- Guard productionBrowserSourceMaps and serverSourceMaps behind
  HOMARR_ENABLE_SOURCE_MAPS env var (default off)
- Guard .map copy step and NODE_OPTIONS in Dockerfile behind
  ENABLE_SOURCE_MAPS build arg (default false)
- run.sh checks HOMARR_ENABLE_SOURCE_MAPS before setting --enable-source-maps
- Add Docker-based memory regression CI job (<700MB threshold)

Closes #6123
2026-06-29 10:08:52 +02:00
Thomas 597b5fbc8d Merge pull request #6097 from homarr-labs/docs/update-helm-docs
Update Helm chart documentation
2026-06-27 13:24:44 +02:00
github-actions[bot] 5412577519 docs(helm): Update Helm chart documentation 2026-06-27 11:22:16 +00:00
108 changed files with 2153 additions and 6046 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.68.0
- 1.67.0
- 1.66.1
- 1.66.0
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.18.0
node-version: 24.17.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
+1 -1
View File
@@ -1,2 +1,2 @@
[tools]
node = "24.18.0"
node = "24.17.0"
+1 -1
View File
@@ -1 +1 @@
24.18.0
24.17.0
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"oxc.oxc-vscode",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors",
"million.million-lint",
"lokalise.i18n-ally"
+4 -185
View File
@@ -1,193 +1,12 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers
# Specific features / integrations
# These owners come from the PR that introduced each integration implementation.
# Specific rules override the global fallback because CODEOWNERS uses the last
# matching pattern; do not include @homarr-labs/maintainers here unless they
# should also be requested for that integration.
# AdGuard Home - introduced in https://github.com/homarr-labs/homarr/pull/929 by @hillaliy
apps/docs/docs/integrations/adguard-home @hillaliy
packages/integrations/src/adguard-home @hillaliy
# Anchor - introduced in https://github.com/homarr-labs/homarr/pull/5040 by @ohmzi
apps/docs/docs/integrations/anchor @ohmzi
packages/integrations/src/anchor @ohmzi
# ArchiveTeam Warrior - introduced in https://github.com/homarr-labs/homarr/pull/5784 by @darbyjack
apps/docs/docs/integrations/archive-team-warrior @darbyjack
packages/integrations/src/archive-team-warrior @darbyjack
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/audiobookshelf @ajnart
packages/integrations/src/audiobookshelf @ajnart
# Beszel - introduced in https://github.com/homarr-labs/homarr/pull/5900 by @ajnart
apps/docs/docs/integrations/beszel @ajnart
packages/integrations/src/beszel @ajnart
# Codeberg - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/codeberg @Aandree5
packages/integrations/src/codeberg @Aandree5
# Coolify - introduced in https://github.com/homarr-labs/homarr/pull/4789 by @Rhiz3K
apps/docs/docs/integrations/coolify @Rhiz3K
packages/integrations/src/coolify @Rhiz3K
# Dash. - introduced in https://github.com/homarr-labs/homarr/pull/1541 by @manuel-rw
apps/docs/docs/integrations/dash-dot @manuel-rw
packages/integrations/src/dashdot @manuel-rw
# Docker Hub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/docker-hub @Aandree5
packages/integrations/src/docker-hub @Aandree5
# Emby - introduced in https://github.com/homarr-labs/homarr/pull/2466 by @Meierschlumpf
apps/docs/docs/integrations/emby @Meierschlumpf
packages/integrations/src/emby @Meierschlumpf
# GitHub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/github @Aandree5
packages/integrations/src/github @Aandree5
# GitHub Container Registry - introduced in https://github.com/homarr-labs/homarr/pull/3727 by @Aandree5
apps/docs/docs/integrations/github-containerregistry @Aandree5
packages/integrations/src/github-container-registry @Aandree5
# GitLab - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/gitlab @Aandree5
packages/integrations/src/gitlab @Aandree5
# Glances - introduced in https://github.com/homarr-labs/homarr/pull/5102 by @manuel-rw
apps/docs/docs/integrations/glances @manuel-rw
packages/integrations/src/glances @manuel-rw
# Gluetun - introduced in https://github.com/homarr-labs/homarr/pull/5812 by @Lok3sh-kumar
apps/docs/docs/integrations/gluetun @Lok3sh-kumar
# Specific features / integrations
# Glueten
apps/docs/docs/integrations/glueten @Lok3sh-kumar
packages/integrations/src/gluetun @Lok3sh-kumar
# Gotify - introduced in https://github.com/homarr-labs/homarr/pull/5847 by @Nerdy-Technician
apps/docs/docs/integrations/gotify @Nerdy-Technician
packages/integrations/src/gotify @Nerdy-Technician
# Home Assistant - introduced in https://github.com/homarr-labs/homarr/pull/578 by @manuel-rw
apps/docs/docs/integrations/home-assistant @manuel-rw
packages/integrations/src/homeassistant @manuel-rw
# ICal - introduced in https://github.com/homarr-labs/homarr/pull/3980 by @NicolasNewman
apps/docs/docs/integrations/ical @NicolasNewman
packages/integrations/src/ical @NicolasNewman
# Immich - introduced in https://github.com/homarr-labs/homarr/pull/5165 by @manuel-rw
apps/docs/docs/integrations/immich @manuel-rw
packages/integrations/src/immich @manuel-rw
# Jellyfin - introduced in https://github.com/homarr-labs/homarr/pull/672 by @manuel-rw
apps/docs/docs/integrations/jellyfin @manuel-rw
packages/integrations/src/jellyfin @manuel-rw
# Jellyseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
apps/docs/docs/integrations/jellyseerr @manuel-rw
packages/integrations/src/jellyseerr @manuel-rw
# LinuxServer.io - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
apps/docs/docs/integrations/linux-server-io @Aandree5
packages/integrations/src/linuxserverio @Aandree5
# Navidrome - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/navidrome @ajnart
packages/integrations/src/navidrome @ajnart
# Nextcloud - introduced in https://github.com/homarr-labs/homarr/pull/2501 by @manuel-rw
apps/docs/docs/integrations/nextcloud @manuel-rw
packages/integrations/src/nextcloud @manuel-rw
# npm - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/npm @Aandree5
packages/integrations/src/npm @Aandree5
# ntfy - introduced in https://github.com/homarr-labs/homarr/pull/2900 by @itzTheMeow
apps/docs/docs/integrations/ntfy @ajnart @manuel-rw
packages/integrations/src/ntfy @ajnart @manuel-rw
# OpenMediaVault - introduced in https://github.com/homarr-labs/homarr/pull/1142 by @hillaliy
apps/docs/docs/integrations/open-media-vault @hillaliy
packages/integrations/src/openmediavault @hillaliy
# OPNsense - introduced in https://github.com/homarr-labs/homarr/pull/3424 by @Oupsman
apps/docs/docs/integrations/opnsense @Oupsman
packages/integrations/src/opnsense @Oupsman
# Overseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
apps/docs/docs/integrations/overseerr @manuel-rw
packages/integrations/src/overseerr @manuel-rw
# Paperless-ngx - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/paperless-ngx @ajnart
packages/integrations/src/paperless-ngx @ajnart
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
apps/docs/docs/integrations/peanut @afonsojramos
packages/integrations/src/peanut @afonsojramos
# Pi-hole - introduced in https://github.com/homarr-labs/homarr/pull/521 by @Meierschlumpf
apps/docs/docs/integrations/pi-hole @Meierschlumpf
packages/integrations/src/pi-hole @Meierschlumpf
# Plex - introduced in https://github.com/homarr-labs/homarr/pull/1342 by @hillaliy
apps/docs/docs/integrations/plex @hillaliy
packages/integrations/src/plex @hillaliy
# Prowlarr - introduced in https://github.com/homarr-labs/homarr/pull/965 by @hillaliy
apps/docs/docs/integrations/prowlarr @hillaliy
packages/integrations/src/prowlarr @hillaliy
# Proxmox - introduced in https://github.com/homarr-labs/homarr/pull/1969 by @Meierschlumpf
apps/docs/docs/integrations/proxmox @Meierschlumpf
packages/integrations/src/proxmox @Meierschlumpf
# Quay - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
apps/docs/docs/integrations/quay @Aandree5
packages/integrations/src/quay @Aandree5
# search.ch - introduced in https://github.com/homarr-labs/homarr/pull/5180 by @Meierschlumpf
apps/docs/docs/integrations/search-ch @Meierschlumpf
packages/integrations/src/search-ch @Meierschlumpf
# Speedtest Tracker - introduced in https://github.com/homarr-labs/homarr/pull/5363 by @kylegrieder
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
packages/integrations/src/speedtest-tracker @kylegrieder
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
apps/docs/docs/integrations/technitium-dns @lopsch
packages/integrations/src/technitium @lopsch
# Tracearr - introduced in https://github.com/homarr-labs/homarr/pull/5060 by @StrandedTurtle
apps/docs/docs/integrations/tracearr @StrandedTurtle
packages/integrations/src/tracearr @StrandedTurtle
# TrueNAS - introduced in https://github.com/homarr-labs/homarr/pull/3745 by @manuel-rw
apps/docs/docs/integrations/truenas @manuel-rw @afonsojramos
packages/integrations/src/truenas @manuel-rw @afonsojramos
# Umami - introduced in https://github.com/homarr-labs/homarr/pull/5362 by @febrile42
apps/docs/docs/integrations/umami @febrile42
packages/integrations/src/umami @febrile42
# UniFi Controller - introduced in https://github.com/homarr-labs/homarr/pull/2236 by @pitschi
apps/docs/docs/integrations/unifi-controller @pitschi
packages/integrations/src/unifi-controller @pitschi
# Unraid - introduced in https://github.com/homarr-labs/homarr/pull/4439 by @manuel-rw
apps/docs/docs/integrations/unraid @manuel-rw
packages/integrations/src/unraid @manuel-rw
# Uptime Kuma - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# Exempt Renovate-managed files (no owners)
# Exempt Renovate‑managed files (no owners)
package.json
**/package.json
package-lock.json
+1 -2
View File
@@ -1,4 +1,4 @@
FROM node:24.18.0-alpine AS base
FROM node:24.17.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -42,7 +42,6 @@ RUN mkdir -p /var/cache/nginx && \
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
@@ -1,35 +0,0 @@
---
title: "ArchiveTeam Warrior"
description: "ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { archiveTeamWarriorIntegration } from ".";
<IntegrationHeader integration={archiveTeamWarriorIntegration} categories={["archiving"]} />
The ArchiveTeam Warrior integration lets Homarr connect to your ArchiveTeam Warrior instance so you can manage and monitor your archiving tasks directly from your dashboard.
### Adding the integration
<AddingIntegration />
### Secrets
ArchiveTeam Warrior does not require authentication by default. If your instance is protected with basic authentication, you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [],
},
]}
/>
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const archiveTeamWarriorIntegration: IntegrationDefinition = {
name: "ArchiveTeam Warrior",
description:
"ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive.",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
path: "../../integrations/archiveteam-warrior",
};
@@ -1,54 +0,0 @@
---
title: 'Docker labels'
description: 'Configure Homarr apps automatically using Docker container labels during onboarding.'
hide_title: true
---
# Docker label discovery
Homarr can read `homarr.*` labels on your containers to pre-populate apps during onboarding. Homepage-style `homepage.*` labels are supported as a fallback when no `homarr.name` label is set.
## Required labels
| Label | Description |
| --- | --- |
| `homarr.name` | Display name for the app |
| `homarr.group` | Category section name on the board |
| `homarr.href` | URL opened when clicking the app |
## Optional labels
| Label | Description |
| --- | --- |
| `homarr.icon` | Icon URL |
| `homarr.description` | App description |
| `homarr.ping` | Ping URL for status checks |
| `homarr.id` | Stable external ID (defaults to container ID) |
| `homarr.board` | Target board name |
| `homarr.integration` | Integration kind (e.g. `sonarr`) for onboarding integration setup |
| `homarr.widget` | Widget kind hint (parsed, not auto-placed yet) |
| `homarr.hide` | Hide container from discovery |
## Homepage fallback
When `homarr.name` is absent, Homarr reads `homepage.name`, `homepage.group`, `homepage.href`, `homepage.icon`, and `homepage.description` if homepage label reading is enabled (default: on).
## Example
```yaml
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
labels:
homarr.name: "Sonarr"
homarr.group: "Media"
homarr.href: "http://sonarr:8989"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
homarr.integration: "sonarr"
```
## Onboarding behavior
During the integrations step, Homarr scans labeled containers first. Labeled services appear with their configured name, group, and URL. Unlabeled running containers still use image-based discovery as a fallback.
When you finish onboarding, selected label-based apps are synced into category sections on your board. Integration widgets are placed separately in the default section.
@@ -1,8 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const dockerLabelsIntegration: IntegrationDefinition = {
name: "Docker labels",
description: "Configure Homarr apps automatically using Docker container labels.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg",
path: "../../integrations/docker-labels",
};
+2 -4
View File
@@ -93,11 +93,9 @@ They are then available in the app list and can be used as normally added apps.
### Configuration
Homarr supports Docker labels for automatic app discovery during onboarding. See [Docker labels](../docker-labels/index.mdx) for the full reference.
Currently Homarr supports one label for configuration of the containers:
Currently Homarr supports one label for hiding containers:
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget, tools page, and label discovery.
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget and tools page.
### Security
Mounting docker sockets can be risky, as they permit full control over your docker service.
+1 -3
View File
@@ -63,9 +63,7 @@ import withoutIntegrations from './img/without-integrations.png';
### Interactions
- Hover over a marked date to preview its events in a popover.
- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
- Click the date again or click outside to close the pinned popover.
- View calendar events by clicking on the marked dates.
### Adding the widget
@@ -50,7 +50,7 @@ interface IntegrationDraft {
initialUrl: string;
initialName?: string;
dockerPort?: number | null;
source: "manual" | "docker" | "label";
source: "manual" | "docker";
}
export const InitIntegrations = () => {
@@ -65,7 +65,6 @@ export const InitIntegrations = () => {
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
const { mutateAsync: syncLabelApps } = clientApi.onboard.syncDockerLabelApps.useMutation();
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
undefined,
@@ -99,7 +98,7 @@ export const InitIntegrations = () => {
initialUrl: item.suggestedUrl,
initialName: item.containerName,
dockerPort: item.publishedPort,
source: item.source,
source: "docker",
});
}
newKinds.add(item.kind);
@@ -133,8 +132,7 @@ export const InitIntegrations = () => {
const newDrafts = new Map(drafts);
for (const kind of selectedKinds) {
const existing = newDrafts.get(kind);
const useUrlTemplate = baseHost && existing?.source !== "label";
if (useUrlTemplate) {
if (baseHost) {
newDrafts.set(kind, {
kind,
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
@@ -154,32 +152,10 @@ export const InitIntegrations = () => {
const finishSetupAsync = async () => {
setPhase("done");
try {
const selectedApps = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
const labelApps = selectedApps.filter((app) => app.source === "label");
const imageApps = selectedApps.filter((app) => app.source !== "label");
if (labelApps.length > 0) {
const labelAppsWithHost = labelApps.filter((app): app is typeof app & { host: string } => Boolean(app.host));
const missingHostCount = labelApps.length - labelAppsWithHost.length;
if (missingHostCount > 0) {
throw new Error("Some selected Docker label apps are missing host metadata");
}
if (labelAppsWithHost.length > 0) {
const syncResult = await syncLabelApps(
labelAppsWithHost.map((app) => ({
containerId: app.containerId,
host: app.host,
})),
);
if (syncResult.notFound > 0) {
throw new Error("Some selected Docker label apps were not found");
}
}
}
if (imageApps.length > 0) {
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
if (appsToCreate.length > 0) {
await createApps(
imageApps.map((app) => {
appsToCreate.map((app) => {
const href = baseHost
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
: app.suggestedUrl;
@@ -191,7 +167,6 @@ export const InitIntegrations = () => {
}),
);
}
await setupIntegrations();
router.refresh();
} catch {
@@ -361,16 +336,9 @@ export const InitIntegrations = () => {
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={500} lineClamp={1}>
{app.containerName}
</Text>
{app.group && (
<Text size="xs" c="dimmed" lineClamp={1}>
{app.group}
</Text>
)}
</Stack>
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
@@ -391,14 +359,8 @@ export const InitIntegrations = () => {
{t("action.skip")}
</Button>
<Button
onClick={() => {
if (selectedKinds.length === 0) {
void finishSetupAsync();
return;
}
handleContinueToConfig();
}}
disabled={(selectedKinds.length === 0 && selectedAppCount === 0) || undefined}
onClick={handleContinueToConfig}
disabled={selectedKinds.length === 0 || undefined}
rightSection={<IconArrowRight size={16} stroke={1.5} />}
suppressHydrationWarning
>
@@ -442,8 +404,7 @@ const ConfigurePhase = ({
const currentKind = selectedKinds[currentIndex]!;
const draft = drafts.get(currentKind);
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
const useUrlTemplate = baseHost && draft?.source !== "label";
const computedUrl = useUrlTemplate
const computedUrl = baseHost
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
: draft?.initialUrl;
@@ -10,7 +10,6 @@ import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
@@ -80,20 +79,17 @@ export default async function ApiPage() {
const toolGroups = getMcpToolGroups();
return (
<>
<DynamicBreadcrumb />
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
</>
<Stack>
<ApiPageTabs
documentationLabel={t("documentation.label")}
apiKeyLabel={t("apiKey.label")}
mcpLabel={t("mcp.label")}
documentationPanel={<ScalarApiReference document={document} />}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
baseUrl={baseUrl}
hasApiKeys={apiKeys.length > 0}
toolGroups={toolGroups}
/>
</Stack>
);
}
@@ -6,7 +6,6 @@ import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
@@ -32,13 +31,10 @@ export default async function BackupPage() {
const t = await getScopedI18n("management.page.tool.backup");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
</>
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
);
}
@@ -7,7 +7,6 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NavigationLink } from "./_navigation";
interface LayoutProps {
@@ -25,7 +24,6 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
return (
<ManageContainer size="xl">
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, group.name]])} />
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
@@ -1,5 +1,3 @@
import type { MutableRefObject } from "react";
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -18,7 +16,6 @@ import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
interface BoardItemContentProps {
item: SectionItem;
@@ -33,33 +30,30 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
return (
<>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
</WidgetContextMenu>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
</Card>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -89,7 +83,6 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
@@ -1,250 +0,0 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { Group, Menu, Switch } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
interface WidgetContextMenuProps {
item: SectionItem;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
children: ReactNode;
}
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const settings = useSettings();
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
options,
setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
const persistBoard = useCallback(
(updatedItems: typeof board.items) => {
saveBoard({ ...board, items: updatedItems });
},
[board, saveBoard],
);
const openEditModal = useCallback(() => {
openModal(
{
kind: item.kind,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
integrationIds: item.integrationIds,
},
onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
if (!isEditMode) {
persistBoard(
board.items.map((boardItem) =>
boardItem.id !== item.id
? boardItem
: {
...boardItem,
options: editResult.options,
advancedOptions: editResult.advancedOptions,
integrationIds: editResult.integrationIds,
},
),
);
}
},
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
);
}, [
openModal,
item,
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
integrationData,
currentDefinition,
settings,
isEditMode,
persistBoard,
board,
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
const newOptions = { ...options, [key]: checked };
updateItemOptions({ itemId: item.id, newOptions });
persistBoard(
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
);
},
[options, item.id, updateItemOptions, persistBoard, board],
);
if (!session) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return (
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
</>
)}
<>
{!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
disabled={isPending}
>
{tMenu("settings")}
</Menu.Item>
</>
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
);
};
@@ -10,8 +10,3 @@
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.itemCard:global(.iframe-wrapper) {
background-color: transparent !important;
border-color: transparent !important;
}
@@ -14,8 +14,6 @@ interface DynamicBreadcrumbProps {
nonInteractable?: string[];
}
const categorySegments = new Set(["tools"]);
/**
* Breadcrumb is client side rendered. Elements are automatically
* calculated and translated using dynamic keys.
@@ -62,11 +60,8 @@ export const DynamicBreadcrumb = ({
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
const mappedValue = dynamicMappings?.get(pathnamePart);
const isNonInteractable =
nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart);
if (mappedValue) {
if (isNonInteractable) {
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{mappedValue}</Text>;
}
return (
@@ -76,7 +71,7 @@ export const DynamicBreadcrumb = ({
);
}
if (isNonInteractable) {
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
}
+7 -15
View File
@@ -35,22 +35,14 @@ export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> =
type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
const workspaceConfigContent = await fsPromises.readFile(
path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"),
"utf-8",
);
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
try {
const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8");
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
return new Map(Object.entries(parseResult.catalog ?? {}));
} catch (error) {
// The workspace config is absent in Docker (only the runner stage is shipped),
// so a missing file is expected. Surface any other failure (e.g. malformed YAML).
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return new Map();
}
throw error;
}
return new Map(Object.entries(parseResult.catalog ?? {}));
};
const workspaceConfigCatalogSchema = z.object({
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.18.0-trixie AS base
FROM node:24.17.0-trixie AS base
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
+3 -3
View File
@@ -5,8 +5,8 @@
"better-sqlite3": "^12.10.0"
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"node": ">=24.17.0",
"pnpm": ">=11.8.0"
},
"packageManager": "pnpm@11.9.0"
"packageManager": "pnpm@11.8.0"
}
+3 -3
View File
@@ -61,10 +61,10 @@
"vitest": "catalog:"
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"node": ">=24.17.0",
"pnpm": ">=11.8.0"
},
"packageManager": "pnpm@11.9.0",
"packageManager": "pnpm@11.8.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
+39 -170
View File
@@ -3,13 +3,10 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, inArray, like, or } from "@homarr/db";
import { eq, like, or } from "@homarr/db";
import { placeAllWidgetsAsync } from "@homarr/db/queries";
import { getServerSettingsAsync } from "@homarr/db/queries";
import {
apps,
boards,
groups,
icons,
integrationItems,
@@ -36,8 +33,6 @@ import { MissingSecretError, testConnectionAsync } from "../integration/integrat
import { mapTestConnectionError } from "../integration/map-test-connection-error";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
const logger = createLogger({ module: "onboardDockerDiscovery" });
interface PortInfo {
IP?: string;
PublicPort?: number;
@@ -48,30 +43,6 @@ interface SuggestedUrlResult {
publishedPort: number | null;
}
type DockerDiscoverySource = "label" | "docker";
interface DiscoveredIntegration {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
source: DockerDiscoverySource;
group?: string;
}
interface DiscoveredApp {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
source: DockerDiscoverySource;
group?: string;
host?: string;
}
const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => {
const port = ports?.[0];
if (port?.PublicPort) {
@@ -174,91 +145,51 @@ export const onboardRouter = createTRPCRouter({
}),
discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
const emptyResult = {
integrations: [] as DiscoveredIntegration[],
apps: [] as DiscoveredApp[],
integrations: [] as {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}[],
apps: [] as {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}[],
};
try {
const { DockerSingleton, dockerLabels, listDiscoveredContainersAsync } = await import("@homarr/docker");
const serverSettings = await getServerSettingsAsync(ctx.db);
const labeledServices = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
const labeledContainerIds = new Set(labeledServices.map((service) => service.containerId));
const seenKinds = new Set<IntegrationKind>();
const discoveredIntegrations: DiscoveredIntegration[] = [];
const discoveredApps: DiscoveredApp[] = [];
for (const service of labeledServices) {
if (service.integrationKind && !seenKinds.has(service.integrationKind)) {
seenKinds.add(service.integrationKind);
discoveredIntegrations.push({
containerId: service.containerId,
containerName: service.name,
kind: service.integrationKind,
suggestedUrl: service.href,
publishedPort: null,
iconUrl: service.icon ?? null,
source: "label",
group: service.group,
});
continue;
}
discoveredApps.push({
containerId: service.containerId,
containerName: service.name,
suggestedUrl: service.href,
publishedPort: null,
iconUrl: service.icon ?? null,
source: "label",
group: service.group,
host: service.host,
});
}
const { DockerSingleton, dockerLabels } = await import("@homarr/docker");
const dockerInstances = DockerSingleton.getInstances();
if (dockerInstances.length === 0) {
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
return {
status: hasResults ? ("success" as const) : ("empty" as const),
integrations: discoveredIntegrations,
apps: discoveredApps,
};
return { status: "empty" as const, ...emptyResult };
}
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containerList = await instance.listContainers({ all: false });
return containerList
.filter((container) => !(dockerLabels.hide in container.Labels))
.filter((container) => !labeledContainerIds.has(container.Id))
.map((container) => ({ ...container, host }));
return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host }));
}),
);
const containers = results.flatMap((result, index) => {
if (result.status === "fulfilled") {
return result.value;
}
const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
logger.warn("Failed to list containers for onboarding discovery", {
host: dockerInstances[index]?.host,
cause: result.reason,
});
return [];
});
if (containers.length === 0 && discoveredIntegrations.length === 0 && discoveredApps.length === 0) {
if (containers.length === 0) {
return { status: "empty" as const, ...emptyResult };
}
const likeQueries = containers.map((container) =>
like(icons.name, `%${extractContainerImageName(container.Image)}%`),
);
const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`));
const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : [];
const seenKinds = new Set<IntegrationKind>();
const discoveredIntegrations = emptyResult.integrations;
const discoveredApps = emptyResult.apps;
const cdnIconUrl = (slug: string) =>
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
@@ -292,7 +223,6 @@ export const onboardRouter = createTRPCRouter({
suggestedUrl,
publishedPort,
iconUrl,
source: "docker",
});
} else if (!kind && dbIcon) {
discoveredApps.push({
@@ -301,61 +231,19 @@ export const onboardRouter = createTRPCRouter({
suggestedUrl,
publishedPort,
iconUrl,
source: "docker",
host: container.host,
});
}
}
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
return {
status: hasResults ? ("success" as const) : ("empty" as const),
status: "success" as const,
integrations: discoveredIntegrations,
apps: discoveredApps,
};
} catch (error) {
logger.warn("Docker discovery failed during onboarding", { cause: error });
} catch {
return { status: "unavailable" as const, ...emptyResult };
}
}),
syncDockerLabelApps: onboardingProcedure
.requiresStep("integrations")
.input(
z.array(
z.object({
containerId: z.string(),
host: z.string(),
}),
),
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) {
return { created: 0, updated: 0, skipped: 0, notFound: 0 };
}
const { listDiscoveredContainersAsync, syncDiscoveredServicesAsync } = await import("@homarr/docker");
const serverSettings = await getServerSettingsAsync(ctx.db);
const labeledServices = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
const selectedKeys = new Set(input.map((entry) => `${entry.host}/${entry.containerId}`));
const selectedServices = labeledServices.filter((service) =>
selectedKeys.has(`${service.host}/${service.containerId}`),
);
const syncResult = await syncDiscoveredServicesAsync(selectedServices, {
targetBoardName: serverSettings.docker.targetBoardName,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
defaultItemWidth: serverSettings.docker.defaultItemWidth,
defaultItemHeight: serverSettings.docker.defaultItemHeight,
});
return {
...syncResult,
notFound: input.length - selectedServices.length,
};
}),
createAppsFromDiscovery: onboardingProcedure
.requiresStep("integrations")
.input(
@@ -369,17 +257,9 @@ export const onboardRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) return;
const existingApps = await ctx.db.query.apps.findMany({ columns: { href: true } });
const existingHrefs = new Set(
existingApps.map((app) => app.href).filter((href): href is string => Boolean(href)),
);
const appsToCreate = input.filter((app) => !app.href || !existingHrefs.has(app.href));
if (appsToCreate.length === 0) return;
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
await ctx.db.insert(apps).values(
appsToCreate.map((app) => ({
input.map((app) => ({
id: createId(),
name: app.name,
iconUrl: app.iconUrl ?? defaultIcon,
@@ -390,26 +270,16 @@ export const onboardRouter = createTRPCRouter({
}),
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
const db = ctx.db;
const serverSettings = await getServerSettingsAsync(db);
const allIntegrations = await db.query.integrations.findMany();
const allApps = await db.query.apps.findMany();
const labelSyncedAppIds = new Set(
(await db.query.dockerAppSources.findMany({ columns: { appId: true } })).map((source) => source.appId),
);
const appsForWidgetPlacement = allApps.filter((app) => !labelSyncedAppIds.has(app.id));
const boardList = await db.query.boards.findMany({
orderBy: asc(boards.name),
const board = await db.query.boards.findFirst({
with: {
sections: true,
layouts: true,
},
});
const targetBoardName = serverSettings.docker.targetBoardName;
const board = targetBoardName
? (boardList.find((entry) => entry.name === targetBoardName) ?? boardList.at(0))
: boardList.at(0);
if (!board) {
await nextOnboardingStepAsync(db, undefined);
@@ -427,16 +297,15 @@ export const onboardRouter = createTRPCRouter({
const layout = board.layouts[0];
if (section && layout) {
const emptySectionLayouts = await db.query.itemLayouts.findMany({
where: eq(itemLayouts.sectionId, section.id),
columns: { itemId: true },
const existingItems = await db.query.items.findMany({
where: eq(items.boardId, board.id),
});
const emptySectionItemIds = emptySectionLayouts.map((entry) => entry.itemId);
if (emptySectionItemIds.length > 0) {
await db.delete(integrationItems).where(inArray(integrationItems.itemId, emptySectionItemIds));
await db.delete(itemLayouts).where(inArray(itemLayouts.itemId, emptySectionItemIds));
await db.delete(items).where(inArray(items.id, emptySectionItemIds));
for (const item of existingItems) {
await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id));
await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id));
}
if (existingItems.length > 0) {
await db.delete(items).where(eq(items.boardId, board.id));
}
await placeAllWidgetsAsync(
@@ -448,7 +317,7 @@ export const onboardRouter = createTRPCRouter({
columnCount: layout.columnCount,
},
allIntegrations,
appsForWidgetPlacement,
allApps,
);
}
@@ -1,17 +0,0 @@
CREATE TABLE `docker_app_source` (
`host` varchar(512) NOT NULL,
`container_id` varchar(128) NOT NULL,
`external_id` varchar(512) NOT NULL,
`app_id` varchar(64) NOT NULL,
`board_id` varchar(64) NOT NULL,
`item_id` varchar(64),
`integration_id` varchar(64),
`updated_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `docker_app_source_host_container_id_pk` PRIMARY KEY(`host`,`container_id`)
);
--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
@@ -295,13 +295,6 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1780764600000,
"tag": "0042_add_docker_app_source",
"breakpoints": true
}
]
}
@@ -1,17 +0,0 @@
CREATE TABLE "docker_app_source" (
"host" varchar(512) NOT NULL,
"container_id" varchar(128) NOT NULL,
"external_id" varchar(512) NOT NULL,
"app_id" varchar(64) NOT NULL,
"board_id" varchar(64) NOT NULL,
"item_id" varchar(64),
"integration_id" varchar(64),
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "docker_app_source_host_container_id_pk" PRIMARY KEY("host","container_id")
);
--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "docker_app_source__external_id_idx" ON "docker_app_source" USING btree ("host","external_id");
@@ -57,13 +57,6 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780764600000,
"tag": "0008_add_docker_app_source",
"breakpoints": true
}
]
}
@@ -1,17 +0,0 @@
CREATE TABLE `docker_app_source` (
`host` text NOT NULL,
`container_id` text NOT NULL,
`external_id` text NOT NULL,
`app_id` text NOT NULL,
`board_id` text NOT NULL,
`item_id` text,
`integration_id` text,
`updated_at` integer NOT NULL,
PRIMARY KEY(`host`, `container_id`),
FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
@@ -281,13 +281,6 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1780764600000,
"tag": "0040_add_docker_app_source",
"breakpoints": true
}
]
}
-1
View File
@@ -51,7 +51,6 @@ export const {
cronJobConfigurations,
customWidgetDefinitions,
customWidgetSecrets,
dockerAppSources,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
-46
View File
@@ -547,33 +547,6 @@ export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = mysqlTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -853,22 +826,3 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
-46
View File
@@ -546,33 +546,6 @@ export const cronJobConfigurations = pgTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = pgTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -852,22 +825,3 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
-46
View File
@@ -536,33 +536,6 @@ export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
isEnabled: int({ mode: "boolean" }).default(true).notNull(),
});
export const dockerAppSources = sqliteTable(
"docker_app_source",
{
host: text().notNull(),
containerId: text().notNull(),
externalId: text().notNull(),
appId: text()
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: text().references(() => items.id, { onDelete: "set null" }),
integrationId: text().references(() => integrations.id, { onDelete: "set null" }),
updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -842,22 +815,3 @@ export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one
references: [customWidgetDefinitions.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
@@ -165,7 +165,6 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/unraid"
| "/docs/integrations/adguard-home"
| "/docs/integrations/anchor"
| "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/aria2"
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/beszel"
+1 -1
View File
@@ -502,7 +502,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
defaultUrl: "http://localhost:8001",
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
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: {
+2 -6
View File
@@ -25,15 +25,11 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"dockerode": "catalog:",
"superjson": "catalog:"
"dockerode": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
"typescript": "catalog:"
}
}
@@ -1,472 +0,0 @@
import type { ContainerInfo } from "dockerode";
import { describe, expect, test } from "vitest";
import { dockerLabels, homepageLabels } from "../../labels";
import { parseContainerLabels } from "../parse-container-labels";
const createContainer = (
labels: Record<string, string>,
id = "container-abc123",
): Pick<ContainerInfo, "Id" | "Labels"> => ({
Id: id,
Labels: labels,
});
describe("parseContainerLabels", () => {
describe("homarr native labels", () => {
test("parses all homarr labels into DiscoveredService", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "https://jellyfin.local",
[dockerLabels.icon]: "https://cdn.example/icon.png",
[dockerLabels.description]: "Media server",
[dockerLabels.ping]: "https://jellyfin.local/health",
[dockerLabels.id]: "jellyfin-1",
[dockerLabels.board]: "homelab",
[dockerLabels.integration]: "jellyfin",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Media",
name: "Jellyfin",
href: "https://jellyfin.local",
icon: "https://cdn.example/icon.png",
description: "Media server",
pingUrl: "https://jellyfin.local/health",
externalId: "jellyfin-1",
boardName: "homelab",
integrationKind: "jellyfin",
widgetKind: "mediaServer",
});
});
test("parses minimal required homarr labels (name, group, href)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Networking",
[dockerLabels.name]: "Pi-hole",
[dockerLabels.href]: "http://pihole:80/admin",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Networking",
name: "Pi-hole",
href: "http://pihole:80/admin",
externalId: "container-abc123",
});
});
test("uses container ID as externalId when homarr.id is absent", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
},
"sha256:deadbeef1234",
),
"socket",
);
expect(result?.externalId).toBe("sha256:deadbeef1234");
});
test("uses homarr.id as externalId when present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
[dockerLabels.id]: "plex-server-main",
}),
"socket",
);
expect(result?.externalId).toBe("plex-server-main");
});
});
describe("homepage fallback labels", () => {
test("uses homepage labels when homarr.name is absent", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
[homepageLabels.icon]: "https://cdn.example/sonarr.png",
[homepageLabels.description]: "TV shows",
}),
"192.168.1.10:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "192.168.1.10:2375",
group: "Downloads",
name: "Sonarr",
href: "https://sonarr.local",
icon: "https://cdn.example/sonarr.png",
description: "TV shows",
externalId: "container-abc123",
});
});
test("does not use homepage fallback when readHomepageLabels is false", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
}),
"socket",
{ readHomepageLabels: false },
);
expect(result).toBeNull();
});
test("homepage labels have no ping, board, integration, or widget support", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Emby",
[homepageLabels.href]: "http://emby:8096",
}),
"socket",
);
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
});
});
describe("label priority and mixing", () => {
test("prefers homarr labels over homepage when both are present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Homarr Group",
[dockerLabels.name]: "Homarr Name",
[dockerLabels.href]: "https://homarr.local",
[homepageLabels.group]: "Homepage Group",
[homepageLabels.name]: "Homepage Name",
[homepageLabels.href]: "https://homepage.local",
}),
"socket",
);
expect(result?.group).toBe("Homarr Group");
expect(result?.name).toBe("Homarr Name");
expect(result?.href).toBe("https://homarr.local");
});
test("disables homepage fallback entirely when homarr.name is present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "MyApp",
[dockerLabels.href]: "http://myapp:3000",
[homepageLabels.group]: "FromHomepage",
}),
"socket",
);
expect(result).toBeNull();
});
test("uses homarr icon with homepage name/group/href when homarr.name absent but homarr.icon present", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Jellyfin",
[homepageLabels.href]: "http://jellyfin:8096",
[dockerLabels.icon]: "https://custom-icon.example/jf.png",
}),
"socket",
);
expect(result?.name).toBe("Jellyfin");
expect(result?.icon).toBe("https://custom-icon.example/jf.png");
});
});
describe("homarr.hide", () => {
test("skips container when homarr.hide is present with value 'true'", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "true",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is present with empty value", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is any string (key presence is enough)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "anything",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("missing required labels", () => {
test("returns null when group is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "NoGroup",
[dockerLabels.href]: "https://nogroup.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when name is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.href]: "https://noname.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when href is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "NoHref",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when homepage labels are incomplete (missing group)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.name]: "Incomplete",
[homepageLabels.href]: "http://incomplete.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("whitespace handling", () => {
test("trims whitespace from all label values", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: " Media ",
[dockerLabels.name]: " Jellyfin ",
[dockerLabels.href]: " https://jellyfin.local ",
[dockerLabels.icon]: " https://icon.png ",
[dockerLabels.description]: " A media server ",
}),
"socket",
);
expect(result?.group).toBe("Media");
expect(result?.name).toBe("Jellyfin");
expect(result?.href).toBe("https://jellyfin.local");
expect(result?.icon).toBe("https://icon.png");
expect(result?.description).toBe("A media server");
});
test("treats whitespace-only values as empty (missing)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: " ",
[dockerLabels.href]: "https://example.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("integration and widget kind validation", () => {
test("parses valid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Radarr",
[dockerLabels.href]: "http://radarr:7878",
[dockerLabels.integration]: "radarr",
}),
"socket",
);
expect(result?.integrationKind).toBe("radarr");
});
test("ignores invalid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom App",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.integration]: "nonExistentIntegration",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
test("parses valid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "http://jellyfin:8096",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result?.widgetKind).toBe("mediaServer");
});
test("ignores invalid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.widget]: "totallyFakeWidget",
}),
"socket",
);
expect(result?.widgetKind).toBeUndefined();
});
test("handles empty integration kind gracefully", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "App",
[dockerLabels.href]: "http://app:3000",
[dockerLabels.integration]: "",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
});
describe("real-world scenarios", () => {
test("homepage-only service (common migration use case)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Monitoring",
[homepageLabels.name]: "Grafana",
[homepageLabels.href]: "http://grafana:3000",
[homepageLabels.icon]: "grafana",
[homepageLabels.description]: "Metrics and dashboards",
}),
"tcp://192.168.1.50:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "tcp://192.168.1.50:2375",
group: "Monitoring",
name: "Grafana",
href: "http://grafana:3000",
icon: "grafana",
description: "Metrics and dashboards",
externalId: "container-abc123",
});
});
test("service targeting a specific board", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Infrastructure",
[dockerLabels.name]: "Traefik",
[dockerLabels.href]: "http://traefik:8080",
[dockerLabels.board]: "networking-board",
[dockerLabels.icon]: "https://cdn.example/traefik.svg",
}),
"socket",
);
expect(result?.boardName).toBe("networking-board");
});
test("service with all optional fields empty uses defaults", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Tools",
[dockerLabels.name]: "Portainer",
[dockerLabels.href]: "http://portainer:9000",
},
"abc123def456",
),
"socket",
);
expect(result?.icon).toBeUndefined();
expect(result?.description).toBeUndefined();
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
expect(result?.externalId).toBe("abc123def456");
});
test("container with no labels returns null", () => {
const result = parseContainerLabels(createContainer({}), "socket");
expect(result).toBeNull();
});
});
});
-8
View File
@@ -1,8 +0,0 @@
export type { DiscoveredService } from "./types";
export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
export { listDiscoveredContainersAsync } from "./list-discovered-containers";
export {
syncDiscoveredServicesAsync,
type SyncDiscoveredServicesOptions,
type SyncDiscoveredServicesResult,
} from "./sync-discovered-services";
@@ -1,45 +0,0 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { dockerLabels, homepageLabels } from "../labels";
import { DockerSingleton } from "../singleton";
import type { ParseContainerLabelsOptions } from "./parse-container-labels";
import { parseContainerLabels } from "./parse-container-labels";
import type { DiscoveredService } from "./types";
const logger = createLogger({ module: "dockerDiscovery" });
const hasDiscoveryLabels = (labels: Record<string, string>) =>
Object.values(dockerLabels).some((label) => label !== dockerLabels.hide && label in labels) ||
Object.values(homepageLabels).some((label) => label in labels);
export const listDiscoveredContainersAsync = async (
options: ParseContainerLabelsOptions = {},
): Promise<DiscoveredService[]> => {
const dockerInstances = DockerSingleton.getInstances();
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containers = await instance.listContainers({ all: true });
return containers
.filter((container) => hasDiscoveryLabels(container.Labels ?? {}))
.map((container) => parseContainerLabels(container, host, options))
.filter((service): service is DiscoveredService => service !== null);
}),
);
return results.flatMap((result, index) => {
if (result.status === "fulfilled") {
return result.value;
}
logger.warn(
new ErrorWithMetadata(
"Failed to list containers for discovery",
{ host: dockerInstances[index]?.host },
{ cause: result.reason },
),
);
return [];
});
};
@@ -1,116 +0,0 @@
import type { ContainerInfo } from "dockerode";
import { integrationKinds, widgetKinds } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { dockerLabels, homepageLabels } from "../labels";
import type { DiscoveredService } from "./types";
export interface ParseContainerLabelsOptions {
readHomepageLabels?: boolean;
}
const homarrLabelKeys = {
group: dockerLabels.group,
name: dockerLabels.name,
href: dockerLabels.href,
icon: dockerLabels.icon,
description: dockerLabels.description,
ping: dockerLabels.ping,
id: dockerLabels.id,
board: dockerLabels.board,
integration: dockerLabels.integration,
widget: dockerLabels.widget,
} as const;
const homepageLabelKeys = {
group: homepageLabels.group,
name: homepageLabels.name,
href: homepageLabels.href,
icon: homepageLabels.icon,
description: homepageLabels.description,
} as const;
const integrationKindSet = new Set<string>(integrationKinds);
const widgetKindSet = new Set<string>(widgetKinds);
const readLabel = (labels: Record<string, string>, key: string) => {
const value = labels[key]?.trim();
if (!value) {
return undefined;
}
return value;
};
const resolveLabelValue = (
labels: Record<string, string>,
homarrKey: string,
homepageKey: string,
useHomepageFallback: boolean,
) => readLabel(labels, homarrKey) ?? (useHomepageFallback ? readLabel(labels, homepageKey) : undefined);
export const parseContainerLabels = (
container: Pick<ContainerInfo, "Id" | "Labels">,
host: string,
options: ParseContainerLabelsOptions = {},
): DiscoveredService | null => {
const labels = container.Labels ?? {};
if (dockerLabels.hide in labels) {
return null;
}
const readHomepageLabels = options.readHomepageLabels ?? true;
const hasHomarrName = Boolean(readLabel(labels, dockerLabels.name));
const useHomepageFallback = readHomepageLabels && !hasHomarrName;
const name = resolveLabelValue(labels, homarrLabelKeys.name, homepageLabelKeys.name, useHomepageFallback);
const href = resolveLabelValue(labels, homarrLabelKeys.href, homepageLabelKeys.href, useHomepageFallback);
const group = resolveLabelValue(labels, homarrLabelKeys.group, homepageLabelKeys.group, useHomepageFallback);
if (!name || !href || !group) {
return null;
}
const icon =
readLabel(labels, homarrLabelKeys.icon) ??
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.icon) : undefined);
const description =
readLabel(labels, homarrLabelKeys.description) ??
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.description) : undefined);
const pingUrl = readLabel(labels, homarrLabelKeys.ping);
const boardName = readLabel(labels, homarrLabelKeys.board);
const externalId = readLabel(labels, homarrLabelKeys.id) ?? container.Id;
const integrationKind = parseIntegrationKind(readLabel(labels, homarrLabelKeys.integration));
const widgetKind = parseWidgetKind(readLabel(labels, homarrLabelKeys.widget));
return {
containerId: container.Id,
host,
group,
name,
href,
icon,
description,
pingUrl,
externalId,
boardName,
integrationKind,
widgetKind,
};
};
const parseIntegrationKind = (value: string | undefined): IntegrationKind | undefined => {
if (!value || !integrationKindSet.has(value)) {
return undefined;
}
return value as IntegrationKind;
};
const parseWidgetKind = (value: string | undefined): WidgetKind | undefined => {
if (!value || !widgetKindSet.has(value)) {
return undefined;
}
return value as WidgetKind;
};
@@ -1,471 +0,0 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { emptySuperJSON } from "@homarr/definitions";
import type { InferInsertModel, InferSelectModel } from "@homarr/db";
import { and, asc, db, eq, handleTransactionsAsync } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { apps, boards, dockerAppSources, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
import type { DiscoveredService } from "./types";
const logger = createLogger({ module: "dockerDiscoverySync" });
export interface SyncDiscoveredServicesOptions {
targetBoardName?: string | null;
enableStatusByDefault?: boolean;
forceDisableStatus?: boolean;
defaultItemWidth?: number;
defaultItemHeight?: number;
}
export interface SyncDiscoveredServicesResult {
created: number;
updated: number;
skipped: number;
}
interface SyncMetadata {
appId: string;
itemId?: string;
}
interface AppItemOptions {
appId: string;
openInNewTab: boolean;
showTitle: boolean;
descriptionDisplayMode: "hidden";
layout: "column";
pingEnabled: boolean;
}
type AppRow = InferSelectModel<typeof apps>;
type BoardWithRelations = NonNullable<Awaited<ReturnType<typeof loadBoardsWithRelationsAsync>>>[number];
type BoardItem = BoardWithRelations["items"][number];
const createSyncKey = (service: DiscoveredService) => `${service.host}/${service.containerId}`;
const appsMatchByHref = (app: Pick<AppRow, "href">, href: string) => app.href === href;
export const syncDiscoveredServicesAsync = async (
discoveredServices: DiscoveredService[],
options: SyncDiscoveredServicesOptions = {},
): Promise<SyncDiscoveredServicesResult> => {
const itemWidth = options.defaultItemWidth ?? 1;
const itemHeight = options.defaultItemHeight ?? 1;
const result: SyncDiscoveredServicesResult = { created: 0, updated: 0, skipped: 0 };
const syncMetadata = new Map<string, SyncMetadata>();
const existingApps = await db.query.apps.findMany();
const existingSources = await db.query.dockerAppSources.findMany();
const boardList = await loadBoardsWithRelationsAsync();
const appsToInsert: InferInsertModel<typeof apps>[] = [];
const appsToUpdate: { id: string; values: Partial<InferInsertModel<typeof apps>> }[] = [];
const sectionsToInsert: InferInsertModel<typeof sections>[] = [];
const itemsToInsert: InferInsertModel<typeof items>[] = [];
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
const pendingSectionsByBoard = new Map<string, InferInsertModel<typeof sections>[]>();
const sectionYOffsetByBoard = new Map<string, number>();
const itemYOffsetBySection = new Map<string, number>();
const pendingItemLayouts: InferInsertModel<typeof itemLayouts>[] = [];
for (const service of discoveredServices) {
const syncKey = createSyncKey(service);
if (syncMetadata.has(syncKey)) {
result.skipped += 1;
continue;
}
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
if (!board) {
logger.warn("No target board found for discovered service", { syncKey, boardName: service.boardName });
result.skipped += 1;
continue;
}
const firstLayout = getFirstLayout(board.layouts);
if (!firstLayout) {
logger.warn("Board has no layouts for discovered service", { boardId: board.id, syncKey });
result.skipped += 1;
continue;
}
const existingSource = existingSources.find(
(source) => source.host === service.host && source.externalId === service.externalId,
);
const existingApp = existingSource
? existingApps.find((app) => app.id === existingSource.appId)
: existingApps.find((app) => appsMatchByHref(app, service.href));
let appId: string;
let wasCreated = false;
let wasUpdated = false;
if (existingApp) {
appId = existingApp.id;
const updateValues = buildAppUpdateValues(service, existingApp);
if (updateValues) {
appsToUpdate.push({ id: appId, values: updateValues });
wasUpdated = true;
Object.assign(existingApp, updateValues);
}
} else {
appId = createId();
const newApp = buildAppInsert(service, appId);
appsToInsert.push(newApp);
existingApps.push(newApp as AppRow);
wasCreated = true;
}
const categorySection = findOrCreateCategorySection(
board,
service.group,
sectionsToInsert,
pendingSectionsByBoard,
sectionYOffsetByBoard,
);
const existingItem = findAppItemForApp(board.items, itemsToInsert, pendingItemLayouts, appId, categorySection.id);
if (!existingItem) {
const newItemId = createId();
const newItemLayouts = board.layouts.map((layout) => ({
itemId: newItemId,
sectionId: categorySection.id,
layoutId: layout.id,
xOffset: 0,
yOffset: getNextItemYOffset(
board,
categorySection.id,
layout.id,
itemYOffsetBySection,
pendingItemLayouts,
itemHeight,
),
width: itemWidth,
height: itemHeight,
}));
itemsToInsert.push({
id: newItemId,
boardId: board.id,
kind: "app",
options: SuperJSON.stringify(buildAppItemOptions(appId, service, options)),
advancedOptions: SuperJSON.stringify({
title: null,
customCssClasses: [],
borderColor: "",
}),
});
itemLayoutsToInsert.push(...newItemLayouts);
pendingItemLayouts.push(...newItemLayouts);
syncMetadata.set(syncKey, { appId, itemId: newItemId });
wasCreated = true;
} else {
syncMetadata.set(syncKey, { appId, itemId: existingItem.id });
}
if (wasCreated) {
result.created += 1;
} else if (wasUpdated) {
result.updated += 1;
} else {
result.skipped += 1;
}
}
const sourcesToUpsert: InferInsertModel<typeof dockerAppSources>[] = [];
for (const [syncKey, meta] of syncMetadata.entries()) {
const service = discoveredServices.find((svc) => createSyncKey(svc) === syncKey);
if (!service) continue;
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
if (!board) continue;
sourcesToUpsert.push({
host: service.host,
containerId: service.containerId,
externalId: service.externalId,
appId: meta.appId,
boardId: board.id,
itemId: meta.itemId ?? null,
});
}
if (
appsToInsert.length === 0 &&
appsToUpdate.length === 0 &&
sectionsToInsert.length === 0 &&
itemsToInsert.length === 0 &&
sourcesToUpsert.length === 0
) {
return result;
}
const insertCollection = createDbInsertCollectionForTransaction(["apps", "sections", "items", "itemLayouts"]);
insertCollection.apps.push(...appsToInsert);
insertCollection.sections.push(...sectionsToInsert);
insertCollection.items.push(...itemsToInsert);
insertCollection.itemLayouts.push(...itemLayoutsToInsert);
await handleTransactionsAsync(db, {
async handleAsync(innerDb) {
for (const { id, values } of appsToUpdate) {
await innerDb
.update(apps as never)
.set(values)
.where(eq(apps.id, id));
}
await insertCollection.insertAllAsync(innerDb);
const table = dockerAppSources as never;
for (const source of sourcesToUpsert) {
const existing = await innerDb.query.dockerAppSources.findFirst({
where: and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)),
});
if (existing) {
await innerDb
.update(table)
.set({
appId: source.appId,
boardId: source.boardId,
itemId: source.itemId,
externalId: source.externalId,
updatedAt: new Date(),
})
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)));
} else {
await innerDb.insert(table).values(source as never);
}
}
},
handleSync(innerDb) {
for (const { id, values } of appsToUpdate) {
innerDb
.update(apps as never)
.set(values)
.where(eq(apps.id, id))
.run();
}
insertCollection.insertAll(innerDb);
for (const source of sourcesToUpsert) {
try {
innerDb.insert(dockerAppSources).values(source).run();
} catch {
innerDb
.update(dockerAppSources)
.set({
appId: source.appId,
boardId: source.boardId,
itemId: source.itemId,
externalId: source.externalId,
updatedAt: new Date(),
})
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)))
.run();
}
}
},
});
return result;
};
const loadBoardsWithRelationsAsync = async () =>
db.query.boards.findMany({
orderBy: asc(boards.name),
with: {
layouts: true,
sections: true,
items: {
with: {
layouts: true,
},
},
},
});
const resolveTargetBoard = (
boardList: BoardWithRelations[],
service: DiscoveredService,
defaultBoardName: string | null | undefined,
) => {
const boardName = service.boardName ?? defaultBoardName ?? undefined;
if (boardName) {
return boardList.find((board) => board.name === boardName) ?? boardList.at(0);
}
return boardList.at(0);
};
const getFirstLayout = (boardLayouts: InferSelectModel<typeof layouts>[]) =>
[...boardLayouts].sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint).at(0);
const buildAppInsert = (service: DiscoveredService, appId: string): InferInsertModel<typeof apps> => ({
id: appId,
name: service.name,
description: service.description ?? null,
iconUrl: service.icon ?? "",
href: service.href,
pingUrl: service.pingUrl ?? null,
});
const buildAppUpdateValues = (
service: DiscoveredService,
existingApp: AppRow,
): Partial<InferInsertModel<typeof apps>> | null => {
const values: Partial<InferInsertModel<typeof apps>> = {};
if (existingApp.name !== service.name) {
values.name = service.name;
}
if (existingApp.href !== service.href) {
values.href = service.href;
}
if (service.icon !== undefined && existingApp.iconUrl !== service.icon) {
values.iconUrl = service.icon;
}
if (service.description !== undefined && existingApp.description !== service.description) {
values.description = service.description;
}
if (service.pingUrl !== undefined && existingApp.pingUrl !== service.pingUrl) {
values.pingUrl = service.pingUrl;
}
return Object.keys(values).length > 0 ? values : null;
};
const buildAppItemOptions = (
appId: string,
service: DiscoveredService,
options: SyncDiscoveredServicesOptions,
): AppItemOptions => ({
appId,
openInNewTab: true,
showTitle: true,
descriptionDisplayMode: "hidden",
layout: "column",
pingEnabled:
!(options.forceDisableStatus ?? false) && (Boolean(service.pingUrl) || (options.enableStatusByDefault ?? true)),
});
const findOrCreateCategorySection = (
board: BoardWithRelations,
groupName: string,
sectionsToInsert: InferInsertModel<typeof sections>[],
pendingSectionsByBoard: Map<string, InferInsertModel<typeof sections>[]>,
sectionYOffsetByBoard: Map<string, number>,
): InferInsertModel<typeof sections> & { id: string } => {
const pendingForBoard = pendingSectionsByBoard.get(board.id) ?? [];
const pendingMatch = pendingForBoard.find((section) => section.kind === "category" && section.name === groupName);
if (pendingMatch) {
return pendingMatch as InferInsertModel<typeof sections> & { id: string };
}
const existingSection = board.sections.find((section) => section.kind === "category" && section.name === groupName);
if (existingSection) {
return existingSection;
}
const categorySections = board.sections.filter((section) => section.kind === "category");
const pendingCategorySections = pendingForBoard.filter((section) => section.kind === "category");
const maxYOffset = Math.max(
0,
...categorySections.map((section) => section.yOffset ?? 0),
...pendingCategorySections.map((section) => section.yOffset ?? 0),
sectionYOffsetByBoard.get(board.id) ?? 0,
);
const yOffset = maxYOffset + 1;
sectionYOffsetByBoard.set(board.id, yOffset);
const newSection: InferInsertModel<typeof sections> = {
id: createId(),
boardId: board.id,
kind: "category",
xOffset: 0,
yOffset,
name: groupName,
options: emptySuperJSON,
};
sectionsToInsert.push(newSection);
board.sections.push(newSection as BoardWithRelations["sections"][number]);
pendingSectionsByBoard.set(board.id, [...pendingForBoard, newSection]);
return newSection as InferInsertModel<typeof sections> & { id: string };
};
const findAppItemForApp = (
boardItems: BoardItem[],
pendingItems: InferInsertModel<typeof items>[],
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
appId: string,
sectionId: string,
) => {
const dbMatch = boardItems.find((item) => itemMatchesAppInSection(item, appId, sectionId));
if (dbMatch) {
return dbMatch;
}
const pendingMatch = pendingItems.find((item) => {
if (item.kind !== "app") {
return false;
}
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options ?? emptySuperJSON);
if (itemOptions.appId !== appId) {
return false;
}
return pendingLayouts.some((layout) => layout.itemId === item.id && layout.sectionId === sectionId);
});
return pendingMatch ? { id: pendingMatch.id } : undefined;
};
const itemMatchesAppInSection = (item: BoardItem, appId: string, sectionId: string) => {
if (item.kind !== "app") {
return false;
}
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options);
if (itemOptions.appId !== appId) {
return false;
}
return item.layouts.some((layout) => layout.sectionId === sectionId);
};
const getNextItemYOffset = (
board: BoardWithRelations,
sectionId: string,
layoutId: string,
itemYOffsetBySection: Map<string, number>,
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
itemHeight: number,
) => {
const sectionKey = `${board.id}/${sectionId}/${layoutId}`;
const trackedYOffset = itemYOffsetBySection.get(sectionKey);
if (trackedYOffset !== undefined) {
const nextYOffset = trackedYOffset + itemHeight;
itemYOffsetBySection.set(sectionKey, nextYOffset);
return trackedYOffset;
}
const dbLayouts = board.items.flatMap((item) =>
item.layouts.filter((layout) => layout.sectionId === sectionId && layout.layoutId === layoutId),
);
const pendingSectionLayouts = pendingLayouts.filter(
(layout) => layout.sectionId === sectionId && layout.layoutId === layoutId,
);
const maxYOffset = [...dbLayouts, ...pendingSectionLayouts].reduce(
(max, layout) => Math.max(max, layout.yOffset + layout.height),
0,
);
itemYOffsetBySection.set(sectionKey, maxYOffset + itemHeight);
return maxYOffset;
};
-16
View File
@@ -1,16 +0,0 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
export interface DiscoveredService {
containerId: string;
host: string;
group: string;
name: string;
href: string;
icon?: string;
description?: string;
pingUrl?: string;
externalId: string;
boardName?: string;
integrationKind?: IntegrationKind;
widgetKind?: WidgetKind;
}
-1
View File
@@ -9,4 +9,3 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
export type ContainerState = (typeof containerStates)[number];
export * from "./labels";
export * from "./discovery";
+1 -18
View File
@@ -1,21 +1,4 @@
export const dockerLabels = {
// Label to hide a container from Homarrs docker integration
hide: "homarr.hide",
group: "homarr.group",
name: "homarr.name",
href: "homarr.href",
icon: "homarr.icon",
description: "homarr.description",
ping: "homarr.ping",
id: "homarr.id",
board: "homarr.board",
integration: "homarr.integration",
widget: "homarr.widget",
} as const;
export const homepageLabels = {
group: "homepage.group",
name: "homepage.name",
href: "homepage.href",
icon: "homepage.icon",
description: "homepage.description",
} as const;
@@ -35,7 +35,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
if (session == null) throw new Error("Session was unexpectitly null");
const [stats, cpuTemp] = await Promise.all([this.getAllStatsAsync(), this.getCpuTempAsync()]);
const stats = await this.getAllStatsAsync();
return {
cpuUtilization: stats.cpu.total,
@@ -58,7 +58,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
cpuModelName: stats.quicklook?.cpu_name ?? "Unknown",
loadAverage: null,
smart: [],
cpuTemp,
cpuTemp: undefined,
gpu: stats.gpu.map((gpu) => ({
gpuId: gpu.gpu_id,
name: gpu.name,
@@ -80,23 +80,6 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
return await response.text();
}
private async getCpuTempAsync(): Promise<number | undefined> {
// CPU temperature is optional data, so a transport error or payload drift on
// /api/4/sensors must not fail the whole getSystemInfoAsync() call.
try {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/sensors"));
if (!response.ok) {
return undefined;
}
const sensors = await sensorsSchema.parseAsync(await response.json());
return parseGlancesCpuTempFromSensors(sensors);
} catch {
return undefined;
}
}
private async getAllStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all"));
@@ -190,33 +173,3 @@ const allSchema = z.object({
)
.default([]),
});
const sensorsSchema = z.array(
z.object({
label: z.string(),
value: z.number(),
type: z.string().optional(),
}),
);
const cpuTempLabelPriority = ["CPU", "Package id 0"] as const;
export const parseGlancesCpuTempFromSensors = (sensors: z.infer<typeof sensorsSchema>): number | undefined => {
const temperatureSensors = sensors.filter(
(sensor) => sensor.type === "temperature_core" || sensor.type === "temperature",
);
for (const label of cpuTempLabelPriority) {
const match = temperatureSensors.find((sensor) => sensor.label === label);
if (match) {
return match.value;
}
}
const firstCore = temperatureSensors[0];
if (firstCore) {
return firstCore.value;
}
return undefined;
};
@@ -69,24 +69,36 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
const links: CalendarLink[] = [];
// titleSlug is only present when Lidarr returns it; guard so a missing value
// neither breaks the link nor drops the event.
if (event.artist.titleSlug) {
links.push({
href: this.externalUrl(`/artist/${event.artist.titleSlug}`).toString(),
name: "Lidarr",
logo: "/images/apps/lidarr.svg",
color: undefined,
isDark: true,
});
}
for (const link of event.artist.links) {
const externalLink = lidarrExternalCalendarLinks[link.name];
if (!externalLink) {
continue;
switch (link.name) {
case "vgmdb":
links.push({
href: link.url,
name: "VgmDB",
color: "#f5c518",
isDark: false,
logo: "/images/apps/vgmdb.svg",
});
break;
case "imdb":
links.push({
href: link.url,
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.png",
});
break;
case "last":
links.push({
href: link.url,
name: "LastFM",
color: "#cf222a",
isDark: false,
logo: "/images/apps/lastfm.svg",
});
break;
}
links.push({ ...externalLink, href: link.url });
}
return links;
@@ -97,7 +109,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
): z.infer<typeof lidarrCalendarEventSchema>["images"][number] | undefined => {
const flatImages = [...event.images];
const sortedImages = flatImages.toSorted(
const sortedImages = flatImages.sort(
(imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
);
@@ -137,16 +149,6 @@ const lidarrCalendarEventSchema = z.object({
title: z.string(),
overview: z.string().optional(),
images: lidarrCalendarEventImageSchema,
artist: z.object({
links: z.array(z.object({ url: z.string().url(), name: z.string() })),
artistName: z.string(),
titleSlug: z.string().optional(),
}),
artist: z.object({ links: z.array(z.object({ url: z.string().url(), name: z.string() })), artistName: z.string() }),
releaseDate: z.string().transform((value) => new Date(value)),
});
const lidarrExternalCalendarLinks: Record<string, Omit<CalendarLink, "href">> = {
vgmdb: { name: "VgmDB", color: "#f5c518", isDark: false, logo: "/images/apps/vgmdb.svg" },
imdb: { name: "IMDb", color: "#f5c518", isDark: false, logo: "/images/apps/imdb.png" },
last: { name: "LastFM", color: "#cf222a", isDark: false, logo: "/images/apps/lastfm.svg" },
};
@@ -1,11 +1,11 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { GlancesIntegration, parseGlancesCpuTempFromSensors } from "../src/glances/glances-integration";
import { GlancesIntegration } from "../src/glances/glances-integration";
// ─── module mocks ────────────────────────────────────────────────────────────
@@ -64,19 +64,6 @@ function makeResponse(body: unknown) {
} as never;
}
function mockGlancesFetch(statsBody: unknown, sensorsBody: unknown) {
mockFetch.mockImplementation(((url: string | URL) => {
const urlString = url.toString();
if (urlString.endsWith("/api/4/all")) {
return Promise.resolve(makeResponse(statsBody));
}
if (urlString.endsWith("/api/4/sensors")) {
return Promise.resolve(makeResponse(sensorsBody));
}
return Promise.reject(new Error(`Unexpected fetch URL: ${urlString}`));
}) as typeof fetchWithTrustedCertificatesAsync);
}
const integrationInput = {
id: "test-glances",
name: "Glances",
@@ -88,12 +75,8 @@ const integrationInput = {
// ─── tests ───────────────────────────────────────────────────────────────────
describe("GlancesIntegration schema", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("parses stats when quicklook is present", async () => {
mockGlancesFetch({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }, []);
mockFetch.mockResolvedValueOnce(makeResponse({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }));
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -103,7 +86,7 @@ describe("GlancesIntegration schema", () => {
});
test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => {
mockGlancesFetch(baseStats, []);
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
@@ -112,41 +95,9 @@ describe("GlancesIntegration schema", () => {
});
test("does not throw a Zod parse error when quicklook is missing", async () => {
mockGlancesFetch(baseStats, []);
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
const integration = new GlancesIntegration(integrationInput);
await expect(integration.getSystemInfoAsync()).resolves.not.toThrow();
});
test("reads CPU temperature from sensors", async () => {
mockGlancesFetch(baseStats, [
{ label: "CPU", unit: "C", value: 58, type: "temperature_core" },
{ label: "Core 0", unit: "C", value: 54, type: "temperature_core" },
]);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
expect(result.cpuTemp).toBe(58);
});
});
describe("parseGlancesCpuTempFromSensors", () => {
test("prefers CPU label over core sensors", () => {
const result = parseGlancesCpuTempFromSensors([
{ label: "Core 0", value: 54, type: "temperature_core" },
{ label: "CPU", value: 58, type: "temperature_core" },
]);
expect(result).toBe(58);
});
test("falls back to Package id 0", () => {
const result = parseGlancesCpuTempFromSensors([
{ label: "Core 0", value: 54, type: "temperature_core" },
{ label: "Package id 0", value: 62, type: "temperature_core" },
]);
expect(result).toBe(62);
});
});
@@ -1,76 +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 { LidarrIntegration } from "../src/media-organizer/lidarr/lidarr-integration";
vi.mock("@homarr/db", async (importActual) => {
const actual = await importActual<typeof import("@homarr/db")>();
return { ...actual, db: createDb() };
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
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(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integrationInput = {
id: "test-lidarr",
name: "Lidarr",
url: "http://localhost:8686",
decryptedSecrets: [{ kind: "apiKey" as const, value: "test-key" }],
externalUrl: null,
};
describe("LidarrIntegration calendar", () => {
test("includes Lidarr artist link in calendar events", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{
title: "Test Album",
overview: "Overview",
images: [],
artist: {
artistName: "Test Artist",
titleSlug: "test-artist",
links: [{ name: "last", url: "https://last.fm/music/Test+Artist" }],
},
releaseDate: "2026-01-01T00:00:00Z",
},
]),
} as never);
const integration = new LidarrIntegration(integrationInput);
const events = await integration.getCalendarEventsAsync(new Date("2026-01-01"), new Date("2026-01-31"));
expect(events[0]?.links[0]).toMatchObject({
name: "Lidarr",
href: "http://localhost:8686/artist/test-artist",
logo: "/images/apps/lidarr.svg",
});
// The existing external artist link must survive the Lidarr-link prepend.
expect(events[0]?.links).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "LastFM",
href: "https://last.fm/music/Test+Artist",
logo: "/images/apps/lastfm.svg",
}),
]),
);
});
});
-9
View File
@@ -5,7 +5,6 @@ export const defaultServerSettingsKeys = [
"analytics",
"crawlingAndIndexing",
"board",
"docker",
"user",
"appearance",
"culture",
@@ -31,14 +30,6 @@ export const defaultServerSettings = {
enableStatusByDefault: true,
forceDisableStatus: false,
},
docker: {
targetBoardName: null as string | null,
readHomepageLabels: true,
pruneRemoved: false,
createIntegrations: true,
defaultItemWidth: 1,
defaultItemHeight: 1,
},
user: {
enableGravatar: true,
},
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "",
"logout": "",
"login": "",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "这里",
"iconPicker": {
"label": "图标 URL",
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。"
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "切换到暗黑模式",
"switchToLightMode": "切换到日间模式",
"management": "管理中心",
"docker": "",
"preferences": "偏好设置",
"logout": "退出登录",
"login": "登录",
@@ -2357,9 +2352,6 @@
"name": "Docker 状态",
"description": "您的容器状态 (这个小部件只能用管理员权限添加)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "启用项目排序"
},
@@ -2413,12 +2405,6 @@
"title": "实例"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "未找到",
"text": "找不到请求的资源"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "首页",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "Umami 活跃访客"
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "获取 Docker 容器失败",
"noColumns": ""
"internalServerError": "获取 Docker 容器失败"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "crwdns4473:0crwdne4473:0",
"speedtest": "crwdns4475:0crwdne4475:0",
"analytics": "crwdns4477:0crwdne4477:0",
"vpn": "crwdns5687:0crwdne5687:0",
"mediaLibrary": "crwdns4979:0crwdne4979:0",
"uptimeMonitoring": "crwdns4981:0crwdne4981:0"
},
@@ -1413,10 +1412,7 @@
"here": "crwdns552:0crwdne552:0",
"iconPicker": {
"label": "crwdns554:0crwdne554:0",
"header": "crwdns556:0{countIcons}crwdne556:0",
"headerLoading": "crwdns5681:0crwdne5681:0",
"noResults": "crwdns5683:0{search}crwdne5683:0",
"error": "crwdns5685:0crwdne5685:0"
"header": "crwdns556:0{countIcons}crwdne556:0"
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "crwdns594:0crwdne594:0",
"switchToLightMode": "crwdns596:0crwdne596:0",
"management": "crwdns598:0crwdne598:0",
"docker": "crwdns5703:0crwdne5703:0",
"preferences": "crwdns600:0crwdne600:0",
"logout": "crwdns602:0crwdne602:0",
"login": "crwdns604:0crwdne604:0",
@@ -2357,9 +2352,6 @@
"name": "crwdns3424:0crwdne3424:0",
"description": "crwdns3426:0crwdne3426:0",
"option": {
"columns": {
"label": "crwdns5705:0crwdne5705:0"
},
"enableRowSorting": {
"label": "crwdns3808:0crwdne3808:0"
},
@@ -2413,12 +2405,6 @@
"title": "crwdns3876:0crwdne3876:0"
}
},
"vpn": {
"name": "crwdns5689:0crwdne5689:0",
"description": "crwdns5691:0crwdne5691:0",
"serviceUnavailable": "crwdns5693:0crwdne5693:0",
"option": {}
},
"ups": {
"name": "crwdns4481:0crwdne4481:0",
"description": "crwdns4483:0crwdne4483:0",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": "crwdns5653:0crwdne5653:0"
},
"empty": {
"noSystems": "crwdns5677:0crwdne5677:0"
},
"metric": {
"cpu": "crwdns4865:0crwdne4865:0",
"memory": "crwdns4867:0crwdne4867:0",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "crwdns4927:0crwdne4927:0",
"empty": {
"noSystems": "crwdns5679:0crwdne5679:0"
},
"error": {
"internalServerError": "crwdns5661:0crwdne5661:0",
"liveConnectionFailed": "crwdns5663:0crwdne5663:0",
@@ -4153,11 +4133,6 @@
"title": "crwdns1568:0crwdne1568:0",
"text": "crwdns1570:0crwdne1570:0"
},
"error": {
"title": "crwdns5697:0crwdne5697:0",
"text": "crwdns5699:0crwdne5699:0",
"digest": "crwdns5701:0{digest}crwdne5701:0"
},
"navbar": {
"items": {
"home": "crwdns1572:0crwdne1572:0",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "crwdns4223:0crwdne4223:0"
},
"vpn": {
"label": "crwdns5695:0crwdne5695:0"
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "crwdns2262:0crwdne2262:0",
"noColumns": "crwdns5707:0crwdne5707:0"
"internalServerError": "crwdns2262:0crwdne2262:0"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "zde",
"iconPicker": {
"label": "URL ikony",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Přepnout do tmavého režimu",
"switchToLightMode": "Přepnout do světlého režimu",
"management": "Správa",
"docker": "",
"preferences": "Vaše předvolby",
"logout": "Odhlásit se",
"login": "Přihlásit se",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": "Nepodařilo se nalézt požadovaný zdroj"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Domovská stránka",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "her",
"iconPicker": {
"label": "Ikon-URL",
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Skift til mørk tilstand",
"switchToLightMode": "Skift til lys tilstand",
"management": "Håndtering",
"docker": "",
"preferences": "Dine indstillinger",
"logout": "Log ud",
"login": "Log ind",
@@ -2357,9 +2352,6 @@
"name": "Docker statistik",
"description": "Statistik for dine containere (Denne widget kan kun tilføjes med administratorrettigheder)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Aktiver sortering"
},
@@ -2413,12 +2405,6 @@
"title": "Instanser"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Ikke Fundet",
"text": "Kunne ikke finde den anmodede ressource"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Hjem",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Kunne ikke hente Docker-containere",
"noColumns": ""
"internalServerError": "Kunne ikke hente Docker-containere"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "Hier",
"iconPicker": {
"label": "",
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Zum dunklen Design wechseln",
"switchToLightMode": "Zum hellen Design wechseln",
"management": "Verwaltung",
"docker": "",
"preferences": "Ihre Einstellungen",
"logout": "Abmelden",
"login": "Anmelden",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Nicht gefunden",
"text": "Konnte die angeforderte Ressource nicht finden"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Startseite",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Fehler beim Abrufen der Docker Container",
"noColumns": ""
"internalServerError": "Fehler beim Abrufen der Docker Container"
}
},
"kubernetes": {
+73 -102
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "Überwachung",
"speedtest": "Geschwindigkeitstest",
"analytics": "Nutzungsdaten",
"vpn": "",
"mediaLibrary": "Medienbibliothek",
"uptimeMonitoring": "Überwachung der Betriebszeit"
},
@@ -1413,10 +1412,7 @@
"here": "Hier",
"iconPicker": {
"label": "Icon URL",
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.",
"headerLoading": "Tippe um die Symbole zu durchsuchen…",
"noResults": "Keine Symbole stimmen mit \"{search}\" überein.",
"error": "Konnte Symbole nicht laden."
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Zum dunklen Design wechseln",
"switchToLightMode": "Zum hellen Design wechseln",
"management": "Verwaltung",
"docker": "",
"preferences": "Ihre Einstellungen",
"logout": "Abmelden",
"login": "Anmelden",
@@ -2357,9 +2352,6 @@
"name": "Docker Statistiken",
"description": "Statistiken Ihrer Container (Dieses Widget kann nur mit Administratorrechten hinzugefügt werden)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Sortierung von Elementen aktivieren"
},
@@ -2413,12 +2405,6 @@
"title": "Instanzen"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "UPS",
"description": "Überwachen Sie den Status Ihrer UPS Geräte über einen NUT Server",
@@ -3409,53 +3395,53 @@
},
"showNowPlaying": {
"label": "\"Sie hören\" anzeigen",
"description": "Zeige aktuell gespielte Titel an (Navidrome)"
"description": ""
},
"showNowPlayingList": {
"label": "\"Sie hören jetzt\" Wiedergabeliste anzeigen",
"description": "Einzelne Tracks anstelle der aktiven Streamanzahl anzeigen (Navidrome)"
"label": "",
"description": ""
},
"maxNowPlayingItems": {
"label": "Maximale jetzt abgespielte Elemente",
"description": "Maximale Anzahl an Titeln, die in der \"Sie hören jetzt\" Wiedergabeliste angezeigt werden (Navidrome)"
"label": "",
"description": ""
},
"showLibraryCount": {
"label": "Anzahl der Bibliotheken anzeigen",
"description": "Zeige die Anzahl der Bibliotheken in deiner Instanz (Hörbücherregal)"
"label": "",
"description": ""
},
"showAudiobooks": {
"label": "Hörbücher anzeigen",
"description": "Zeige die Gesamtzahl der Hörbücher in allen Bibliotheken (Hörbücherregal)"
"label": "",
"description": ""
},
"showPodcasts": {
"label": "Podcasts anzeigen",
"description": "Zeige die Gesamtzahl der Podcasts in allen Bibliotheken (Hörbücherregal)"
"label": "",
"description": ""
},
"showListeningTime": {
"label": "Hördauer anzeigen",
"label": "",
"description": "Zeigt deine gesamte Hörzeit (Audiobookshelf)"
},
"showActiveSessions": {
"label": "Aktive Streams anzeigen",
"description": "Zeige die Anzahl der aktuell aktiven Wiedergabesitzungen (Hörbücherregal)"
"description": ""
},
"compactMode": {
"label": "Kompakter Modus",
"description": "Verwenden Sie ein dichteres Layout mit kleineren Kacheln, ideal für kleine Widget-Größen"
"label": "",
"description": ""
}
},
"artists": "Künstler",
"albums": "Alben",
"songs": "Musik",
"libraryCount": "Bibliotheken",
"audiobooks": "Hörbücher",
"podcasts": "Podcasts",
"listeningTime": "Hördauer",
"activeSessions": "Aktive Sitzungen",
"nowPlaying": "Aktuelle Wiedergabe",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"artists": "",
"albums": "",
"songs": "",
"libraryCount": "",
"audiobooks": "",
"podcasts": "",
"listeningTime": "",
"activeSessions": "",
"nowPlaying": "",
"nowPlayingCount": "",
"error": {
"internalServerError": "Fehler beim Abrufen der Audio-Statistikdaten"
"internalServerError": ""
}
},
"systemResources": {
@@ -3471,7 +3457,7 @@
"option": {
"cpu": "CPU",
"memory": "Speicher",
"gpu": "GPU",
"gpu": "",
"network": "Netzwerk"
}
},
@@ -3488,7 +3474,7 @@
"card": {
"cpu": "CPU",
"memory": "Arbeitsspeicher",
"gpu": "GPU",
"gpu": "",
"network": "Netzwerk",
"up": "OBEN",
"down": "UNTEN"
@@ -3524,93 +3510,93 @@
}
},
"customApi": {
"name": "Benutzerdefinierte API",
"description": "Zeigt Daten von jeder externen API mit benutzerdefinierter Konfiguration an.",
"name": "",
"description": "",
"option": {
"definitionId": {
"label": "Definitions ID"
"label": ""
},
"refreshInterval": {
"label": "Aktualisierungsintervall",
"description": "Wie oft Daten von der API neu geladen werden sollen (in Sekunden)"
"label": "",
"description": ""
}
},
"noDefinition": "Keine Definition ausgewählt",
"definitionNotFound": "Widget-Definition nicht gefunden. Möglicherweise wurde sie gelöscht.",
"noDisplayOutput": "Keine Anzeige-Ausgabe",
"executeConfirm": "Bist du dir sicher, dass du diese Aktion ausführen möchtest?",
"executing": "Wird ausgeführt...",
"executeSuccess": "Aktion erfolgreich ausgeführt",
"executeFailed": "Aktion fehlgeschlagen. Überprüfen Sie die Widget-Konfiguration."
"noDefinition": "",
"definitionNotFound": "",
"noDisplayOutput": "",
"executeConfirm": "",
"executing": "",
"executeSuccess": "",
"executeFailed": ""
},
"beszelSystemTable": {
"name": "Beszel Systeme (Tabelle)",
"description": "Tabellenansicht aller Beszel-überwachten Systeme mit sortierbaren Spalten und Statusindikatoren",
"name": "",
"description": "",
"option": {
"statusFilter": {
"label": "Status Filter"
"label": ""
},
"sortBy": {
"label": "Sortieren nach"
"label": ""
},
"sortDirection": {
"label": "Sortierreihenfolge"
"label": ""
},
"showCpu": {
"label": "CPU anzeigen"
"label": ""
},
"showMemory": {
"label": "Arbeitsspeicher anzeigen"
"label": ""
},
"showDisk": {
"label": "Datenträger anzeigen"
"label": ""
},
"showGpu": {
"label": "GPU anzeigen"
"label": ""
},
"showLoadAvg": {
"label": "Zeige Belastungsdurchschnitt"
"label": ""
},
"showNet": {
"label": "Netzwerk anzeigen"
"label": ""
},
"showTemp": {
"label": "Temperatur anzeigen"
"label": ""
},
"showBattery": {
"label": "Akkustand anzeigen"
"label": ""
},
"showServices": {
"label": "Dienste anzeigen"
"label": ""
},
"showUptime": {
"label": "Laufzeit anzeigen"
"label": ""
},
"showAgent": {
"label": "Agent-Version anzeigen"
"label": ""
}
},
"noRecords": "Keine Systeme gefunden",
"noRecords": "",
"error": {
"internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen."
"internalServerError": ""
},
"column": {
"system": "System",
"cpu": "CPU",
"memory": "Arbeitsspeicher",
"disk": "Datenträger",
"gpu": "GPU",
"loadAvg": "Systemlast",
"net": "Netz",
"temp": "Temperatur",
"battery": "Batterie",
"services": "Dienste",
"uptime": "Betriebszeit",
"agent": "Agent"
"system": "",
"cpu": "",
"memory": "",
"disk": "",
"gpu": "",
"loadAvg": "",
"net": "",
"temp": "",
"battery": "",
"services": "",
"uptime": "",
"agent": ""
}
},
"beszelSystemGrid": {
"name": "Beszel Systems (Grid)",
"name": "",
"description": "",
"option": {
"statusFilter": {
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "CPU",
"memory": "Arbeitsspeicher",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "System auswählen",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Nicht gefunden",
"text": "Konnte die angeforderte Ressource nicht finden"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Startseite",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Fehler beim Abrufen der Docker Container",
"noColumns": ""
"internalServerError": "Fehler beim Abrufen der Docker Container"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "εδώ",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Εναλλαγή σε σκοτεινή λειτουργία",
"switchToLightMode": "Εναλλαγή σε φωτεινή λειτουργία",
"management": "Διαχείριση",
"docker": "",
"preferences": "Οι ρυθμίσεις σας",
"logout": "Αποσύνδεση",
"login": "Σύνδεση",
@@ -2357,9 +2352,6 @@
"name": "Στατιστικά Docker",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Αρχική",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "here",
"iconPicker": {
"label": "Icon URL",
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Switch to dark mode",
"switchToLightMode": "Switch to light mode",
"management": "Management",
"docker": "",
"preferences": "Your preferences",
"logout": "Log-out",
"login": "Log-in",
@@ -2357,9 +2352,6 @@
"name": "Docker stats",
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Enable items sorting"
},
@@ -2413,12 +2405,6 @@
"title": "Instances"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+3 -9
View File
@@ -1598,9 +1598,7 @@
},
"menu": {
"label": {
"settings": "Settings",
"options": "Options",
"actions": "Actions"
"settings": "Settings"
}
},
"create": {
@@ -2359,9 +2357,6 @@
"name": "Docker stats",
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
"option": {
"columns": {
"label": "Columns to show"
},
"enableRowSorting": {
"label": "Enable items sorting"
},
@@ -4313,7 +4308,7 @@
"notStarted": "Not started"
},
"name": {
"management": "Management",
"management": "Management",
"dashboard": "Dashboard"
}
}
@@ -5101,8 +5096,7 @@
}
},
"error": {
"internalServerError": "Failed to fetch Docker containers",
"noColumns": "Select at least one column in the widget settings"
"internalServerError": "Failed to fetch Docker containers"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "aquí",
"iconPicker": {
"label": "URL del icono",
"header": "Escribe nombre u objetos para filtrar iconos... Homarr buscará a través de iconos {countIcons} por ti.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Escribe nombre u objetos para filtrar iconos... Homarr buscará a través de iconos {countIcons} por ti."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Cambiar a modo oscuro",
"switchToLightMode": "Cambiar a modo claro",
"management": "Administración",
"docker": "",
"preferences": "Tus preferencias",
"logout": "Cerrar sesión",
"login": "Iniciar sesión",
@@ -2357,9 +2352,6 @@
"name": "Estadísticas de Docker",
"description": "Estadísticas de tus contenedores (Este widget sólo puede ser añadido con privilegios de administrador)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Habilitar ordenar elementos"
},
@@ -2413,12 +2405,6 @@
"title": "Instancias"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "No Encontrado",
"text": "No se pudo encontrar el recurso solicitado"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Inicio",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Fallo al obtener los contenedores de Docker",
"noColumns": ""
"internalServerError": "Fallo al obtener los contenedores de Docker"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "Teie eelistused",
"logout": "",
"login": "",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+3 -32
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "Valvonta",
"speedtest": "Nopeustestaus",
"analytics": "Analytiikka",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1045,7 +1044,7 @@
"description": "Vastausta ei voitu jäsentää. Tarkista, että URL-osoite osoittaa palvelun perusosoitteeseen."
},
"authorization": {
"title": "Tunnistautumisvirhe",
"title": "Tuonnistautumisvirhe",
"description": "Pyyntöä ei hyväksytty. Varmista, että kirjautumistiedot ovat oikein ja että niille on määritetty riittävät käyttöoikeudet."
},
"statusCode": {
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "Kuvakkeen URL-osoite",
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "Hallinta",
"docker": "",
"preferences": "Asetuksesi",
"logout": "Kirjaudu ulos",
"login": "",
@@ -2357,9 +2352,6 @@
"name": "Docker-tilastot",
"description": "Konttiesi tilastot (tämä widget voidaan lisätä vain ylläpito-oikeuksilla)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Salli kohteiden järjestys"
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Aloitus",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "Umamin aktiiviset käyttäjät"
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "ici",
"iconPicker": {
"label": "URL de l'icône :",
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Activer le mode sombre",
"switchToLightMode": "Activer le mode clair",
"management": "Administration",
"docker": "",
"preferences": "Vos préférences",
"logout": "Se déconnecter",
"login": "Connexion",
@@ -2357,9 +2352,6 @@
"name": "Statistiques de Docker",
"description": "Statistiques de vos conteneurs (Ce widget ne peut être ajouté qu'avec les privilèges d'administrateur)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Activer le tri des éléments"
},
@@ -2413,12 +2405,6 @@
"title": "Instances"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Non trouvé",
"text": "Impossible de trouver la ressource demandée"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Accueil",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Impossible de récupérer les conteneurs Docker",
"noColumns": ""
"internalServerError": "Impossible de récupérer les conteneurs Docker"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "כאן",
"iconPicker": {
"label": "קישור של הסמל",
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} .",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} ."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "עבור למצב כהה",
"switchToLightMode": "עבור למצב בהיר",
"management": "ניהול",
"docker": "",
"preferences": "העדפות שלך",
"logout": "התנתק/י",
"login": "התחבר/י",
@@ -2357,9 +2352,6 @@
"name": "סטטיסטיקות דוקר",
"description": "סטטיסטיקות של המכולות שלך (ניתן להוסיף ווידג'ט זה רק עם הרשאות מנהל)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "אפשר מיון פריטים"
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "לא נמצא",
"text": "לא ניתן לאתר את המשאב המבוקש"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "מסך הבית",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "נכשל בקבלת קונטיינרי דוקר",
"noColumns": ""
"internalServerError": "נכשל בקבלת קונטיינרי דוקר"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "Vaše preferencije",
"logout": "",
"login": "Prijaviti se",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Dom",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "itt",
"iconPicker": {
"label": "Ikonhivatkozás",
"header": "Írjon be nevet vagy objektumokat az ikonok szűréséhez... A Homarr a {countIcons} ikonok között keres.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Írjon be nevet vagy objektumokat az ikonok szűréséhez... A Homarr a {countIcons} ikonok között keres."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Váltás sötét módba",
"switchToLightMode": "Váltás világos módra",
"management": "Kezelés",
"docker": "",
"preferences": "Saját preferenciák",
"logout": "Kijelentkezés",
"login": "Bejelentkezés",
@@ -2357,9 +2352,6 @@
"name": "Docker statisztika",
"description": "A konténereid statisztikái (Ezt a widgetet csak rendszergazdai jogosultsággal lehet hozzáadni)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Az elemek rendezésének engedélyezése"
},
@@ -2413,12 +2405,6 @@
"title": "Példányok"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Nyitólap",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "Monitoraggio",
"speedtest": "Speed test",
"analytics": "Analytics",
"vpn": "VPN",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring"
},
@@ -1413,10 +1412,7 @@
"here": "qui",
"iconPicker": {
"label": "Icona URL",
"header": "Digita il nome o gli oggetti da filtrare per le icone... Homarr cercherà tra le icone {countIcons} per te.",
"headerLoading": "Digita per cercare icone…",
"noResults": "Nessuna icona corrisponde a \"{search}\".",
"error": "Impossibile caricare le icone."
"header": "Digita il nome o gli oggetti da filtrare per le icone... Homarr cercherà tra le icone {countIcons} per te."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Passa alla modalità scura",
"switchToLightMode": "Passa al tema chiaro",
"management": "Gestione",
"docker": "Docker",
"preferences": "Le tue impostazioni",
"logout": "Disconnettiti",
"login": "Accedi",
@@ -2357,9 +2352,6 @@
"name": "Statistiche del docker",
"description": "Statistiche dei tuoi contenitori (questo widget può essere aggiunto solo con privilegi di amministratore)",
"option": {
"columns": {
"label": "Colonne da mostrare"
},
"enableRowSorting": {
"label": "Abilita l'ordinamento degli elementi"
},
@@ -2413,12 +2405,6 @@
"title": "Istanze"
}
},
"vpn": {
"name": "VPN",
"description": "Panoramica delle tue connessioni VPN",
"serviceUnavailable": "VPN non disponibile",
"option": {}
},
"ups": {
"name": "UPS",
"description": "Monitora lo stato dei tuoi dispositivi UPS tramite un server NUT",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione."
},
"empty": {
"noSystems": "Nessun sistema trovato"
},
"metric": {
"cpu": "CPU",
"memory": "Memoria",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "Seleziona sistema",
"empty": {
"noSystems": "Nessun sistema trovato"
},
"error": {
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione.",
"liveConnectionFailed": "Connessione Live non riuscita. L'istanza di Beszel potrebbe non supportare lo streaming in tempo reale.",
@@ -4153,11 +4133,6 @@
"title": "Non trovato",
"text": "Impossibile trovare la risorsa richiesta"
},
"error": {
"title": "Questa pagina non può essere caricata",
"text": "Si è verificato un errore inatteso durante il caricamento di questa pagina. La navigazione è ancora disponibile: prova a ricaricare o usa un'altra sezione dalla barra laterale.",
"digest": "Riferimento errore: {digest}"
},
"navbar": {
"items": {
"home": "Home",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "Visitatori Attivi Umami"
},
"vpn": {
"label": "Stato VPN"
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Recupero dei container Docker non riuscito",
"noColumns": "Seleziona almeno una colonna nelle impostazioni dei widget"
"internalServerError": "Recupero dei container Docker non riuscito"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "こちら",
"iconPicker": {
"label": "アイコン URL",
"header": "アイコンをフィルタするために、名前またはオブジェクトを入力してください... Homarrは {countIcons} アイコンを検索します。",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "アイコンをフィルタするために、名前またはオブジェクトを入力してください... Homarrは {countIcons} アイコンを検索します。"
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "ダークモードに切り替え",
"switchToLightMode": "ライトモードに切り替え",
"management": "管理",
"docker": "",
"preferences": "個人設定",
"logout": "ログアウト",
"login": "ログイン",
@@ -2357,9 +2352,6 @@
"name": "Dockerのステータス",
"description": "あなたのコンテナの統計(このウィジェットは管理者権限でのみ追加できます)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "アイテムの並び替えを有効にする"
},
@@ -2413,12 +2405,6 @@
"title": "インスタンス"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "見つかりません",
"text": "要求されたリソースが見つかりませんでした"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "ホーム",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Docker コンテナの取得に失敗しました",
"noColumns": ""
"internalServerError": "Docker コンテナの取得に失敗しました"
}
},
"kubernetes": {
+58 -87
View File
@@ -288,10 +288,10 @@
"changePingIconsEnabled": {
"notification": {
"success": {
"message": "Ping 아이콘 전환 완료"
"message": ""
},
"error": {
"message": "Ping 아이콘을 전환할 수 없습니다"
"message": ""
}
}
},
@@ -701,56 +701,56 @@
},
"action": {
"create": "생성",
"import": "가져오기",
"save": "저장",
"delete": "사용자 지정 위젯 삭제",
"deleteConfirm": "\"{name}\"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"export": "내보내기",
"addMapping": "매핑 추가",
"addColumn": "열 추가",
"edit": "편집",
"removeMapping": "매핑 제거",
"removeColumn": "열 제거",
"addItem": "항목 추가",
"removeItem": "항목 제거",
"addBar": "바 추가",
"duplicate": "복사",
"copyAiPrompt": "AI 프롬프트 복사",
"enable": "활성화",
"disable": "비활성화",
"menu": "동작"
"import": "",
"save": "",
"delete": "",
"deleteConfirm": "",
"export": "",
"addMapping": "",
"addColumn": "",
"edit": "",
"removeMapping": "",
"removeColumn": "",
"addItem": "",
"removeItem": "",
"addBar": "",
"duplicate": "",
"copyAiPrompt": "",
"enable": "",
"disable": "",
"menu": ""
},
"field": {
"name": "이름",
"description": "설명",
"iconUrl": "아이콘 URL",
"url": "URL",
"authType": "인증 유형",
"headerName": "헤더/쿼리 파라미터 이름",
"method": "메서드",
"requestBody": "요청 본문",
"displayType": "표시 유형",
"jsonPath": "JSON 경로",
"jsonPathHint": "JSONPath 문법으로 값을 추출하십시오. 예: $.data.temperature 또는 $.items[0].name",
"label": "레이블",
"unit": "단위",
"mappings": "매핑",
"tablePath": "JSON 경로 배열",
"tablePathHint": "응답에서 배열이 위치한 경로입니다. 예: $.data.items",
"columns": "열",
"header": "헤더",
"items": "항목",
"gridColumns": "그리드 열 개수",
"cardStyle": "카드 스타일",
"bars": "바",
"valuePath": "JSON 경로 값",
"maxPath": "JSON 경로 최댓값",
"showPercentage": "백분율 표시",
"barSize": "바 크기",
"goodValues": "정상 값(쉼표로 구분)",
"dotSize": "점 크기",
"valueSize": "값 크기",
"labelPosition": "레이블 위치",
"name": "",
"description": "",
"iconUrl": "",
"url": "",
"authType": "",
"headerName": "",
"method": "",
"requestBody": "",
"displayType": "",
"jsonPath": "",
"jsonPathHint": "",
"label": "",
"unit": "",
"mappings": "",
"tablePath": "",
"tablePathHint": "",
"columns": "",
"header": "",
"items": "",
"gridColumns": "",
"cardStyle": "",
"bars": "",
"valuePath": "",
"maxPath": "",
"showPercentage": "",
"barSize": "",
"goodValues": "",
"dotSize": "",
"valueSize": "",
"labelPosition": "",
"kvLayout": "레이아웃",
"striped": "",
"compact": "컴팩트 모드",
@@ -849,14 +849,14 @@
"secret": {
"apiKey": "",
"username": "",
"password": "비밀번호",
"savedPlaceholder": "••••••• (저장됨, 유지하려면 공백)"
"password": "",
"savedPlaceholder": ""
},
"preview": {
"title": "미리보기",
"test": "테스트",
"hint": "현재 설정으로 API 응답을 미리 보려면 테스트를 클릭하십시오.",
"rawResponse": "원본 JSON 응답"
"title": "",
"test": "",
"hint": "",
"rawResponse": ""
},
"notification": {
"created": "\"{name}\" 위젯 생성 완료.",
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "모니터링",
"speedtest": "속도 테스트",
"analytics": "분석",
"vpn": "",
"mediaLibrary": "미디어 라이브러리",
"uptimeMonitoring": "업타임 모니터링"
},
@@ -1413,10 +1412,7 @@
"here": "여기",
"iconPicker": {
"label": "아이콘 URL",
"header": "이름 또는 주제를 입력해 아이콘을 필터링하세요... Homarr가 {countIcons}에서 아이콘을 검색합니다.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "이름 또는 주제를 입력해 아이콘을 필터링하세요... Homarr가 {countIcons}에서 아이콘을 검색합니다."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "다크 모드로 전환",
"switchToLightMode": "라이트 모드로 전환",
"management": "관리",
"docker": "",
"preferences": "기본 설정",
"logout": "로그아웃",
"login": "로그인",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "찾을 수 없음",
"text": "요청한 리소스를 찾을 수 없습니다"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "홈",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "",
"logout": "",
"login": "Prisijungti",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Pagrindinis",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "Jūsu iestatījumi",
"logout": "",
"login": "Pieslēgties",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Sākums",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "hier",
"iconPicker": {
"label": "Icoon-URL",
"header": "Typ naam of objecten om te filteren op iconen… Homarr doorzoekt {countIcons} iconen voor je.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Typ naam of objecten om te filteren op iconen… Homarr doorzoekt {countIcons} iconen voor je."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Schakel naar donkere modus",
"switchToLightMode": "Schakel naar lichte modus",
"management": "Beheer",
"docker": "",
"preferences": "Jouw voorkeuren",
"logout": "Uitloggen",
"login": "Inloggen",
@@ -2357,9 +2352,6 @@
"name": "Docker statistieken",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Niet gevonden",
"text": "Kon de aangevraagde bron niet vinden"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Home",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Docker-containers zijn niet opgehaald",
"noColumns": ""
"internalServerError": "Docker-containers zijn niet opgehaald"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "her",
"iconPicker": {
"label": "Ikon-URL",
"header": "Skriv navn eller objekt å filtrere for ikoner... Homarr vil søke gjennom {countIcons} ikoner for deg.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Skriv navn eller objekt å filtrere for ikoner... Homarr vil søke gjennom {countIcons} ikoner for deg."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Bytt til mørk modus",
"switchToLightMode": "Bytt til lys modus",
"management": "Administrasjon",
"docker": "",
"preferences": "Dine innstillinger",
"logout": "Logg ut",
"login": "Logg Inn",
@@ -2357,9 +2352,6 @@
"name": "Docker status",
"description": "Statistikk over containerne dine (denne widgeten kan bare legges til med administratorrettigheter)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Aktiver sortering av elementer"
},
@@ -2413,12 +2405,6 @@
"title": "Instanser"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Ikke funnet",
"text": "Fant ikke den forespurte ressursen"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Hjem",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "Umami Aktive Besøkende"
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Klarte ikke hente Docker-containere",
"noColumns": ""
"internalServerError": "Klarte ikke hente Docker-containere"
}
},
"kubernetes": {
File diff suppressed because it is too large. Load diff
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "Monitoramento",
"speedtest": "Teste de Velocidade",
"analytics": "Análises",
"vpn": "",
"mediaLibrary": "Biblioteca de mídia",
"uptimeMonitoring": "Monitoramento do Uptime"
},
@@ -1413,10 +1412,7 @@
"here": "aqui",
"iconPicker": {
"label": "URL do ícone",
"header": "Digite o nome ou os objetos para filtrar os ícones... O Homarr pesquisará {countIcons} ícones para você.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Digite o nome ou os objetos para filtrar os ícones... O Homarr pesquisará {countIcons} ícones para você."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Alternar para o modo escuro",
"switchToLightMode": "Alternar para o modo claro",
"management": "Gerenciamento",
"docker": "",
"preferences": "Suas preferências",
"logout": "Sair",
"login": "“Login”",
@@ -2357,9 +2352,6 @@
"name": "Estatísticas do Docker",
"description": "Estatísticas dos seus contêineres (Esse widget só pode ser adicionado com privilégios de administrador)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Ativar ordenação de itens"
},
@@ -2413,12 +2405,6 @@
"title": "Instâncias"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "UPS",
"description": "Monitore o status de seus dispositivos UPS através de um servidor NUT",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Não encontrado",
"text": "Não foi possível encontrar o recurso solicitado"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Início",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": "Visitantes Ativos do Umami"
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Falha ao buscar contêineres Docker",
"noColumns": ""
"internalServerError": "Falha ao buscar contêineres Docker"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "",
"logout": "",
"login": "",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"docker": "",
"preferences": "Preferințele dvs.",
"logout": "",
"login": "Utilizator",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Acasă",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "здесь",
"iconPicker": {
"label": "URL иконки",
"header": "Введите название или объекты для фильтрации иконок... Homarr найдёт для вас среди {countIcons} иконок.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Введите название или объекты для фильтрации иконок... Homarr найдёт для вас среди {countIcons} иконок."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Переключить на тёмную тему",
"switchToLightMode": "Переключить на светлую тему",
"management": "Управление",
"docker": "",
"preferences": "Настройки",
"logout": "Выйти",
"login": "Вход",
@@ -2357,9 +2352,6 @@
"name": "Статистика Docker",
"description": "Статистика контейнеров (этот виджет можно добавить только с правами администратора)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Включить сортировку элементов"
},
@@ -2413,12 +2405,6 @@
"title": "Экземпляры"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Не найдено",
"text": "Запрошенный ресурс не найден"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Главная",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Произошла ошибка при обновлении данных о контейнерах",
"noColumns": ""
"internalServerError": "Произошла ошибка при обновлении данных о контейнерах"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "tu",
"iconPicker": {
"label": "URL ikony",
"header": "Zadajte názov alebo objekty na filtrovanie ikon... Homarr pre vás vyhľadá ikony na stránke {countIcons} .",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Zadajte názov alebo objekty na filtrovanie ikon... Homarr pre vás vyhľadá ikony na stránke {countIcons} ."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Prepnúť na tmavý motív",
"switchToLightMode": "Prepnúť na svetlý motív",
"management": "Administrácia",
"docker": "",
"preferences": "Vaše preferencie",
"logout": "Odhlásiť",
"login": "Prihlásiť sa",
@@ -2357,9 +2352,6 @@
"name": "Docker stav",
"description": "Štatistiky vašich kontajnerov (Túto miniaplikáciu je možné pridať iba s oprávneniami správcu)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Nenájdené",
"text": "Nepodarilo sa nájsť požadovaný zdroj"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Domovská stránka",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Nepodarilo sa načítať kontajnery Docker",
"noColumns": ""
"internalServerError": "Nepodarilo sa načítať kontajnery Docker"
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "Svetli način",
"management": "Nastavitve",
"docker": "",
"preferences": "Prilagoditev",
"logout": "Odjava",
"login": "Prijava",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Domov",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Byt till mörkt läge",
"switchToLightMode": "Byt till ljust läge",
"management": "Administration",
"docker": "",
"preferences": "Dina inställningar",
"logout": "Logga ut",
"login": "Logga in",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Hittades ej",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Hem",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "buraya",
"iconPicker": {
"label": "Simge URL'si",
"header": "Simgeleri filtrelemek için isim veya nesne yazın... Homarr sizin için {countIcons} simge arasında arama yapacaktır.",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "Simgeleri filtrelemek için isim veya nesne yazın... Homarr sizin için {countIcons} simge arasında arama yapacaktır."
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Koyu Temaya Geç",
"switchToLightMode": "Aydınlık Temaya Geç",
"management": "Yönetim",
"docker": "",
"preferences": "Tercihleriniz",
"logout": "Oturumu kapat",
"login": "Giriş",
@@ -2357,9 +2352,6 @@
"name": "Docker istatistikleri",
"description": "Konteynerlerinizin istatistikleri (Bu widget yalnızca yönetici ayrıcalıklarıyla eklenebilir)",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": "Enable items sorting"
},
@@ -2413,12 +2405,6 @@
"title": "Instances"
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Bulunamadı",
"text": "İstenen kaynak bulunamadı"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Ana Sayfa",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "Docker konteynerlerları getirilemedi",
"noColumns": ""
"internalServerError": "Docker konteynerlerları getirilemedi"
}
},
"kubernetes": {
+10 -39
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "Медіа бібліотека",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "тут",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "Перемикнути на темну тему",
"switchToLightMode": "Перемикнути на світлу тему",
"management": "Управління",
"docker": "",
"preferences": "Ваші налаштування",
"logout": "Вийти",
"login": "Логін",
@@ -1943,7 +1938,7 @@
"addTable": "Додати таблицю",
"deleteTable": "Видалити таблицю",
"colorCell": "",
"mergeCell": "Ввімкнути/вимкнути об'єднання клітини",
"mergeCell": "",
"addColumnLeft": "",
"addColumnRight": "",
"deleteColumn": "",
@@ -2106,13 +2101,13 @@
"label": ""
},
"1y": {
"label": "1 рік"
"label": ""
},
"2y": {
"label": ""
},
"5y": {
"label": "5 років"
"label": ""
},
"10y": {
"label": ""
@@ -2147,7 +2142,7 @@
"label": ""
},
"1mo": {
"label": "1 місяць"
"label": ""
}
}
}
@@ -2187,7 +2182,7 @@
"label": "Температура за Фаренгейтом"
},
"disableTemperatureDecimals": {
"label": "Вимкнути десяткові дроби температури"
"label": ""
},
"showCurrentWindSpeed": {
"label": "Показати поточну швидкість вітру",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2367,7 +2359,7 @@
"label": "",
"option": {
"name": "",
"state": "Стан",
"state": "",
"cpuUsage": "",
"memoryUsage": ""
}
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -2484,7 +2470,7 @@
"videoDecision": {
"directPlay": "",
"transcode": "",
"directStream": "Прямий потік"
"directStream": ""
}
},
"recentActivity": {
@@ -2564,7 +2550,7 @@
"label": "",
"description": "",
"option": {
"chart": "Графік",
"chart": "",
"events": "",
"topPages": "",
"topReferrers": ""
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "Не знайдено",
"text": "Не вдалося знайти запитуваний ресурс"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Головна",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "",
"iconPicker": {
"label": "",
"header": "",
"headerLoading": "",
"noResults": "",
"error": ""
"header": ""
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "",
"switchToLightMode": "Đổi qua chế độ nền sáng",
"management": "",
"docker": "",
"preferences": "Cá nhân hoá",
"logout": "",
"login": "Đăng nhập",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "",
"text": ""
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "Trang chủ",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "",
"noColumns": ""
"internalServerError": ""
}
},
"kubernetes": {
+2 -31
View File
@@ -1021,7 +1021,6 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"vpn": "",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
@@ -1413,10 +1412,7 @@
"here": "此處",
"iconPicker": {
"label": "Icon 網址",
"header": "輸入名稱或物件來篩選 icons,Homarr 會自動搜尋 {countIcons}",
"headerLoading": "",
"noResults": "",
"error": ""
"header": "輸入名稱或物件來篩選 icons,Homarr 會自動搜尋 {countIcons}"
},
"colorScheme": {
"options": {
@@ -1468,7 +1464,6 @@
"switchToDarkMode": "切換至暗黑模式",
"switchToLightMode": "切換至日間模式",
"management": "管理中心",
"docker": "",
"preferences": "偏好設定",
"logout": "登出",
"login": "登入",
@@ -2357,9 +2352,6 @@
"name": "",
"description": "",
"option": {
"columns": {
"label": ""
},
"enableRowSorting": {
"label": ""
},
@@ -2413,12 +2405,6 @@
"title": ""
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"option": {}
},
"ups": {
"name": "",
"description": "",
@@ -3653,9 +3639,6 @@
"error": {
"internalServerError": ""
},
"empty": {
"noSystems": ""
},
"metric": {
"cpu": "",
"memory": "",
@@ -3729,9 +3712,6 @@
}
},
"selectSystem": "",
"empty": {
"noSystems": ""
},
"error": {
"internalServerError": "",
"liveConnectionFailed": "",
@@ -4153,11 +4133,6 @@
"title": "不存在",
"text": "找不到請求的資源"
},
"error": {
"title": "",
"text": "",
"digest": ""
},
"navbar": {
"items": {
"home": "首頁",
@@ -4604,9 +4579,6 @@
},
"umamiActiveVisitors": {
"label": ""
},
"vpn": {
"label": ""
}
},
"interval": {
@@ -5099,8 +5071,7 @@
}
},
"error": {
"internalServerError": "取得 Docker 容器失敗",
"noColumns": ""
"internalServerError": "取得 Docker 容器失敗"
}
},
"kubernetes": {
-2
View File
@@ -8,8 +8,6 @@
transition: scale 0.2s ease-in-out;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
touch-action: manipulation;
}
.appWithUrl:hover > .appIcon {
-1
View File
@@ -87,7 +87,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
+7 -33
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useState } from "react";
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -13,12 +13,10 @@ interface CalendarDayProps {
disabled: boolean;
rootWidth: number;
rootHeight: number;
onOpen: (close: () => void) => void;
}
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
const [opened, setOpened] = useState(false);
const [pinned, setPinned] = useState(false);
const { primaryColor } = useMantineTheme();
const { ref, height } = useElementSize();
const board = useRequiredBoard();
@@ -31,19 +29,6 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onO
const isTooSmallForIndicators = height < 30;
const handleMouseEnter = useCallback(() => {
if (disabled) return;
onOpen(() => {
setOpened(false);
setPinned(false);
});
setOpened(true);
}, [disabled, onOpen]);
const handleMouseLeave = useCallback(() => {
if (!pinned) setOpened(false);
}, [pinned]);
return (
<Popover
position="bottom"
@@ -54,10 +39,7 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onO
transitionProps={{
transition: "pop",
}}
onChange={(value) => {
setOpened(value);
if (!value) setPinned(false);
}}
onChange={setOpened}
opened={opened}
disabled={disabled}
>
@@ -77,10 +59,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onO
cursor: disabled ? "default" : "pointer",
}}
onClick={() => {
if (!disabled) setPinned((prev) => !prev);
if (disabled) return;
setOpened((prev) => !prev);
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
@@ -89,15 +71,7 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onO
</Container>
</Popover.Target>
{/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
<Popover.Dropdown
maw="calc(100vw - 24px)"
w={512}
pe={4}
pb={0}
style={{ overflow: "hidden" }}
onMouseEnter={() => setOpened(true)}
onMouseLeave={handleMouseLeave}
>
<Popover.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
<CalendarEventList events={events} />
</Popover.Dropdown>
</Popover>
+10 -50
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
@@ -16,15 +16,11 @@ import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
export default function CalendarWidget(
props: WidgetComponentProps<"calendar">,
) {
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(new Date());
if (props.integrationIds.length === 0) {
return (
<CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />
);
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
@@ -35,13 +31,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
}: FetchCalendarProps) => {
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -71,20 +61,9 @@ const FetchCalendar = ({
},
});
const events = useMemo(
() => data?.flatMap((item) => item.events) ?? [],
[data],
);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
return (
<CalendarBase
isEditMode={isEditMode}
events={events}
month={month}
setMonth={setMonth}
options={options}
/>
);
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
};
interface CalendarBaseProps {
@@ -95,13 +74,7 @@ interface CalendarBaseProps {
options: WidgetComponentProps<"calendar">["options"];
}
const CalendarBase = ({
isEditMode,
events,
month,
setMonth,
options,
}: CalendarBaseProps) => {
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -112,11 +85,6 @@ const CalendarBase = ({
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const activeCloseRef = useRef<(() => void) | null>(null);
const onDayOpen = useCallback((close: () => void) => {
activeCloseRef.current?.();
activeCloseRef.current = close;
}, []);
return (
<Calendar
@@ -182,14 +150,9 @@ const CalendarBase = ({
const eventsForDate = normalizedEvents
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
.filter(
(event) =>
event.metadata?.type !== "radarr" ||
options.releaseType.includes(event.metadata.releaseType),
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
)
.toSorted(
(eventA, eventB) =>
eventA.startDate.getTime() - eventB.startDate.getTime(),
);
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
return (
<CalendarDay
@@ -198,7 +161,6 @@ const CalendarBase = ({
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
onOpen={onDayOpen}
/>
);
}}
@@ -236,9 +198,7 @@ export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
splitEvents.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate)
? event.endDate
: currentStart.endOf("day").toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
+1 -32
View File
@@ -1,4 +1,3 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
@@ -10,35 +9,7 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
label: stringOrTranslation;
icon?: TablerIcon;
onClick: () => void;
hidden?: boolean;
disabled?: boolean;
color?: string;
}
export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
}
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
> {
kind: TKind;
options: inferOptionsFromDefinition<TOptions>;
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
integrationIds: string[];
context: WidgetContextMenuContext;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
}
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
@@ -85,7 +56,6 @@ export interface WidgetDefinition {
}
>
>;
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
}
export interface WidgetProps<TKind extends WidgetKind> {
@@ -100,7 +70,6 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
@@ -57,7 +57,6 @@ export default function DnsHoleControlsWidget({
},
{
staleTime: 5 * 1000,
refetchInterval: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
@@ -26,7 +26,6 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
},
{
staleTime: 5 * 1000,
refetchInterval: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
+2 -21
View File
@@ -1,10 +1,10 @@
"use client";
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
@@ -213,15 +213,6 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const columns = useMemo(() => createColumns(t), [t]);
const columnVisibility: MRT_VisibilityState = {
name: options.columns.includes("name"),
state: options.columns.includes("state"),
host: options.columns.includes("host"),
cpuUsage: options.columns.includes("cpuUsage"),
memoryUsage: options.columns.includes("memoryUsage"),
actions: options.columns.includes("actions"),
};
const table = useTranslatedMantineReactTable({
columns,
data: containers,
@@ -242,9 +233,6 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
density: "xs",
},
state: {
columnVisibility,
},
mantinePaperProps: {
flex: 1,
withBorder: false,
@@ -272,13 +260,6 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
},
});
if (options.columns.length === 0)
return (
<Center h="100%">
<Text>{t("error.noColumns")}</Text>
</Center>
);
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
-19
View File
@@ -12,29 +12,10 @@ const columnsList = [
"memoryUsage",
] as const satisfies (keyof RouterOutputs["docker"]["getContainers"]["containers"][number])[];
const allColumnsList = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const columnTranslationKeyMap = {
name: "docker.field.name.label",
state: "docker.field.state.label",
host: "docker.field.host.label",
cpuUsage: "docker.field.stats.cpu.label",
memoryUsage: "docker.field.stats.memory.label",
actions: "docker.action.title",
} as const satisfies Record<(typeof allColumnsList)[number], string>;
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
createOptions() {
return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({
defaultValue: [...allColumnsList],
options: allColumnsList.map((value) => ({
value,
label: (t) => t(columnTranslationKeyMap[value]),
})),
searchable: true,
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
+1 -2
View File
@@ -1,7 +1,6 @@
"use client";
import "../widgets-common.css";
import "./styles.css";
import { useCallback, useMemo, useState } from "react";
import type { MantineStyleProp } from "@mantine/core";
@@ -578,7 +577,7 @@ export default function DownloadClientsWidget({
enableColumnOrdering: isEditMode,
enableRowVirtualization: true,
rowVirtualizerOptions: { overscan: 5 },
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined, bg: "transparent" },
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined },
mantineTableContainerProps: { style: { height: "100%" } },
mantineTableProps: {
className: "downloads-widget-table",
-12
View File
@@ -1,12 +0,0 @@
/* Make the MantineReactTable transparent so the board card's opacity-controlled
background shows through. MRT defaults to var(--mantine-color-body) which is opaque. */
.downloads-widget-table,
.downloads-widget-table .mantine-Table-table,
.downloads-widget-table .mantine-Table-thead,
.downloads-widget-table .mantine-Table-tbody,
.downloads-widget-table .mantine-Table-tfoot,
.downloads-widget-table .mantine-Table-tr,
.downloads-widget-table th,
.downloads-widget-table td {
background-color: transparent !important;
}
+1 -6
View File
@@ -61,12 +61,7 @@ import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
export type {
WidgetDefinition,
WidgetContextMenuAction,
WidgetContextActionProps,
WidgetOptionsSettings,
} from "./definition";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more