Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4040f28048 | ||
|
|
be3d522659 | ||
|
|
2dcd81676c | ||
|
|
cf655b8c8e | ||
|
|
05e7d8c529 | ||
|
|
dd2eb3aa66 | ||
|
|
aec6bb18c5 | ||
|
|
f9da71a19b | ||
|
|
b83c8b6546 | ||
|
|
0e4189f693 | ||
|
|
1cf010e9fb | ||
|
|
7e58858353 | ||
|
|
9ed55d57dd | ||
|
|
8cac0b11c1 | ||
|
|
5f011e8456 | ||
|
|
9e1b529288 | ||
|
|
4ad115dd2d | ||
|
|
27209a5dde | ||
|
|
ed33748a49 | ||
|
|
f66469d797 |
No files matched your search
@@ -163,3 +163,65 @@ jobs:
|
||||
run: cp .env.example .env
|
||||
- name: Build
|
||||
run: pnpm turbo build --affected
|
||||
|
||||
memory-regression:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: homarr-memory-test
|
||||
network: host
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Start container and measure memory
|
||||
run: |
|
||||
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
|
||||
# Wait for container to be healthy (up to 60s)
|
||||
healthy=false
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
|
||||
echo "Container healthy after $((i * 2))s"
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$healthy" != "true" ]; then
|
||||
echo "ERROR: Container never became healthy"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
# Warm up: hit the dashboard page a few times
|
||||
for i in $(seq 1 5); do
|
||||
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
|
||||
sleep 2
|
||||
done
|
||||
# Measure RSS after warmup
|
||||
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
|
||||
echo "Raw memory: $MEM_KB"
|
||||
# Convert to numeric MB (handles GiB and MiB suffixes)
|
||||
MEM_NUM=$(echo "$MEM_KB" | awk '{
|
||||
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
|
||||
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
|
||||
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
|
||||
else { print $1 / 1048576 }
|
||||
}')
|
||||
echo "Memory usage: ${MEM_NUM} MB"
|
||||
MAX_MB=700
|
||||
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
|
||||
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
with:
|
||||
node-version: 24.17.0
|
||||
node-version: 24.18.0
|
||||
package-manager-cache: false
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[tools]
|
||||
node = "24.17.0"
|
||||
node = "24.18.0"
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"oxc.oxc-vscode",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"million.million-lint",
|
||||
"lokalise.i18n-ally"
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make source maps opt-in to restore v1.67.0 memory baseline ([57ef0af](https://github.com/homarr-labs/homarr/commit/57ef0af4a252ea39727caeba7e13587dabc6254e)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
|
||||
* revert production source maps to restore v1.67.0 memory baseline ([60a69e3](https://github.com/homarr-labs/homarr/commit/60a69e31efc7168b8f4309daf75954d7730d883b)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
|
||||
|
||||
## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26)
|
||||
|
||||
### Features
|
||||
|
||||
+185
-4
@@ -1,12 +1,193 @@
|
||||
# ── CODEOWNERS ───────────────────────────────────────
|
||||
* @homarr-labs/maintainers
|
||||
|
||||
# Specific features / integrations
|
||||
# Glueten
|
||||
apps/docs/docs/integrations/glueten @Lok3sh-kumar
|
||||
# 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
|
||||
packages/integrations/src/gluetun @Lok3sh-kumar
|
||||
|
||||
# Exempt Renovate‑managed files (no owners)
|
||||
# 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)
|
||||
package.json
|
||||
**/package.json
|
||||
package-lock.json
|
||||
|
||||
+2
-15
@@ -1,4 +1,4 @@
|
||||
FROM node:24.17.0-alpine AS base
|
||||
FROM node:24.18.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
|
||||
|
||||
RUN corepack enable pnpm && pnpm build
|
||||
|
||||
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
|
||||
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
|
||||
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
|
||||
# See https://github.com/homarr-labs/homarr/issues/5891
|
||||
RUN cd apps/nextjs/.next/server && \
|
||||
find . -name '*.map' | while IFS= read -r map; do \
|
||||
dest="../standalone/apps/nextjs/.next/server/$map"; \
|
||||
mkdir -p "$(dirname "$dest")"; \
|
||||
cp "$map" "$dest"; \
|
||||
done
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -53,6 +42,7 @@ 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
|
||||
|
||||
@@ -75,9 +65,6 @@ ENV DB_DRIVER='better-sqlite3'
|
||||
ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
|
||||
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
ENV NODE_OPTIONS='--enable-source-maps'
|
||||
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -383,6 +383,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
|
||||
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
|
||||
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
|
||||
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
|
||||
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
|
||||
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
|
||||
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
|
||||
@@ -395,7 +396,6 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG | string | `nil` | Extra arguments for user search filter (& based) |
|
||||
| env.AUTH_LDAP_USER_MAIL_ATTRIBUTE | string | `"mail"` | Attribute used for mail field |
|
||||
| env.AUTH_LOGOUT_REDIRECT_URL | string | `nil` | URL to redirect to after clicking logging out. |
|
||||
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
|
||||
| env.AUTH_OIDC_AUTO_LOGIN | string | `"false"` | Automatically redirect to OIDC login |
|
||||
| env.AUTH_OIDC_CLIENT_NAME | string | `"OIDC"` | Display name of provider (in login screen) |
|
||||
| env.AUTH_OIDC_GROUPS_ATTRIBUTE | string | `"groups"` | Attribute used for groups (roles) claim |
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,9 @@
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,8 @@
|
||||
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",
|
||||
};
|
||||
@@ -93,9 +93,11 @@ They are then available in the app list and can be used as normally added apps.
|
||||
|
||||
### Configuration
|
||||
|
||||
Currently Homarr supports one label for configuration of the containers:
|
||||
Homarr supports Docker labels for automatic app discovery during onboarding. See [Docker labels](../docker-labels/index.mdx) for the full reference.
|
||||
|
||||
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget and tools page.
|
||||
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.
|
||||
|
||||
### Security
|
||||
Mounting docker sockets can be risky, as they permit full control over your docker service.
|
||||
|
||||
@@ -31,8 +31,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
productionBrowserSourceMaps: true,
|
||||
// react compiler breaks mantine-react-table, so disabled for now
|
||||
//reactCompiler: true,
|
||||
/** We already do typechecking as separate tasks in CI */
|
||||
@@ -43,8 +41,6 @@ const nextConfig: NextConfig = {
|
||||
*/
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
serverSourceMaps: true,
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
|
||||
@@ -50,7 +50,7 @@ interface IntegrationDraft {
|
||||
initialUrl: string;
|
||||
initialName?: string;
|
||||
dockerPort?: number | null;
|
||||
source: "manual" | "docker";
|
||||
source: "manual" | "docker" | "label";
|
||||
}
|
||||
|
||||
export const InitIntegrations = () => {
|
||||
@@ -65,6 +65,7 @@ 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,
|
||||
@@ -98,7 +99,7 @@ export const InitIntegrations = () => {
|
||||
initialUrl: item.suggestedUrl,
|
||||
initialName: item.containerName,
|
||||
dockerPort: item.publishedPort,
|
||||
source: "docker",
|
||||
source: item.source,
|
||||
});
|
||||
}
|
||||
newKinds.add(item.kind);
|
||||
@@ -132,7 +133,8 @@ export const InitIntegrations = () => {
|
||||
const newDrafts = new Map(drafts);
|
||||
for (const kind of selectedKinds) {
|
||||
const existing = newDrafts.get(kind);
|
||||
if (baseHost) {
|
||||
const useUrlTemplate = baseHost && existing?.source !== "label";
|
||||
if (useUrlTemplate) {
|
||||
newDrafts.set(kind, {
|
||||
kind,
|
||||
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
|
||||
@@ -152,10 +154,32 @@ export const InitIntegrations = () => {
|
||||
const finishSetupAsync = async () => {
|
||||
setPhase("done");
|
||||
try {
|
||||
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
|
||||
if (appsToCreate.length > 0) {
|
||||
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) {
|
||||
await createApps(
|
||||
appsToCreate.map((app) => {
|
||||
imageApps.map((app) => {
|
||||
const href = baseHost
|
||||
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
|
||||
: app.suggestedUrl;
|
||||
@@ -167,6 +191,7 @@ export const InitIntegrations = () => {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await setupIntegrations();
|
||||
router.refresh();
|
||||
} catch {
|
||||
@@ -336,9 +361,16 @@ export const InitIntegrations = () => {
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{app.containerName}
|
||||
</Text>
|
||||
<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>
|
||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||
</Group>
|
||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||
@@ -359,8 +391,14 @@ export const InitIntegrations = () => {
|
||||
{t("action.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinueToConfig}
|
||||
disabled={selectedKinds.length === 0 || undefined}
|
||||
onClick={() => {
|
||||
if (selectedKinds.length === 0) {
|
||||
void finishSetupAsync();
|
||||
return;
|
||||
}
|
||||
handleContinueToConfig();
|
||||
}}
|
||||
disabled={(selectedKinds.length === 0 && selectedAppCount === 0) || undefined}
|
||||
rightSection={<IconArrowRight size={16} stroke={1.5} />}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
@@ -404,7 +442,8 @@ const ConfigurePhase = ({
|
||||
const currentKind = selectedKinds[currentIndex]!;
|
||||
const draft = drafts.get(currentKind);
|
||||
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
|
||||
const computedUrl = baseHost
|
||||
const useUrlTemplate = baseHost && draft?.source !== "label";
|
||||
const computedUrl = useUrlTemplate
|
||||
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
|
||||
: draft?.initialUrl;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -79,17 +80,20 @@ export default async function ApiPage() {
|
||||
const toolGroups = getMcpToolGroups();
|
||||
|
||||
return (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
|
||||
@@ -31,10 +32,13 @@ export default async function BackupPage() {
|
||||
const t = await getScopedI18n("management.page.tool.backup");
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("title")}</Title>
|
||||
<BackupExportCard />
|
||||
<BackupImportCard />
|
||||
</Stack>
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("title")}</Title>
|
||||
<BackupExportCard />
|
||||
<BackupImportCard />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -24,6 +25,7 @@ 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,3 +1,5 @@
|
||||
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";
|
||||
@@ -16,6 +18,7 @@ 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;
|
||||
@@ -30,30 +33,33 @@ 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 (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -83,6 +89,7 @@ interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"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,3 +10,8 @@
|
||||
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,6 +14,8 @@ interface DynamicBreadcrumbProps {
|
||||
nonInteractable?: string[];
|
||||
}
|
||||
|
||||
const categorySegments = new Set(["tools"]);
|
||||
|
||||
/**
|
||||
* Breadcrumb is client side rendered. Elements are automatically
|
||||
* calculated and translated using dynamic keys.
|
||||
@@ -60,8 +62,11 @@ 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 (nonInteractable?.includes(pathnamePart)) {
|
||||
if (isNonInteractable) {
|
||||
return <Text key={href}>{mappedValue}</Text>;
|
||||
}
|
||||
return (
|
||||
@@ -71,7 +76,7 @@ export const DynamicBreadcrumb = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
if (isNonInteractable) {
|
||||
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +35,22 @@ 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 workspaceConfigContent = await fsPromises.readFile(
|
||||
path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"),
|
||||
"utf-8",
|
||||
);
|
||||
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
|
||||
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
|
||||
const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
|
||||
|
||||
return new Map(Object.entries(parseResult.catalog ?? {}));
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const workspaceConfigCatalogSchema = z.object({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.17.0-trixie AS base
|
||||
FROM node:24.18.0-trixie AS base
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"better-sqlite3": "^12.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.17.0",
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.9.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.9.0"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.68.0",
|
||||
"version": "1.68.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -61,7 +61,7 @@
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.17.0",
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.9.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
|
||||
@@ -3,10 +3,13 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { eq, like, or } from "@homarr/db";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { asc, eq, inArray, like, or } from "@homarr/db";
|
||||
import { placeAllWidgetsAsync } from "@homarr/db/queries";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
apps,
|
||||
boards,
|
||||
groups,
|
||||
icons,
|
||||
integrationItems,
|
||||
@@ -33,6 +36,8 @@ 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;
|
||||
@@ -43,6 +48,30 @@ 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) {
|
||||
@@ -145,51 +174,91 @@ export const onboardRouter = createTRPCRouter({
|
||||
}),
|
||||
discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
|
||||
const emptyResult = {
|
||||
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;
|
||||
}[],
|
||||
integrations: [] as DiscoveredIntegration[],
|
||||
apps: [] as DiscoveredApp[],
|
||||
};
|
||||
|
||||
try {
|
||||
const { DockerSingleton, dockerLabels } = await import("@homarr/docker");
|
||||
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 dockerInstances = DockerSingleton.getInstances();
|
||||
if (dockerInstances.length === 0) {
|
||||
return { status: "empty" as const, ...emptyResult };
|
||||
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
|
||||
return {
|
||||
status: hasResults ? ("success" as const) : ("empty" as const),
|
||||
integrations: discoveredIntegrations,
|
||||
apps: discoveredApps,
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
dockerInstances.map(async ({ instance, host }) => {
|
||||
const containerList = await instance.listContainers({ all: false });
|
||||
return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host }));
|
||||
return containerList
|
||||
.filter((container) => !(dockerLabels.hide in container.Labels))
|
||||
.filter((container) => !labeledContainerIds.has(container.Id))
|
||||
.map((container) => ({ ...container, host }));
|
||||
}),
|
||||
);
|
||||
|
||||
const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
|
||||
const containers = results.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
return result.value;
|
||||
}
|
||||
|
||||
if (containers.length === 0) {
|
||||
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) {
|
||||
return { status: "empty" as const, ...emptyResult };
|
||||
}
|
||||
|
||||
const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`));
|
||||
const likeQueries = containers.map((container) =>
|
||||
like(icons.name, `%${extractContainerImageName(container.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`;
|
||||
|
||||
@@ -223,6 +292,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
suggestedUrl,
|
||||
publishedPort,
|
||||
iconUrl,
|
||||
source: "docker",
|
||||
});
|
||||
} else if (!kind && dbIcon) {
|
||||
discoveredApps.push({
|
||||
@@ -231,19 +301,61 @@ export const onboardRouter = createTRPCRouter({
|
||||
suggestedUrl,
|
||||
publishedPort,
|
||||
iconUrl,
|
||||
source: "docker",
|
||||
host: container.host,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasResults = discoveredIntegrations.length > 0 || discoveredApps.length > 0;
|
||||
return {
|
||||
status: "success" as const,
|
||||
status: hasResults ? ("success" as const) : ("empty" as const),
|
||||
integrations: discoveredIntegrations,
|
||||
apps: discoveredApps,
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.warn("Docker discovery failed during onboarding", { cause: error });
|
||||
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(
|
||||
@@ -257,9 +369,17 @@ 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(
|
||||
input.map((app) => ({
|
||||
appsToCreate.map((app) => ({
|
||||
id: createId(),
|
||||
name: app.name,
|
||||
iconUrl: app.iconUrl ?? defaultIcon,
|
||||
@@ -270,16 +390,26 @@ 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 board = await db.query.boards.findFirst({
|
||||
const boardList = await db.query.boards.findMany({
|
||||
orderBy: asc(boards.name),
|
||||
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);
|
||||
@@ -297,15 +427,16 @@ export const onboardRouter = createTRPCRouter({
|
||||
const layout = board.layouts[0];
|
||||
|
||||
if (section && layout) {
|
||||
const existingItems = await db.query.items.findMany({
|
||||
where: eq(items.boardId, board.id),
|
||||
const emptySectionLayouts = await db.query.itemLayouts.findMany({
|
||||
where: eq(itemLayouts.sectionId, section.id),
|
||||
columns: { itemId: true },
|
||||
});
|
||||
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));
|
||||
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));
|
||||
}
|
||||
|
||||
await placeAllWidgetsAsync(
|
||||
@@ -317,7 +448,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
columnCount: layout.columnCount,
|
||||
},
|
||||
allIntegrations,
|
||||
allApps,
|
||||
appsForWidgetPlacement,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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,6 +295,13 @@
|
||||
"when": 1780764500000,
|
||||
"tag": "0041_merge_custom_widget_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "5",
|
||||
"when": 1780764600000,
|
||||
"tag": "0042_add_docker_app_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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,6 +57,13 @@
|
||||
"when": 1780764500000,
|
||||
"tag": "0007_merge_custom_widget_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1780764600000,
|
||||
"tag": "0008_add_docker_app_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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,6 +281,13 @@
|
||||
"when": 1780764500000,
|
||||
"tag": "0039_merge_custom_widget_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "6",
|
||||
"when": 1780764600000,
|
||||
"tag": "0040_add_docker_app_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export const {
|
||||
cronJobConfigurations,
|
||||
customWidgetDefinitions,
|
||||
customWidgetSecrets,
|
||||
dockerAppSources,
|
||||
} = schema;
|
||||
|
||||
export type User = InferSelectModel<typeof schema.users>;
|
||||
|
||||
@@ -547,6 +547,33 @@ 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],
|
||||
@@ -826,3 +853,22 @@ 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],
|
||||
}),
|
||||
}));
|
||||
@@ -546,6 +546,33 @@ 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],
|
||||
@@ -825,3 +852,22 @@ 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],
|
||||
}),
|
||||
}));
|
||||
@@ -536,6 +536,33 @@ 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],
|
||||
@@ -815,3 +842,22 @@ 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,6 +165,7 @@ 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"
|
||||
|
||||
@@ -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: null,
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
|
||||
},
|
||||
// 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: {
|
||||
|
||||
@@ -25,11 +25,15 @@
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"dockerode": "catalog:"
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"dockerode": "catalog:",
|
||||
"superjson": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
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";
|
||||
@@ -0,0 +1,45 @@
|
||||
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 [];
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,471 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
|
||||
|
||||
export type ContainerState = (typeof containerStates)[number];
|
||||
export * from "./labels";
|
||||
export * from "./discovery";
|
||||
@@ -1,4 +1,21 @@
|
||||
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 = await this.getAllStatsAsync();
|
||||
const [stats, cpuTemp] = await Promise.all([this.getAllStatsAsync(), this.getCpuTempAsync()]);
|
||||
|
||||
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: undefined,
|
||||
cpuTemp,
|
||||
gpu: stats.gpu.map((gpu) => ({
|
||||
gpuId: gpu.gpu_id,
|
||||
name: gpu.name,
|
||||
@@ -80,6 +80,23 @@ 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"));
|
||||
|
||||
@@ -173,3 +190,33 @@ 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,36 +69,24 @@ 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) {
|
||||
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;
|
||||
const externalLink = lidarrExternalCalendarLinks[link.name];
|
||||
if (!externalLink) {
|
||||
continue;
|
||||
}
|
||||
links.push({ ...externalLink, href: link.url });
|
||||
}
|
||||
|
||||
return links;
|
||||
@@ -109,7 +97,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
|
||||
): z.infer<typeof lidarrCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
const sortedImages = flatImages.toSorted(
|
||||
(imageA, imageB) =>
|
||||
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
|
||||
);
|
||||
@@ -149,6 +137,16 @@ 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() }),
|
||||
artist: z.object({
|
||||
links: z.array(z.object({ url: z.string().url(), name: z.string() })),
|
||||
artistName: z.string(),
|
||||
titleSlug: z.string().optional(),
|
||||
}),
|
||||
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 { describe, expect, test, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { GlancesIntegration } from "../src/glances/glances-integration";
|
||||
import { GlancesIntegration, parseGlancesCpuTempFromSensors } from "../src/glances/glances-integration";
|
||||
|
||||
// ─── module mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,6 +64,19 @@ 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",
|
||||
@@ -75,8 +88,12 @@ const integrationInput = {
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GlancesIntegration schema", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
test("parses stats when quicklook is present", async () => {
|
||||
mockFetch.mockResolvedValueOnce(makeResponse({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }));
|
||||
mockGlancesFetch({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }, []);
|
||||
|
||||
const integration = new GlancesIntegration(integrationInput);
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
@@ -86,7 +103,7 @@ describe("GlancesIntegration schema", () => {
|
||||
});
|
||||
|
||||
test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => {
|
||||
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
|
||||
mockGlancesFetch(baseStats, []);
|
||||
|
||||
const integration = new GlancesIntegration(integrationInput);
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
@@ -95,9 +112,41 @@ describe("GlancesIntegration schema", () => {
|
||||
});
|
||||
|
||||
test("does not throw a Zod parse error when quicklook is missing", async () => {
|
||||
mockFetch.mockResolvedValueOnce(makeResponse(baseStats));
|
||||
mockGlancesFetch(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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// @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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ export const defaultServerSettingsKeys = [
|
||||
"analytics",
|
||||
"crawlingAndIndexing",
|
||||
"board",
|
||||
"docker",
|
||||
"user",
|
||||
"appearance",
|
||||
"culture",
|
||||
@@ -30,6 +31,14 @@ 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,
|
||||
},
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "",
|
||||
"management": "",
|
||||
"docker": "",
|
||||
"preferences": "",
|
||||
"logout": "",
|
||||
"login": "",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "这里",
|
||||
"iconPicker": {
|
||||
"label": "图标 URL",
|
||||
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。"
|
||||
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "切换到暗黑模式",
|
||||
"switchToLightMode": "切换到日间模式",
|
||||
"management": "管理中心",
|
||||
"docker": "",
|
||||
"preferences": "偏好设置",
|
||||
"logout": "退出登录",
|
||||
"login": "登录",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "Docker 状态",
|
||||
"description": "您的容器状态 (这个小部件只能用管理员权限添加)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": "启用项目排序"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "实例"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "未找到",
|
||||
"text": "找不到请求的资源"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "首页",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": "Umami 活跃访客"
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "获取 Docker 容器失败"
|
||||
"internalServerError": "获取 Docker 容器失败",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "crwdns4473:0crwdne4473:0",
|
||||
"speedtest": "crwdns4475:0crwdne4475:0",
|
||||
"analytics": "crwdns4477:0crwdne4477:0",
|
||||
"vpn": "crwdns5687:0crwdne5687:0",
|
||||
"mediaLibrary": "crwdns4979:0crwdne4979:0",
|
||||
"uptimeMonitoring": "crwdns4981:0crwdne4981:0"
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "crwdns552:0crwdne552:0",
|
||||
"iconPicker": {
|
||||
"label": "crwdns554:0crwdne554:0",
|
||||
"header": "crwdns556:0{countIcons}crwdne556:0"
|
||||
"header": "crwdns556:0{countIcons}crwdne556:0",
|
||||
"headerLoading": "crwdns5681:0crwdne5681:0",
|
||||
"noResults": "crwdns5683:0{search}crwdne5683:0",
|
||||
"error": "crwdns5685:0crwdne5685:0"
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"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",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "crwdns3424:0crwdne3424:0",
|
||||
"description": "crwdns3426:0crwdne3426:0",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": "crwdns5705:0crwdne5705:0"
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": "crwdns3808:0crwdne3808:0"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"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",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": "crwdns5653:0crwdne5653:0"
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": "crwdns5677:0crwdne5677:0"
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "crwdns4865:0crwdne4865:0",
|
||||
"memory": "crwdns4867:0crwdne4867:0",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "crwdns4927:0crwdne4927:0",
|
||||
"empty": {
|
||||
"noSystems": "crwdns5679:0crwdne5679:0"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "crwdns5661:0crwdne5661:0",
|
||||
"liveConnectionFailed": "crwdns5663:0crwdne5663:0",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"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",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": "crwdns4223:0crwdne4223:0"
|
||||
},
|
||||
"vpn": {
|
||||
"label": "crwdns5695:0crwdne5695:0"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "crwdns2262:0crwdne2262:0"
|
||||
"internalServerError": "crwdns2262:0crwdne2262:0",
|
||||
"noColumns": "crwdns5707:0crwdne5707:0"
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "zde",
|
||||
"iconPicker": {
|
||||
"label": "URL ikony",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"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",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": "Nepodařilo se nalézt požadovaný zdroj"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Domovská stránka",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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."
|
||||
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Skift til mørk tilstand",
|
||||
"switchToLightMode": "Skift til lys tilstand",
|
||||
"management": "Håndtering",
|
||||
"docker": "",
|
||||
"preferences": "Dine indstillinger",
|
||||
"logout": "Log ud",
|
||||
"login": "Log ind",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "Docker statistik",
|
||||
"description": "Statistik for dine containere (Denne widget kan kun tilføjes med administratorrettigheder)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": "Aktiver sortering"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Instanser"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Ikke Fundet",
|
||||
"text": "Kunne ikke finde den anmodede ressource"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Hjem",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Kunne ikke hente Docker-containere"
|
||||
"internalServerError": "Kunne ikke hente Docker-containere",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "Hier",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie."
|
||||
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Zum dunklen Design wechseln",
|
||||
"switchToLightMode": "Zum hellen Design wechseln",
|
||||
"management": "Verwaltung",
|
||||
"docker": "",
|
||||
"preferences": "Ihre Einstellungen",
|
||||
"logout": "Abmelden",
|
||||
"login": "Anmelden",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Nicht gefunden",
|
||||
"text": "Konnte die angeforderte Ressource nicht finden"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Startseite",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Fehler beim Abrufen der Docker Container"
|
||||
"internalServerError": "Fehler beim Abrufen der Docker Container",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "Überwachung",
|
||||
"speedtest": "Geschwindigkeitstest",
|
||||
"analytics": "Nutzungsdaten",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "Medienbibliothek",
|
||||
"uptimeMonitoring": "Überwachung der Betriebszeit"
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Zum dunklen Design wechseln",
|
||||
"switchToLightMode": "Zum hellen Design wechseln",
|
||||
"management": "Verwaltung",
|
||||
"docker": "",
|
||||
"preferences": "Ihre Einstellungen",
|
||||
"logout": "Abmelden",
|
||||
"login": "Anmelden",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Instanzen"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "UPS",
|
||||
"description": "Überwachen Sie den Status Ihrer UPS Geräte über einen NUT Server",
|
||||
@@ -3395,53 +3409,53 @@
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "\"Sie hören\" anzeigen",
|
||||
"description": ""
|
||||
"description": "Zeige aktuell gespielte Titel an (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "\"Sie hören jetzt\" Wiedergabeliste anzeigen",
|
||||
"description": "Einzelne Tracks anstelle der aktiven Streamanzahl anzeigen (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Maximale jetzt abgespielte Elemente",
|
||||
"description": "Maximale Anzahl an Titeln, die in der \"Sie hören jetzt\" Wiedergabeliste angezeigt werden (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Anzahl der Bibliotheken anzeigen",
|
||||
"description": "Zeige die Anzahl der Bibliotheken in deiner Instanz (Hörbücherregal)"
|
||||
},
|
||||
"showAudiobooks": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Hörbücher anzeigen",
|
||||
"description": "Zeige die Gesamtzahl der Hörbücher in allen Bibliotheken (Hörbücherregal)"
|
||||
},
|
||||
"showPodcasts": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Podcasts anzeigen",
|
||||
"description": "Zeige die Gesamtzahl der Podcasts in allen Bibliotheken (Hörbücherregal)"
|
||||
},
|
||||
"showListeningTime": {
|
||||
"label": "",
|
||||
"label": "Hördauer anzeigen",
|
||||
"description": "Zeigt deine gesamte Hörzeit (Audiobookshelf)"
|
||||
},
|
||||
"showActiveSessions": {
|
||||
"label": "Aktive Streams anzeigen",
|
||||
"description": ""
|
||||
"description": "Zeige die Anzahl der aktuell aktiven Wiedergabesitzungen (Hörbücherregal)"
|
||||
},
|
||||
"compactMode": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Kompakter Modus",
|
||||
"description": "Verwenden Sie ein dichteres Layout mit kleineren Kacheln, ideal für kleine Widget-Größen"
|
||||
}
|
||||
},
|
||||
"artists": "",
|
||||
"albums": "",
|
||||
"songs": "",
|
||||
"libraryCount": "",
|
||||
"audiobooks": "",
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"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}}",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "Fehler beim Abrufen der Audio-Statistikdaten"
|
||||
}
|
||||
},
|
||||
"systemResources": {
|
||||
@@ -3457,7 +3471,7 @@
|
||||
"option": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Speicher",
|
||||
"gpu": "",
|
||||
"gpu": "GPU",
|
||||
"network": "Netzwerk"
|
||||
}
|
||||
},
|
||||
@@ -3474,7 +3488,7 @@
|
||||
"card": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Arbeitsspeicher",
|
||||
"gpu": "",
|
||||
"gpu": "GPU",
|
||||
"network": "Netzwerk",
|
||||
"up": "OBEN",
|
||||
"down": "UNTEN"
|
||||
@@ -3510,93 +3524,93 @@
|
||||
}
|
||||
},
|
||||
"customApi": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Benutzerdefinierte API",
|
||||
"description": "Zeigt Daten von jeder externen API mit benutzerdefinierter Konfiguration an.",
|
||||
"option": {
|
||||
"definitionId": {
|
||||
"label": ""
|
||||
"label": "Definitions ID"
|
||||
},
|
||||
"refreshInterval": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Aktualisierungsintervall",
|
||||
"description": "Wie oft Daten von der API neu geladen werden sollen (in Sekunden)"
|
||||
}
|
||||
},
|
||||
"noDefinition": "",
|
||||
"definitionNotFound": "",
|
||||
"noDisplayOutput": "",
|
||||
"executeConfirm": "",
|
||||
"executing": "",
|
||||
"executeSuccess": "",
|
||||
"executeFailed": ""
|
||||
"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."
|
||||
},
|
||||
"beszelSystemTable": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Beszel Systeme (Tabelle)",
|
||||
"description": "Tabellenansicht aller Beszel-überwachten Systeme mit sortierbaren Spalten und Statusindikatoren",
|
||||
"option": {
|
||||
"statusFilter": {
|
||||
"label": ""
|
||||
"label": "Status Filter"
|
||||
},
|
||||
"sortBy": {
|
||||
"label": ""
|
||||
"label": "Sortieren nach"
|
||||
},
|
||||
"sortDirection": {
|
||||
"label": ""
|
||||
"label": "Sortierreihenfolge"
|
||||
},
|
||||
"showCpu": {
|
||||
"label": ""
|
||||
"label": "CPU anzeigen"
|
||||
},
|
||||
"showMemory": {
|
||||
"label": ""
|
||||
"label": "Arbeitsspeicher anzeigen"
|
||||
},
|
||||
"showDisk": {
|
||||
"label": ""
|
||||
"label": "Datenträger anzeigen"
|
||||
},
|
||||
"showGpu": {
|
||||
"label": ""
|
||||
"label": "GPU anzeigen"
|
||||
},
|
||||
"showLoadAvg": {
|
||||
"label": ""
|
||||
"label": "Zeige Belastungsdurchschnitt"
|
||||
},
|
||||
"showNet": {
|
||||
"label": ""
|
||||
"label": "Netzwerk anzeigen"
|
||||
},
|
||||
"showTemp": {
|
||||
"label": ""
|
||||
"label": "Temperatur anzeigen"
|
||||
},
|
||||
"showBattery": {
|
||||
"label": ""
|
||||
"label": "Akkustand anzeigen"
|
||||
},
|
||||
"showServices": {
|
||||
"label": ""
|
||||
"label": "Dienste anzeigen"
|
||||
},
|
||||
"showUptime": {
|
||||
"label": ""
|
||||
"label": "Laufzeit anzeigen"
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
"label": "Agent-Version anzeigen"
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"noRecords": "Keine Systeme gefunden",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen."
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"disk": "",
|
||||
"gpu": "",
|
||||
"loadAvg": "",
|
||||
"net": "",
|
||||
"temp": "",
|
||||
"battery": "",
|
||||
"services": "",
|
||||
"uptime": "",
|
||||
"agent": ""
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"beszelSystemGrid": {
|
||||
"name": "",
|
||||
"name": "Beszel Systems (Grid)",
|
||||
"description": "",
|
||||
"option": {
|
||||
"statusFilter": {
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Arbeitsspeicher",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "System auswählen",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Nicht gefunden",
|
||||
"text": "Konnte die angeforderte Ressource nicht finden"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Startseite",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Fehler beim Abrufen der Docker Container"
|
||||
"internalServerError": "Fehler beim Abrufen der Docker Container",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "εδώ",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Εναλλαγή σε σκοτεινή λειτουργία",
|
||||
"switchToLightMode": "Εναλλαγή σε φωτεινή λειτουργία",
|
||||
"management": "Διαχείριση",
|
||||
"docker": "",
|
||||
"preferences": "Οι ρυθμίσεις σας",
|
||||
"logout": "Αποσύνδεση",
|
||||
"login": "Σύνδεση",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "Στατιστικά Docker",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Αρχική",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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."
|
||||
"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": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Switch to dark mode",
|
||||
"switchToLightMode": "Switch to light mode",
|
||||
"management": "Management",
|
||||
"docker": "",
|
||||
"preferences": "Your preferences",
|
||||
"logout": "Log-out",
|
||||
"login": "Log-in",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Instances"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1598,7 +1598,9 @@
|
||||
},
|
||||
"menu": {
|
||||
"label": {
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"options": "Options",
|
||||
"actions": "Actions"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -4311,7 +4313,7 @@
|
||||
"notStarted": "Not started"
|
||||
},
|
||||
"name": {
|
||||
"management": "Management",
|
||||
"management": "Management",
|
||||
"dashboard": "Dashboard"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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."
|
||||
"header": "Escribe nombre u objetos para filtrar iconos... Homarr buscará a través de iconos {countIcons} por ti.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"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",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Instancias"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "No Encontrado",
|
||||
"text": "No se pudo encontrar el recurso solicitado"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Inicio",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Fallo al obtener los contenedores de Docker"
|
||||
"internalServerError": "Fallo al obtener los contenedores de Docker",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "",
|
||||
"management": "",
|
||||
"docker": "",
|
||||
"preferences": "Teie eelistused",
|
||||
"logout": "",
|
||||
"login": "",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "Valvonta",
|
||||
"speedtest": "Nopeustestaus",
|
||||
"analytics": "Analytiikka",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1044,7 +1045,7 @@
|
||||
"description": "Vastausta ei voitu jäsentää. Tarkista, että URL-osoite osoittaa palvelun perusosoitteeseen."
|
||||
},
|
||||
"authorization": {
|
||||
"title": "Tuonnistautumisvirhe",
|
||||
"title": "Tunnistautumisvirhe",
|
||||
"description": "Pyyntöä ei hyväksytty. Varmista, että kirjautumistiedot ovat oikein ja että niille on määritetty riittävät käyttöoikeudet."
|
||||
},
|
||||
"statusCode": {
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "Kuvakkeen URL-osoite",
|
||||
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta."
|
||||
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "",
|
||||
"management": "Hallinta",
|
||||
"docker": "",
|
||||
"preferences": "Asetuksesi",
|
||||
"logout": "Kirjaudu ulos",
|
||||
"login": "",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Aloitus",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": "Umamin aktiiviset käyttäjät"
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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."
|
||||
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Activer le mode sombre",
|
||||
"switchToLightMode": "Activer le mode clair",
|
||||
"management": "Administration",
|
||||
"docker": "",
|
||||
"preferences": "Vos préférences",
|
||||
"logout": "Se déconnecter",
|
||||
"login": "Connexion",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Instances"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Non trouvé",
|
||||
"text": "Impossible de trouver la ressource demandée"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Accueil",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Impossible de récupérer les conteneurs Docker"
|
||||
"internalServerError": "Impossible de récupérer les conteneurs Docker",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "כאן",
|
||||
"iconPicker": {
|
||||
"label": "קישור של הסמל",
|
||||
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} ."
|
||||
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} .",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "עבור למצב כהה",
|
||||
"switchToLightMode": "עבור למצב בהיר",
|
||||
"management": "ניהול",
|
||||
"docker": "",
|
||||
"preferences": "העדפות שלך",
|
||||
"logout": "התנתק/י",
|
||||
"login": "התחבר/י",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "סטטיסטיקות דוקר",
|
||||
"description": "סטטיסטיקות של המכולות שלך (ניתן להוסיף ווידג'ט זה רק עם הרשאות מנהל)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": "אפשר מיון פריטים"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "לא נמצא",
|
||||
"text": "לא ניתן לאתר את המשאב המבוקש"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "מסך הבית",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "נכשל בקבלת קונטיינרי דוקר"
|
||||
"internalServerError": "נכשל בקבלת קונטיינרי דוקר",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "",
|
||||
"management": "",
|
||||
"docker": "",
|
||||
"preferences": "Vaše preferencije",
|
||||
"logout": "",
|
||||
"login": "Prijaviti se",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Dom",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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."
|
||||
"header": "Írjon be nevet vagy objektumokat az ikonok szűréséhez... A Homarr a {countIcons} ikonok között keres.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"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",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Példányok"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Nyitólap",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "Monitoraggio",
|
||||
"speedtest": "Speed test",
|
||||
"analytics": "Analytics",
|
||||
"vpn": "VPN",
|
||||
"mediaLibrary": "Media Library",
|
||||
"uptimeMonitoring": "Uptime Monitoring"
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Passa alla modalità scura",
|
||||
"switchToLightMode": "Passa al tema chiaro",
|
||||
"management": "Gestione",
|
||||
"docker": "Docker",
|
||||
"preferences": "Le tue impostazioni",
|
||||
"logout": "Disconnettiti",
|
||||
"login": "Accedi",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"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",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione."
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": "Nessun sistema trovato"
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"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",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": "Visitatori Attivi Umami"
|
||||
},
|
||||
"vpn": {
|
||||
"label": "Stato VPN"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Recupero dei container Docker non riuscito"
|
||||
"internalServerError": "Recupero dei container Docker non riuscito",
|
||||
"noColumns": "Seleziona almeno una colonna nelle impostazioni dei widget"
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "こちら",
|
||||
"iconPicker": {
|
||||
"label": "アイコン URL",
|
||||
"header": "アイコンをフィルタするために、名前またはオブジェクトを入力してください... Homarrは {countIcons} アイコンを検索します。"
|
||||
"header": "アイコンをフィルタするために、名前またはオブジェクトを入力してください... Homarrは {countIcons} アイコンを検索します。",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "ダークモードに切り替え",
|
||||
"switchToLightMode": "ライトモードに切り替え",
|
||||
"management": "管理",
|
||||
"docker": "",
|
||||
"preferences": "個人設定",
|
||||
"logout": "ログアウト",
|
||||
"login": "ログイン",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "Dockerのステータス",
|
||||
"description": "あなたのコンテナの統計(このウィジェットは管理者権限でのみ追加できます)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": "アイテムの並び替えを有効にする"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "インスタンス"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "見つかりません",
|
||||
"text": "要求されたリソースが見つかりませんでした"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "ホーム",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Docker コンテナの取得に失敗しました"
|
||||
"internalServerError": "Docker コンテナの取得に失敗しました",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -288,10 +288,10 @@
|
||||
"changePingIconsEnabled": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Ping 아이콘 전환 완료"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "Ping 아이콘을 전환할 수 없습니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -701,56 +701,56 @@
|
||||
},
|
||||
"action": {
|
||||
"create": "생성",
|
||||
"import": "",
|
||||
"save": "",
|
||||
"delete": "",
|
||||
"deleteConfirm": "",
|
||||
"export": "",
|
||||
"addMapping": "",
|
||||
"addColumn": "",
|
||||
"edit": "",
|
||||
"removeMapping": "",
|
||||
"removeColumn": "",
|
||||
"addItem": "",
|
||||
"removeItem": "",
|
||||
"addBar": "",
|
||||
"duplicate": "",
|
||||
"copyAiPrompt": "",
|
||||
"enable": "",
|
||||
"disable": "",
|
||||
"menu": ""
|
||||
"import": "가져오기",
|
||||
"save": "저장",
|
||||
"delete": "사용자 지정 위젯 삭제",
|
||||
"deleteConfirm": "\"{name}\"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"export": "내보내기",
|
||||
"addMapping": "매핑 추가",
|
||||
"addColumn": "열 추가",
|
||||
"edit": "편집",
|
||||
"removeMapping": "매핑 제거",
|
||||
"removeColumn": "열 제거",
|
||||
"addItem": "항목 추가",
|
||||
"removeItem": "항목 제거",
|
||||
"addBar": "바 추가",
|
||||
"duplicate": "복사",
|
||||
"copyAiPrompt": "AI 프롬프트 복사",
|
||||
"enable": "활성화",
|
||||
"disable": "비활성화",
|
||||
"menu": "동작"
|
||||
},
|
||||
"field": {
|
||||
"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": "",
|
||||
"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": "레이블 위치",
|
||||
"kvLayout": "레이아웃",
|
||||
"striped": "",
|
||||
"compact": "컴팩트 모드",
|
||||
@@ -849,14 +849,14 @@
|
||||
"secret": {
|
||||
"apiKey": "",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"savedPlaceholder": ""
|
||||
"password": "비밀번호",
|
||||
"savedPlaceholder": "••••••• (저장됨, 유지하려면 공백)"
|
||||
},
|
||||
"preview": {
|
||||
"title": "",
|
||||
"test": "",
|
||||
"hint": "",
|
||||
"rawResponse": ""
|
||||
"title": "미리보기",
|
||||
"test": "테스트",
|
||||
"hint": "현재 설정으로 API 응답을 미리 보려면 테스트를 클릭하십시오.",
|
||||
"rawResponse": "원본 JSON 응답"
|
||||
},
|
||||
"notification": {
|
||||
"created": "\"{name}\" 위젯 생성 완료.",
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "모니터링",
|
||||
"speedtest": "속도 테스트",
|
||||
"analytics": "분석",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "미디어 라이브러리",
|
||||
"uptimeMonitoring": "업타임 모니터링"
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "여기",
|
||||
"iconPicker": {
|
||||
"label": "아이콘 URL",
|
||||
"header": "이름 또는 주제를 입력해 아이콘을 필터링하세요... Homarr가 {countIcons}에서 아이콘을 검색합니다."
|
||||
"header": "이름 또는 주제를 입력해 아이콘을 필터링하세요... Homarr가 {countIcons}에서 아이콘을 검색합니다.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "다크 모드로 전환",
|
||||
"switchToLightMode": "라이트 모드로 전환",
|
||||
"management": "관리",
|
||||
"docker": "",
|
||||
"preferences": "기본 설정",
|
||||
"logout": "로그아웃",
|
||||
"login": "로그인",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "찾을 수 없음",
|
||||
"text": "요청한 리소스를 찾을 수 없습니다"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "홈",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "",
|
||||
"management": "",
|
||||
"docker": "",
|
||||
"preferences": "",
|
||||
"logout": "",
|
||||
"login": "Prisijungti",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Pagrindinis",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "",
|
||||
"management": "",
|
||||
"docker": "",
|
||||
"preferences": "Jūsu iestatījumi",
|
||||
"logout": "",
|
||||
"login": "Pieslēgties",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Sākums",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "hier",
|
||||
"iconPicker": {
|
||||
"label": "Icoon-URL",
|
||||
"header": "Typ naam of objecten om te filteren op iconen… Homarr doorzoekt {countIcons} iconen voor je."
|
||||
"header": "Typ naam of objecten om te filteren op iconen… Homarr doorzoekt {countIcons} iconen voor je.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Schakel naar donkere modus",
|
||||
"switchToLightMode": "Schakel naar lichte modus",
|
||||
"management": "Beheer",
|
||||
"docker": "",
|
||||
"preferences": "Jouw voorkeuren",
|
||||
"logout": "Uitloggen",
|
||||
"login": "Inloggen",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "Docker statistieken",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Niet gevonden",
|
||||
"text": "Kon de aangevraagde bron niet vinden"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Home",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Docker-containers zijn niet opgehaald"
|
||||
"internalServerError": "Docker-containers zijn niet opgehaald",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "her",
|
||||
"iconPicker": {
|
||||
"label": "Ikon-URL",
|
||||
"header": "Skriv navn eller objekt å filtrere for ikoner... Homarr vil søke gjennom {countIcons} ikoner for deg."
|
||||
"header": "Skriv navn eller objekt å filtrere for ikoner... Homarr vil søke gjennom {countIcons} ikoner for deg.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Bytt til mørk modus",
|
||||
"switchToLightMode": "Bytt til lys modus",
|
||||
"management": "Administrasjon",
|
||||
"docker": "",
|
||||
"preferences": "Dine innstillinger",
|
||||
"logout": "Logg ut",
|
||||
"login": "Logg Inn",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Instanser"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Ikke funnet",
|
||||
"text": "Fant ikke den forespurte ressursen"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Hjem",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": "Umami Aktive Besøkende"
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Klarte ikke hente Docker-containere"
|
||||
"internalServerError": "Klarte ikke hente Docker-containere",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "Monitoramento",
|
||||
"speedtest": "Teste de Velocidade",
|
||||
"analytics": "Análises",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "Biblioteca de mídia",
|
||||
"uptimeMonitoring": "Monitoramento do Uptime"
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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ê."
|
||||
"header": "Digite o nome ou os objetos para filtrar os ícones... O Homarr pesquisará {countIcons} ícones para você.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Alternar para o modo escuro",
|
||||
"switchToLightMode": "Alternar para o modo claro",
|
||||
"management": "Gerenciamento",
|
||||
"docker": "",
|
||||
"preferences": "Suas preferências",
|
||||
"logout": "Sair",
|
||||
"login": "“Login”",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"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",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Não encontrado",
|
||||
"text": "Não foi possível encontrar o recurso solicitado"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Início",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": "Visitantes Ativos do Umami"
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Falha ao buscar contêineres Docker"
|
||||
"internalServerError": "Falha ao buscar contêineres Docker",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "",
|
||||
"management": "",
|
||||
"docker": "",
|
||||
"preferences": "",
|
||||
"logout": "",
|
||||
"login": "",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "",
|
||||
"management": "",
|
||||
"docker": "",
|
||||
"preferences": "Preferințele dvs.",
|
||||
"logout": "",
|
||||
"login": "Utilizator",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Acasă",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "здесь",
|
||||
"iconPicker": {
|
||||
"label": "URL иконки",
|
||||
"header": "Введите название или объекты для фильтрации иконок... Homarr найдёт для вас среди {countIcons} иконок."
|
||||
"header": "Введите название или объекты для фильтрации иконок... Homarr найдёт для вас среди {countIcons} иконок.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Переключить на тёмную тему",
|
||||
"switchToLightMode": "Переключить на светлую тему",
|
||||
"management": "Управление",
|
||||
"docker": "",
|
||||
"preferences": "Настройки",
|
||||
"logout": "Выйти",
|
||||
"login": "Вход",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "Статистика Docker",
|
||||
"description": "Статистика контейнеров (этот виджет можно добавить только с правами администратора)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": "Включить сортировку элементов"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Экземпляры"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Не найдено",
|
||||
"text": "Запрошенный ресурс не найден"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Главная",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Произошла ошибка при обновлении данных о контейнерах"
|
||||
"internalServerError": "Произошла ошибка при обновлении данных о контейнерах",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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} ."
|
||||
"header": "Zadajte názov alebo objekty na filtrovanie ikon... Homarr pre vás vyhľadá ikony na stránke {countIcons} .",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"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",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Nenájdené",
|
||||
"text": "Nepodarilo sa nájsť požadovaný zdroj"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Domovská stránka",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Nepodarilo sa načítať kontajnery Docker"
|
||||
"internalServerError": "Nepodarilo sa načítať kontajnery Docker",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "Svetli način",
|
||||
"management": "Nastavitve",
|
||||
"docker": "",
|
||||
"preferences": "Prilagoditev",
|
||||
"logout": "Odjava",
|
||||
"login": "Prijava",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Domov",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"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",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Hittades ej",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Hem",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"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."
|
||||
"header": "Simgeleri filtrelemek için isim veya nesne yazın... Homarr sizin için {countIcons} simge arasında arama yapacaktır.",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Koyu Temaya Geç",
|
||||
"switchToLightMode": "Aydınlık Temaya Geç",
|
||||
"management": "Yönetim",
|
||||
"docker": "",
|
||||
"preferences": "Tercihleriniz",
|
||||
"logout": "Oturumu kapat",
|
||||
"login": "Giriş",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Instances"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Bulunamadı",
|
||||
"text": "İstenen kaynak bulunamadı"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Ana Sayfa",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Docker konteynerlerları getirilemedi"
|
||||
"internalServerError": "Docker konteynerlerları getirilemedi",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "Медіа бібліотека",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "тут",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Перемикнути на темну тему",
|
||||
"switchToLightMode": "Перемикнути на світлу тему",
|
||||
"management": "Управління",
|
||||
"docker": "",
|
||||
"preferences": "Ваші налаштування",
|
||||
"logout": "Вийти",
|
||||
"login": "Логін",
|
||||
@@ -1938,7 +1943,7 @@
|
||||
"addTable": "Додати таблицю",
|
||||
"deleteTable": "Видалити таблицю",
|
||||
"colorCell": "",
|
||||
"mergeCell": "",
|
||||
"mergeCell": "Ввімкнути/вимкнути об'єднання клітини",
|
||||
"addColumnLeft": "",
|
||||
"addColumnRight": "",
|
||||
"deleteColumn": "",
|
||||
@@ -2101,13 +2106,13 @@
|
||||
"label": ""
|
||||
},
|
||||
"1y": {
|
||||
"label": ""
|
||||
"label": "1 рік"
|
||||
},
|
||||
"2y": {
|
||||
"label": ""
|
||||
},
|
||||
"5y": {
|
||||
"label": ""
|
||||
"label": "5 років"
|
||||
},
|
||||
"10y": {
|
||||
"label": ""
|
||||
@@ -2142,7 +2147,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"1mo": {
|
||||
"label": ""
|
||||
"label": "1 місяць"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2182,7 +2187,7 @@
|
||||
"label": "Температура за Фаренгейтом"
|
||||
},
|
||||
"disableTemperatureDecimals": {
|
||||
"label": ""
|
||||
"label": "Вимкнути десяткові дроби температури"
|
||||
},
|
||||
"showCurrentWindSpeed": {
|
||||
"label": "Показати поточну швидкість вітру",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2359,7 +2367,7 @@
|
||||
"label": "",
|
||||
"option": {
|
||||
"name": "",
|
||||
"state": "",
|
||||
"state": "Стан",
|
||||
"cpuUsage": "",
|
||||
"memoryUsage": ""
|
||||
}
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -2470,7 +2484,7 @@
|
||||
"videoDecision": {
|
||||
"directPlay": "",
|
||||
"transcode": "",
|
||||
"directStream": ""
|
||||
"directStream": "Прямий потік"
|
||||
}
|
||||
},
|
||||
"recentActivity": {
|
||||
@@ -2550,7 +2564,7 @@
|
||||
"label": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"chart": "",
|
||||
"chart": "Графік",
|
||||
"events": "",
|
||||
"topPages": "",
|
||||
"topReferrers": ""
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Не знайдено",
|
||||
"text": "Не вдалося знайти запитуваний ресурс"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Головна",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": ""
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "",
|
||||
"switchToLightMode": "Đổi qua chế độ nền sáng",
|
||||
"management": "",
|
||||
"docker": "",
|
||||
"preferences": "Cá nhân hoá",
|
||||
"logout": "",
|
||||
"login": "Đăng nhập",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "",
|
||||
"text": ""
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Trang chủ",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"vpn": "",
|
||||
"mediaLibrary": "",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "此處",
|
||||
"iconPicker": {
|
||||
"label": "Icon 網址",
|
||||
"header": "輸入名稱或物件來篩選 icons,Homarr 會自動搜尋 {countIcons}"
|
||||
"header": "輸入名稱或物件來篩選 icons,Homarr 會自動搜尋 {countIcons}",
|
||||
"headerLoading": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "切換至暗黑模式",
|
||||
"switchToLightMode": "切換至日間模式",
|
||||
"management": "管理中心",
|
||||
"docker": "",
|
||||
"preferences": "偏好設定",
|
||||
"logout": "登出",
|
||||
"login": "登入",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"liveConnectionFailed": "",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "不存在",
|
||||
"text": "找不到請求的資源"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "首頁",
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": ""
|
||||
},
|
||||
"vpn": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "取得 Docker 容器失敗"
|
||||
"internalServerError": "取得 Docker 容器失敗",
|
||||
"noColumns": ""
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
transition: scale 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.appWithUrl:hover > .appIcon {
|
||||
|
||||
@@ -87,6 +87,7 @@ 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 && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -9,7 +10,35 @@ import type { stringOrTranslation } from "@homarr/translation";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { WidgetImports } from ".";
|
||||
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
|
||||
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>;
|
||||
}
|
||||
|
||||
const createWithDynamicImport =
|
||||
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
|
||||
@@ -56,6 +85,7 @@ export interface WidgetDefinition {
|
||||
}
|
||||
>
|
||||
>;
|
||||
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
|
||||
}
|
||||
|
||||
export interface WidgetProps<TKind extends WidgetKind> {
|
||||
@@ -70,6 +100,7 @@ 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"];
|
||||
|
||||
@@ -61,7 +61,12 @@ import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
import * as customApi from "./custom-api";
|
||||
|
||||
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
|
||||
export type {
|
||||
WidgetDefinition,
|
||||
WidgetContextMenuAction,
|
||||
WidgetContextActionProps,
|
||||
WidgetOptionsSettings,
|
||||
} from "./definition";
|
||||
export type { WidgetComponentProps };
|
||||
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconZoomQuestion } from "@tabler/icons-react";
|
||||
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
@@ -14,5 +15,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
|
||||
}),
|
||||
}));
|
||||
},
|
||||
contextActions: ({ integrationIds }) => [
|
||||
{
|
||||
key: "search",
|
||||
label: (t) => t("search.mode.media.action.search.label"),
|
||||
icon: IconSearch,
|
||||
onClick: () => {
|
||||
openMediaRequestSearch({ integrationIds });
|
||||
},
|
||||
},
|
||||
],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconChartBar } from "@tabler/icons-react";
|
||||
import { IconChartBar, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
|
||||
@@ -9,5 +10,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
|
||||
createOptions() {
|
||||
return {};
|
||||
},
|
||||
contextActions: ({ integrationIds }) => [
|
||||
{
|
||||
key: "search",
|
||||
label: (t) => t("search.mode.media.action.search.label"),
|
||||
icon: IconSearch,
|
||||
onClick: () => {
|
||||
openMediaRequestSearch({ integrationIds });
|
||||
},
|
||||
},
|
||||
],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -263,8 +264,21 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
setIsEditing(handleEditToggleCallback);
|
||||
}, [setIsEditing, handleEditToggleCallback]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!canChange || isEditing) return;
|
||||
// Ignore double-clicks bubbling up from interactive controls (e.g. the
|
||||
// edit/save ActionIcon), which would otherwise toggle edit mode twice.
|
||||
if (event.target instanceof Element && event.target.closest("button, a")) {
|
||||
return;
|
||||
}
|
||||
setIsEditing(handleEditToggleCallback);
|
||||
},
|
||||
[canChange, isEditing, setIsEditing, handleEditToggleCallback],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box h="100%">
|
||||
<Box h="100%" onDoubleClick={handleDoubleClick}>
|
||||
<RichTextEditor
|
||||
p={0}
|
||||
mt={0}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ReleasesRepository } from "./releases/releases-repository";
|
||||
interface CommonInput<TType> {
|
||||
defaultValue?: TType;
|
||||
withDescription?: boolean;
|
||||
skipContextMenu?: boolean;
|
||||
}
|
||||
|
||||
interface TextInput extends CommonInput<string> {
|
||||
@@ -91,6 +92,7 @@ const optionsFactory = {
|
||||
type: "switch" as const,
|
||||
defaultValue: input?.defaultValue ?? false,
|
||||
withDescription: input?.withDescription ?? false,
|
||||
skipContextMenu: input?.skipContextMenu ?? false,
|
||||
}),
|
||||
text: (input?: TextInput) => ({
|
||||
type: "text" as const,
|
||||
|
||||
@@ -247,7 +247,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
!hasError &&
|
||||
formatter.relativeTime(repository.latestReleaseAt, {
|
||||
now,
|
||||
style: "narrow",
|
||||
style: "long",
|
||||
})}
|
||||
</Text>
|
||||
{hasError ? (
|
||||
@@ -564,7 +564,7 @@ const ExpandedDisplay = ({
|
||||
<Text className="releases-repository-expanded-createdAt-date" span fw={700}>
|
||||
{formatter.relativeTime(repository.createdAt, {
|
||||
now,
|
||||
style: "narrow",
|
||||
style: "long",
|
||||
})}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
Generated
+781
-391
File diff suppressed because it is too large.
Load diff
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user