Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a029760f66 | ||
|
|
34d45d7448 | ||
|
|
db558990a0 | ||
|
|
8ffae729cc | ||
|
|
fbebe31ef3 | ||
|
|
0a94110a67 | ||
|
|
04069610fa | ||
|
|
f2572bdeef | ||
|
|
d08bc942a2 | ||
|
|
e505e94644 | ||
|
|
e32b956385 | ||
|
|
5e02ad4e78 | ||
|
|
8bdbc30376 | ||
|
|
44e9c22092 | ||
|
|
d2912c5e31 | ||
|
|
e3e077fcb8 | ||
|
|
ca298cd688 | ||
|
|
ce886f7ca1 | ||
|
|
b39e27fd3b | ||
|
|
74ff68993e | ||
|
|
9a7915d230 | ||
|
|
4b7b7c5d1c | ||
|
|
c3633e7834 | ||
|
|
3d31ce8eec | ||
|
|
58e7fe730a | ||
|
|
92b949c47f | ||
|
|
3ef0b68112 | ||
|
|
ba3a060b06 | ||
|
|
974bf83bde | ||
|
|
896c60df55 | ||
|
|
9d1465c4fc | ||
|
|
7b443c9da3 | ||
|
|
64beae817d | ||
|
|
a3b7ca9a8c | ||
|
|
1c602a305b | ||
|
|
ff24a5b009 | ||
|
|
d5ae666c41 | ||
|
|
b29d4f5445 | ||
|
|
4c04f91c17 | ||
|
|
d0865c3b15 | ||
|
|
367eb82db5 | ||
|
|
731d63c19c | ||
|
|
a81894dd8a | ||
|
|
99282742c6 | ||
|
|
31781d25ad | ||
|
|
c9b3611b77 | ||
|
|
d156d70b31 | ||
|
|
67c3cba167 | ||
|
|
67ec3cab3d | ||
|
|
eed8c5fe91 | ||
|
|
3ce3eefa5e | ||
|
|
6f9a30799e | ||
|
|
d33f2155a7 | ||
|
|
957211164f | ||
|
|
84498d4e7b | ||
|
|
a6e4df0460 | ||
|
|
503735bc0d | ||
|
|
c47be12e69 | ||
|
|
7859faaa1d | ||
|
|
bff6ec735e | ||
|
|
1fbe844598 | ||
|
|
365ef7f02b | ||
|
|
2b1a03849d | ||
|
|
4c243bc374 | ||
|
|
ecd02f73c0 | ||
|
|
cd23b36f3c | ||
|
|
f7f29eade9 | ||
|
|
32a51c1e60 | ||
|
|
da32acb4f0 | ||
|
|
1da13d9bb3 | ||
|
|
cb0fec088f | ||
|
|
a2fe8dd461 | ||
|
|
b6dfcd69ba | ||
|
|
a38b0ef71d | ||
|
|
2cf3027eeb | ||
|
|
fa18f6fc71 | ||
|
|
331925c197 | ||
|
|
0e51f745d3 | ||
|
|
d6f720d0e6 | ||
|
|
5324bf12d4 | ||
|
|
2922a02ecf | ||
|
|
87cdcf28e0 | ||
|
|
441b181e79 | ||
|
|
eba639b59c | ||
|
|
da36283ad1 | ||
|
|
c050368782 | ||
|
|
82b50f0fd7 | ||
|
|
a0149d5894 | ||
|
|
8579fcbfc2 | ||
|
|
b6f3aba23e | ||
|
|
31e218295d | ||
|
|
aca3b5d861 | ||
|
|
a7a2f0223a | ||
|
|
7773fdc10b | ||
|
|
7019a1d0a4 | ||
|
|
2d535caeac | ||
|
|
2013fbe80d | ||
|
|
219ef58926 | ||
|
|
97195bc463 |
No files matched your search
@@ -33,6 +33,10 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.77.0
|
||||
- 1.76.2
|
||||
- 1.76.1
|
||||
- 1.76.0
|
||||
- 1.75.0
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
|
||||
@@ -68,8 +68,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
package-manager-cache: false
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
- name: Install dependencies
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
run: |
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
|
||||
|
||||
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
|
||||
|
||||
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
|
||||
|
||||
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
|
||||
|
||||
## [1.76.0](https://github.com/homarr-labs/homarr/compare/v1.75.0...v1.76.0) (2026-08-21)
|
||||
|
||||
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
|
||||
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.75.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.76.1"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
---
|
||||
title: 'App'
|
||||
description: 'Embeds an app into the board.'
|
||||
title: "App"
|
||||
description: "Embeds an app into the board."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { appWidget } from '.';
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { appWidget } from ".";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={appWidget}
|
||||
categories={['Links', 'Apps']}
|
||||
/>
|
||||
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
|
||||
It also supports a status check to indicate whether Homarr can reach the application or website.
|
||||
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import githubApp from './img/github.png';
|
||||
import jellyseerrApp from './img/jellyseerr-description-tooltip.png';
|
||||
import piHoleApp from './img/pi-hole-ping-icon.png';
|
||||
import githubApp from "./img/github.png";
|
||||
import jellyseerrApp from "./img/jellyseerr-description-tooltip.png";
|
||||
import piHoleApp from "./img/pi-hole-ping-icon.png";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className="flex gap-1">
|
||||
<img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} />
|
||||
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
|
||||
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
|
||||
@@ -34,4 +31,5 @@ import piHoleApp from './img/pi-hole-ping-icon.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={appWidget.configuration} />
|
||||
@@ -1,29 +1,26 @@
|
||||
---
|
||||
title: 'Bookmarks'
|
||||
description: 'Displays multiple app links'
|
||||
title: "Bookmarks"
|
||||
description: "Displays multiple app links"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { bookmarksWidget } from '.';
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { bookmarksWidget } from ".";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={bookmarksWidget}
|
||||
categories={['Links', 'Apps']}
|
||||
/>
|
||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
The bookmarks widget allows you to display multiple app links in a single widget.
|
||||
You can add links to your favorite applications, websites, or any other resources you want quick access to.
|
||||
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import gridIconsOnly from './img/grid-icons-only.png';
|
||||
import gridWithTitle from './img/grid-with-title.png';
|
||||
import verticalListAll from './img/vertical-list-all.png';
|
||||
import vericalListSimple from './img/vertical-list-simple.png';
|
||||
import gridIconsOnly from "./img/grid-icons-only.png";
|
||||
import gridWithTitle from "./img/grid-with-title.png";
|
||||
import verticalListAll from "./img/vertical-list-all.png";
|
||||
import vericalListSimple from "./img/vertical-list-simple.png";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
@@ -33,12 +30,13 @@ import vericalListSimple from './img/vertical-list-simple.png';
|
||||
|
||||
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
|
||||
<img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={bookmarksWidget.configuration} />
|
||||
@@ -178,35 +178,37 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<GeneralSettingsContent board={board} form={form} />
|
||||
<LayoutSettingsContent form={form} />
|
||||
<BackgroundSettingsContent form={form} />
|
||||
<ColorSettingsContent form={form} />
|
||||
<CustomCssSettingsContent form={form} />
|
||||
<BehaviorSettingsContent form={form} />
|
||||
<Stack gap="xl">
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<GeneralSettingsContent board={board} form={form} />
|
||||
<LayoutSettingsContent form={form} />
|
||||
<BackgroundSettingsContent form={form} />
|
||||
<ColorSettingsContent form={form} />
|
||||
<CustomCssSettingsContent form={form} />
|
||||
<BehaviorSettingsContent form={form} />
|
||||
|
||||
{hasFullAccess && (
|
||||
<SectionCard title={tSection("access.title")}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</SectionCard>
|
||||
)}
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
|
||||
{hasFullAccess && (
|
||||
<SectionCard title={tSection("access.title")}>
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</UnsavedChangesBar>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ export const integrationSecretIcons = {
|
||||
opnsenseApiSecret: IconPassword,
|
||||
patchmonApiKey: IconKey,
|
||||
patchmonApiSecret: IconPassword,
|
||||
siteManagerApiKey: IconKey,
|
||||
githubAppId: IconCode,
|
||||
githubInstallationId: IconPlug,
|
||||
privateKey: IconKey,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { extractMcpTools } from "../_extract-tools";
|
||||
import { serializeMcpToolResult } from "../_serialize-result";
|
||||
|
||||
const logger = createLogger({ module: "mcpRoute" });
|
||||
|
||||
@@ -203,7 +204,7 @@ const mcpHandler = createMcpHandler(
|
||||
const input = args && Object.keys(args).length > 0 ? args : undefined;
|
||||
const result = await procedure(input);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
content: [{ type: "text" as const, text: serializeMcpToolResult(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { serializeMcpToolResult } from "./_serialize-result";
|
||||
|
||||
describe("serializeMcpToolResult", () => {
|
||||
it("serializes objects as JSON strings", () => {
|
||||
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
|
||||
});
|
||||
|
||||
it("serializes primitive values", () => {
|
||||
expect(serializeMcpToolResult("hello")).toBe('"hello"');
|
||||
expect(serializeMcpToolResult(42)).toBe("42");
|
||||
expect(serializeMcpToolResult(true)).toBe("true");
|
||||
});
|
||||
|
||||
it("serializes null as the string null", () => {
|
||||
expect(serializeMcpToolResult(null)).toBe("null");
|
||||
});
|
||||
|
||||
it("serializes void results as the string null so MCP content text is always a string", () => {
|
||||
expect(serializeMcpToolResult(undefined)).toBe("null");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function serializeMcpToolResult(result: unknown): string {
|
||||
return JSON.stringify(result) ?? "null";
|
||||
}
|
||||
@@ -44,9 +44,13 @@ interface WidgetContextMenuProps {
|
||||
|
||||
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
|
||||
const isLinkWidget = item.kind === "app" || item.kind === "bookmarks";
|
||||
// Keep the Homarr menu in edit mode so these widgets can still be edited or removed.
|
||||
if (isLinkWidget && !isEditMode) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
|
||||
|
||||
@@ -301,6 +301,8 @@ interface UseCssVariableConfiguration {
|
||||
isDynamic: boolean;
|
||||
}
|
||||
|
||||
const minimumResizeWidthDelta = 20;
|
||||
|
||||
/**
|
||||
* This hook is used to configure the css variables for the gridstack
|
||||
* Those css variables are used to define the size of the gridstack items
|
||||
@@ -319,9 +321,30 @@ const useCssVariableConfiguration = ({
|
||||
columnCount,
|
||||
isDynamic,
|
||||
}: UseCssVariableConfiguration) => {
|
||||
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!gridRef.current) return;
|
||||
|
||||
const currentWidth = wrapperRef.current.clientWidth;
|
||||
const currentHeight = wrapperRef.current.clientHeight;
|
||||
const prevSize = prevSizeRef.current;
|
||||
|
||||
// Only skip recalculation when the width change is insignificant
|
||||
// and, for dynamic sections, the height is unchanged. This prevents infinite
|
||||
// feedback loops when the vertical scrollbar appears/disappears, causing
|
||||
// clientWidth to oscillate by ~15px, while still reacting to height changes.
|
||||
if (
|
||||
prevSize !== null &&
|
||||
Math.abs(currentWidth - prevSize.width) < minimumResizeWidthDelta &&
|
||||
(!isDynamic || currentHeight === prevSize.height)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevSizeRef.current = { width: currentWidth, height: currentHeight };
|
||||
|
||||
handleResizeChange(
|
||||
wrapperRef.current,
|
||||
gridRef.current,
|
||||
|
||||
@@ -36,6 +36,8 @@ export const categoryTranslationKeys: Record<string, string> = {
|
||||
documents: "integration.category.documents",
|
||||
mediaLibrary: "integration.category.mediaLibrary",
|
||||
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
||||
unifiOs: "integration.category.unifiOs",
|
||||
unifiSiteManager: "integration.category.unifiSiteManager",
|
||||
};
|
||||
|
||||
export interface IntegrationGridItem {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,474 @@
|
||||
# Intégration UniFi OS pour Homarr — conception
|
||||
|
||||
**Date :** 2026-09-08
|
||||
**Base :** homarr-labs/homarr `2b1a03849` (v1.77.0)
|
||||
**Branche :** `feat/unifi-os-integration`
|
||||
**Statut :** conception validée, prête pour le plan d'implémentation
|
||||
|
||||
> ⚠️ Aucune clé d'API ne figure dans ce document. Les clés utilisées pendant la
|
||||
> conception ont été fournies hors dépôt et doivent être révoquées en fin de projet.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ajouter à Homarr une intégration **UniFi OS**, en complément — et non en
|
||||
remplacement — de l'intégration `unifiController` existante.
|
||||
|
||||
L'intégration existante s'authentifie par identifiant/mot de passe et lit
|
||||
`/api/s/{site}/stat/sites`, l'API interne de l'ancien Network Controller.
|
||||
UniFi OS expose depuis la version 9 une **API d'intégration officielle**,
|
||||
authentifiée par clé d'API, stable et documentée. C'est celle que cible ce
|
||||
projet.
|
||||
|
||||
L'intégration existante n'est pas touchée. Les deux coexistent.
|
||||
|
||||
## 2. Décisions cadrées
|
||||
|
||||
| # | Décision | Justification |
|
||||
|---|---|---|
|
||||
| D1 | **Fork personnel**, pas de PR upstream | Choix de l'auteur. L'issue upstream [#4703](https://github.com/homarr-labs/homarr/issues/4703) demandait cette fonctionnalité et a été fermée par triage automatique, pas par refus technique : la porte reste ouverte pour plus tard. |
|
||||
| D2 | **Hybride local + cloud optionnel** | L'API locale ne fournit ni latence ni perte de paquets. L'API cloud Site Manager les fournit. Le cloud reste facultatif pour que l'intégration fonctionne sans compte UI ni accès Internet. |
|
||||
| D3 | **Widget dédié**, pas de réutilisation des widgets `networkController*` | Le contrat `NetworkControllerSummary` est taillé pour l'ancienne API. S'y conformer obligerait à inventer des valeurs. Un contrat propre évite ça. |
|
||||
| D4 | **Déploiement par rsync du build standalone** | Le LXC exécute déjà un build standalone piloté par systemd. C'est le changement le plus petit possible, donc le plus facile à annuler. |
|
||||
| D5 | **Structure client / mapping / orchestration séparés** | Modèle TrueNAS et Synology déjà présents dans le dépôt. Isole le cloud, optionnel et lent, de la partie locale, obligatoire et rapide. |
|
||||
|
||||
**Conséquence de D3 :** on ne modifie pas `NetworkControllerSummary`. Les champs
|
||||
`latency` / `ping` restent `number` obligatoires pour `unifiController` et
|
||||
`opnsense`. **Aucune régression possible sur les intégrations existantes.**
|
||||
|
||||
## 3. Constatations vérifiées sur l'API
|
||||
|
||||
Toutes les valeurs ci-dessous ont été relevées sur une console réelle
|
||||
(UniFi Network **10.6.102**) le 2026-09-08, et non déduites de la documentation.
|
||||
|
||||
### 3.1 API locale — `{url}/proxy/network/integration/v1`
|
||||
|
||||
Authentification : en-tête `X-API-KEY`, clé générée **dans l'application Network**
|
||||
(Paramètres → Control Plane → Integrations). Une clé du Site Manager cloud est
|
||||
refusée avec un `401` sur cette API — les deux systèmes de clés sont distincts.
|
||||
|
||||
| Endpoint | Renvoie | Relevé |
|
||||
|---|---|---|
|
||||
| `GET /info` | `applicationVersion` | `10.6.102` |
|
||||
| `GET /sites` | `id`, `internalReference`, `name` — **aucune statistique** | 1 site |
|
||||
| `GET /sites/{id}/clients` | `type`, `access.type`, `name`, `ipAddress`, `macAddress`, `uplinkDeviceId`, `connectedAt` | 51 clients |
|
||||
| — | **`limit` est plafonné à 200.** Une demande à 1000 renvoie `limit: 200`. | mesuré |
|
||||
| `GET /sites/{id}/devices` | `state`, `model`, `macAddress`, `firmwareVersion`, `firmwareUpdatable`, `features` | 6 équipements |
|
||||
| `GET /sites/{id}/devices/{id}/statistics/latest` | `uptimeSec`, `cpuUtilizationPct`, `memoryUtilizationPct`, `uplink.{tx,rx}RateBps` | — |
|
||||
| `GET /sites/{id}/vpn/servers` | `type`, `enabled` | 1 serveur WireGuard actif — **non consommé en v1**, voir §12 |
|
||||
| `GET /sites/{id}/wans` | `id`, `name` **uniquement** — inexploitable pour l'état | 2 WAN |
|
||||
|
||||
**Types de clients** (discriminant `type`) : `WIRED`, `WIRELESS`, `VPN`,
|
||||
`TELEPORT`. Sous-objet `access.type` : `DEFAULT`, `GUEST`.
|
||||
|
||||
**Filtrage.** Supporté, avec une syntaxe **préfixe** :
|
||||
|
||||
```
|
||||
✅ and(type.eq('WIRED'),access.type.eq('DEFAULT'))
|
||||
❌ type.eq('WIRED') and access.type.eq('DEFAULT') → 400 invalid-filter
|
||||
❌ type.eq('WIRED') & access.type.eq('DEFAULT') → 400 invalid-filter
|
||||
❌ type.eq('WIRED'),access.type.eq('DEFAULT') → 400 invalid-filter
|
||||
```
|
||||
|
||||
Combiné à `limit=1`, il permet d'obtenir un `totalCount` sans rapatrier les
|
||||
enregistrements. Utilisé uniquement au-delà de 200 clients (voir §7).
|
||||
|
||||
**Écart avec la documentation.** Ubiquiti ne publie l'OpenAPI que pour
|
||||
`v10.4.57`. La console en `10.6.102` renvoie des champs absents de ce spec
|
||||
(`macAddress`, `uplinkDeviceId` sur les clients). **Corollaire : on code contre
|
||||
les réponses réelles, validées par des schémas zod tolérants (`passthrough`),
|
||||
et on ne génère pas de client depuis le spec publié.**
|
||||
|
||||
### 3.2 API cloud Site Manager — `https://api.ui.com`
|
||||
|
||||
Authentification : en-tête `X-API-KEY`, clé générée sur `unifi.ui.com`.
|
||||
|
||||
| Endpoint | Utilité |
|
||||
|---|---|
|
||||
| `GET /v1/sites` | Corrélation (`meta.gatewayMac`), `ispInfo`, IP publique par WAN, `percentages.wanUptime` |
|
||||
| `POST /v1/isp-metrics/5m/query` | `avgLatency`, `maxLatency`, `packetLoss`, `uptime`, débits |
|
||||
|
||||
**Deux contraintes mesurées :**
|
||||
|
||||
1. `GET /v1/isp-metrics/5m` sans filtre renvoie **133 Ko** (288 points sur 24 h).
|
||||
Il faut passer par `POST .../query` borné dans le temps.
|
||||
2. Une fenêtre de 15 min renvoie un tableau **vide**. Une fenêtre de **3 h**
|
||||
fonctionne. Le point le plus récent accuse un retard mesuré de **~27 minutes**.
|
||||
|
||||
Ces deux faits pilotent le design du cache (§7) et l'affichage de la fraîcheur (§8).
|
||||
|
||||
### 3.3 Corrélation locale ↔ cloud
|
||||
|
||||
Une clé Site Manager donne accès à **tous** les sites du compte UI, y compris des
|
||||
consoles dont l'utilisateur n'est pas propriétaire. Le compte de référence en
|
||||
exposait deux. Prendre les métriques cloud sans discriminer afficherait la
|
||||
latence d'un autre réseau.
|
||||
|
||||
**Clé de jointure retenue : le MAC du gateway.**
|
||||
|
||||
```
|
||||
LOCAL devices[] → gateway.macAddress aa:bb:cc:11:22:33
|
||||
CLOUD sites[] → meta.gatewayMac aa:bb:cc:11:22:33 ✅ correspondance
|
||||
CLOUD sites[] → meta.gatewayMac dd:ee:ff:44:55:66 ✗ autre console du compte
|
||||
```
|
||||
|
||||
*(adresses anonymisées ; la correspondance a été vérifiée sur la console réelle)*
|
||||
|
||||
Retenu plutôt que le nom du site : un nom se renomme, un MAC non. **En l'absence
|
||||
de correspondance, `unifi-sm-api.ts` renvoie `null` et n'essaie aucune
|
||||
approximation.** Un manque visible vaut mieux qu'une donnée silencieusement fausse.
|
||||
|
||||
## 4. Contrat de données
|
||||
|
||||
```ts
|
||||
export interface UnifiOsSummary {
|
||||
site: {
|
||||
id: string;
|
||||
name: string;
|
||||
applicationVersion: string;
|
||||
};
|
||||
|
||||
wan: {
|
||||
/** Dérivé de l'état du gateway côté local. */
|
||||
state: "online" | "offline" | "unknown";
|
||||
/** Débit temps réel de l'uplink du gateway, en bits/s. Local. */
|
||||
throughput: { txBps: number; rxBps: number } | null;
|
||||
/** Bloc cloud. `null` si la clé Site Manager n'est pas configurée
|
||||
* ou si aucun site cloud ne correspond au MAC du gateway. */
|
||||
isp: {
|
||||
name: string;
|
||||
externalIp: string | null;
|
||||
/** Nullables : le cloud omet ces mesures pour les périodes où le WAN
|
||||
* était coupé. Un `0` fabriqué s'afficherait comme « 0 ms » à côté d'un
|
||||
* voyant vert, au moment précis où la connexion est tombée. */
|
||||
latencyMs: number | null;
|
||||
maxLatencyMs: number | null;
|
||||
packetLossPct: number | null;
|
||||
uptimePct: number | null;
|
||||
/** Horodatage de la mesure. Rendu obligatoire : toute donnée cloud
|
||||
* voyage avec sa fraîcheur. */
|
||||
measuredAt: Date;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/** `null` si l'appel a échoué. Surtout pas un compteur à zéro : le lecteur
|
||||
* ne pourrait pas distinguer « échec » de « aucun client connecté ». */
|
||||
clients: {
|
||||
total: number;
|
||||
wired: number;
|
||||
wireless: number;
|
||||
vpn: number; // VPN + TELEPORT
|
||||
guests: number; // access.type === "GUEST", tous types confondus
|
||||
} | null;
|
||||
|
||||
/** `null` si l'appel a échoué, pour la même raison. */
|
||||
devices: {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
firmwareUpdatable: number;
|
||||
} | null;
|
||||
|
||||
gateway: {
|
||||
name: string;
|
||||
model: string;
|
||||
macAddress: string;
|
||||
uptimeSec: number | null;
|
||||
cpuPct: number | null;
|
||||
memoryPct: number | null;
|
||||
} | null;
|
||||
}
|
||||
```
|
||||
|
||||
**`measuredAt` est obligatoire dans le bloc `isp`.** Le type impose que la
|
||||
fraîcheur accompagne la donnée cloud : il devient impossible d'afficher une
|
||||
latence sans savoir de quand elle date.
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
### 5.1 Nouveaux fichiers
|
||||
|
||||
```
|
||||
packages/integrations/src/unifi-os/
|
||||
├── unifi-os-integration.ts # extends Integration — orchestre, mappe, dégrade
|
||||
├── unifi-os-api.ts # client HTTP local
|
||||
├── unifi-sm-api.ts # client HTTP cloud, optionnel, cache interne
|
||||
├── unifi-os-types.ts # schémas zod + mappers purs
|
||||
└── test/
|
||||
├── unifi-os-types.spec.ts
|
||||
└── unifi-os-integration.spec.ts
|
||||
```
|
||||
|
||||
Chaque unité répond à une question unique :
|
||||
|
||||
- `unifi-os-api.ts` — *comment parler à la console locale ?* Endpoints,
|
||||
pagination, résolution du `siteId`. Ne connaît pas Homarr.
|
||||
- `unifi-sm-api.ts` — *comment parler au cloud ?* Fenêtre glissante de 3 h,
|
||||
corrélation par MAC, cache interne. **Instancié uniquement si la clé cloud
|
||||
est présente.**
|
||||
- `unifi-os-types.ts` — *comment valider et transformer ?* Schémas zod et
|
||||
fonctions pures. Aucun accès réseau, donc testable sans mock.
|
||||
- `unifi-os-integration.ts` — *comment Homarr consomme tout ça ?* La seule
|
||||
unité qui connaît la classe `Integration`.
|
||||
|
||||
### 5.2 Points d'attache dans le monorepo
|
||||
|
||||
| Fichier | Modification |
|
||||
|---|---|
|
||||
| `packages/definitions/src/integration.ts` | Ajouter le secret `siteManagerApiKey` à `integrationSecretKindObject` ; ajouter `"unifiOs"` à `integrationCategories` ; ajouter le kind `unifiOs` avec `secretKinds: [["apiKey"], ["apiKey", "siteManagerApiKey"]]`, `category: ["unifiOs"]`, `defaultPort: 443` |
|
||||
| `packages/definitions/src/widget.ts` | Ajouter le `WidgetKind` `unifiOs` |
|
||||
| `packages/integrations/src/base/creator.ts` | Enregistrer `unifiOs: UnifiOsIntegration` |
|
||||
| `packages/integrations/src/types.ts` | Exporter `./unifi-os/unifi-os-types` |
|
||||
| `packages/integrations/src/interfaces/unifi-os-summary/` | Interface `UnifiOsSummaryIntegration` |
|
||||
| `packages/request-handler/src/unifi-os.ts` | `createIntegrationRequestHandler` |
|
||||
| `packages/api/src/router/widgets/unifi-os.ts` + `index.ts` | Routeur tRPC |
|
||||
| `packages/widgets/src/unifi-os/{index.ts,component.tsx}` | Définition + composant |
|
||||
| `packages/widgets/src/{manifest.ts,index.tsx,refetch-intervals.ts}` | Enregistrement |
|
||||
| `packages/translation/src/lang/en.json` | Libellés du widget + clé `integration.category.unifiOs` |
|
||||
|
||||
### 5.3 Pourquoi une catégorie dédiée, et pas `networkController`
|
||||
|
||||
`integrationCategories` n'est pas une étiquette de présentation, c'est un
|
||||
mécanisme de câblage. `getIntegrationKindsByCategory("networkController")`
|
||||
alimente trois endroits :
|
||||
|
||||
```
|
||||
widget-integration-map.ts → intégrations proposées par les widgets networkController*
|
||||
router/widgets/network-controller.ts → middleware tRPC
|
||||
request-handler/network-controller.ts → typé IntegrationKindByCategory<"networkController">,
|
||||
appelle getNetworkSummaryAsync()
|
||||
```
|
||||
|
||||
Déclarer `unifiOs` dans cette catégorie la rendrait sélectionnable dans les
|
||||
widgets de l'ancienne intégration **et** exigerait qu'elle implémente
|
||||
`getNetworkSummaryAsync()` — l'interface que la décision D3 écarte
|
||||
explicitement. Le typage `IntegrationKindByCategory` transforme cette
|
||||
contradiction en erreur de compilation, pas en défaut silencieux.
|
||||
|
||||
On ajoute donc une catégorie `unifiOs`. Le dépôt a déjà un précédent de
|
||||
catégorie mono-intégration : `beszel`. Une catégorie sans traduction ne casse
|
||||
rien — `beszel` n'en a pas — mais on ajoute quand même
|
||||
`integration.category.unifiOs`.
|
||||
|
||||
**Base d'URL.** L'utilisateur saisit l'origine de la console
|
||||
(`https://console.exemple.tld`). Le client local préfixe systématiquement
|
||||
`/proxy/network/integration/v1`. Ce préfixe appartient au client, pas à la
|
||||
configuration : l'utilisateur n'a pas à le connaître.
|
||||
|
||||
**Précédent de conception validé :** le champ `secretKinds` accepte plusieurs
|
||||
jeux alternatifs, et la classe de base expose `hasSecretValue()`. L'intégration
|
||||
`ntfy` utilise déjà exactement ce motif (`[["topic"], ["topic", "apiKey"]]`).
|
||||
La clé cloud optionnelle ne demande donc aucun mécanisme nouveau.
|
||||
|
||||
**Note TLS.** La classe de base `Integration` applique les certificats de
|
||||
confiance de Homarr à l'URL de l'intégration uniquement. L'appel vers
|
||||
`api.ui.com` sort de ce périmètre : c'est du HTTPS public à certificat valide,
|
||||
un `fetch` standard suffit. Il ne doit **pas** passer par
|
||||
`fetchWithTrustedCertificatesAsync`, pour ne pas laisser croire que les deux
|
||||
appels partagent la même configuration.
|
||||
|
||||
## 6. Séquence d'un rafraîchissement
|
||||
|
||||
```
|
||||
1. GET /info → applicationVersion
|
||||
2. GET /sites → siteId, name
|
||||
3. Promise.allSettled([
|
||||
GET /sites/{id}/clients?limit=200 → comptage par type (voir §7)
|
||||
GET /sites/{id}/devices → états, firmwares, gateway
|
||||
])
|
||||
4. GET /sites/{id}/devices/{gatewayId}/statistics/latest (si gateway trouvé)
|
||||
5. si clé cloud présente :
|
||||
unifi-sm-api.getIspMetricsAsync(gatewayMac) → cache 5 min
|
||||
```
|
||||
|
||||
**`Promise.allSettled`, pas `Promise.all`.** Un endpoint en échec dégrade son
|
||||
bloc à `null` ; il ne fait pas tomber le widget entier. L'étape 4 dépend de
|
||||
l'étape 3 : sans gateway identifié, `gateway` et `wan.throughput` valent `null`
|
||||
et le reste s'affiche normalement.
|
||||
|
||||
## 7. Cache et gestion d'erreurs
|
||||
|
||||
**Deux caches de rythmes différents**, parce que les deux sources n'évoluent pas
|
||||
à la même vitesse :
|
||||
|
||||
| Couche | TTL | Motif |
|
||||
|---|---|---|
|
||||
| `createIntegrationRequestHandler` | 60 s | Les compteurs locaux sont vivants |
|
||||
| Cache interne de `unifi-sm-api.ts`, clé = MAC gateway | 5 min | La donnée cloud ne change que toutes les 5 min et arrive avec ~27 min de retard |
|
||||
|
||||
Sans le second cache, on émettrait ~120 appels/h vers `api.ui.com` pour ~12
|
||||
valeurs distinctes, avec un risque de limitation de débit. Le motif du cache
|
||||
statique par instance est déjà utilisé par `TrueNasClient`.
|
||||
|
||||
**`fallbackToStaleOnError: true`** sur le handler. Une console injoignable
|
||||
pendant quelques secondes affiche la dernière valeur connue au lieu d'une tuile
|
||||
d'erreur — à condition que l'interface signale que la donnée est périmée (§8).
|
||||
|
||||
**Comptage des clients — règle explicite.** L'API plafonne `limit` à 200
|
||||
(mesuré). Le comportement est donc conditionné par `totalCount`, renvoyé dès la
|
||||
première page :
|
||||
|
||||
| Condition | Stratégie | Coût |
|
||||
|---|---|---|
|
||||
| `totalCount ≤ 200` | La première page contient tout. Comptage en mémoire par `type` et `access.type`. | 1 requête |
|
||||
| `totalCount > 200` | Bascule sur des requêtes filtrées `limit=1`, dont on ne lit que `totalCount`. | 5 requêtes de taille fixe |
|
||||
|
||||
La seconde branche utilise la syntaxe préfixe du §3.1, par exemple
|
||||
`and(type.eq('WIRELESS'),access.type.eq('GUEST'))`.
|
||||
|
||||
On ne pagine **pas** en boucle : au-delà de 200 clients, rapatrier tous les
|
||||
enregistrements pour n'en garder que des compteurs coûte plus cher que cinq
|
||||
requêtes bornées, et la charge croîtrait avec la taille du réseau.
|
||||
|
||||
*Écrire les deux branches maintenant est justifié : ce n'est pas de
|
||||
l'optimisation spéculative mais la gestion d'une limite d'API mesurée, qui
|
||||
produirait sinon des compteurs silencieusement faux au-delà de 200 clients.*
|
||||
|
||||
## 8. Widget
|
||||
|
||||
**Disposition retenue : bandeau WAN dominant + lignes** (option B des maquettes).
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ ● Nom du site │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ ● Internet ↑14,1 ↓3,3 Mb/s │ │
|
||||
│ │ Nom FAI · 203.0.113.10 │ │
|
||||
│ │ 11 ms · 0 % perte [27min]│ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ Clients 51 · 44 filaire · 7 Wi-Fi │
|
||||
│ Équipements 6 / 6 en ligne │
|
||||
│ ● Firmware 3 à mettre à jour │
|
||||
│ Network 10.6.102 Gateway up 11 j 16 h │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Justification : la hiérarchie visuelle suit l'ordre de consultation réel — on
|
||||
regarde d'abord si Internet fonctionne, ensuite le détail.
|
||||
|
||||
**Le badge de fraîcheur n'est pas cosmétique.** Il porte deux rôles distincts :
|
||||
signaler le retard structurel des métriques cloud, et signaler une donnée servie
|
||||
depuis le cache périmé après une erreur. Sans lui, le widget juxtaposerait des
|
||||
données de fraîcheurs très différentes sans le dire.
|
||||
|
||||
**Comportement dégradé :**
|
||||
|
||||
| Situation | Rendu |
|
||||
|---|---|
|
||||
| Clé cloud absente | Bloc ISP masqué. Le bandeau garde l'état WAN et le débit. |
|
||||
| Cloud injoignable | Idem, plus un indicateur discret. |
|
||||
| Aucune correspondance de MAC | Bloc ISP masqué. Jamais la latence d'un autre site. |
|
||||
| Gateway introuvable | `throughput`, `uptime`, CPU/RAM masqués ; clients et équipements affichés. |
|
||||
| Console injoignable | Dernière valeur connue + badge périmé, puis tuile d'erreur au-delà du TTL. |
|
||||
|
||||
**Taille.** `WidgetDefinition` n'expose aucune contrainte de taille minimale :
|
||||
l'adaptation est une affaire de CSS dans le composant. Le bandeau passe en deux
|
||||
lignes en tuile étroite, et les lignes de détail se réduisent à leurs chiffres.
|
||||
|
||||
**Rafraîchissement.** Entrée dans `widgetQueryRefetchIntervals` avec
|
||||
`intervalSeconds: 60`. Les widgets `networkController` sont à `null` — pas de
|
||||
rafraîchissement automatique — mais les compteurs de clients ont une valeur
|
||||
d'usage qui justifie l'écart.
|
||||
|
||||
## 9. Tests
|
||||
|
||||
| Niveau | Fichier | Couvre | Réseau |
|
||||
|---|---|---|---|
|
||||
| Unitaire | `unifi-os-types.spec.ts` | Comptage par type et par `access.type`, agrégation d'états, conversion bps, corrélation par MAC **y compris le cas sans correspondance**, tolérance aux champs inconnus | Non |
|
||||
| Intégration | `unifi-os-integration.spec.ts` | Séquence complète sur fixtures capturées, dégradation par bloc, absence de clé cloud, endpoint en échec | Mocké |
|
||||
| Manuel | script hors CI | Vérification contre une console réelle | Oui |
|
||||
|
||||
Le niveau unitaire est le plus important : c'est là que vivent les erreurs de
|
||||
comptage, et c'est le seul qui reste valable si Ubiquiti fait évoluer ses
|
||||
endpoints.
|
||||
|
||||
**Fixtures.** Capturées depuis la console réelle, puis **anonymisées** —
|
||||
adresses MAC, IP publique, noms de clients et identifiants de site remplacés.
|
||||
Un dépôt ne doit pas porter la cartographie d'un réseau privé.
|
||||
|
||||
**Multi-site.** Une URL locale désigne une console, et l'intégration se limite au
|
||||
premier site qu'elle expose. Une console peut techniquement en héberger
|
||||
plusieurs : le widget affiche donc le **nom du site** retenu, pour que ce choix
|
||||
soit visible plutôt que subi. Deux tests couvrent les bornes — une console sans
|
||||
aucun site renvoie une erreur explicite au test de connexion, et le nom du site
|
||||
figure dans le contrat.
|
||||
|
||||
L'ancienne intégration agrégeait tous les sites, ce qui avait produit l'issue
|
||||
[#3222](https://github.com/homarr-labs/homarr/issues/3222) (valeurs nulles en
|
||||
multi-site). Ne pas agréger supprime la classe de défaut plutôt que de la
|
||||
tester.
|
||||
|
||||
## 10. Phasage
|
||||
|
||||
Chaque phase se vérifie seule. C'est ce qui permet de savoir de quel côté
|
||||
chercher quand quelque chose casse.
|
||||
|
||||
| Phase | Contenu | Critère de validation |
|
||||
|---|---|---|
|
||||
| 0 | Fork, `corepack enable`, `pnpm docker:dev:up`, `pnpm dev` | Homarr démarre en local |
|
||||
| 1 | `unifi-os-api.ts`, `unifi-sm-api.ts`, types et mappers, tests unitaires | `pnpm test` vert, sans réseau |
|
||||
| 2 | `UnifiOsIntegration` + enregistrement | Le test de connexion Homarr passe dans l'interface |
|
||||
| 3 | request-handler + routeur tRPC | La query renvoie les vraies valeurs |
|
||||
| 4 | Widget + traductions `en` | Le widget s'affiche sur un board local |
|
||||
| 5 | Build standalone + rsync vers le LXC | Le widget s'affiche en production |
|
||||
|
||||
À l'issue de la phase 2, toute la plomberie est prouvée. Une panne en phase 4
|
||||
est donc nécessairement côté interface.
|
||||
|
||||
## 11. Déploiement
|
||||
|
||||
Cible : LXC Ubuntu 24.04, `10.70.1.13`, 2 vCPU / 2 Go. Homarr y tourne en build
|
||||
standalone sous systemd (`/opt/homarr`, `homarr.service`,
|
||||
`EnvironmentFile=/etc/homarr/homarr.env`), avec nginx et Redis embarqués par
|
||||
`run.sh`.
|
||||
|
||||
**Le build se fait sur le poste de développement** (16 cœurs, 60 Go). Le LXC ne
|
||||
peut pas compiler ce monorepo Next.js.
|
||||
|
||||
Procédure :
|
||||
|
||||
1. `tar` de `/opt/homarr` **et** dump de la base.
|
||||
2. `systemctl stop homarr`.
|
||||
3. `rsync` du build standalone.
|
||||
4. `systemctl start homarr`, puis vérification des logs.
|
||||
|
||||
Rollback : restauration de l'archive et redémarrage.
|
||||
|
||||
**Contrôle préalable obligatoire :** comparer les migrations de
|
||||
`packages/db/migrations/` avec `/opt/homarr/db/migrations/`. Elles sont
|
||||
alignées à `0041_add_right_click_preference.sql` au 2026-09-08. Une divergence
|
||||
rendrait le rollback nettement plus complexe qu'une restauration d'archive.
|
||||
|
||||
**`SECRET_ENCRYPTION_KEY`** dans `/etc/homarr/homarr.env` chiffre les secrets
|
||||
d'intégration en base. Le rsync ne touche pas ce fichier, mais toute
|
||||
modification rendrait les intégrations existantes illisibles.
|
||||
|
||||
## 12. Hors périmètre
|
||||
|
||||
Volontairement exclus de cette spec :
|
||||
|
||||
- **Widget cloud multi-site** (SD-WAN, fabric, vue consolidée de plusieurs
|
||||
consoles). Idée retenue pour un projet ultérieur, qui réutilisera
|
||||
`unifi-sm-api.ts`. Sa mention ici sert à ne pas le construire maintenant.
|
||||
- **État des serveurs VPN.** L'endpoint existe et fonctionne, mais le contrat §4
|
||||
n'expose pas de champ pour lui et la disposition retenue au §8 ne l'affiche pas.
|
||||
Le collecter sans l'afficher serait du code mort.
|
||||
- **Actions d'écriture** (adoption d'équipement, redémarrage de port, gestion de
|
||||
vouchers). L'API les expose ; l'intégration reste en lecture seule.
|
||||
- **Traduction au-delà de l'anglais.** Conséquence de D1.
|
||||
- **Documentation Docusaurus.** Conséquence de D1.
|
||||
- **Pull request upstream.** Conséquence de D1 ; la structure du code ne
|
||||
l'interdit pas plus tard.
|
||||
- **Modification de l'intégration `unifiController`.** Elle reste en l'état.
|
||||
|
||||
## 13. Risques
|
||||
|
||||
| Risque | Portée | Traitement |
|
||||
|---|---|---|
|
||||
| L'OpenAPI publié (`10.4.57`) est en retard sur les consoles | Élevée — déjà constaté | Schémas zod tolérants, code écrit contre les réponses réelles |
|
||||
| La clé Site Manager donne accès à tout le compte UI | Sécurité | Cloud optionnel ; corrélation stricte par MAC ; clé de conception à révoquer |
|
||||
| Retard de ~27 min sur les métriques cloud | Interface | `measuredAt` obligatoire dans le contrat, badge de fraîcheur dans le widget |
|
||||
| Un fork diverge à chaque version de Homarr | Maintenance | Modifications concentrées dans un dossier neuf ; seuls les points d'attache du §5.2 touchent l'existant |
|
||||
| Limitation de débit sur `api.ui.com` | Fiabilité | Cache interne 5 min |
|
||||
| Le déploiement casse l'instance de production | Exploitation | Sauvegarde avant rsync, contrôle des migrations, rollback par restauration |
|
||||
@@ -0,0 +1,399 @@
|
||||
# Intégration UniFi Site Manager pour Homarr — conception
|
||||
|
||||
**Date :** 2026-09-09
|
||||
**Base :** homarr-labs/homarr `2b1a03849` (v1.77.0)
|
||||
**Statut :** conception validée, prête pour le plan d'implémentation
|
||||
**Précède :** `2026-09-08-unifi-os-integration-design.md` (intégration mono-site, locale + cloud optionnel)
|
||||
|
||||
> ⚠️ Aucune clé d'API, adresse MAC réelle, IP publique ou nom de site réel ne
|
||||
> figure dans ce document.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ajouter une intégration **UniFi Site Manager**, distincte de `unifiOs`, lisant
|
||||
**uniquement** l'API cloud `https://api.ui.com`, et un widget affichant une vue
|
||||
d'ensemble **multi-sites**.
|
||||
|
||||
C'est ce que `unifiOs` ne peut structurellement pas faire : son URL désigne une
|
||||
console, donc un site. Le Site Manager voit tous les sites d'un compte.
|
||||
|
||||
## 2. Décisions cadrées
|
||||
|
||||
| # | Décision | Justification |
|
||||
|---|---|---|
|
||||
| D1 | **Deux intégrations indépendantes** | `unifiOs` garde sa clé cloud optionnelle pour son bloc ISP ; `unifiSiteManager` a la sienne. Chacune reste installable et supprimable seule. Coût accepté : la clé est saisie deux fois si l'utilisateur emploie les deux, et deux appelants interrogent le cloud. |
|
||||
| D2 | **Vue d'ensemble des sites d'abord** | Les captures fournies montrent trois vues — liste des sites, métriques ISP historisées, topologie SD-WAN. Ce sont trois projets. Celui-ci construit aussi le socle (intégration, client, routeur) dont les deux autres dépendront. |
|
||||
| D3 | **Tableau, la bande en colonne** | Réutilise `HomarrDataTable`, le composant partagé déjà employé par les widgets beszel, docker et downloads. Tri par colonne gratuit, visibilité responsive documentée par la skill `datatable-migration` du dépôt, et lisible bien au-delà de dix sites. C'est aussi la forme qu'emploie l'interface Ubiquiti. La bande devient une colonne parmi d'autres : elle ne domine plus la lecture, mais elle reste présente et le widget tient à l'échelle. |
|
||||
| D4 | **La bande « Internet Health » EST dans la v1** | Décision inversée en cours de conception. Elle avait été écartée au motif que `internetIssues` est un tableau creux dont il faudrait reconstituer les périodes saines. C'est vrai de cette source — mais `isp-metrics` fournit la même information **déjà dense** (§3.3). La bande coûte alors une boucle de coloration, pas un moteur de graphe. |
|
||||
| D6 | **État SD-WAN par site, sans la topologie** | Ajouté après coup à la demande de l'utilisateur. L'état des tunnels — connecté, hors ligne, ping, erreurs — est indexé par `siteId` et se greffe donc sur les lignes existantes. La topologie (réseaux nommés, sous-réseaux) reste hors v1 : une liste de quinze réseaux n'a pas sa place dans une ligne de tableau. |
|
||||
| D5 | **Rafraîchissement 5 min** | Les compteurs cloud n'évoluent pas plus vite. À contraster avec `unifiOs`, dont la source locale a justifié 5 s. |
|
||||
|
||||
## 3. Constatations vérifiées sur l'API
|
||||
|
||||
Relevées le 2026-09-09 contre `https://api.ui.com` avec une clé réelle.
|
||||
|
||||
### 3.1 Il n'existe pas d'API cachée
|
||||
|
||||
Une trentaine de chemins plausibles ont été sondés :
|
||||
|
||||
- **`/ea/*` est un alias complet de `/v1/*`** — mêmes payloads, mêmes tailles à
|
||||
l'octet près. C'est le namespace « early access », pas une surface parallèle.
|
||||
- `/v1/organizations`, `/orgs`, `/users`, `/me`, `/subscriptions`, `/licenses`,
|
||||
`/backups`, `/tasks`, `/events`, `/topology`, `/clients`, `/networks`, `/wifi`,
|
||||
`/health`, `/status`, `/version` : **tous 404**.
|
||||
- `/v1/isp-metrics/{type}` n'accepte que `5m` et `1h`. `1d` et `24h` → 400.
|
||||
|
||||
**La surface documentée est toute la surface.** Aucune requête cachée à exploiter.
|
||||
|
||||
### 3.2 Ce que la v1 consomme
|
||||
|
||||
| Endpoint | Champs retenus |
|
||||
|---|---|
|
||||
| `GET /v1/sites` | `siteId`, `hostId`, `meta.desc`, `meta.name`, `statistics.percentages.wanUptime`, `statistics.ispInfo.name`, `statistics.wans[*].externalIp`, `statistics.counts.{totalDevice,offlineDevice,pendingUpdateDevice}` |
|
||||
| `GET /v1/hosts` | `id`, `reportedState.deviceState`, `reportedState.firmwareUpdate` |
|
||||
| `GET /v1/sd-wan-configs` | `id`, `name` — nécessaire pour atteindre le statut, qui est indexé par configuration |
|
||||
| `GET /v1/sd-wan-configs/{id}/status` | `status.connections[].sites[]` → `siteId`, `status.{connected,offline,ping,errors}` ; `status.sites[]` → `siteId`, `errors`, `warnings` |
|
||||
|
||||
Corrélation site ↔ host par `hostId`, présent des deux côtés. Contrairement à
|
||||
l'intégration `unifiOs`, aucune ruse par adresse MAC n'est nécessaire ici : la
|
||||
clé de jointure est explicite.
|
||||
|
||||
### 3.3 Deux sources pour la santé, une seule utilisable
|
||||
|
||||
L'interface Ubiquiti affiche une barre « Internet Health ». Deux champs peuvent
|
||||
la produire, et le choix entre eux décide du coût de la fonctionnalité.
|
||||
|
||||
**`statistics.internetIssues` — écarté.** Un tableau **creux** d'anomalies,
|
||||
chacune portant un `index` de tranche de 5 min, et soit `highLatency` avec
|
||||
`latencyAvgMs`/`latencyMaxMs`, soit `notReported` avec un `count`. L'index se
|
||||
convertit par `index × 300` secondes — vérifié. Mais les périodes **saines** n'y
|
||||
sont pas : il faudrait les reconstituer par différence avec la fenêtre.
|
||||
|
||||
**`GET /v1/isp-metrics/{5m|1h}` — retenu.** Vérifié : renvoie **une entrée par
|
||||
site** (`hostId`, `siteId`) avec une série **dense**, chaque période portant
|
||||
`downtime`, `packetLoss`, `avgLatency`, `maxLatency`, `uptime` et `ispName`.
|
||||
Granularité `1h` : 30 jours d'historique disponible. Granularité `5m` : environ
|
||||
24 h.
|
||||
|
||||
La v1 consomme donc `isp-metrics` en `1h` sur une fenêtre de 24 h, soit
|
||||
**24 segments par site**. Rien à reconstituer, rien à décoder.
|
||||
|
||||
**Taille.** Le `GET` non borné pèse 216 Ko en `1h` et 133 Ko en `5m`. La v1 passe
|
||||
par `POST /v1/isp-metrics/1h/query`, qui accepte plusieurs sites et une fenêtre
|
||||
bornée dans une seule requête.
|
||||
|
||||
Reste hors v1 : `/v1/sd-wan-configs` (projet 3), données vérifiées complètes.
|
||||
|
||||
## 4. Contrat de données
|
||||
|
||||
```ts
|
||||
export interface UnifiSiteManagerSite {
|
||||
siteId: string;
|
||||
/** `meta.desc` s'il existe, sinon `meta.name`. */
|
||||
name: string;
|
||||
/** `null` quand le cloud ne rapporte pas d'uptime pour ce site. */
|
||||
wanUptimePct: number | null;
|
||||
ispName: string | null;
|
||||
/** Une entrée par WAN configuré ; vide si aucune IP publique n'est rapportée. */
|
||||
externalIps: string[];
|
||||
deviceCount: number;
|
||||
offlineDeviceCount: number;
|
||||
/** Firmwares d'ÉQUIPEMENTS en attente — distinct de la mise à jour de console. */
|
||||
pendingUpdateCount: number;
|
||||
/** Mise à jour de la CONSOLE. `null` si aucun host ne correspond au site. */
|
||||
consoleUpdateAvailable: boolean | null;
|
||||
/** 24 tranches horaires, la plus ancienne en premier. Toujours 24 entrées :
|
||||
* une tranche sans mesure vaut `"unknown"`, jamais `"healthy"`. */
|
||||
health: UnifiSiteHealthSegment[];
|
||||
/** Dernière mesure de la fenêtre. `null` si le cloud n'en rapporte aucune. */
|
||||
latencyMs: number | null;
|
||||
packetLossPct: number | null;
|
||||
/** `null` si le site n'appartient à aucune configuration SD-WAN. */
|
||||
sdWan: UnifiSiteSdWanStatus | null;
|
||||
}
|
||||
|
||||
export interface UnifiSiteSdWanStatus {
|
||||
configName: string;
|
||||
connected: boolean;
|
||||
offline: boolean;
|
||||
/** `null` quand le cloud rapporte `0` — voir la note ci-dessous. */
|
||||
pingMs: number | null;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown";
|
||||
|
||||
export interface UnifiSiteHealthSegment {
|
||||
/** Début de la tranche horaire. */
|
||||
at: Date;
|
||||
state: UnifiSiteHealthState;
|
||||
}
|
||||
|
||||
export interface UnifiSiteManagerOverview {
|
||||
sites: UnifiSiteManagerSite[];
|
||||
}
|
||||
```
|
||||
|
||||
**`consoleUpdateAvailable` est nullable et ce n'est pas un détail.** `null`
|
||||
signifie « aucun host ne correspond à ce site », ce qui n'est pas la même chose
|
||||
que `false` (« la console est à jour »). Le widget doit les distinguer, comme il
|
||||
distingue partout ailleurs l'inconnu du connu.
|
||||
|
||||
**Dérivation de `state`, par ordre de gravité décroissante :** `downtime > 0`
|
||||
→ `"down"` ; sinon `packetLoss > 0` → `"degraded"` ; sinon `"healthy"`. Une
|
||||
tranche de la fenêtre pour laquelle le cloud ne rapporte rien → `"unknown"`.
|
||||
|
||||
**Cette dernière règle est le point délicat.** Un site enregistré depuis douze
|
||||
heures n'a que douze tranches sur vingt-quatre. Les remplir en vert affirmerait
|
||||
douze heures de bon fonctionnement qui n'ont jamais été mesurées. Le gris dit
|
||||
« on ne sait pas », ce qui est la vérité — même principe que le tiret cadratin
|
||||
ailleurs dans ces widgets.
|
||||
|
||||
**`ping: 0` ne veut pas dire zéro milliseconde.** Relevé sur un tunnel
|
||||
`connected: true` reliant deux sites géographiquement distants — une latence
|
||||
nulle y est physiquement impossible. Le champ signifie donc « non mesuré », et le
|
||||
contrat le traduit par `pingMs: null` plutôt que de propager un zéro que
|
||||
l'interface afficherait comme une performance.
|
||||
|
||||
**Un site peut appartenir à plusieurs configurations SD-WAN.** Le contrat n'en
|
||||
porte qu'une : on retient la première configuration où le site apparaît, et le
|
||||
nom de celle-ci accompagne l'état pour que la ligne reste interprétable. Si le
|
||||
besoin de plusieurs se présente, c'est un élargissement du contrat, pas un
|
||||
correctif.
|
||||
|
||||
**`pendingUpdateCount` et `consoleUpdateAvailable` ne mesurent pas la même
|
||||
chose.** Le premier compte les équipements réseau dont le firmware est en
|
||||
retard ; le second dit si UniFi OS lui-même a une version disponible. L'interface
|
||||
L'interface Ubiquiti les sépare ; le contrat fait de même.
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
### 5.1 Nouveaux fichiers
|
||||
|
||||
```
|
||||
packages/integrations/src/unifi-site-manager/
|
||||
├── unifi-site-manager-integration.ts # classe Integration : orchestre et mappe
|
||||
├── unifi-site-manager-api.ts # client HTTP cloud
|
||||
├── unifi-site-manager-types.ts # schémas zod + mappers purs
|
||||
├── unifi-site-manager-parse.ts # parsing, dépend de @homarr/common/server
|
||||
└── test/
|
||||
```
|
||||
|
||||
**La séparation `types` / `parse` est imposée par un défaut rencontré sur
|
||||
l'intégration précédente**, pas par goût. `packages/integrations/src/types.ts`
|
||||
est importé par des composants **client** (la spotlight, entre autres). Tout
|
||||
module qu'il réexporte doit donc être client-safe. `unifi-os-types.ts` importait
|
||||
`ParseError` depuis `@homarr/common/server`, ce qui a traîné `ioredis` — et donc
|
||||
`node:dns` — dans le bundle navigateur et **empêché l'application de démarrer**.
|
||||
Ni le typecheck, ni 703 tests, ni cinq revues ne l'avaient vu : seul le
|
||||
lancement de l'application l'a révélé.
|
||||
|
||||
Le module de types de cette intégration ne contiendra donc que zod et des
|
||||
fonctions pures, et un test de garde vérifiera l'absence de cet import.
|
||||
|
||||
### 5.2 Points d'attache
|
||||
|
||||
Seize entrées, tous identifiés. L'entrée 14 en couvre quatre fichiers à elle
|
||||
seule, soit dix-neuf fichiers touchés au total.
|
||||
|
||||
| # | Fichier | Ajout |
|
||||
|---|---|---|
|
||||
| 1 | `definitions/src/integration.ts` | catégorie `unifiSiteManager` ; kind avec `secretKinds: [["siteManagerApiKey"]]`, `defaultUrl: "https://api.ui.com"`, `documentationUrl: null` |
|
||||
| 2 | `definitions/src/widget.ts` | `WidgetKind` `unifiSiteManager` |
|
||||
| 3 | `definitions/src/widget-integration-map.ts` | `unifiSiteManager: ["unifiSiteManager"]` |
|
||||
| 4 | `definitions/src/docs/integration-doc-slugs.ts` | `unifiSiteManager: null` |
|
||||
| 5 | `definitions/src/docs/widget-doc-slugs.ts` | `unifiSiteManager: null` |
|
||||
| 6 | `integrations/src/base/creator.ts` | enregistrement de la classe |
|
||||
| 7 | `integrations/src/index.ts` | export de la classe |
|
||||
| 8 | `integrations/src/types.ts` | export des types **client-safe uniquement** |
|
||||
| 9 | `integrations/src/interfaces/unifi-site-manager/` | interface consommée par le handler |
|
||||
| 10 | `request-handler/src/unifi-site-manager.ts` | handler de cache |
|
||||
| 11 | `api/src/router/widgets/unifi-site-manager.ts` | routeur tRPC |
|
||||
| 12 | `api/src/router/widgets/index.ts` | enregistrement du routeur |
|
||||
| 13 | `widgets/src/unifi-site-manager/` | définition + composant |
|
||||
| 14 | `widgets/src/{manifest.ts,index.tsx,catalog.ts,refetch-intervals.ts}` | quatre enregistrements ; l'icône du catalogue doit être **la même référence** que celle de la définition, `manifest.spec.ts` compare par identité |
|
||||
| 15 | `apps/nextjs/.../integration-grid-shared.ts` | `unifiSiteManager: "integration.category.unifiSiteManager"` — table typée `Record<string, string>`, donc **non vérifiée par le compilateur** : une omission passe le typecheck et affiche la clé brute |
|
||||
| 16 | `translation/src/lang/en.json` | libellés du widget + `integration.category.unifiSiteManager` |
|
||||
|
||||
**Un point d'attache en moins qu'`unifiOs` :** le secret `siteManagerApiKey`
|
||||
existe déjà, ainsi que son icône et ses libellés. On le réutilise tel quel.
|
||||
|
||||
**URL fixe.** `defaultUrl: "https://api.ui.com"`, sur le modèle de l'intégration
|
||||
`umami` (`https://api.umami.is/v1`). Le champ `url` est `notNull` en base, d'où
|
||||
le recours à une valeur par défaut plutôt qu'à son absence.
|
||||
|
||||
## 6. Séquence d'un rafraîchissement
|
||||
|
||||
```
|
||||
const [sites, hosts] = await Promise.allSettled([
|
||||
GET /v1/sites, // source obligatoire
|
||||
GET /v1/hosts, // enrichissement
|
||||
]);
|
||||
if (sites.status === "rejected") throw sites.reason; // pas de liste sans sites
|
||||
|
||||
// Séquentiel : la requête bornée exige les hostId/siteId de l'étape précédente.
|
||||
const [metrics, sdWan] = await Promise.allSettled([
|
||||
POST /v1/isp-metrics/1h/query, // fenêtre 24 h, tous les sites
|
||||
GET /v1/sd-wan-configs // puis /{id}/status pour chacune
|
||||
]);
|
||||
// Les deux sont des enrichissements : leur échec n'est jamais fatal.
|
||||
```
|
||||
|
||||
Quatre à cinq appels selon le nombre de configurations SD-WAN, deux niveaux de
|
||||
criticité.
|
||||
|
||||
`/v1/sites` porte la liste : son échec doit remonter, sinon le widget afficherait
|
||||
« aucun site » alors qu'il n'a rien pu lire.
|
||||
|
||||
`/v1/hosts`, `isp-metrics` et le SD-WAN sont des enrichissements. Leur échec met
|
||||
leur part à l'inconnu — `consoleUpdateAvailable` à `null`, les vingt-quatre
|
||||
tranches à `"unknown"`, `sdWan` à `null` — et laisse le reste affiché.
|
||||
`Promise.allSettled` plutôt que `Promise.all` sert exactement cette asymétrie.
|
||||
|
||||
Le SD-WAN coûte deux appels : la liste des configurations, puis le statut de
|
||||
chacune. Avec une seule configuration — le cas courant — cela fait deux requêtes.
|
||||
Le plan borne ce nombre pour qu'un compte à dix configurations ne produise pas
|
||||
onze requêtes par rafraîchissement.
|
||||
|
||||
L'appel aux métriques est **séquentiel** et non parallèle : la requête bornée a
|
||||
besoin des identifiants renvoyés par `/v1/sites`. C'est le prix de ne pas
|
||||
rapatrier les 216 Ko de la variante non bornée.
|
||||
|
||||
## 7. Cache et gestion d'erreurs
|
||||
|
||||
| Couche | Valeur | Motif |
|
||||
|---|---|---|
|
||||
| `createIntegrationRequestHandler` | 5 min | Les compteurs cloud n'évoluent pas plus vite ; interroger davantage expose à une limitation de débit sans rien gagner |
|
||||
| Rafraîchissement du widget | 5 min | Aligné : un intervalle plus court relirait la même entrée de cache |
|
||||
| `fallbackToStaleOnError` | activé, fenêtre 30 min | Sert la dernière valeur connue plutôt qu'une tuile d'erreur pour une coupure passagère |
|
||||
|
||||
**Différence de fond avec `unifiOs`.** Là-bas, le cloud était un enrichissement
|
||||
optionnel : son indisponibilité vidait un bloc et le widget continuait. Ici le
|
||||
cloud **est** la source. Un échec est un vrai échec, et le widget doit afficher
|
||||
une erreur — jamais une liste vide, qui se lirait comme « aucun site », c'est-à-dire
|
||||
comme une information alors qu'il n'y en a aucune.
|
||||
|
||||
Un compte réellement sans site est un cas distinct et légitime : il affiche un
|
||||
état vide explicite, pas une erreur.
|
||||
|
||||
## 8. Widget
|
||||
|
||||
`HomarrDataTable`, une ligne par site.
|
||||
|
||||
| Colonne | Source | Rendu |
|
||||
|---|---|---|
|
||||
| Site | `name` | toujours visible, jamais masquée en responsive |
|
||||
| Santé | `health` | bande de 24 segments d'une heure, du plus ancien au plus récent |
|
||||
| Latence | `latencyMs`, `packetLossPct` | `11 ms · 0 %` ; `—` si aucune mesure |
|
||||
| ISP | `ispName` | `—` si `null` |
|
||||
| IP publique | `externalIps` | joint par ` · ` ; `—` si vide |
|
||||
| Équipements | `deviceCount`, `offlineDeviceCount` | `en ligne / total`, en rouge si des équipements sont hors ligne |
|
||||
| Mises à jour | `pendingUpdateCount` | en jaune si > 0 ; `0` affiché normalement, c'est une vraie mesure |
|
||||
| Console | `consoleUpdateAvailable` | `—` si `null` — l'absence de host correspondant n'est pas « à jour » |
|
||||
| SD-WAN | `sdWan` | `—` si le site n'est dans aucune configuration ; sinon état du tunnel, en rouge si hors ligne, en jaune s'il porte des erreurs. Le ping n'apparaît que s'il est mesuré |
|
||||
|
||||
Colonnes triables. Visibilité responsive selon la largeur de tuile, suivant la
|
||||
convention documentée par la skill `datatable-migration` : les colonnes les moins
|
||||
essentielles disparaissent en premier, `Site` et `Santé` jamais.
|
||||
|
||||
**La bande de santé.** Vingt-quatre segments jointifs séparés par un filet, pour
|
||||
qu'on distingue deux tranches consécutives de même couleur. Vert sain, orange
|
||||
dégradé, rouge coupé, gris inconnu. Dans une colonne de tableau elle sera étroite :
|
||||
c'est le prix de la forme tableau, et c'est ce qui rend l'infobulle nécessaire
|
||||
plutôt que confortable.
|
||||
|
||||
**Couleurs : palette de statut, pas palette catégorielle.** Distinction établie
|
||||
en cours de conception, après avoir échoué à faire passer un jeu vert/orange/rouge
|
||||
au validateur — lequel ne couvre que les palettes **catégorielles**. Sain,
|
||||
dégradé et coupé sont des **statuts**, qui relèvent d'une palette réservée et
|
||||
fixe, non thémée :
|
||||
|
||||
| État | Couleur | Origine |
|
||||
|---|---|---|
|
||||
| `healthy` | `#0ca30c` | statut *good* |
|
||||
| `degraded` | `#fab219` | statut *warning* |
|
||||
| `down` | `#d03b3b` | statut *critical* |
|
||||
| `unknown` | gris neutre de la surface | ce n'est pas un statut, c'est une absence |
|
||||
|
||||
Mesuré au passage : un vert/orange/rouge classique donne un ΔE de **2,9** entre
|
||||
l'orange et le vert en vision protanope — indistinguables. La palette de statut
|
||||
ne prétend pas résoudre cela, elle impose une contrepartie.
|
||||
|
||||
**La contrepartie est obligatoire, et plus forte qu'une infobulle.** La règle
|
||||
attachée aux couleurs de statut est qu'elles ne voyagent jamais seules : icône
|
||||
et libellé les accompagnent. Une icône par segment étant impossible sur
|
||||
vingt-quatre segments, la bande est **doublée d'un résumé textuel** — par exemple
|
||||
`22 h saines · 2 h dégradées` — placé à côté d'elle dans la même colonne. Ce
|
||||
résumé n'est pas décoratif : c'est le canal qui porte l'information quand la
|
||||
couleur ne passe pas.
|
||||
|
||||
**Infobulle par segment** : heure de la tranche, latence moyenne, perte. Elle
|
||||
complète le résumé pour qui veut le détail d'une heure précise.
|
||||
|
||||
**Badge de fraîcheur** en tête de widget, alimenté par `updatedAt`, comme sur le
|
||||
widget `unifiOs`.
|
||||
|
||||
## 9. Tests
|
||||
|
||||
| Niveau | Couvre | Réseau |
|
||||
|---|---|---|
|
||||
| Unitaire, mappers | mapping d'un site complet ; site sans WAN ; site sans `hostId` ; host absent → `consoleUpdateAvailable` à `null` et non `false` ; compte sans aucun site | Non |
|
||||
| Unitaire, SD-WAN | site absent de toute configuration → `sdWan` à `null` ; **`ping: 0` → `pingMs: null` et non `0`** ; erreurs et avertissements comptés ; site présent dans plusieurs configurations → la première est retenue | Non |
|
||||
| Unitaire, santé | dérivation `down` > `degraded` > `healthy` ; **fenêtre incomplète → tranches `"unknown"`, jamais `"healthy"`** ; fenêtre toujours longue de 24 ; métriques absentes → 24 tranches `"unknown"` | Non |
|
||||
| Unitaire, garde | **`unifi-site-manager-types.ts` n'importe pas `@homarr/common/server`** — la seule forme de test qui aurait attrapé le défaut de l'intégration précédente | Non |
|
||||
| Intégration | séquence complète sur fixtures anonymisées ; `/v1/hosts` en échec laisse le tableau affiché ; `/v1/sites` en échec propage l'erreur | Mocké |
|
||||
| Manuel | vérification contre le compte réel, hors CI | Oui |
|
||||
|
||||
Fixtures **anonymisées** : identifiants, noms de sites, IP publiques et adresses
|
||||
MAC remplacés. Un dépôt ne porte pas la cartographie d'un réseau privé.
|
||||
|
||||
## 10. Phasage
|
||||
|
||||
| Phase | Contenu | Critère de validation |
|
||||
|---|---|---|
|
||||
| 1 | types + mappers + tests unitaires | `pnpm test` vert, sans réseau |
|
||||
| 2 | client cloud + tests | idem |
|
||||
| 3 | classe d'intégration + enregistrements (points 1 à 8) | `pnpm turbo typecheck` propre |
|
||||
| 4 | request-handler + routeur tRPC (9 à 12) | typecheck propre |
|
||||
| 5 | widget + enregistrements + traductions (13 à 16) | `manifest.spec.ts` vert, typecheck **global** propre |
|
||||
| 6 | vérification contre le compte réel | Les valeurs correspondent à l'interface Site Manager |
|
||||
|
||||
**La phase 6 n'est pas optionnelle.** Sur l'intégration précédente, elle a
|
||||
révélé deux défauts que le typecheck, 703 tests et cinq revues avaient laissés
|
||||
passer — dont un qui empêchait l'application de démarrer.
|
||||
|
||||
## 11. Hors périmètre
|
||||
|
||||
- **Vue d'analyse ISP** — projet 2. La v1 consomme déjà `isp-metrics` pour la
|
||||
bande de santé ; ce qui reste hors périmètre est l'**outil d'analyse** de la
|
||||
seconde capture : sélecteur de fenêtre 1D à 1M, courbes de latence superposées
|
||||
par site, filtres par ISP, par perte et par uptime. Un résumé et un instrument
|
||||
d'analyse ne sont pas deux tailles du même widget.
|
||||
- **Topologie SD-WAN** — l'**état** des tunnels entre dans la v1 (D6) ; la
|
||||
topologie n'y entre pas. `/v1/sd-wan-configs/{id}` porte les sites, réseaux
|
||||
nommés, sous-réseaux et l'état `paused` — vérifié complet — mais une liste de
|
||||
quinze réseaux n'a pas sa place dans une ligne de tableau. Un widget dédié
|
||||
reste possible et réutilisera le client de cette intégration.
|
||||
- **Écriture** — l'API expose des mutations ; l'intégration reste en lecture seule.
|
||||
- **Documentation Docusaurus et traductions au-delà de l'anglais** — fork
|
||||
personnel, même décision que l'intégration précédente.
|
||||
- **Mutualisation de la clé cloud avec `unifiOs`** — écartée par D1.
|
||||
|
||||
## 12. Risques
|
||||
|
||||
| Risque | Portée | Traitement |
|
||||
|---|---|---|
|
||||
| Un module de types non client-safe casse le build | Élevée — déjà survenu | Séparation `types` / `parse` dès le départ, plus un test de garde |
|
||||
| Une omission dans `categoryTranslationKeys` passe le typecheck | Moyenne — déjà survenu | Point d'attache 15 explicite ; vérification visuelle en phase 6 |
|
||||
| La clé cloud voit tous les sites du compte, y compris non possédés | Sécurité | Assumé et **voulu** ici : c'est une vue multi-sites. Contrairement à `unifiOs`, aucun filtrage n'est souhaitable — mais la documentation du widget doit dire ce qu'il expose |
|
||||
| Limitation de débit sur `api.ui.com` | Fiabilité | Cadence 5 min, deux appels par cycle |
|
||||
| Deux intégrations interrogent le cloud en parallèle | Fiabilité | Conséquence acceptée de D1 ; caches indépendants, ~24 requêtes/heure au total |
|
||||
| `ping: 0` interprété comme une latence nulle | Justesse | Traduit en `pingMs: null` au contrat, testé. Relevé sur un tunnel connecté entre deux sites distants, où zéro est physiquement impossible |
|
||||
| Un compte à nombreuses configurations SD-WAN multiplie les requêtes | Fiabilité | Nombre de configurations interrogées borné par le plan ; au-delà, les sites concernés ont `sdWan` à `null` plutôt qu'une rafale de requêtes |
|
||||
| Le nombre de sites croît | Interface | Le tableau absorbe la croissance là où des cartes ou une liste se dégraderaient ; visibilité responsive des colonnes, `Site` et `Santé` toujours conservées |
|
||||
| La bande est étroite dans une colonne | Lisibilité | Conséquence directe de D3. L'infobulle par segment n'est donc pas un confort mais le moyen principal de lire une tranche |
|
||||
| Quatre états de santé distingués par la seule couleur | Accessibilité | Palette validée avant écriture ; infobulle par segment rendue obligatoire si le jeu à quatre états ne passe pas les contrôles de vision déficiente |
|
||||
| Une tranche sans mesure affichée comme saine | Justesse | État `"unknown"` explicite au contrat, testé ; la fenêtre porte toujours 24 entrées |
|
||||
+2
-2
@@ -9,7 +9,7 @@ http {
|
||||
|
||||
# Route websockets traffic to port 3001
|
||||
location /websockets {
|
||||
proxy_pass http://${HOSTNAME}:3001;
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
@@ -18,7 +18,7 @@ http {
|
||||
|
||||
# Route all other traffic to port 3000
|
||||
location / {
|
||||
proxy_pass http://${HOSTNAME}:3000;
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# Fork personnel — exploitation
|
||||
|
||||
Ce dossier n'existe pas en amont. Il ne créera donc jamais de conflit lors d'un
|
||||
rebase, ce qui est exactement pourquoi les fichiers d'exploitation vivent ici et
|
||||
pas dans `scripts/`.
|
||||
|
||||
## Déployer
|
||||
|
||||
```bash
|
||||
ops/deploy-lxc.sh --dry-run # montre ce qui serait écrit, sans rien toucher
|
||||
ops/deploy-lxc.sh
|
||||
```
|
||||
|
||||
Le script exige un arbre propre, construit, compare les migrations et l'ABI Node
|
||||
avant de toucher quoi que ce soit, sauvegarde `/opt/homarr` **et** la base, arrête
|
||||
le service, synchronise, redémarre, puis affiche les journaux. La commande de
|
||||
retour arrière est imprimée à la fin, horodatage compris.
|
||||
|
||||
Ce qu'il ne remplace jamais : `/etc/homarr/homarr.env`, `run.sh`, `entrypoint.sh`,
|
||||
`nginx.conf`, `redis.conf` — ils appartiennent à l'installation du LXC, pas au
|
||||
build — ni `/var/lib/homarr/db.sqlite`, qui est sauvegardée mais laissée en place.
|
||||
|
||||
Après déploiement, le contrôle qui compte n'est pas que le nouveau widget
|
||||
s'affiche : c'est que les intégrations **préexistantes** fonctionnent encore.
|
||||
C'est la seule preuve que `SECRET_ENCRYPTION_KEY` n'a pas bougé.
|
||||
|
||||
## Déplacer l'état hors de /appdata
|
||||
|
||||
`/appdata` vient de l'image Docker, où c'est un point de montage. Sur une
|
||||
machine, il ne suit aucune convention : l'état d'un service se range sous
|
||||
`/var/lib`. La correction se fait sur l'**instance installée**, jamais dans le
|
||||
dépôt — ni le code ni le Dockerfile ne sont touchés, sans quoi chaque rebase sur
|
||||
l'amont rouvrirait le sujet.
|
||||
|
||||
```bash
|
||||
sudo ops/use-var-lib-homarr.sh /opt/homarr # ou /opt/homarr-dev
|
||||
```
|
||||
|
||||
Le script réécrit `/appdata` en `/var/lib/homarr` dans le `run.sh` et le
|
||||
`redis.conf` de l'instance, sauvegarde chaque fichier avant de le toucher, et
|
||||
ajoute `LOCAL_CERTIFICATE_PATH` au démarrage. Cette dernière ligne n'est pas
|
||||
cosmétique : le chemin des certificats n'est pas lu par `run.sh` mais par
|
||||
l'application, qui retombe sur le dossier de l'image dès qu'il manque — elle
|
||||
recréerait donc `/appdata` au premier certificat chargé.
|
||||
|
||||
Le script ne déplace **pas** les données ; il imprime les commandes à passer une
|
||||
fois, service arrêté. Sur cette installation, `/appdata` ne contient qu'un
|
||||
fichier : le certificat racine qui valide la console UniFi locale. Le contrôle
|
||||
d'après-déplacement est donc tout trouvé — si le widget UniFi OS répond encore,
|
||||
le certificat a suivi.
|
||||
|
||||
À relancer après chaque assemblage : `ops/assemble-standalone.sh` recopie le
|
||||
`run.sh` du dépôt quand l'instance n'en a pas, et celui-là parle de `/appdata`.
|
||||
|
||||
## Suivre les mises à jour d'Homarr
|
||||
|
||||
Aujourd'hui `origin` pointe sur le dépôt d'Homarr lui-même et **aucune copie
|
||||
distante de ce travail n'existe** : une panne de disque effacerait la branche.
|
||||
Premier geste, avant tout le reste :
|
||||
|
||||
```bash
|
||||
git remote rename origin upstream
|
||||
git remote add origin <url-de-ton-Gitea>/homarr.git
|
||||
git push -u origin feat/unifi-os-integration
|
||||
```
|
||||
|
||||
### Le cycle de montée de version
|
||||
|
||||
Homarr publie souvent. Se caler sur une **étiquette de version** plutôt que sur
|
||||
`dev` évite de rebaser sur du code en cours :
|
||||
|
||||
```bash
|
||||
git fetch upstream --tags
|
||||
git log --oneline dev..upstream/dev | head # ce qui est arrivé
|
||||
git rebase v1.78.0 feat/unifi-os-integration # remplace par la version visée
|
||||
```
|
||||
|
||||
Puis, dans cet ordre — chaque étape a déjà attrapé quelque chose :
|
||||
|
||||
```bash
|
||||
pnpm install # le lockfile bouge presque à chaque version
|
||||
pnpm turbo typecheck # les 37 paquets
|
||||
pnpm test packages/integrations/ packages/widgets/
|
||||
pnpm test packages/widgets/src/manifest.spec.ts # les points d'enregistrement
|
||||
ops/deploy-lxc.sh --dry-run
|
||||
```
|
||||
|
||||
`manifest.spec.ts` mérite sa ligne à part : il compare l'icône du catalogue à
|
||||
celle de la définition **par identité**, et c'est le seul test qui détecte un
|
||||
point d'enregistrement perdu dans un conflit.
|
||||
|
||||
### Où les conflits vont tomber
|
||||
|
||||
Le gros du travail vit dans des dossiers neufs — `packages/integrations/src/unifi-os/`,
|
||||
`unifi-site-manager/`, `packages/widgets/src/unifi-*/` — qui ne peuvent pas
|
||||
entrer en conflit. Les points de friction sont les fichiers partagés où ce fork
|
||||
ajoute une ligne dans une liste que l'amont fait vivre aussi :
|
||||
|
||||
| Fichier | Ce qu'on y ajoute |
|
||||
|---|---|
|
||||
| `packages/definitions/src/integration.ts` | les deux genres d'intégration |
|
||||
| `packages/definitions/src/widget.ts` | les deux genres de widget |
|
||||
| `packages/definitions/src/widget-integration-map.ts` | l'association widget ↔ intégration |
|
||||
| `packages/definitions/src/docs/integration-doc-slugs.ts` | les slugs de documentation |
|
||||
| `packages/definitions/src/docs/widget-doc-slugs.ts` | idem, côté widgets |
|
||||
| `packages/integrations/src/base/creator.ts` | la construction des classes |
|
||||
| `packages/integrations/src/index.ts` | les exports |
|
||||
| `packages/integrations/src/types.ts` | les types réexportés **côté client** |
|
||||
| `packages/api/src/router/widgets/index.ts` | les routeurs tRPC |
|
||||
| `packages/widgets/src/manifest.ts` | deux cartes distinctes, module et composant |
|
||||
| `packages/widgets/src/index.tsx` | l'import et l'entrée |
|
||||
| `packages/widgets/src/catalog.ts` | l'icône |
|
||||
| `packages/widgets/src/refetch-intervals.ts` | l'intervalle de rafraîchissement |
|
||||
| `apps/nextjs/src/components/integration/integration-grid-shared.ts` | le libellé de catégorie |
|
||||
| `apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts` | l'icône du secret |
|
||||
| `packages/translation/src/lang/en.json` | tous les libellés |
|
||||
|
||||
Deux de ces lignes ne sont vérifiées par **aucun** compilateur, et les perdre
|
||||
dans un conflit passerait tous les tests :
|
||||
|
||||
- `integration-grid-shared.ts` est typé `Record<string, string>` : une entrée
|
||||
manquante affiche `UNIFISITEMANAGER` en majuscules collées au lieu du nom.
|
||||
- `en.json` n'est pas typé du tout : une clé perdue affiche son chemin brut.
|
||||
|
||||
Les deux se voient d'un coup d'œil sur la page « New integration » — c'est le
|
||||
contrôle visuel à faire après chaque montée de version.
|
||||
|
||||
### Rejouer les résolutions de conflit
|
||||
|
||||
Les mêmes conflits reviendront à chaque version, au même endroit. Git sait les
|
||||
rejouer tout seul :
|
||||
|
||||
```bash
|
||||
git config rerere.enabled true
|
||||
```
|
||||
|
||||
À activer **avant** le premier rebase : Git n'enregistre que ce qu'il voit
|
||||
résoudre. Une fois qu'une résolution est connue, les suivantes sont
|
||||
automatiques — à relire quand même, l'amont peut avoir changé la forme de la
|
||||
liste autour.
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Assemble l'arborescence d'exécution à partir d'un dépôt déjà construit.
|
||||
#
|
||||
# Le dépôt et l'instance qui tourne n'ont PAS la même forme : le clone contient
|
||||
# des sources et un `apps/nextjs/.next/standalone` ; l'instance attend
|
||||
# `apps/nextjs/server.js`, `db/migrations/`, `build/better_sqlite3.node` à sa
|
||||
# racine. Ce script fait la conversion — les mêmes COPY que le stage `runner`
|
||||
# du Dockerfile officiel, d'où vient la disposition de /opt/homarr.
|
||||
#
|
||||
# Usage : ops/assemble-standalone.sh <destination>
|
||||
#
|
||||
# Construire d'abord :
|
||||
# pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
DEST="${1:?usage: ops/assemble-standalone.sh <destination>}"
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cd "$REPO"
|
||||
|
||||
[ -d apps/nextjs/.next/standalone ] || {
|
||||
echo "apps/nextjs/.next/standalone absent — construis d'abord :" >&2
|
||||
echo " pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# L'ordre suit le Dockerfile : le standalone d'abord, le reste par-dessus.
|
||||
cp -a apps/nextjs/.next/standalone/. "$DEST/"
|
||||
mkdir -p "$DEST/apps/nextjs/.next"
|
||||
cp -a apps/nextjs/.next/static "$DEST/apps/nextjs/.next/static"
|
||||
cp -a apps/nextjs/public "$DEST/apps/nextjs/public"
|
||||
cp -a apps/nextjs/next.config.ts apps/nextjs/package.json pnpm-workspace.yaml "$DEST/"
|
||||
|
||||
mkdir -p "$DEST/apps/cli"
|
||||
cp -a packages/cli/cli.cjs "$DEST/apps/cli/cli.cjs"
|
||||
|
||||
# Binaire natif : lié à l'architecture et à l'ABI Node de la machine qui a
|
||||
# construit. Assembler et exécuter sur la même machine rend la question sans
|
||||
# objet ; c'est le déploiement à distance qui doit la poser.
|
||||
mkdir -p "$DEST/build"
|
||||
cp -a node_modules/better-sqlite3/build/Release/better_sqlite3.node "$DEST/build/"
|
||||
|
||||
mkdir -p "$DEST/db"
|
||||
rm -rf "$DEST/db/migrations"
|
||||
cp -a packages/db/migrations "$DEST/db/migrations"
|
||||
|
||||
# run.sh n'est copié que s'il manque, jamais écrasé. Sur le LXC, l'installateur
|
||||
# a adapté le sien — il annonce le port 80 là où celui du dépôt annonce 7575 —
|
||||
# et l'écraser ferait écouter nginx sur deux ports différents selon la famille
|
||||
# d'adresses. Même raison pour entrypoint.sh, nginx.conf et redis.conf, qui
|
||||
# appartiennent à l'installation et ne sont pas copiés du tout.
|
||||
if [ -f "$DEST/run.sh" ]; then
|
||||
echo "run.sh déjà présent — laissé tel quel (il peut être adapté à cette installation)."
|
||||
if ! diff -q scripts/run.sh "$DEST/run.sh" > /dev/null; then
|
||||
echo " il diffère de scripts/run.sh : \`diff scripts/run.sh $DEST/run.sh\` pour voir quoi."
|
||||
fi
|
||||
else
|
||||
cp -a scripts/run.sh "$DEST/run.sh"
|
||||
chmod +x "$DEST/run.sh"
|
||||
fi
|
||||
|
||||
echo "Assemblé dans $DEST ($(du -sh "$DEST" | cut -f1))"
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Déploie le build standalone de ce dépôt sur le LXC Homarr.
|
||||
#
|
||||
# Reproduit la disposition que le Dockerfile officiel assemble (ses COPY du
|
||||
# stage `runner`), parce que l'installation du LXC en vient : /opt/homarr a la
|
||||
# même forme qu'une image, sans le conteneur autour.
|
||||
#
|
||||
# Ce que le script NE touche PAS, volontairement :
|
||||
# - /etc/homarr/homarr.env : secrets et configuration de l'instance
|
||||
# - run.sh, entrypoint.sh, nginx.conf, redis.conf : modifiés par l'installateur
|
||||
# du LXC, ils appartiennent à l'installation et non au build
|
||||
# - /var/lib/homarr/db.sqlite : la base, sauvegardée mais jamais remplacée
|
||||
#
|
||||
# Usage : ops/deploy-lxc.sh [--dry-run]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${HOMARR_HOST:-zachary@10.70.1.13}"
|
||||
REMOTE_DIR="${HOMARR_REMOTE_DIR:-/opt/homarr}"
|
||||
REMOTE_DB="${HOMARR_REMOTE_DB:-/var/lib/homarr/db.sqlite}"
|
||||
SERVICE="${HOMARR_SERVICE:-homarr}"
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DRY_RUN=""
|
||||
[ "${1:-}" = "--dry-run" ] && DRY_RUN="--dry-run"
|
||||
|
||||
say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Contrôles préalables"
|
||||
|
||||
cd "$REPO"
|
||||
[ -z "$(git status --porcelain)" ] || {
|
||||
echo "Arbre de travail sale. Committe ou remise avant de déployer." >&2
|
||||
exit 1
|
||||
}
|
||||
echo "Commit déployé : $(git log --oneline -1)"
|
||||
|
||||
# Le dépôt exige un Node récent (champ engines.node). Le Node par défaut du poste
|
||||
# est plus ancien que ça : sans ce contrôle, l'échec surviendrait au milieu du
|
||||
# build, avec un message de pnpm qui ne dit pas quoi faire.
|
||||
required_node="$(grep -oP '^node = "\K[^"]+' "$REPO/mise.toml" 2>/dev/null || echo '24.18.0')"
|
||||
if ! node -e "process.exit(process.versions.node.localeCompare('$required_node', undefined, { numeric: true }) < 0 ? 1 : 0)"; then
|
||||
echo "Node $(node -v) est trop ancien — il en faut au moins v$required_node." >&2
|
||||
echo "Par exemple : export PATH=\"\$HOME/.local/share/nvm/v24.20.0/bin:\$PATH\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Une migration présente ici et absente en prod n'est pas bloquante — Homarr les
|
||||
# applique au démarrage — mais elle rend le rollback non trivial : restaurer
|
||||
# l'archive ne défait pas un schéma déjà migré. On veut le savoir AVANT.
|
||||
#
|
||||
# Comparaison par dialecte : la prod embarque les migrations de tous les moteurs,
|
||||
# et prendre le maximum toutes dialectes confondues masquerait une divergence
|
||||
# propre à celui qui tourne réellement.
|
||||
DIALECT="${HOMARR_DB_DIALECT:-sqlite}"
|
||||
local_migration="$(find "packages/db/migrations/$DIALECT" -name '*.sql' -printf '%f\n' | sort | tail -1)"
|
||||
remote_migration="$(ssh "$HOST" "find $REMOTE_DIR/db/migrations/$DIALECT -name '*.sql' -printf '%f\n' | sort | tail -1")"
|
||||
echo "Dernière migration — locale : $local_migration | prod : $remote_migration"
|
||||
if [ "$local_migration" != "$remote_migration" ]; then
|
||||
echo
|
||||
echo "!! Les migrations divergent. Le rollback ne sera PAS une simple restauration."
|
||||
echo " Sauvegarde la base avant de continuer, puis relance en connaissance de cause."
|
||||
read -r -p " Continuer quand même ? [oui/NON] " answer
|
||||
[ "$answer" = "oui" ] || exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Build"
|
||||
|
||||
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Assemblage de l'arborescence à envoyer"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
# Même assemblage que pour une installation construite sur place : un seul
|
||||
# endroit décrit la disposition d'exécution, et les deux chemins ne peuvent pas
|
||||
# diverger. `run.sh` en fait partie, mais il est exclu du rsync plus bas —
|
||||
# celui de l'installation est adapté à son port.
|
||||
"$REPO/ops/assemble-standalone.sh" "$STAGE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Contrôle de compatibilité du binaire natif"
|
||||
|
||||
local_abi="$(node -p 'process.versions.modules')"
|
||||
remote_abi="$(ssh "$HOST" 'node -p "process.versions.modules"')"
|
||||
local_arch="$(uname -m)"
|
||||
remote_arch="$(ssh "$HOST" 'uname -m')"
|
||||
echo "ABI Node — local : $local_abi | prod : $remote_abi"
|
||||
echo "Arch — local : $local_arch | prod : $remote_arch"
|
||||
if [ "$local_abi" != "$remote_abi" ] || [ "$local_arch" != "$remote_arch" ]; then
|
||||
echo "!! better_sqlite3.node ne se chargera pas en prod. Build sur le LXC, ou aligne Node." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$DRY_RUN" ]; then
|
||||
say "Mode --dry-run : arrêt avant toute modification de la prod"
|
||||
# -i sans quoi rsync ne dit rien du tout en mode simulation.
|
||||
rsync -ain --delete --rsync-path="sudo rsync" \
|
||||
--exclude 'run.sh' --exclude 'entrypoint.sh' \
|
||||
--exclude 'nginx.conf' --exclude 'redis.conf' \
|
||||
"$STAGE/" "$HOST:$REMOTE_DIR/" > /tmp/homarr-deploy-plan.txt
|
||||
# Écrit d'abord, tronqué ensuite : un `| head` ferme le tuyau et rsync s'arrête
|
||||
# sur SIGPIPE au bout de trente lignes, ce qui donnerait un plan mensonger.
|
||||
head -30 /tmp/homarr-deploy-plan.txt
|
||||
echo
|
||||
echo "$(grep -c '^\*deleting' /tmp/homarr-deploy-plan.txt) suppressions, \
|
||||
$(grep -vc '^\*deleting' /tmp/homarr-deploy-plan.txt) écritures. Plan complet : /tmp/homarr-deploy-plan.txt"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Sauvegarde"
|
||||
|
||||
stamp="$(date +%F-%H%M)"
|
||||
ssh -t "$HOST" "sudo tar czf /root/homarr-backup-$stamp.tar.gz $REMOTE_DIR \
|
||||
&& sudo cp $REMOTE_DB /root/homarr-db-$stamp.sqlite \
|
||||
&& sudo ls -lh /root/homarr-backup-$stamp.tar.gz /root/homarr-db-$stamp.sqlite"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Arrêt du service"
|
||||
|
||||
# Jamais de rsync sur un Next.js en cours d'exécution : il lit ses chunks à la
|
||||
# demande et servirait des morceaux de deux builds différents.
|
||||
ssh -t "$HOST" "sudo systemctl stop $SERVICE && ! systemctl is-active --quiet $SERVICE && echo arrêté"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Envoi"
|
||||
|
||||
# --delete retire ce que le build ne produit plus ; les quatre fichiers exclus
|
||||
# sont protégés de cette suppression par leur exclusion même.
|
||||
rsync -a --delete --rsync-path="sudo rsync" \
|
||||
--exclude 'run.sh' --exclude 'entrypoint.sh' \
|
||||
--exclude 'nginx.conf' --exclude 'redis.conf' \
|
||||
"$STAGE/" "$HOST:$REMOTE_DIR/"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Redémarrage"
|
||||
|
||||
ssh -t "$HOST" "sudo systemctl start $SERVICE"
|
||||
sleep 10
|
||||
ssh "$HOST" "systemctl is-active $SERVICE; curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1/"
|
||||
|
||||
say "Journaux (40 dernières lignes)"
|
||||
ssh -t "$HOST" "sudo journalctl -u $SERVICE -n 40 --no-pager"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Déployé. Vérifie dans le navigateur que les intégrations PRÉEXISTANTES
|
||||
fonctionnent encore — c'est ce qui prouve que SECRET_ENCRYPTION_KEY est intact.
|
||||
|
||||
Retour arrière :
|
||||
ssh $HOST 'sudo systemctl stop $SERVICE \\
|
||||
&& sudo rm -rf $REMOTE_DIR \\
|
||||
&& sudo tar xzf /root/homarr-backup-$stamp.tar.gz -C / \\
|
||||
&& sudo systemctl start $SERVICE'
|
||||
|
||||
La base n'a pas été remplacée ; sa copie est dans /root/homarr-db-$stamp.sqlite.
|
||||
EOF
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Fait pointer une instance Homarr installée sur /var/lib/homarr au lieu de
|
||||
# /appdata.
|
||||
#
|
||||
# `/appdata` vient de l'image Docker, où c'est un point de montage. Sur une
|
||||
# machine, il ne suit aucune convention : l'état d'un service se range sous
|
||||
# /var/lib. Ce script agit sur l'INSTANCE INSTALLÉE — son `run.sh`, son
|
||||
# `redis.conf` — et jamais sur le dépôt : ni le code source ni le Dockerfile ne
|
||||
# sont touchés, pour que le fork reste rebasable sur l'amont.
|
||||
#
|
||||
# Idempotent : le relancer ne fait rien de plus.
|
||||
#
|
||||
# Usage : ops/use-var-lib-homarr.sh <répertoire de l'instance>
|
||||
# ex. : sudo ops/use-var-lib-homarr.sh /opt/homarr
|
||||
# sudo ops/use-var-lib-homarr.sh /opt/homarr-dev
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
DIR="${1:?usage: ops/use-var-lib-homarr.sh <dossier-instance>}"
|
||||
STATE="${HOMARR_STATE_DIR:-/var/lib/homarr}"
|
||||
stamp="$(date +%F-%H%M%S)"
|
||||
|
||||
[ -d "$DIR" ] || { echo "$DIR n'existe pas." >&2; exit 1; }
|
||||
[ -f "$DIR/run.sh" ] || { echo "$DIR/run.sh introuvable — est-ce bien une instance assemblée ?" >&2; exit 1; }
|
||||
|
||||
changed=0
|
||||
|
||||
for file in run.sh redis.conf; do
|
||||
target="$DIR/$file"
|
||||
[ -f "$target" ] || continue
|
||||
grep -q '/appdata' "$target" || continue
|
||||
|
||||
cp -a "$target" "$target.bak-$stamp"
|
||||
sed -i "s#/appdata#$STATE#g" "$target"
|
||||
echo "== $file"
|
||||
diff -u "$target.bak-$stamp" "$target" | sed -n '4,$p' || true
|
||||
changed=1
|
||||
done
|
||||
|
||||
# Le chemin des certificats n'est pas lu par run.sh mais par l'application, qui
|
||||
# retombe sur /appdata/trusted-certificates quand la variable est absente.
|
||||
# Réécrire les `mkdir` ne suffit donc pas : sans cette ligne, Homarr créerait à
|
||||
# nouveau /appdata au premier chargement de certificat.
|
||||
if ! grep -q 'LOCAL_CERTIFICATE_PATH' "$DIR/run.sh"; then
|
||||
[ -f "$DIR/run.sh.bak-$stamp" ] || cp -a "$DIR/run.sh" "$DIR/run.sh.bak-$stamp"
|
||||
awk -v value="$STATE/trusted-certificates" '
|
||||
NR == 1 {
|
||||
print
|
||||
print ""
|
||||
print "# Chemin des certificats de confiance. Sans cette ligne, l'\''application"
|
||||
print "# retombe sur le dossier de l'\''image et le recrée au premier certificat lu."
|
||||
print "export LOCAL_CERTIFICATE_PATH=\"${LOCAL_CERTIFICATE_PATH:-" value "}\""
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$DIR/run.sh" > "$DIR/run.sh.new"
|
||||
cat "$DIR/run.sh.new" > "$DIR/run.sh" # préserve les droits et le propriétaire
|
||||
rm -f "$DIR/run.sh.new"
|
||||
echo "== run.sh : LOCAL_CERTIFICATE_PATH ajouté ($STATE/trusted-certificates)"
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [ "$changed" -eq 0 ]; then
|
||||
echo "Rien à faire : $DIR ne référence déjà plus /appdata."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$STATE/trusted-certificates"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Instance $DIR réglée sur $STATE. Sauvegardes : *.bak-$stamp
|
||||
|
||||
Il reste les DONNÉES, que ce script ne déplace pas — c'est à faire une fois,
|
||||
service arrêté, et c'est irréversible sans sauvegarde :
|
||||
|
||||
sudo systemctl stop homarr
|
||||
sudo mv /appdata/trusted-certificates/*.pem $STATE/trusted-certificates/ 2>/dev/null || true
|
||||
sudo find /appdata -type f # doit ne RIEN afficher avant la suite
|
||||
sudo rm -rf /appdata
|
||||
sudo systemctl start homarr
|
||||
|
||||
Le contrôle qui compte ensuite : l'intégration UniFi OS doit toujours répondre.
|
||||
C'est elle qui valide son certificat avec le CA déplacé — si elle fonctionne,
|
||||
le déplacement a réussi.
|
||||
EOF
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.75.0",
|
||||
"version": "1.77.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -31,6 +31,8 @@ export const widgetRouter = createTRPCRouter({
|
||||
options: lazy(() => import("./options").then((mod) => mod.optionsRouter)),
|
||||
releases: lazy(() => import("./releases").then((mod) => mod.releasesRouter)),
|
||||
networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)),
|
||||
unifiOs: lazy(() => import("./unifi-os").then((mod) => mod.unifiOsRouter)),
|
||||
unifiSiteManager: lazy(() => import("./unifi-site-manager").then((mod) => mod.unifiSiteManagerRouter)),
|
||||
firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)),
|
||||
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
|
||||
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { unifiOsRequestHandler } from "@homarr/request-handler/unifi-os";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const unifiOsRouter = createTRPCRouter({
|
||||
summary: publicProcedure.concat(createManyIntegrationMiddleware("query", "unifiOs")).query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = unifiOsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
summary: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { unifiSiteManagerRequestHandler } from "@homarr/request-handler/unifi-site-manager";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const unifiSiteManagerRouter = createTRPCRouter({
|
||||
overview: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "unifiSiteManager"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = unifiSiteManagerRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
overview: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
|
||||
return "http://127.0.0.1:3000";
|
||||
}
|
||||
|
||||
export const trpcPath = "/api/trpc";
|
||||
|
||||
@@ -13,11 +13,6 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
||||
import { withTimeoutAsync } from "./timeout";
|
||||
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
export const getDefaultUserAgent = () => `Homarr/${packageJson.version} (+https://homarr.dev)`;
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: TrustedCertificateHostname[],
|
||||
): typeof checkServerIdentity => {
|
||||
@@ -83,10 +78,6 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
async (signal) =>
|
||||
fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
"User-Agent": getDefaultUserAgent(),
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
}),
|
||||
@@ -97,10 +88,6 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
|
||||
return fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
"User-Agent": getDefaultUserAgent(),
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
@@ -31,6 +31,8 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
proxmox: "proxmox",
|
||||
nextcloud: "nextcloud",
|
||||
unifiController: "unifi-controller",
|
||||
unifiOs: null,
|
||||
unifiSiteManager: null,
|
||||
opnsense: "opnsense",
|
||||
ntfy: "ntfy",
|
||||
gotify: "gotify",
|
||||
|
||||
@@ -23,6 +23,8 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
minecraftServerStatus: "minecraft-server-status",
|
||||
networkControllerSummary: "network-controller-summary",
|
||||
networkControllerStatus: "network-controller-status",
|
||||
unifiOs: null,
|
||||
unifiSiteManager: null,
|
||||
rssFeed: "rss-feed",
|
||||
bookmarks: "bookmarks",
|
||||
indexerManager: "indexer-manager",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createDocumentationLink } from "./docs";
|
||||
|
||||
export const integrationSecretKindObject = {
|
||||
apiKey: { isPublic: false, multiline: false },
|
||||
siteManagerApiKey: { isPublic: false, multiline: false },
|
||||
username: { isPublic: true, multiline: false },
|
||||
password: { isPublic: false, multiline: false },
|
||||
tokenId: { isPublic: true, multiline: false },
|
||||
@@ -286,6 +287,22 @@ export const integrationDefs = {
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"),
|
||||
defaultPort: 8443,
|
||||
},
|
||||
unifiOs: {
|
||||
name: "UniFi OS",
|
||||
secretKinds: [["apiKey"], ["apiKey", "siteManagerApiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
|
||||
category: ["unifiOs"],
|
||||
documentationUrl: null,
|
||||
defaultPort: 443,
|
||||
},
|
||||
unifiSiteManager: {
|
||||
name: "UniFi Site Manager",
|
||||
secretKinds: [["siteManagerApiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
|
||||
category: ["unifiSiteManager"],
|
||||
documentationUrl: null,
|
||||
defaultUrl: "https://api.ui.com",
|
||||
},
|
||||
opnsense: {
|
||||
name: "OPNsense",
|
||||
secretKinds: [["opnsenseApiKey", "opnsenseApiSecret"]],
|
||||
@@ -593,6 +610,8 @@ export const integrationCategories = [
|
||||
"uptimeMonitoring",
|
||||
"subtitleManager",
|
||||
"reverseProxy",
|
||||
"unifiOs",
|
||||
"unifiSiteManager",
|
||||
] as const;
|
||||
|
||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||
@@ -23,6 +23,8 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
mediaMissing: getIntegrationKindsByCategory("mediaOrganizer"),
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
unifiOs: ["unifiOs"],
|
||||
unifiSiteManager: ["unifiSiteManager"],
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
|
||||
@@ -21,6 +21,8 @@ export const widgetKinds = [
|
||||
"minecraftServerStatus",
|
||||
"networkControllerSummary",
|
||||
"networkControllerStatus",
|
||||
"unifiOs",
|
||||
"unifiSiteManager",
|
||||
"rssFeed",
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
|
||||
@@ -53,6 +53,8 @@ import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||
import { UmamiIntegration } from "../umami/umami-integration";
|
||||
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||
import { UnifiOsIntegration } from "../unifi-os/unifi-os-integration";
|
||||
import { UnifiSiteManagerIntegration } from "../unifi-site-manager/unifi-site-manager-integration";
|
||||
import { UnraidIntegration } from "../unraid/unraid-integration";
|
||||
import { WudIntegration } from "../wud/wud-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
@@ -109,6 +111,8 @@ export const integrationCreators = {
|
||||
emby: EmbyIntegration,
|
||||
nextcloud: NextcloudIntegration,
|
||||
unifiController: UnifiControllerIntegration,
|
||||
unifiOs: UnifiOsIntegration,
|
||||
unifiSiteManager: UnifiSiteManagerIntegration,
|
||||
opnsense: OPNsenseIntegration,
|
||||
ical: ICalIntegration,
|
||||
ntfy: NTFYIntegration,
|
||||
|
||||
@@ -99,5 +99,8 @@ export { anchorNotesListInputSchema } from "./anchor/anchor-types";
|
||||
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
export { UnifiOsIntegration } from "./unifi-os/unifi-os-integration";
|
||||
export { UnifiSiteManagerIntegration } from "./unifi-site-manager/unifi-site-manager-integration";
|
||||
|
||||
// Helpers
|
||||
export { createIntegrationAsync } from "./base/creator";
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { UnifiOsSummary } from "../../unifi-os/unifi-os-types";
|
||||
|
||||
export interface UnifiOsSummaryIntegration {
|
||||
getUnifiOsSummaryAsync(): Promise<UnifiOsSummary>;
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import type { UnifiSiteManagerOverview } from "../../unifi-site-manager/unifi-site-manager-types";
|
||||
|
||||
export interface UnifiSiteManagerIntegration {
|
||||
getSiteOverviewAsync(): Promise<UnifiSiteManagerOverview>;
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export * from "./base/searchable-integration";
|
||||
export * from "./homeassistant/homeassistant-types";
|
||||
export * from "./proxmox/proxmox-types";
|
||||
export * from "./unifi-controller/unifi-controller-types";
|
||||
export * from "./unifi-os/unifi-os-types";
|
||||
export * from "./interfaces/unifi-os-summary/unifi-os-summary-integration";
|
||||
export * from "./unifi-site-manager/unifi-site-manager-types";
|
||||
export * from "./interfaces/unifi-site-manager/unifi-site-manager-integration";
|
||||
export * from "./opnsense/opnsense-types";
|
||||
export * from "./interfaces/media-releases";
|
||||
export * from "./coolify/coolify-types";
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment node
|
||||
import { Response, type RequestInit } from "undici";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import { UnifiOsApi } from "../unifi-os-api";
|
||||
|
||||
const ORIGIN = "https://console.example.test";
|
||||
const API_KEY = "test-key";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const page = (data: unknown[], totalCount = data.length, limit = 200) => ({
|
||||
offset: 0,
|
||||
limit,
|
||||
count: data.length,
|
||||
totalCount,
|
||||
data,
|
||||
});
|
||||
|
||||
const createApi = (handler: (url: string) => Promise<Response>) => {
|
||||
const fetchAsync = vi.fn(async (url: string) => await handler(url));
|
||||
return { api: new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never), fetchAsync };
|
||||
};
|
||||
|
||||
describe("UnifiOsApi request shape", () => {
|
||||
test("prefixes the integration path and sends the API key header", async () => {
|
||||
const fetchAsync = vi.fn(async (_url: string, _init?: RequestInit) =>
|
||||
jsonResponse({ applicationVersion: "10.6.102" }),
|
||||
);
|
||||
const api = new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never);
|
||||
|
||||
await api.getInfoAsync();
|
||||
|
||||
expect(fetchAsync).toHaveBeenCalledWith(
|
||||
`${ORIGIN}/proxy/network/integration/v1/info`,
|
||||
expect.objectContaining({ headers: expect.objectContaining({ "X-API-KEY": API_KEY }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double a slash when the origin has a trailing one", async () => {
|
||||
const fetchAsync = vi.fn(async (_url: string, _init?: RequestInit) =>
|
||||
jsonResponse({ applicationVersion: "10.6.102" }),
|
||||
);
|
||||
const api = new UnifiOsApi(`${ORIGIN}/`, API_KEY, fetchAsync as never);
|
||||
|
||||
await api.getInfoAsync();
|
||||
|
||||
expect(fetchAsync.mock.calls[0]?.[0]).toBe(`${ORIGIN}/proxy/network/integration/v1/info`);
|
||||
});
|
||||
|
||||
test("throws ResponseError on a non-ok status instead of parsing the body", async () => {
|
||||
const { api } = createApi(async () => jsonResponse({ error: { code: 401 } }, 401));
|
||||
|
||||
await expect(api.getInfoAsync()).rejects.toBeInstanceOf(ResponseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnifiOsApi getClientCountsAsync", () => {
|
||||
test("counts from a single page when totalCount fits under the API limit", async () => {
|
||||
const { api, fetchAsync } = createApi(async () =>
|
||||
jsonResponse(
|
||||
page([
|
||||
{ id: "1", name: "a", type: "WIRED", access: { type: "DEFAULT" } },
|
||||
{ id: "2", name: "b", type: "WIRELESS", access: { type: "GUEST" } },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const counts = await api.getClientCountsAsync("site-1");
|
||||
|
||||
expect(counts).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 1 });
|
||||
expect(fetchAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("switches to filtered count queries when the site exceeds the 200 limit", async () => {
|
||||
const requested: string[] = [];
|
||||
const { api } = createApi(async (url) => {
|
||||
requested.push(url);
|
||||
if (!url.includes("filter=")) return jsonResponse(page([], 512));
|
||||
if (url.includes("WIRED")) return jsonResponse(page([], 400, 1));
|
||||
if (url.includes("WIRELESS")) return jsonResponse(page([], 100, 1));
|
||||
if (url.includes("TELEPORT")) return jsonResponse(page([], 2, 1));
|
||||
if (url.includes("VPN")) return jsonResponse(page([], 10, 1));
|
||||
return jsonResponse(page([], 5, 1));
|
||||
});
|
||||
|
||||
const counts = await api.getClientCountsAsync("site-1");
|
||||
|
||||
expect(counts).toEqual({ total: 512, wired: 400, wireless: 100, vpn: 12, guests: 5 });
|
||||
expect(requested.some((url) => url.includes("and("))).toBe(false);
|
||||
expect(requested.filter((url) => url.includes("filter=")).length).toBe(5);
|
||||
});
|
||||
|
||||
test("uses the prefix filter syntax the API actually accepts", async () => {
|
||||
const requested: string[] = [];
|
||||
const { api } = createApi(async (url) => {
|
||||
requested.push(url);
|
||||
return jsonResponse(page([], url.includes("filter=") ? 1 : 512, 1));
|
||||
});
|
||||
|
||||
await api.getClientCountsAsync("site-1");
|
||||
|
||||
const guestQuery = requested.find((url) => url.includes("GUEST"));
|
||||
expect(decodeURIComponent(guestQuery ?? "")).toContain("access.type.eq('GUEST')");
|
||||
expect(decodeURIComponent(guestQuery ?? "")).not.toContain(" and ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnifiOsApi getFirstSiteAsync", () => {
|
||||
test("returns null for a console exposing no site rather than throwing", async () => {
|
||||
const { api } = createApi(async () => jsonResponse(page([])));
|
||||
|
||||
expect(await api.getFirstSiteAsync()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
// @vitest-environment node
|
||||
import { fetch as platformFetch, Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { UnifiSmApi } from "../unifi-sm-api";
|
||||
import { UnifiOsIntegration } from "../unifi-os-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Les deux transports sont mockés séparément parce qu'ils *doivent* rester
|
||||
// séparés : le local passe par les certificats de confiance de Homarr, le cloud
|
||||
// par le `fetch` de la plateforme. Mocker les deux permet d'affirmer qu'aucune
|
||||
// requête vers api.ui.com n'emprunte le transport local — la garantie du §5.3.
|
||||
vi.mock("undici", async (importOriginalAsync) => {
|
||||
const actual = await importOriginalAsync<typeof import("undici")>();
|
||||
return { ...actual, fetch: vi.fn() };
|
||||
});
|
||||
|
||||
/** Transport local : certificats de confiance, console auto-signée. */
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
/** Transport cloud : `fetch` de la plateforme, certificat public ordinaire. */
|
||||
const mockCloudFetch = vi.mocked(platformFetch);
|
||||
|
||||
const CLOUD_ORIGIN = "https://api.ui.com";
|
||||
|
||||
const GATEWAY_MAC = "aa:bb:cc:11:22:33";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const page = (data: unknown[]) => ({ offset: 0, limit: 200, count: data.length, totalCount: data.length, data });
|
||||
|
||||
const localRoutes: Record<string, unknown> = {
|
||||
"/info": { applicationVersion: "10.6.102" },
|
||||
"/sites": page([{ id: "site-1", name: "Example site", internalReference: "default" }]),
|
||||
"/sites/site-1/clients": page([
|
||||
{ id: "c1", name: "a", type: "WIRED", access: { type: "DEFAULT" } },
|
||||
{ id: "c2", name: "b", type: "WIRELESS", access: { type: "DEFAULT" } },
|
||||
]),
|
||||
"/sites/site-1/devices": page([
|
||||
{
|
||||
id: "gw",
|
||||
name: "Gateway",
|
||||
model: "UXG Pro",
|
||||
macAddress: GATEWAY_MAC,
|
||||
state: "ONLINE",
|
||||
firmwareUpdatable: true,
|
||||
features: ["gateway"],
|
||||
},
|
||||
{
|
||||
id: "sw",
|
||||
name: "Switch",
|
||||
model: "USW Lite 8",
|
||||
macAddress: "aa:bb:cc:99:88:77",
|
||||
state: "OFFLINE",
|
||||
firmwareUpdatable: false,
|
||||
features: ["switching"],
|
||||
},
|
||||
]),
|
||||
"/sites/site-1/devices/gw/statistics/latest": {
|
||||
uptimeSec: 1009049,
|
||||
cpuUtilizationPct: 19.1,
|
||||
memoryUtilizationPct: 63.1,
|
||||
uplink: { txRateBps: 14140912, rxRateBps: 3346784 },
|
||||
},
|
||||
};
|
||||
|
||||
const routeLocal = (rawUrl: string): Response | null => {
|
||||
const path = new URL(rawUrl).pathname.replace("/proxy/network/integration/v1", "");
|
||||
const body = localRoutes[path];
|
||||
return body === undefined ? null : jsonResponse(body);
|
||||
};
|
||||
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
|
||||
new UnifiOsIntegration({
|
||||
id: "test-unifi-os",
|
||||
name: "Test UniFi OS",
|
||||
url: "https://console.example.test",
|
||||
externalUrl: null,
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
const localOnlySecrets: IntegrationSecret[] = [{ kind: "apiKey", value: "local-key" }];
|
||||
const hybridSecrets: IntegrationSecret[] = [
|
||||
{ kind: "apiKey", value: "local-key" },
|
||||
{ kind: "siteManagerApiKey", value: "cloud-key" },
|
||||
];
|
||||
|
||||
/** Toute URL demandée au transport local, quel que soit l'appelant. */
|
||||
const localTransportUrls = () => mockFetch.mock.calls.map(([url]) => String(url));
|
||||
/** Toute URL demandée au transport cloud. */
|
||||
const cloudTransportUrls = () => mockCloudFetch.mock.calls.map(([url]) => String(url));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockCloudFetch.mockReset();
|
||||
UnifiSmApi.clearCache();
|
||||
});
|
||||
|
||||
describe("UnifiOsIntegration getUnifiOsSummaryAsync", () => {
|
||||
test("builds the summary from the local API alone", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.site).toEqual({ id: "site-1", name: "Example site", applicationVersion: "10.6.102" });
|
||||
expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 });
|
||||
expect(summary.devices).toEqual({ total: 2, online: 1, offline: 1, firmwareUpdatable: 1 });
|
||||
expect(summary.wan.state).toBe("online");
|
||||
expect(summary.wan.throughput).toEqual({ txBps: 14140912, rxBps: 3346784 });
|
||||
expect(summary.gateway).toMatchObject({ name: "Gateway", uptimeSec: 1009049, cpuPct: 19.1 });
|
||||
});
|
||||
|
||||
test("leaves the ISP block null and touches no transport at all without a Site Manager key", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.wan.isp).toBeNull();
|
||||
// Le transport cloud est le seul chemin vers api.ui.com : ne pas l'appeler
|
||||
// du tout prouve qu'aucune requête cloud n'a été émise.
|
||||
expect(mockCloudFetch).not.toHaveBeenCalled();
|
||||
expect(localTransportUrls().every((url) => !url.startsWith(CLOUD_ORIGIN))).toBe(true);
|
||||
});
|
||||
|
||||
test("fills the ISP block from the cloud transport, never from the trusted-certificate one", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
mockCloudFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.startsWith(`${CLOUD_ORIGIN}/v1/sites`)) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{
|
||||
siteId: "cloud-1",
|
||||
hostId: "host-1",
|
||||
meta: { gatewayMac: GATEWAY_MAC },
|
||||
statistics: { wans: { WAN: { externalIp: "203.0.113.10" } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (raw.includes("isp-metrics")) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
periods: [
|
||||
{
|
||||
metricTime: "2026-09-08T09:10:00Z",
|
||||
data: {
|
||||
wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(hybridSecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.wan.isp).toMatchObject({ name: "Example ISP", latencyMs: 11, externalIp: "203.0.113.10" });
|
||||
expect(summary.wan.isp?.measuredAt).toEqual(new Date("2026-09-08T09:10:00Z"));
|
||||
|
||||
// Le bloc ISP n'a pu être rempli que par des réponses servies par le mock
|
||||
// cloud : les deux appels y sont donc bien passés.
|
||||
expect(cloudTransportUrls()).toEqual([`${CLOUD_ORIGIN}/v1/sites`, `${CLOUD_ORIGIN}/v1/isp-metrics/5m/query`]);
|
||||
// Le cœur du §5.3 : la clé Site Manager ne voyage jamais sur le transport
|
||||
// qui embarque les autorités personnalisées de l'administrateur.
|
||||
expect(localTransportUrls().every((url) => url.startsWith("https://console.example.test"))).toBe(true);
|
||||
});
|
||||
|
||||
test("never sends the Site Manager key through the trusted-certificate transport", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
mockCloudFetch.mockImplementation(async () => jsonResponse({}, 500));
|
||||
|
||||
await createIntegration(hybridSecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
const localKeys = mockFetch.mock.calls.map(
|
||||
([, init]) => (init?.headers as Record<string, string> | undefined)?.["X-API-KEY"],
|
||||
);
|
||||
|
||||
expect(localKeys.every((key) => key === "local-key")).toBe(true);
|
||||
expect(localKeys).not.toContain("cloud-key");
|
||||
});
|
||||
|
||||
test("degrades only the clients block when the clients endpoint fails", async () => {
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.includes("/clients")) return jsonResponse({ error: "boom" }, 500);
|
||||
return routeLocal(raw) ?? jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.clients).toBeNull();
|
||||
expect(summary.devices).toEqual({ total: 2, online: 1, offline: 1, firmwareUpdatable: 1 });
|
||||
expect(summary.gateway).not.toBeNull();
|
||||
});
|
||||
|
||||
test("degrades only the devices block when the devices endpoint fails", async () => {
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.includes("/devices") && !raw.includes("/statistics")) return jsonResponse({ error: "boom" }, 500);
|
||||
return routeLocal(raw) ?? jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.devices).toBeNull();
|
||||
expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 });
|
||||
});
|
||||
|
||||
test("keeps clients and devices when the gateway cannot be identified", async () => {
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.endsWith("/devices?limit=200")) {
|
||||
return jsonResponse(
|
||||
page([
|
||||
{
|
||||
id: "sw",
|
||||
name: "Switch",
|
||||
model: "USW Lite 8",
|
||||
macAddress: "aa:bb:cc:99:88:77",
|
||||
state: "ONLINE",
|
||||
firmwareUpdatable: false,
|
||||
features: ["switching"],
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
return routeLocal(raw) ?? jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.gateway).toBeNull();
|
||||
expect(summary.wan.throughput).toBeNull();
|
||||
expect(summary.wan.state).toBe("unknown");
|
||||
expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import type { UnifiOsClient, UnifiOsDevice } from "../unifi-os-types";
|
||||
import {
|
||||
countClients,
|
||||
findGateway,
|
||||
matchCloudSite,
|
||||
normalizeMacAddress,
|
||||
summarizeDevices,
|
||||
unifiOsClientSchema,
|
||||
} from "../unifi-os-types";
|
||||
|
||||
const client = (type: string, accessType = "DEFAULT"): UnifiOsClient => ({
|
||||
id: `client-${type}-${accessType}-${Math.random()}`,
|
||||
name: "client",
|
||||
type,
|
||||
access: { type: accessType },
|
||||
});
|
||||
|
||||
const device = (overrides: Partial<UnifiOsDevice> = {}): UnifiOsDevice => ({
|
||||
id: "device-1",
|
||||
name: "device",
|
||||
model: "USW Lite 8",
|
||||
macAddress: "aa:bb:cc:11:22:33",
|
||||
state: "ONLINE",
|
||||
firmwareUpdatable: false,
|
||||
features: ["switching"],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("normalizeMacAddress", () => {
|
||||
test("lowercases and strips separators so both APIs compare equal", () => {
|
||||
expect(normalizeMacAddress("AA:BB:CC:11:22:33")).toBe("aabbcc112233");
|
||||
expect(normalizeMacAddress("aa-bb-cc-11-22-33")).toBe("aabbcc112233");
|
||||
expect(normalizeMacAddress("AABBCC112233")).toBe("aabbcc112233");
|
||||
});
|
||||
});
|
||||
|
||||
describe("countClients", () => {
|
||||
test("counts each connection type separately", () => {
|
||||
const counts = countClients([
|
||||
client("WIRED"),
|
||||
client("WIRED"),
|
||||
client("WIRELESS"),
|
||||
client("VPN"),
|
||||
client("TELEPORT"),
|
||||
]);
|
||||
|
||||
expect(counts).toEqual({ total: 5, wired: 2, wireless: 1, vpn: 2, guests: 0 });
|
||||
});
|
||||
|
||||
test("folds TELEPORT into vpn because both are remote access", () => {
|
||||
expect(countClients([client("TELEPORT")]).vpn).toBe(1);
|
||||
});
|
||||
|
||||
test("counts guests across every connection type", () => {
|
||||
const counts = countClients([client("WIRELESS", "GUEST"), client("WIRED", "GUEST"), client("WIRED")]);
|
||||
|
||||
expect(counts.guests).toBe(2);
|
||||
expect(counts.wired).toBe(2);
|
||||
});
|
||||
|
||||
test("treats an unknown connection type as counted but unclassified", () => {
|
||||
const counts = countClients([client("SOMETHING_NEW")]);
|
||||
|
||||
expect(counts.total).toBe(1);
|
||||
expect(counts.wired + counts.wireless + counts.vpn).toBe(0);
|
||||
});
|
||||
|
||||
test("tolerates a client without an access block", () => {
|
||||
const counts = countClients([{ id: "x", name: "x", type: "WIRED" }]);
|
||||
|
||||
expect(counts).toEqual({ total: 1, wired: 1, wireless: 0, vpn: 0, guests: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeDevices", () => {
|
||||
test("splits online from everything else and counts pending firmware", () => {
|
||||
const counts = summarizeDevices([
|
||||
device({ id: "a", state: "ONLINE", firmwareUpdatable: true }),
|
||||
device({ id: "b", state: "OFFLINE" }),
|
||||
device({ id: "c", state: "UPDATING", firmwareUpdatable: true }),
|
||||
]);
|
||||
|
||||
expect(counts).toEqual({ total: 3, online: 1, offline: 2, firmwareUpdatable: 2 });
|
||||
});
|
||||
|
||||
test("returns zeroes for an empty site rather than throwing", () => {
|
||||
expect(summarizeDevices([])).toEqual({ total: 0, online: 0, offline: 0, firmwareUpdatable: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findGateway", () => {
|
||||
test("prefers the device advertising the gateway feature", () => {
|
||||
const gateway = device({ id: "gw", features: ["gateway"], model: "UXG Pro" });
|
||||
expect(findGateway([device(), gateway])?.id).toBe("gw");
|
||||
});
|
||||
|
||||
test("falls back to a gateway-looking model when features are empty", () => {
|
||||
const gateway = device({ id: "gw", features: [], model: "Gateway Pro" });
|
||||
expect(findGateway([device(), gateway])?.id).toBe("gw");
|
||||
});
|
||||
|
||||
test("returns null when no gateway is present instead of picking any device", () => {
|
||||
expect(findGateway([device()])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchCloudSite", () => {
|
||||
const siteA = { siteId: "a", meta: { gatewayMac: "AA:BB:CC:11:22:33" } };
|
||||
const siteB = { siteId: "b", meta: { gatewayMac: "dd:ee:ff:44:55:66" } };
|
||||
|
||||
test("matches on the gateway MAC regardless of formatting", () => {
|
||||
expect(matchCloudSite("aa-bb-cc-11-22-33", [siteB, siteA])?.siteId).toBe("a");
|
||||
});
|
||||
|
||||
test("returns null rather than another account site when nothing matches", () => {
|
||||
expect(matchCloudSite("11:22:33:44:55:66", [siteA, siteB])).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the local gateway is unknown", () => {
|
||||
expect(matchCloudSite(null, [siteA, siteB])).toBeNull();
|
||||
});
|
||||
|
||||
// `normalizeMacAddress` réduit "", "unknown" et "null" à la même chaîne vide.
|
||||
// Sans garde, ces valeurs se correspondraient entre elles et le widget
|
||||
// afficherait le réseau d'un autre compte comme celui de l'utilisateur.
|
||||
test("returns null when the local MAC normalizes to nothing", () => {
|
||||
const placeholder = { siteId: "placeholder", meta: { gatewayMac: "unknown" } };
|
||||
|
||||
for (const localMac of ["", "unknown", "null"]) {
|
||||
expect(matchCloudSite(localMac, [placeholder, siteA, siteB])).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("skips cloud sites whose gateway MAC normalizes to nothing", () => {
|
||||
const placeholder = { siteId: "placeholder", meta: { gatewayMac: "unknown" } };
|
||||
|
||||
expect(matchCloudSite("aa:bb:cc:11:22:33", [placeholder, siteA])?.siteId).toBe("a");
|
||||
expect(matchCloudSite("11:22:33:44:55:66", [placeholder])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("client-safety", () => {
|
||||
// `unifi-os-types.ts` is re-exported by `packages/integrations/src/types.ts`,
|
||||
// which client components import (e.g. via the spotlight search group). An
|
||||
// import of `@homarr/common/server` here drags `ioredis` — and transitively
|
||||
// Node's `dns` module — into the browser bundle and breaks `pnpm dev`/build.
|
||||
// Only a source-text check catches this: a type-only "does it compile" check
|
||||
// would not, since the breakage is a bundler resolution failure, not a type
|
||||
// error. This regression guard is what would have caught the original bug.
|
||||
test("does not import @homarr/common/server", () => {
|
||||
const path = fileURLToPath(new URL("../unifi-os-types.ts", import.meta.url));
|
||||
const source = readFileSync(path, "utf-8");
|
||||
|
||||
expect(source).not.toContain("@homarr/common/server");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unifiOsClientSchema", () => {
|
||||
test("keeps fields the published OpenAPI does not document", () => {
|
||||
const parsed = unifiOsClientSchema.parse({
|
||||
id: "1",
|
||||
name: "n",
|
||||
type: "WIRED",
|
||||
access: { type: "DEFAULT" },
|
||||
macAddress: "aa:bb:cc:11:22:33",
|
||||
uplinkDeviceId: "u1",
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({ macAddress: "aa:bb:cc:11:22:33", uplinkDeviceId: "u1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// @vitest-environment node
|
||||
import { Response, type RequestInit } from "undici";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { UnifiSmApi } from "../unifi-sm-api";
|
||||
|
||||
const GATEWAY_MAC = "aa:bb:cc:11:22:33";
|
||||
const OTHER_MAC = "dd:ee:ff:44:55:66";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const sitesBody = {
|
||||
data: [
|
||||
{
|
||||
siteId: "mine",
|
||||
meta: { desc: "Mine", gatewayMac: GATEWAY_MAC },
|
||||
statistics: { wans: { WAN: { externalIp: "203.0.113.10" } } },
|
||||
},
|
||||
{ siteId: "theirs", meta: { desc: "Theirs", gatewayMac: OTHER_MAC } },
|
||||
],
|
||||
};
|
||||
|
||||
const metricsBody = (metricTime: string) => ({
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
periods: [
|
||||
{
|
||||
metricTime: "2026-09-08T08:00:00Z",
|
||||
data: { wan: { avgLatency: 99, maxLatency: 99, packetLoss: 5, uptime: 90, ispName: "Old" } },
|
||||
},
|
||||
{
|
||||
metricTime,
|
||||
data: { wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const createApi = () => {
|
||||
const fetchAsync = vi.fn(async (url: string, _init?: RequestInit) =>
|
||||
url.includes("isp-metrics") ? jsonResponse(metricsBody("2026-09-08T09:10:00Z")) : jsonResponse(sitesBody),
|
||||
);
|
||||
return { api: new UnifiSmApi("cloud-key", fetchAsync as never), fetchAsync };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
UnifiSmApi.clearCache();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-09-08T09:37:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("UnifiSmApi getIspMetricsAsync", () => {
|
||||
test("returns the newest period of the site matching the gateway MAC", async () => {
|
||||
const { api } = createApi();
|
||||
|
||||
const metrics = await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
name: "Example ISP",
|
||||
externalIp: "203.0.113.10",
|
||||
latencyMs: 11,
|
||||
maxLatencyMs: 12,
|
||||
packetLossPct: 0,
|
||||
uptimePct: 100,
|
||||
measuredAt: new Date("2026-09-08T09:10:00Z"),
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the newest period by metricTime even when periods arrive out of order", async () => {
|
||||
const fetchAsync = vi.fn(async (url: string, _init?: RequestInit) => {
|
||||
if (!url.includes("isp-metrics")) return jsonResponse(sitesBody);
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
periods: [
|
||||
{
|
||||
metricTime: "2026-09-08T09:10:00Z",
|
||||
data: {
|
||||
wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" },
|
||||
},
|
||||
},
|
||||
{
|
||||
metricTime: "2026-09-08T08:00:00Z",
|
||||
data: { wan: { avgLatency: 99, maxLatency: 99, packetLoss: 5, uptime: 90, ispName: "Old" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
const api = new UnifiSmApi("cloud-key", fetchAsync as never);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(metrics?.latencyMs).toBe(11);
|
||||
expect(metrics?.measuredAt).toEqual(new Date("2026-09-08T09:10:00Z"));
|
||||
});
|
||||
|
||||
// Ubiquiti omet la latence pour les périodes où le WAN était coupé. Un `0`
|
||||
// fabriqué s'afficherait comme « 0 ms » au pire moment possible.
|
||||
test("passes missing cloud measurements through as null instead of zero", async () => {
|
||||
const fetchAsync = vi.fn(async (url: string, _init?: RequestInit) => {
|
||||
if (!url.includes("isp-metrics")) return jsonResponse(sitesBody);
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
periods: [{ metricTime: "2026-09-08T09:10:00Z", data: { wan: { ispName: "Example ISP" } } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
const api = new UnifiSmApi("cloud-key", fetchAsync as never);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(metrics).toMatchObject({
|
||||
latencyMs: null,
|
||||
maxLatencyMs: null,
|
||||
packetLossPct: null,
|
||||
uptimePct: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when no cloud site matches, never another account site", async () => {
|
||||
const { api } = createApi();
|
||||
|
||||
expect(await api.getIspMetricsAsync("11:22:33:44:55:66")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the local gateway could not be identified", async () => {
|
||||
const { api, fetchAsync } = createApi();
|
||||
|
||||
expect(await api.getIspMetricsAsync(null)).toBeNull();
|
||||
expect(fetchAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("queries a window wide enough for the cloud lag", async () => {
|
||||
const { api, fetchAsync } = createApi();
|
||||
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
const body = JSON.parse(
|
||||
String(fetchAsync.mock.calls.find(([url]) => String(url).includes("isp-metrics"))?.[1]?.body),
|
||||
);
|
||||
const begin = new Date(body.sites[0].beginTimestamp).getTime();
|
||||
const end = new Date(body.sites[0].endTimestamp).getTime();
|
||||
|
||||
// Une fenêtre de 15 min renvoie un tableau vide sur l'API réelle.
|
||||
expect(end - begin).toBeGreaterThanOrEqual(3 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("serves a second call from cache instead of hitting the cloud again", async () => {
|
||||
const { api, fetchAsync } = createApi();
|
||||
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
const callsAfterFirst = fetchAsync.mock.calls.length;
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(fetchAsync.mock.calls.length).toBe(callsAfterFirst);
|
||||
});
|
||||
|
||||
test("refetches once the cache entry has expired", async () => {
|
||||
const { api, fetchAsync } = createApi();
|
||||
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
const callsAfterFirst = fetchAsync.mock.calls.length;
|
||||
|
||||
vi.setSystemTime(new Date("2026-09-08T09:43:00Z"));
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(fetchAsync.mock.calls.length).toBeGreaterThan(callsAfterFirst);
|
||||
});
|
||||
|
||||
test("returns null when the cloud rejects the key rather than propagating", async () => {
|
||||
const fetchAsync = vi.fn(async (_url: string, _init?: RequestInit) =>
|
||||
jsonResponse({ message: "unauthorized" }, 401),
|
||||
);
|
||||
const api = new UnifiSmApi("bad-key", fetchAsync as never);
|
||||
|
||||
expect(await api.getIspMetricsAsync(GATEWAY_MAC)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import type { RequestInit, Response } from "undici";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { parseUnifiOsAsync } from "./unifi-os-parse";
|
||||
import type { UnifiOsClientCounts, UnifiOsDevice, UnifiOsDeviceStatistics, UnifiOsSite } from "./unifi-os-types";
|
||||
import {
|
||||
countClients,
|
||||
createUnifiOsPageSchema,
|
||||
unifiOsClientSchema,
|
||||
unifiOsDeviceSchema,
|
||||
unifiOsDeviceStatisticsSchema,
|
||||
unifiOsInfoSchema,
|
||||
unifiOsSiteSchema,
|
||||
} from "./unifi-os-types";
|
||||
|
||||
/**
|
||||
* Déclaré sur les types d'undici, pas sur ceux du DOM : le dépôt n'utilise que
|
||||
* `undici` côté serveur. Aligner les deux évite les `as unknown as` qui
|
||||
* désactivaient le contrôle de type à la frontière du transport.
|
||||
*/
|
||||
export type UnifiOsFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise<Response>;
|
||||
|
||||
const INTEGRATION_PATH = "/proxy/network/integration/v1";
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Plafond imposé par l'API : demander davantage renvoie quand même 200. */
|
||||
const MAX_PAGE_LIMIT = 200;
|
||||
|
||||
const PARSE_ERROR_MESSAGE = "Invalid UniFi OS response";
|
||||
|
||||
const clientPageSchema = createUnifiOsPageSchema(unifiOsClientSchema);
|
||||
const devicePageSchema = createUnifiOsPageSchema(unifiOsDeviceSchema);
|
||||
const sitePageSchema = createUnifiOsPageSchema(unifiOsSiteSchema);
|
||||
|
||||
/**
|
||||
* Client de l'API d'intégration locale d'UniFi OS.
|
||||
*
|
||||
* Ne connaît ni Homarr ni la notion d'intégration : il ne sait que parler à une
|
||||
* console. Le `fetch` est injecté pour que les tests n'aient pas à mocker un module.
|
||||
*/
|
||||
export class UnifiOsApi {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
origin: string,
|
||||
private readonly apiKey: string,
|
||||
private readonly fetchAsync: UnifiOsFetch,
|
||||
) {
|
||||
this.baseUrl = `${origin.replace(/\/+$/, "")}${INTEGRATION_PATH}`;
|
||||
}
|
||||
|
||||
public async getInfoAsync(): Promise<{ applicationVersion: string }> {
|
||||
return await this.requestAsync("/info", unifiOsInfoSchema);
|
||||
}
|
||||
|
||||
public async getFirstSiteAsync(): Promise<UnifiOsSite | null> {
|
||||
const page = await this.requestAsync("/sites", sitePageSchema);
|
||||
return page.data[0] ?? null;
|
||||
}
|
||||
|
||||
public async getDevicesAsync(siteId: string): Promise<UnifiOsDevice[]> {
|
||||
const page = await this.requestAsync(
|
||||
`/sites/${encodeURIComponent(siteId)}/devices?limit=${MAX_PAGE_LIMIT}`,
|
||||
devicePageSchema,
|
||||
);
|
||||
return page.data;
|
||||
}
|
||||
|
||||
public async getDeviceStatisticsAsync(siteId: string, deviceId: string): Promise<UnifiOsDeviceStatistics> {
|
||||
return await this.requestAsync(
|
||||
`/sites/${encodeURIComponent(siteId)}/devices/${encodeURIComponent(deviceId)}/statistics/latest`,
|
||||
unifiOsDeviceStatisticsSchema,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sous le plafond de 200 clients, une seule page suffit et le comptage se fait
|
||||
* en mémoire. Au-delà, rapatrier tous les enregistrements pour n'en tirer que
|
||||
* des compteurs coûterait plus cher que cinq requêtes bornées.
|
||||
*/
|
||||
public async getClientCountsAsync(siteId: string): Promise<UnifiOsClientCounts> {
|
||||
const path = `/sites/${encodeURIComponent(siteId)}/clients`;
|
||||
const firstPage = await this.requestAsync(`${path}?limit=${MAX_PAGE_LIMIT}`, clientPageSchema);
|
||||
|
||||
if (firstPage.totalCount <= MAX_PAGE_LIMIT) {
|
||||
return countClients(firstPage.data);
|
||||
}
|
||||
|
||||
const countByFilterAsync = async (filter: string) => {
|
||||
const page = await this.requestAsync(`${path}?limit=1&filter=${encodeURIComponent(filter)}`, clientPageSchema);
|
||||
return page.totalCount;
|
||||
};
|
||||
|
||||
const [wired, wireless, vpn, teleport, guests] = await Promise.all([
|
||||
countByFilterAsync("type.eq('WIRED')"),
|
||||
countByFilterAsync("type.eq('WIRELESS')"),
|
||||
countByFilterAsync("type.eq('VPN')"),
|
||||
countByFilterAsync("type.eq('TELEPORT')"),
|
||||
countByFilterAsync("access.type.eq('GUEST')"),
|
||||
]);
|
||||
|
||||
return { total: firstPage.totalCount, wired, wireless, vpn: vpn + teleport, guests };
|
||||
}
|
||||
|
||||
private async requestAsync<TOutput>(path: string, schema: z.ZodType<TOutput>): Promise<TOutput> {
|
||||
const response = await this.fetchAsync(`${this.baseUrl}${path}`, {
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
return await parseUnifiOsAsync(await response.json(), schema, PARSE_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { fetch as platformFetch } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { UnifiOsSummaryIntegration } from "../interfaces/unifi-os-summary/unifi-os-summary-integration";
|
||||
import type { UnifiOsFetch } from "./unifi-os-api";
|
||||
import { UnifiOsApi } from "./unifi-os-api";
|
||||
import { UnifiSmApi } from "./unifi-sm-api";
|
||||
import type { UnifiOsDeviceStatistics, UnifiOsSummary } from "./unifi-os-types";
|
||||
import { findGateway, summarizeDevices } from "./unifi-os-types";
|
||||
|
||||
/**
|
||||
* Transport du cloud Site Manager (§5.3).
|
||||
*
|
||||
* `fetchWithTrustedCertificatesAsync` ajoute **toutes** les autorités
|
||||
* personnalisées téléversées par l'administrateur Homarr, et installe un
|
||||
* `checkServerIdentity` qui saute la vérification du nom d'hôte pour les
|
||||
* empreintes épinglées. Une autorité ajoutée pour faire accepter le certificat
|
||||
* auto-signé de la console deviendrait alors une ancre de confiance valable
|
||||
* pour `api.ui.com` aussi : son détenteur pourrait intercepter l'appel cloud et
|
||||
* récupérer la clé Site Manager, qui lit tous les sites du compte Ubiquiti.
|
||||
*
|
||||
* `api.ui.com` présente un certificat public ordinaire : le `fetch` de la
|
||||
* plateforme suffit, et c'est le seul qui soit sûr ici. Le `timeout` du client
|
||||
* est traduit en `AbortSignal`, `fetch` ne connaissant pas cette option.
|
||||
*/
|
||||
const fetchCloudAsync: UnifiOsFetch = async (url, init) => {
|
||||
const { timeout, ...requestInit } = init ?? {};
|
||||
|
||||
return await platformFetch(url, {
|
||||
...requestInit,
|
||||
...(timeout === undefined ? {} : { signal: AbortSignal.timeout(timeout) }),
|
||||
});
|
||||
};
|
||||
|
||||
export class UnifiOsIntegration extends Integration implements UnifiOsSummaryIntegration {
|
||||
public async getUnifiOsSummaryAsync(): Promise<UnifiOsSummary> {
|
||||
return await this.buildSummaryAsync(fetchWithTrustedCertificatesAsync);
|
||||
}
|
||||
|
||||
protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const api = this.createLocalApi(fetchAsync);
|
||||
|
||||
try {
|
||||
await api.getInfoAsync();
|
||||
} catch (error) {
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
}
|
||||
|
||||
const site = await api.getFirstSiteAsync();
|
||||
if (site === null) {
|
||||
// Une clé valide sur une console sans site est configurable mais inutile :
|
||||
// mieux vaut le dire au moment du test que laisser un widget vide.
|
||||
return TestConnectionError.UnknownResult(new Error("The UniFi OS console exposes no site"));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private createLocalApi(fetchAsync: UnifiOsFetch) {
|
||||
return new UnifiOsApi(this.integration.url, this.getSecretValue("apiKey"), fetchAsync);
|
||||
}
|
||||
|
||||
private async buildSummaryAsync(fetchAsync: UnifiOsFetch): Promise<UnifiOsSummary> {
|
||||
const api = this.createLocalApi(fetchAsync);
|
||||
|
||||
const [info, site] = await Promise.all([api.getInfoAsync(), api.getFirstSiteAsync()]);
|
||||
if (site === null) {
|
||||
throw new Error("The UniFi OS console exposes no site");
|
||||
}
|
||||
|
||||
const [clientsResult, devicesResult] = await Promise.allSettled([
|
||||
api.getClientCountsAsync(site.id),
|
||||
api.getDevicesAsync(site.id),
|
||||
]);
|
||||
|
||||
// `null` marks a failed call so the UI never mistakes it for a real zero
|
||||
// count (a site with genuinely no clients or devices).
|
||||
const clients = clientsResult.status === "fulfilled" ? clientsResult.value : null;
|
||||
const devices = devicesResult.status === "fulfilled" ? devicesResult.value : [];
|
||||
const gatewayDevice = findGateway(devices);
|
||||
|
||||
// Les statistiques dépendent du gateway : sans gateway identifié, il n'y a
|
||||
// rien à demander. Un échec ici ne doit vider que le débit et l'uptime.
|
||||
let statistics: UnifiOsDeviceStatistics | null = null;
|
||||
if (gatewayDevice !== null) {
|
||||
try {
|
||||
statistics = await api.getDeviceStatisticsAsync(site.id, gatewayDevice.id);
|
||||
} catch {
|
||||
statistics = null;
|
||||
}
|
||||
}
|
||||
|
||||
const isp = await this.readIspMetricsAsync(gatewayDevice?.macAddress ?? null);
|
||||
|
||||
return {
|
||||
site: { id: site.id, name: site.name, applicationVersion: info.applicationVersion },
|
||||
wan: {
|
||||
state: gatewayDevice === null ? "unknown" : gatewayDevice.state === "ONLINE" ? "online" : "offline",
|
||||
throughput:
|
||||
statistics?.uplink === undefined
|
||||
? null
|
||||
: { txBps: statistics.uplink.txRateBps, rxBps: statistics.uplink.rxRateBps },
|
||||
isp,
|
||||
},
|
||||
clients,
|
||||
devices: devicesResult.status === "fulfilled" ? summarizeDevices(devices) : null,
|
||||
gateway:
|
||||
gatewayDevice === null
|
||||
? null
|
||||
: {
|
||||
name: gatewayDevice.name,
|
||||
model: gatewayDevice.model,
|
||||
macAddress: gatewayDevice.macAddress,
|
||||
uptimeSec: statistics?.uptimeSec ?? null,
|
||||
cpuPct: statistics?.cpuUtilizationPct ?? null,
|
||||
memoryPct: statistics?.memoryUtilizationPct ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le cloud n'est jamais interrogé sans clé configurée : le module n'est même
|
||||
* pas instancié, ce qui rend impossible un appel réseau accidentel. Il reçoit
|
||||
* `fetchCloudAsync`, jamais le transport à certificats de confiance du local.
|
||||
*/
|
||||
private async readIspMetricsAsync(gatewayMac: string | null) {
|
||||
if (!this.hasSecretValue("siteManagerApiKey")) return null;
|
||||
|
||||
const smApi = new UnifiSmApi(this.getSecretValue("siteManagerApiKey"), fetchCloudAsync);
|
||||
return await smApi.getIspMetricsAsync(gatewayMac);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Server-only: pulled out of `unifi-os-types.ts` because `ParseError` comes
|
||||
* from `@homarr/common/server`, which drags in `ioredis` and Node's `dns`.
|
||||
* `unifi-os-types.ts` is re-exported by `packages/integrations/src/types.ts`,
|
||||
* which client components import, so it must stay free of server-only code.
|
||||
*/
|
||||
export const parseUnifiOsAsync = async <TOutput>(
|
||||
value: unknown,
|
||||
schema: z.ZodType<TOutput>,
|
||||
message: string,
|
||||
): Promise<TOutput> => {
|
||||
const result = await schema.safeParseAsync(value);
|
||||
if (!result.success) {
|
||||
throw new ParseError(message, { cause: result.error });
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Ubiquiti ne publie l'OpenAPI que pour la 10.4.57 alors que les consoles sont
|
||||
* en 10.6.x et renvoient déjà des champs absents du spec. Tous les objets sont
|
||||
* donc « loose » : un champ inattendu ne doit jamais faire échouer un widget.
|
||||
*/
|
||||
|
||||
export const unifiOsInfoSchema = z.looseObject({
|
||||
applicationVersion: z.string(),
|
||||
});
|
||||
|
||||
export const unifiOsSiteSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
internalReference: z.string().optional(),
|
||||
});
|
||||
|
||||
export const unifiOsClientSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
access: z.looseObject({ type: z.string() }).optional(),
|
||||
});
|
||||
|
||||
export const unifiOsDeviceSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
model: z.string(),
|
||||
macAddress: z.string(),
|
||||
state: z.string(),
|
||||
firmwareUpdatable: z.boolean().optional(),
|
||||
features: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const unifiOsDeviceStatisticsSchema = z.looseObject({
|
||||
uptimeSec: z.number().optional(),
|
||||
cpuUtilizationPct: z.number().optional(),
|
||||
memoryUtilizationPct: z.number().optional(),
|
||||
uplink: z.looseObject({ txRateBps: z.number(), rxRateBps: z.number() }).optional(),
|
||||
});
|
||||
|
||||
/** Enveloppe de pagination commune à tous les endpoints de liste. */
|
||||
export const createUnifiOsPageSchema = <TItem extends z.ZodType>(item: TItem) =>
|
||||
z.looseObject({
|
||||
offset: z.number(),
|
||||
limit: z.number(),
|
||||
count: z.number(),
|
||||
totalCount: z.number(),
|
||||
data: z.array(item),
|
||||
});
|
||||
|
||||
export const unifiSmSiteSchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
hostId: z.string().optional(),
|
||||
meta: z.looseObject({
|
||||
desc: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
gatewayMac: z.string().optional(),
|
||||
}),
|
||||
statistics: z
|
||||
.looseObject({
|
||||
ispInfo: z.looseObject({ name: z.string().optional() }).optional(),
|
||||
percentages: z.looseObject({ wanUptime: z.number().optional() }).optional(),
|
||||
wans: z.record(z.string(), z.looseObject({ externalIp: z.string().optional() })).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const unifiSmWanMetricsSchema = z.looseObject({
|
||||
avgLatency: z.number().optional(),
|
||||
maxLatency: z.number().optional(),
|
||||
packetLoss: z.number().optional(),
|
||||
uptime: z.number().optional(),
|
||||
ispName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const unifiSmIspMetricsResponseSchema = z.looseObject({
|
||||
data: z.looseObject({
|
||||
metrics: z.array(
|
||||
z.looseObject({
|
||||
periods: z.array(
|
||||
z.looseObject({
|
||||
metricTime: z.string(),
|
||||
data: z.looseObject({ wan: unifiSmWanMetricsSchema.optional() }),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UnifiOsClient = z.infer<typeof unifiOsClientSchema>;
|
||||
export type UnifiOsDevice = z.infer<typeof unifiOsDeviceSchema>;
|
||||
export type UnifiOsSite = z.infer<typeof unifiOsSiteSchema>;
|
||||
export type UnifiOsDeviceStatistics = z.infer<typeof unifiOsDeviceStatisticsSchema>;
|
||||
|
||||
export interface UnifiOsClientCounts {
|
||||
total: number;
|
||||
wired: number;
|
||||
wireless: number;
|
||||
vpn: number;
|
||||
guests: number;
|
||||
}
|
||||
|
||||
export interface UnifiOsDeviceCounts {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
firmwareUpdatable: number;
|
||||
}
|
||||
|
||||
export interface UnifiOsGateway {
|
||||
name: string;
|
||||
model: string;
|
||||
macAddress: string;
|
||||
uptimeSec: number | null;
|
||||
cpuPct: number | null;
|
||||
memoryPct: number | null;
|
||||
}
|
||||
|
||||
export interface UnifiOsIspMetrics {
|
||||
name: string;
|
||||
externalIp: string | null;
|
||||
/** Nullables : le cloud omet ces mesures pour les périodes où le WAN
|
||||
* était coupé. Un `0` fabriqué s'afficherait comme « 0 ms » à côté d'un
|
||||
* voyant vert, au moment précis où la connexion est tombée. */
|
||||
latencyMs: number | null;
|
||||
maxLatencyMs: number | null;
|
||||
packetLossPct: number | null;
|
||||
uptimePct: number | null;
|
||||
/** Horodatage de la mesure : la donnée cloud accuse ~27 min de retard. */
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
export interface UnifiOsSummary {
|
||||
site: {
|
||||
id: string;
|
||||
name: string;
|
||||
applicationVersion: string;
|
||||
};
|
||||
wan: {
|
||||
state: "online" | "offline" | "unknown";
|
||||
throughput: { txBps: number; rxBps: number } | null;
|
||||
isp: UnifiOsIspMetrics | null;
|
||||
};
|
||||
/** `null` when the call failed — never a zero count, which the UI could not
|
||||
* tell apart from a site with genuinely no clients. */
|
||||
clients: UnifiOsClientCounts | null;
|
||||
/** `null` when the call failed, for the same reason. */
|
||||
devices: UnifiOsDeviceCounts | null;
|
||||
gateway: UnifiOsGateway | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'API locale renvoie `aa:bb:cc:11:22:33`, le cloud peut renvoyer une autre
|
||||
* casse ou d'autres séparateurs. La comparaison se fait sur une forme canonique.
|
||||
*/
|
||||
export const normalizeMacAddress = (mac: string): string => mac.toLowerCase().replace(/[^0-9a-f]/g, "");
|
||||
|
||||
const VPN_CLIENT_TYPES = new Set(["VPN", "TELEPORT"]);
|
||||
|
||||
export const countClients = (clients: UnifiOsClient[]): UnifiOsClientCounts => {
|
||||
const counts: UnifiOsClientCounts = { total: 0, wired: 0, wireless: 0, vpn: 0, guests: 0 };
|
||||
|
||||
for (const client of clients) {
|
||||
counts.total += 1;
|
||||
|
||||
if (client.type === "WIRED") counts.wired += 1;
|
||||
else if (client.type === "WIRELESS") counts.wireless += 1;
|
||||
else if (VPN_CLIENT_TYPES.has(client.type)) counts.vpn += 1;
|
||||
|
||||
if (client.access?.type === "GUEST") counts.guests += 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
};
|
||||
|
||||
export const summarizeDevices = (devices: UnifiOsDevice[]): UnifiOsDeviceCounts => {
|
||||
const online = devices.filter((device) => device.state === "ONLINE").length;
|
||||
|
||||
return {
|
||||
total: devices.length,
|
||||
online,
|
||||
// Tout ce qui n'est pas ONLINE est indisponible du point de vue du dashboard,
|
||||
// qu'il s'agisse d'OFFLINE, d'UPDATING ou d'un état futur inconnu.
|
||||
offline: devices.length - online,
|
||||
firmwareUpdatable: devices.filter((device) => device.firmwareUpdatable === true).length,
|
||||
};
|
||||
};
|
||||
|
||||
const GATEWAY_MODEL_HINT = /gateway|udm|uxg|ucg|usg/i;
|
||||
|
||||
export const findGateway = (devices: UnifiOsDevice[]): UnifiOsDevice | null =>
|
||||
devices.find((device) => device.features.includes("gateway")) ??
|
||||
devices.find((device) => GATEWAY_MODEL_HINT.test(device.model)) ??
|
||||
null;
|
||||
|
||||
/**
|
||||
* Une clé Site Manager voit tous les sites du compte, y compris ceux d'autres
|
||||
* consoles. Sans correspondance stricte sur le MAC du gateway, on afficherait
|
||||
* la latence d'un autre réseau. Renvoyer null est le seul comportement sûr.
|
||||
*
|
||||
* `normalizeMacAddress` retire tout caractère hors `[0-9a-f]` : `""`,
|
||||
* `"unknown"` et `"null"` se réduisent donc à la même chaîne vide. Sans les deux
|
||||
* gardes ci-dessous, un gateway local sans MAC exploitable correspondrait à
|
||||
* n'importe quel site cloud dont le `gatewayMac` est un marqueur de la même
|
||||
* forme — et le widget afficherait le FAI, l'IP publique et la latence du réseau
|
||||
* d'un voisin de compte comme s'ils étaient ceux de l'utilisateur.
|
||||
*/
|
||||
export const matchCloudSite = <TSite extends { meta: { gatewayMac?: string } }>(
|
||||
gatewayMac: string | null,
|
||||
sites: TSite[],
|
||||
): TSite | null => {
|
||||
if (gatewayMac === null) return null;
|
||||
|
||||
const normalized = normalizeMacAddress(gatewayMac);
|
||||
if (normalized === "") return null;
|
||||
|
||||
return (
|
||||
sites.find((site) => {
|
||||
if (site.meta.gatewayMac === undefined) return false;
|
||||
|
||||
const candidate = normalizeMacAddress(site.meta.gatewayMac);
|
||||
return candidate !== "" && candidate === normalized;
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { UnifiOsFetch } from "./unifi-os-api";
|
||||
import { parseUnifiOsAsync } from "./unifi-os-parse";
|
||||
import type { UnifiOsIspMetrics } from "./unifi-os-types";
|
||||
import { matchCloudSite, unifiSmIspMetricsResponseSchema, unifiSmSiteSchema } from "./unifi-os-types";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const logger = createLogger({ module: "unifiSmApi" });
|
||||
|
||||
const BASE_URL = "https://api.ui.com";
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Les métriques cloud sont agrégées par tranches de 5 min et publiées avec un
|
||||
* retard mesuré d'environ 27 min. Les rafraîchir plus souvent que le cache
|
||||
* n'apporterait aucune donnée nouvelle et rapprocherait d'une limitation de débit.
|
||||
*/
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Une fenêtre de 15 min renvoie un tableau vide sur l'API réelle ; 3 h fonctionne. */
|
||||
const QUERY_WINDOW_MS = 3 * 60 * 60 * 1000;
|
||||
|
||||
const siteListSchema = z.looseObject({ data: z.array(unifiSmSiteSchema) });
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number;
|
||||
value: UnifiOsIspMetrics | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client de l'API cloud Site Manager. Entièrement optionnel : il n'est instancié
|
||||
* que lorsqu'une clé Site Manager est configurée.
|
||||
*
|
||||
* Toute erreur est absorbée et convertie en `null`. Le cloud est un enrichissement ;
|
||||
* son indisponibilité ne doit jamais faire tomber le widget.
|
||||
*/
|
||||
export class UnifiSmApi {
|
||||
private static readonly cache = new Map<string, CacheEntry>();
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly fetchAsync: UnifiOsFetch,
|
||||
) {}
|
||||
|
||||
/** Réservé aux tests : le cache est statique et survivrait d'un cas à l'autre. */
|
||||
public static clearCache(): void {
|
||||
UnifiSmApi.cache.clear();
|
||||
}
|
||||
|
||||
public async getIspMetricsAsync(gatewayMac: string | null): Promise<UnifiOsIspMetrics | null> {
|
||||
if (gatewayMac === null) return null;
|
||||
|
||||
const cached = UnifiSmApi.cache.get(gatewayMac);
|
||||
if (cached !== undefined && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
let value: UnifiOsIspMetrics | null = null;
|
||||
try {
|
||||
value = await this.fetchIspMetricsAsync(gatewayMac);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to read UniFi Site Manager metrics", { error });
|
||||
}
|
||||
|
||||
UnifiSmApi.cache.set(gatewayMac, { expiresAt: Date.now() + CACHE_TTL_MS, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
private async fetchIspMetricsAsync(gatewayMac: string): Promise<UnifiOsIspMetrics | null> {
|
||||
const sitesResponse = await this.fetchAsync(`${BASE_URL}/v1/sites`, {
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!sitesResponse.ok) return null;
|
||||
|
||||
const sites = await parseUnifiOsAsync(
|
||||
await sitesResponse.json(),
|
||||
siteListSchema,
|
||||
"Invalid Site Manager sites response",
|
||||
);
|
||||
const site = matchCloudSite(gatewayMac, sites.data);
|
||||
if (site === null) return null;
|
||||
|
||||
const end = new Date();
|
||||
const begin = new Date(end.getTime() - QUERY_WINDOW_MS);
|
||||
|
||||
const metricsResponse = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/5m/query`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json", "Content-Type": "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
body: JSON.stringify({
|
||||
sites: [
|
||||
{
|
||||
hostId: site.hostId,
|
||||
siteId: site.siteId,
|
||||
beginTimestamp: begin.toISOString(),
|
||||
endTimestamp: end.toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!metricsResponse.ok) return null;
|
||||
|
||||
const parsed = await parseUnifiOsAsync(
|
||||
await metricsResponse.json(),
|
||||
unifiSmIspMetricsResponseSchema,
|
||||
"Invalid Site Manager ISP metrics response",
|
||||
);
|
||||
|
||||
const periods = parsed.data.metrics[0]?.periods ?? [];
|
||||
const latest = periods.reduce<(typeof periods)[number] | undefined>((newest, period) => {
|
||||
if (newest === undefined) return period;
|
||||
return new Date(period.metricTime).getTime() > new Date(newest.metricTime).getTime() ? period : newest;
|
||||
}, undefined);
|
||||
const wan = latest?.data.wan;
|
||||
if (latest === undefined || wan === undefined) return null;
|
||||
|
||||
const externalIp = Object.values(site.statistics?.wans ?? {})
|
||||
.map((entry) => entry.externalIp)
|
||||
.find((ip) => ip !== undefined);
|
||||
|
||||
// Ubiquiti omet ces mesures pour les périodes où le WAN était coupé. Les
|
||||
// remplacer par `0` fabriquerait un « 0 ms » rassurant à l'instant précis où
|
||||
// la connexion est tombée : on laisse passer `null`, que le widget affiche
|
||||
// comme une absence.
|
||||
return {
|
||||
name: wan.ispName ?? site.statistics?.ispInfo?.name ?? "",
|
||||
externalIp: externalIp ?? null,
|
||||
latencyMs: wan.avgLatency ?? null,
|
||||
maxLatencyMs: wan.maxLatency ?? null,
|
||||
packetLossPct: wan.packetLoss ?? null,
|
||||
uptimePct: wan.uptime ?? site.statistics?.percentages?.wanUptime ?? null,
|
||||
measuredAt: new Date(latest.metricTime),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// @vitest-environment node
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Style des specs du dépôt : voir packages/integrations/test/nextcloud.spec.ts. */
|
||||
import type { RequestInit } from "undici";
|
||||
import { Response } from "undici";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import { MAX_SD_WAN_CONFIGS, RECENT_METRICS_WINDOW_HOURS, UnifiSiteManagerApi } from "../unifi-site-manager-api";
|
||||
|
||||
const API_KEY = "cloud-key";
|
||||
const NOW = new Date("2026-09-09T20:00:00Z");
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const createApi = (handler: (url: string, init?: RequestInit) => Promise<Response>) => {
|
||||
const fetchAsync = vi.fn(async (url: string, init?: RequestInit) => await handler(url, init));
|
||||
return { api: new UnifiSiteManagerApi(API_KEY, fetchAsync as never), fetchAsync };
|
||||
};
|
||||
|
||||
describe("UnifiSiteManagerApi request shape", () => {
|
||||
test("calls the cloud host and sends the API key", async () => {
|
||||
const { api, fetchAsync } = createApi(async () => jsonResponse({ data: [] }));
|
||||
|
||||
await api.getSitesAsync();
|
||||
|
||||
expect(fetchAsync).toHaveBeenCalledWith(
|
||||
"https://api.ui.com/v1/sites",
|
||||
expect.objectContaining({ headers: expect.objectContaining({ "X-API-KEY": API_KEY }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("throws ResponseError on a non-ok status instead of parsing the body", async () => {
|
||||
const { api } = createApi(async () => jsonResponse({ message: "unauthorized" }, 401));
|
||||
|
||||
await expect(api.getSitesAsync()).rejects.toBeInstanceOf(ResponseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnifiSiteManagerApi getSdWanStatusesAsync", () => {
|
||||
const emptyStatus = { data: { status: { connections: [], sites: [] } } };
|
||||
|
||||
test("fetches the status of every configuration it lists", async () => {
|
||||
const requested: string[] = [];
|
||||
const { api } = createApi(async (url) => {
|
||||
requested.push(url);
|
||||
if (url.endsWith("/v1/sd-wan-configs")) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{ id: "c1", name: "First" },
|
||||
{ id: "c2", name: "Second" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return jsonResponse(emptyStatus);
|
||||
});
|
||||
|
||||
const statuses = await api.getSdWanStatusesAsync();
|
||||
|
||||
expect(statuses.map((entry) => entry.configName)).toEqual(["First", "Second"]);
|
||||
expect(requested).toContain("https://api.ui.com/v1/sd-wan-configs/c1/status");
|
||||
expect(requested).toContain("https://api.ui.com/v1/sd-wan-configs/c2/status");
|
||||
});
|
||||
|
||||
/** Sans borne, un compte à dix configurations produirait onze requêtes par cycle. */
|
||||
test("stops after MAX_SD_WAN_CONFIGS configurations", async () => {
|
||||
const { api, fetchAsync } = createApi(async (url) =>
|
||||
url.endsWith("/v1/sd-wan-configs")
|
||||
? jsonResponse({
|
||||
data: Array.from({ length: 9 }, (_, index) => ({ id: `c${index}`, name: `Config ${index}` })),
|
||||
})
|
||||
: jsonResponse(emptyStatus),
|
||||
);
|
||||
|
||||
const statuses = await api.getSdWanStatusesAsync();
|
||||
|
||||
expect(statuses).toHaveLength(MAX_SD_WAN_CONFIGS);
|
||||
expect(fetchAsync).toHaveBeenCalledTimes(MAX_SD_WAN_CONFIGS + 1);
|
||||
});
|
||||
|
||||
test("skips a configuration whose status call fails rather than losing the others", async () => {
|
||||
const { api } = createApi(async (url) => {
|
||||
if (url.endsWith("/v1/sd-wan-configs")) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{ id: "c1", name: "First" },
|
||||
{ id: "c2", name: "Second" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return url.includes("/c1/") ? jsonResponse({}, 500) : jsonResponse(emptyStatus);
|
||||
});
|
||||
|
||||
const statuses = await api.getSdWanStatusesAsync();
|
||||
|
||||
expect(statuses.map((entry) => entry.configName)).toEqual(["Second"]);
|
||||
});
|
||||
|
||||
test("returns an empty list when the account has no configuration", async () => {
|
||||
const { api } = createApi(async () => jsonResponse({ data: [] }));
|
||||
|
||||
expect(await api.getSdWanStatusesAsync()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
interface IspMetricsRequestBody {
|
||||
sites: { siteId: string; hostId?: string; beginTimestamp: string; endTimestamp: string }[];
|
||||
}
|
||||
|
||||
/** Les corps envoyés, indexés par granularité — l'URL seule ne dit rien de la fenêtre. */
|
||||
const collectIspMetricsRequests = (handler?: (granularity: string) => Promise<Response>) => {
|
||||
const bodies = new Map<string, IspMetricsRequestBody>();
|
||||
const { api, fetchAsync } = createApi(async (url, init) => {
|
||||
const granularity = url.includes("/5m/") ? "5m" : "1h";
|
||||
bodies.set(granularity, JSON.parse(String(init?.body)) as IspMetricsRequestBody);
|
||||
return handler === undefined ? jsonResponse({ data: { metrics: [] } }) : await handler(granularity);
|
||||
});
|
||||
return { api, fetchAsync, bodies };
|
||||
};
|
||||
|
||||
const spanHoursOf = (site: { beginTimestamp: string; endTimestamp: string }) =>
|
||||
(new Date(site.endTimestamp).getTime() - new Date(site.beginTimestamp).getTime()) / 3_600_000;
|
||||
|
||||
const metricsEntry = (siteId: string, metricTimes: string[]) => ({
|
||||
siteId,
|
||||
periods: metricTimes.map((metricTime) => ({ metricTime, data: { wan: { downtime: 0, packetLoss: 0 } } })),
|
||||
});
|
||||
|
||||
describe("UnifiSiteManagerApi getIspMetricsAsync", () => {
|
||||
test("asks for every site in a single bounded query", async () => {
|
||||
const { api, bodies } = collectIspMetricsRequests();
|
||||
|
||||
await api.getIspMetricsAsync(
|
||||
[
|
||||
{ siteId: "a", hostId: "h1" },
|
||||
{ siteId: "b", hostId: "h2" },
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
const hourly = bodies.get("1h");
|
||||
expect(hourly!.sites).toHaveLength(2);
|
||||
expect(spanHoursOf(hourly!.sites[0]!)).toBeGreaterThanOrEqual(24);
|
||||
});
|
||||
|
||||
test("returns an empty list rather than calling the cloud when there is no site", async () => {
|
||||
const { api, fetchAsync } = createApi(async () => jsonResponse({ data: { metrics: [] } }));
|
||||
|
||||
expect(await api.getIspMetricsAsync([], NOW)).toEqual([]);
|
||||
expect(fetchAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* La granularité horaire est en retard de près de quatre heures sur le compte
|
||||
* réel : la requête à 5 minutes remplit ces heures-là, et rien qu'elles.
|
||||
* Vérifier les CORPS et pas seulement les chemins — deux fenêtres identiques
|
||||
* passeraient un test qui ne regarde que l'URL.
|
||||
*/
|
||||
test("queries both granularities, the five-minute one only over the recent hours", async () => {
|
||||
const { api, fetchAsync, bodies } = collectIspMetricsRequests();
|
||||
|
||||
await api.getIspMetricsAsync([{ siteId: "a", hostId: "h1" }], NOW);
|
||||
|
||||
const requested = fetchAsync.mock.calls.map((call) => String(call[0]));
|
||||
expect(requested).toContain("https://api.ui.com/v1/isp-metrics/1h/query");
|
||||
expect(requested).toContain("https://api.ui.com/v1/isp-metrics/5m/query");
|
||||
|
||||
const hourly = bodies.get("1h")!.sites[0]!;
|
||||
const recent = bodies.get("5m")!.sites[0]!;
|
||||
expect(spanHoursOf(hourly)).toBe(24);
|
||||
expect(spanHoursOf(recent)).toBe(RECENT_METRICS_WINDOW_HOURS);
|
||||
// Même bord droit : les deux fenêtres s'arrêtent à l'instant demandé.
|
||||
expect(recent.endTimestamp).toBe(hourly.endTimestamp);
|
||||
expect(new Date(recent.beginTimestamp).getTime()).toBeGreaterThan(new Date(hourly.beginTimestamp).getTime());
|
||||
// Le site est demandé à l'identique aux deux granularités.
|
||||
expect(recent.siteId).toBe("a");
|
||||
expect(recent.hostId).toBe("h1");
|
||||
});
|
||||
|
||||
test("issues the two queries concurrently rather than one after the other", async () => {
|
||||
const started: string[] = [];
|
||||
let releaseHourly: (() => void) | undefined;
|
||||
const { api } = collectIspMetricsRequests(async (granularity) => {
|
||||
started.push(granularity);
|
||||
// La requête horaire ne rend la main qu'une fois la seconde partie : en
|
||||
// séquence, ce test se bloquerait au lieu de passer.
|
||||
if (granularity === "1h") {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseHourly = resolve;
|
||||
});
|
||||
} else {
|
||||
releaseHourly?.();
|
||||
}
|
||||
return jsonResponse({ data: { metrics: [] } });
|
||||
});
|
||||
|
||||
await api.getIspMetricsAsync([{ siteId: "a" }], NOW);
|
||||
|
||||
expect(started).toEqual(["1h", "5m"]);
|
||||
});
|
||||
|
||||
test("merges the periods of both granularities into one entry per site", async () => {
|
||||
const { api } = collectIspMetricsRequests(async (granularity) =>
|
||||
jsonResponse({
|
||||
data: {
|
||||
metrics: [
|
||||
granularity === "1h"
|
||||
? metricsEntry("a", ["2026-09-09T17:00:00Z", "2026-09-09T18:00:00Z"])
|
||||
: metricsEntry("a", ["2026-09-09T19:30:00Z", "2026-09-09T19:35:00Z"]),
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync([{ siteId: "a" }], NOW);
|
||||
|
||||
expect(metrics).toHaveLength(1);
|
||||
expect(metrics[0]!.periods.map((period) => period.metricTime)).toEqual([
|
||||
"2026-09-09T17:00:00Z",
|
||||
"2026-09-09T18:00:00Z",
|
||||
"2026-09-09T19:30:00Z",
|
||||
"2026-09-09T19:35:00Z",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a site reported by only one of the two granularities", async () => {
|
||||
const { api } = collectIspMetricsRequests(async (granularity) =>
|
||||
jsonResponse({
|
||||
data: {
|
||||
metrics:
|
||||
granularity === "1h"
|
||||
? [metricsEntry("only-hourly", ["2026-09-09T17:00:00Z"])]
|
||||
: [metricsEntry("only-recent", ["2026-09-09T19:30:00Z"])],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync([{ siteId: "only-hourly" }, { siteId: "only-recent" }], NOW);
|
||||
|
||||
expect(metrics.map((metric) => metric.siteId).toSorted()).toEqual(["only-hourly", "only-recent"]);
|
||||
expect(metrics.find((metric) => metric.siteId === "only-recent")!.periods).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** La fraîcheur est un enrichissement : la perdre ne doit pas emporter la bande. */
|
||||
test("still returns the hourly window when the five-minute query fails", async () => {
|
||||
const { api } = collectIspMetricsRequests(async (granularity) =>
|
||||
granularity === "5m"
|
||||
? jsonResponse({ message: "rate limited" }, 429)
|
||||
: jsonResponse({ data: { metrics: [metricsEntry("a", ["2026-09-09T18:00:00Z"])] } }),
|
||||
);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync([{ siteId: "a" }], NOW);
|
||||
|
||||
expect(metrics.map((metric) => metric.siteId)).toEqual(["a"]);
|
||||
expect(metrics[0]!.periods).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** L'inverse n'est pas vrai : sans la fenêtre horaire, il n'y a pas de bande. */
|
||||
test("throws when the hourly query fails, even if the five-minute one succeeded", async () => {
|
||||
const { api } = collectIspMetricsRequests(async (granularity) =>
|
||||
granularity === "1h"
|
||||
? jsonResponse({ message: "unauthorized" }, 401)
|
||||
: jsonResponse({ data: { metrics: [metricsEntry("a", ["2026-09-09T19:30:00Z"])] } }),
|
||||
);
|
||||
|
||||
await expect(api.getIspMetricsAsync([{ siteId: "a" }], NOW)).rejects.toBeInstanceOf(ResponseError);
|
||||
});
|
||||
});
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment node
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Style des specs du dépôt : voir packages/integrations/test/nextcloud.spec.ts. */
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import * as undici from "undici";
|
||||
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { UnifiSiteManagerIntegration } from "../unifi-site-manager-integration";
|
||||
|
||||
vi.mock("undici", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof undici>()),
|
||||
fetch: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mocked separately from `undici` on purpose: `fetchWithTrustedCertificatesAsync`
|
||||
// also calls undici's `fetch` underneath, so mocking `undici` alone would
|
||||
// intercept both transports and hide a regression that routes the Site
|
||||
// Manager key through the trusted-certificate (admin CA bundle) transport.
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(undici.fetch);
|
||||
const mockTrustedFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const SITES = {
|
||||
data: [
|
||||
{
|
||||
siteId: "site-1",
|
||||
hostId: "host-1",
|
||||
meta: { desc: "Example site" },
|
||||
statistics: {
|
||||
percentages: { wanUptime: 99.5 },
|
||||
ispInfo: { name: "Example ISP" },
|
||||
wans: { WAN: { externalIp: "203.0.113.10" } },
|
||||
counts: { totalDevice: 6, offlineDevice: 0, pendingUpdateDevice: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const HOSTS = { data: [{ id: "host-1", reportedState: { deviceState: "updateAvailable" } }] };
|
||||
const METRICS = { data: { metrics: [{ siteId: "site-1", periods: [] }] } };
|
||||
const SD_WAN_CONFIGS = { data: [{ id: "config-1", name: "Example mesh" }] };
|
||||
const SD_WAN_STATUS = {
|
||||
data: {
|
||||
status: {
|
||||
connections: [
|
||||
{ sites: [{ siteId: "site-1", status: { connected: true, offline: false, ping: 12, errors: [] } }] },
|
||||
],
|
||||
sites: [{ siteId: "site-1", errors: [], warnings: [] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const route = (url: string) => {
|
||||
if (url.endsWith("/v1/sites")) return jsonResponse(SITES);
|
||||
if (url.endsWith("/v1/hosts")) return jsonResponse(HOSTS);
|
||||
if (url.includes("isp-metrics")) return jsonResponse(METRICS);
|
||||
if (url.endsWith("/v1/sd-wan-configs")) return jsonResponse(SD_WAN_CONFIGS);
|
||||
if (url.includes("/sd-wan-configs/")) return jsonResponse(SD_WAN_STATUS);
|
||||
return jsonResponse({}, 404);
|
||||
};
|
||||
|
||||
const createIntegration = () =>
|
||||
new UnifiSiteManagerIntegration({
|
||||
id: "test-site-manager",
|
||||
name: "Test Site Manager",
|
||||
url: "https://api.ui.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [{ kind: "siteManagerApiKey", value: "cloud-key" }] as IntegrationSecret[],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockTrustedFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("UnifiSiteManagerIntegration getSiteOverviewAsync", () => {
|
||||
test("builds one entry per site", async () => {
|
||||
mockFetch.mockImplementation(async (url) => route(String(url)));
|
||||
|
||||
const overview = await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
expect(overview.sites).toHaveLength(1);
|
||||
expect(overview.sites[0]).toMatchObject({
|
||||
siteId: "site-1",
|
||||
name: "Example site",
|
||||
ispName: "Example ISP",
|
||||
consoleUpdateAvailable: true,
|
||||
});
|
||||
expect(overview.sites[0]!.sdWan).toMatchObject({ configName: "Example mesh", connected: true, pingMs: 12 });
|
||||
});
|
||||
|
||||
/** /v1/sites porte la liste : sans elle il n'y a rien à afficher, pas « aucun site ». */
|
||||
test("propagates a failure of the sites call", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).endsWith("/v1/sites") ? jsonResponse({}, 500) : route(String(url)),
|
||||
);
|
||||
|
||||
await expect(createIntegration().getSiteOverviewAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("keeps the sites when the hosts call fails, with console state unknown", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).endsWith("/v1/hosts") ? jsonResponse({}, 500) : route(String(url)),
|
||||
);
|
||||
|
||||
const overview = await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
expect(overview.sites).toHaveLength(1);
|
||||
expect(overview.sites[0]!.consoleUpdateAvailable).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps the sites when the metrics call fails, with the window unknown", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).includes("isp-metrics") ? jsonResponse({}, 500) : route(String(url)),
|
||||
);
|
||||
|
||||
const overview = await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
expect(overview.sites).toHaveLength(1);
|
||||
expect(overview.sites[0]!.summary.unknown).toBe(24);
|
||||
});
|
||||
|
||||
test("keeps the sites when the SD-WAN calls fail, with sdWan null", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).includes("sd-wan") ? jsonResponse({}, 500) : route(String(url)),
|
||||
);
|
||||
|
||||
const overview = await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
expect(overview.sites).toHaveLength(1);
|
||||
expect(overview.sites[0]!.sdWan).toBeNull();
|
||||
});
|
||||
|
||||
test("returns an empty site list for an account with no sites, without erroring", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).endsWith("/v1/sites") ? jsonResponse({ data: [] }) : route(String(url)),
|
||||
);
|
||||
|
||||
await expect(createIntegration().getSiteOverviewAsync()).resolves.toEqual({ sites: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnifiSiteManagerIntegration cloud-only transport", () => {
|
||||
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
|
||||
|
||||
/**
|
||||
* `testingAsync` is protected; this bypasses that at runtime the same way
|
||||
* the wud/gluetun specs do, so the test can call it directly instead of
|
||||
* going through the DB-backed certificate lookup in `testConnectionAsync`.
|
||||
*/
|
||||
const invokeTesting = (integration: UnifiSiteManagerIntegration, input: IntegrationTestingInput) => {
|
||||
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
|
||||
return testing(input);
|
||||
};
|
||||
|
||||
/**
|
||||
* Regression guard for the vulnerability this integration exists to avoid:
|
||||
* if a future edit makes `testingAsync` destructure and use `fetchAsync`
|
||||
* from its input (as the sibling `unifi-os` integration legitimately does),
|
||||
* that `fetchAsync` is built from Homarr's trusted-certificate transport —
|
||||
* the one that trusts every admin-uploaded CA — and this test catches it.
|
||||
*/
|
||||
test("never routes the Site Manager key through the trusted-certificate transport", async () => {
|
||||
mockFetch.mockImplementation(async (url) => route(String(url)));
|
||||
|
||||
await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
// Stand-in for the real `input.fetchAsync`, which in production is built
|
||||
// on top of `fetchWithTrustedCertificatesAsync`. If `testingAsync` used
|
||||
// it, this mock would record the call.
|
||||
const input = { fetchAsync: mockTrustedFetch } as unknown as IntegrationTestingInput;
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockTrustedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
// @vitest-environment node
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Style des specs du dépôt : voir packages/integrations/test/nextcloud.spec.ts. */
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildHealthWindow,
|
||||
buildSdWanIndex,
|
||||
deriveHealthState,
|
||||
HEALTH_WINDOW_HOURS,
|
||||
mapSites,
|
||||
resolveHealthWindowEnd,
|
||||
summariseHealth,
|
||||
} from "../unifi-site-manager-types";
|
||||
|
||||
const hoursBefore = (reference: Date, hours: number) => new Date(reference.getTime() - hours * 3_600_000);
|
||||
|
||||
const NOW = new Date("2026-09-09T20:00:00Z");
|
||||
|
||||
const period = (at: Date, wan: Record<string, number>) => ({
|
||||
metricTime: at.toISOString(),
|
||||
data: { wan },
|
||||
});
|
||||
|
||||
describe("deriveHealthState", () => {
|
||||
test("ranks an outage above packet loss", () => {
|
||||
expect(deriveHealthState({ downtime: 60, packetLoss: 5 })).toBe("down");
|
||||
});
|
||||
|
||||
test("reports packet loss as degraded", () => {
|
||||
expect(deriveHealthState({ downtime: 0, packetLoss: 2 })).toBe("degraded");
|
||||
});
|
||||
|
||||
test("reports a clean period as healthy", () => {
|
||||
expect(deriveHealthState({ downtime: 0, packetLoss: 0 })).toBe("healthy");
|
||||
});
|
||||
|
||||
/** Champs absents : le cloud les omet parfois. Absent n'est pas zéro. */
|
||||
test("treats a period with no measurements as unknown", () => {
|
||||
expect(deriveHealthState({})).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHealthWindow", () => {
|
||||
test("always returns one segment per hour of the window", () => {
|
||||
expect(buildHealthWindow([], NOW)).toHaveLength(HEALTH_WINDOW_HOURS);
|
||||
});
|
||||
|
||||
/**
|
||||
* Le cas central. Une fenêtre de 24 h dont le cloud ne rapporte que 16 heures
|
||||
* a été observée sur l'API réelle : les trous sont au milieu, pas seulement au
|
||||
* début. Les peindre en vert affirmerait un bon fonctionnement jamais mesuré.
|
||||
*/
|
||||
test("fills hours the cloud did not report with unknown, never healthy", () => {
|
||||
const window = buildHealthWindow([period(hoursBefore(NOW, 2), { downtime: 0, packetLoss: 0 })], NOW);
|
||||
|
||||
expect(window.filter((segment) => segment.state === "healthy")).toHaveLength(1);
|
||||
expect(window.filter((segment) => segment.state === "unknown")).toHaveLength(HEALTH_WINDOW_HOURS - 1);
|
||||
});
|
||||
|
||||
test("orders segments oldest first", () => {
|
||||
const window = buildHealthWindow([], NOW);
|
||||
|
||||
expect(window.at(0)!.at.getTime()).toBeLessThan(window.at(-1)!.at.getTime());
|
||||
});
|
||||
|
||||
test("ignores periods that fall outside the window", () => {
|
||||
const window = buildHealthWindow([period(hoursBefore(NOW, 100), { downtime: 0, packetLoss: 0 })], NOW);
|
||||
|
||||
expect(window.every((segment) => segment.state === "unknown")).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps the worst state when several readings share an hour", () => {
|
||||
const at = hoursBefore(NOW, 3);
|
||||
const window = buildHealthWindow(
|
||||
[period(at, { downtime: 0, packetLoss: 0 }), period(at, { downtime: 30, packetLoss: 0 })],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(window.filter((segment) => segment.state === "down")).toHaveLength(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce qu'affirme la fusion des deux granularités du côté de l'API : les points
|
||||
* de 5 minutes n'ont besoin d'aucun traitement propre. Douze points d'une même
|
||||
* heure se replient sur UNE tranche, et le pire l'emporte — une coupure de
|
||||
* cinq minutes ne se dilue pas dans onze relevés sains.
|
||||
*/
|
||||
test("collapses five-minute points onto the hour they belong to, worst first", () => {
|
||||
const hour = hoursBefore(NOW, 1);
|
||||
const fiveMinutePoints = Array.from({ length: 12 }, (_, index) =>
|
||||
period(
|
||||
new Date(hour.getTime() + index * 300_000),
|
||||
index === 7 ? { downtime: 30 } : { downtime: 0, packetLoss: 0 },
|
||||
),
|
||||
);
|
||||
|
||||
const window = buildHealthWindow(fiveMinutePoints, NOW);
|
||||
|
||||
expect(window.filter((segment) => segment.state !== "unknown")).toHaveLength(1);
|
||||
expect(window.filter((segment) => segment.state === "down")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Les métriques ISP du cloud arrivent avec 25 à 35 minutes de retard (mesuré sur
|
||||
* le compte réel) : l'heure courante n'est pas encore mesurée. Une fenêtre
|
||||
* finissant sur elle réservait donc sa dernière tranche à du gris permanent
|
||||
* pendant la première moitié de chaque heure.
|
||||
*/
|
||||
describe("resolveHealthWindowEnd", () => {
|
||||
test("ends on the freshest reported period instead of the wall clock", () => {
|
||||
const freshest = hoursBefore(NOW, 1);
|
||||
const end = resolveHealthWindowEnd(
|
||||
[{ siteId: "site-1", periods: [period(hoursBefore(NOW, 3), {}), period(freshest, {})] }],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(end.getTime()).toBe(freshest.getTime());
|
||||
});
|
||||
|
||||
/**
|
||||
* Le maximum est GLOBAL. Les lignes du tableau se lisent heure contre heure :
|
||||
* une fin propre à chaque site ferait glisser sa fenêtre par rapport à celles
|
||||
* de ses voisines, et la colonne cesserait de désigner la même heure.
|
||||
*/
|
||||
test("takes the maximum across every site rather than per site", () => {
|
||||
const freshest = hoursBefore(NOW, 1);
|
||||
const end = resolveHealthWindowEnd(
|
||||
[
|
||||
{ siteId: "site-1", periods: [period(hoursBefore(NOW, 6), {})] },
|
||||
{ siteId: "site-2", periods: [period(freshest, {})] },
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(end.getTime()).toBe(freshest.getTime());
|
||||
});
|
||||
|
||||
test("falls back to the current time when no site reported any period", () => {
|
||||
expect(resolveHealthWindowEnd([], NOW)).toBe(NOW);
|
||||
expect(resolveHealthWindowEnd([{ siteId: "site-1", periods: [] }], NOW)).toBe(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseHealth", () => {
|
||||
test("counts hours per state so the bar is readable without colour", () => {
|
||||
const summary = summariseHealth([
|
||||
{ at: NOW, state: "healthy" },
|
||||
{ at: NOW, state: "healthy" },
|
||||
{ at: NOW, state: "degraded" },
|
||||
{ at: NOW, state: "unknown" },
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({ healthy: 2, degraded: 1, down: 0, unknown: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSdWanIndex", () => {
|
||||
const status = (siteId: string, overrides: Record<string, unknown> = {}) => ({
|
||||
status: {
|
||||
connections: [
|
||||
{ sites: [{ siteId, status: { connected: true, offline: false, ping: 0, errors: [], ...overrides } }] },
|
||||
],
|
||||
sites: [{ siteId, errors: [] as unknown[], warnings: [] as string[] }],
|
||||
},
|
||||
});
|
||||
|
||||
test("indexes a site by its identifier", () => {
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1").status }]);
|
||||
|
||||
expect(index.get("site-1")).toMatchObject({ configName: "Mesh", connected: true, offline: false });
|
||||
});
|
||||
|
||||
/**
|
||||
* Relevé sur un tunnel connecté entre deux sites distants : `ping: 0`. Une
|
||||
* latence nulle y est physiquement impossible — le champ veut dire « non
|
||||
* mesuré », et propager le zéro l'afficherait comme une performance.
|
||||
*/
|
||||
test("reads a ping of zero as unmeasured, not as zero milliseconds", () => {
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1").status }]);
|
||||
|
||||
expect(index.get("site-1")!.pingMs).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps a measured ping", () => {
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1", { ping: 12 }).status }]);
|
||||
|
||||
expect(index.get("site-1")!.pingMs).toBe(12);
|
||||
});
|
||||
|
||||
test("counts errors and warnings", () => {
|
||||
const raw = status("site-1", { errors: ["a", "b"] }).status;
|
||||
raw.sites[0]!.warnings = ["w"];
|
||||
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: raw }]);
|
||||
|
||||
expect(index.get("site-1")).toMatchObject({ errorCount: 2, warningCount: 1 });
|
||||
});
|
||||
|
||||
test("keeps the first configuration when a site appears in several", () => {
|
||||
const index = buildSdWanIndex([
|
||||
{ configName: "First", status: status("site-1").status },
|
||||
{ configName: "Second", status: status("site-1").status },
|
||||
]);
|
||||
|
||||
expect(index.get("site-1")!.configName).toBe("First");
|
||||
});
|
||||
|
||||
test("leaves a site absent from every configuration out of the index", () => {
|
||||
expect(buildSdWanIndex([]).get("site-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSites", () => {
|
||||
const site = {
|
||||
siteId: "site-1",
|
||||
hostId: "host-1",
|
||||
meta: { desc: "Example site", name: "default" },
|
||||
statistics: {
|
||||
percentages: { wanUptime: 99.5 },
|
||||
ispInfo: { name: "Example ISP" },
|
||||
wans: { WAN: { externalIp: "203.0.113.10" }, WAN2: { externalIp: "203.0.113.11" } },
|
||||
counts: { totalDevice: 6, offlineDevice: 1, pendingUpdateDevice: 3 },
|
||||
},
|
||||
};
|
||||
|
||||
const host = { id: "host-1", reportedState: { deviceState: "updateAvailable" } };
|
||||
|
||||
test("maps a complete site", () => {
|
||||
const [mapped] = mapSites([site], [host], [], new Map(), NOW);
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
siteId: "site-1",
|
||||
name: "Example site",
|
||||
wanUptimePct: 99.5,
|
||||
ispName: "Example ISP",
|
||||
externalIps: ["203.0.113.10", "203.0.113.11"],
|
||||
deviceCount: 6,
|
||||
offlineDeviceCount: 1,
|
||||
pendingUpdateCount: 3,
|
||||
consoleUpdateAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to meta.name when there is no description", () => {
|
||||
const [mapped] = mapSites([{ ...site, meta: { name: "default" } }], [], [], new Map(), NOW);
|
||||
|
||||
expect(mapped!.name).toBe("default");
|
||||
});
|
||||
|
||||
/**
|
||||
* `null` veut dire « aucun host ne correspond », ce qui n'est pas « la console
|
||||
* est à jour ». Le widget doit pouvoir les distinguer.
|
||||
*/
|
||||
test("leaves consoleUpdateAvailable null when no host matches", () => {
|
||||
const [mapped] = mapSites([site], [], [], new Map(), NOW);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce test affirmait `false`. `deviceState` n'est pas un booléen : le compte
|
||||
* réel renvoie `"setup"`, `"updateAvailable"`, et Ubiquiti en ajoutera
|
||||
* d'autres. Tout ce qui n'est pas la chaîne magique tombait donc sur `false`,
|
||||
* que le widget affiche « à jour » — une affirmation sur le firmware tirée
|
||||
* d'un état qui n'en parle pas.
|
||||
*/
|
||||
test("treats a device state that says nothing about firmware as unknown, not as up to date", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[{ id: "host-1", reportedState: { deviceState: "connected" } }],
|
||||
[],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBeNull();
|
||||
});
|
||||
|
||||
/** Le cloud se prononce : aucune version disponible. C'est le seul « à jour ». */
|
||||
test("reports a console as up to date when the cloud states no available version", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[{ id: "host-1", reportedState: { deviceState: "setup", firmwareUpdate: { latestAvailableVersion: null } } }],
|
||||
[],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
test("reports an available version as an update even without a device state", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[{ id: "host-1", reportedState: { firmwareUpdate: { latestAvailableVersion: "9.9.9" } } }],
|
||||
[],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Le piège d'origine : `undefined === "updateAvailable"` vaut `false`, donc une
|
||||
* absence totale d'information s'affichait comme une console à jour.
|
||||
*/
|
||||
test("leaves consoleUpdateAvailable null when the host reports neither field", () => {
|
||||
const [mapped] = mapSites([site], [{ id: "host-1", reportedState: {} }], [], new Map(), NOW);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBeNull();
|
||||
});
|
||||
|
||||
test("yields an empty list for an account with no sites", () => {
|
||||
expect(mapSites([], [], [], new Map(), NOW)).toEqual([]);
|
||||
});
|
||||
|
||||
test("attaches the health window of the matching metrics entry", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[],
|
||||
[{ siteId: "site-1", periods: [period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0 })] }],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.health).toHaveLength(HEALTH_WINDOW_HOURS);
|
||||
expect(mapped!.summary.healthy).toBe(1);
|
||||
expect(mapped!.latencyMs).toBeNull();
|
||||
});
|
||||
|
||||
test("takes latency and loss from the most recent period", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[],
|
||||
[
|
||||
{
|
||||
siteId: "site-1",
|
||||
periods: [
|
||||
period(hoursBefore(NOW, 5), { downtime: 0, packetLoss: 9, avgLatency: 99 }),
|
||||
period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0, avgLatency: 11 }),
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.latencyMs).toBe(11);
|
||||
expect(mapped!.packetLossPct).toBe(0);
|
||||
});
|
||||
|
||||
test("ends the health window on the freshest reported hour, not on the wall clock", () => {
|
||||
const freshest = hoursBefore(NOW, 1);
|
||||
const metrics = [{ siteId: "site-1", periods: [period(freshest, { downtime: 0, packetLoss: 0 })] }];
|
||||
|
||||
const [mapped] = mapSites([site], [], metrics, new Map(), resolveHealthWindowEnd(metrics, NOW));
|
||||
|
||||
expect(mapped!.health.at(-1)!.at.getTime()).toBe(freshest.getTime());
|
||||
expect(mapped!.health.at(-1)!.state).toBe("healthy");
|
||||
});
|
||||
|
||||
/**
|
||||
* Une seule fin pour toutes les lignes : le site à jour remplit sa dernière
|
||||
* tranche, celui qui retarde garde son gris à droite. C'est une information
|
||||
* vraie — il n'a rien rapporté depuis — et non un artefact d'horloge.
|
||||
*/
|
||||
test("keeps the trailing grey of a site lagging the others, on one shared window", () => {
|
||||
const metrics = [
|
||||
{ siteId: "site-1", periods: [period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0 })] },
|
||||
{ siteId: "site-2", periods: [period(hoursBefore(NOW, 4), { downtime: 0, packetLoss: 0 })] },
|
||||
];
|
||||
|
||||
const [current, lagging] = mapSites(
|
||||
[site, { ...site, siteId: "site-2" }],
|
||||
[],
|
||||
metrics,
|
||||
new Map(),
|
||||
resolveHealthWindowEnd(metrics, NOW),
|
||||
);
|
||||
|
||||
expect(current!.health.at(-1)!.state).toBe("healthy");
|
||||
expect(lagging!.health.at(-1)!.state).toBe("unknown");
|
||||
expect(lagging!.health.at(-1)!.at.getTime()).toBe(current!.health.at(-1)!.at.getTime());
|
||||
});
|
||||
|
||||
test("keeps a window ending now when the account reported no period at all", () => {
|
||||
const [mapped] = mapSites([site], [], [], new Map(), resolveHealthWindowEnd([], NOW));
|
||||
|
||||
expect(mapped!.summary.unknown).toBe(HEALTH_WINDOW_HOURS);
|
||||
expect(mapped!.health.at(-1)!.at.getTime()).toBe(NOW.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce module est réexporté par packages/integrations/src/types.ts, lui-même
|
||||
* importé par des composants client. Un import de @homarr/common/server y
|
||||
* traîne ioredis puis node:dns dans le bundle navigateur et empêche
|
||||
* l'application de démarrer — défaut réellement survenu sur l'intégration
|
||||
* précédente, invisible au typecheck comme aux tests unitaires.
|
||||
*/
|
||||
describe("client bundle safety", () => {
|
||||
test("the types module does not import server-only code", () => {
|
||||
// `import.meta.url` plutôt que `__dirname` : ce dernier n'existe pas en ESM,
|
||||
// et aucun autre spec du dépôt ne s'y fie.
|
||||
const source = readFileSync(new URL("../unifi-site-manager-types.ts", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toContain("@homarr/common/server");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import type { RequestInit, Response } from "undici";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { parseSiteManagerAsync } from "./unifi-site-manager-parse";
|
||||
import type { IspMetricsEntry, SdWanStatus, SiteManagerHost, SiteManagerSite } from "./unifi-site-manager-types";
|
||||
import {
|
||||
HEALTH_WINDOW_HOURS,
|
||||
ispMetricsResponseSchema,
|
||||
sdWanConfigListSchema,
|
||||
sdWanStatusSchema,
|
||||
siteManagerHostListSchema,
|
||||
siteManagerSiteListSchema,
|
||||
} from "./unifi-site-manager-types";
|
||||
|
||||
/** L'API cloud d'Ubiquiti. Jamais configurable : ce n'est pas une console locale. */
|
||||
const BASE_URL = "https://api.ui.com";
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Le statut SD-WAN est indexé par configuration : il faut d'abord les lister,
|
||||
* puis interroger chacune. Sans borne, un compte à dix configurations
|
||||
* produirait onze requêtes par rafraîchissement.
|
||||
*/
|
||||
export const MAX_SD_WAN_CONFIGS = 5;
|
||||
|
||||
/**
|
||||
* Fenêtre de la requête à 5 minutes.
|
||||
*
|
||||
* Mesuré sur un compte réel : la dernière heure que rapporte `/1h/query` a 2 h 43
|
||||
* et 3 h 43 de retard selon le site, là où `/5m/query` rapporte des points de 24
|
||||
* et 34 minutes. Le bout droit de la bande était donc structurellement gris et la
|
||||
* latence affichée vieille de trois heures. Quatre heures couvrent le pire retard
|
||||
* observé sans rapatrier une fenêtre fine de 24 h.
|
||||
*/
|
||||
export const RECENT_METRICS_WINDOW_HOURS = 4;
|
||||
|
||||
/**
|
||||
* Rassemble les deux granularités en une entrée par site.
|
||||
*
|
||||
* Les périodes des deux réponses sont concaténées sans déduplication : en aval,
|
||||
* `buildHealthWindow` plaque chaque période sur son heure et garde le pire état
|
||||
* quand plusieurs y tombent, et `latestPeriod` prend le maximum par
|
||||
* `metricTime`. Les points de 5 minutes se replient donc d'eux-mêmes sur les
|
||||
* tranches horaires, et la latence vient du point le plus frais.
|
||||
*
|
||||
* Un site présent dans une seule des deux réponses doit ressortir : la fusion
|
||||
* part des deux listes, jamais de l'intersection.
|
||||
*/
|
||||
const mergeIspMetrics = (hourly: IspMetricsEntry[], recent: IspMetricsEntry[]): IspMetricsEntry[] => {
|
||||
const bySite = new Map<string, IspMetricsEntry>();
|
||||
|
||||
for (const entry of [...hourly, ...recent]) {
|
||||
const existing = bySite.get(entry.siteId);
|
||||
// Copie des périodes : les tableaux viennent de la réponse analysée, et les
|
||||
// concaténer en place muterait celle-ci sous les autres lecteurs.
|
||||
if (existing === undefined) {
|
||||
bySite.set(entry.siteId, { ...entry, periods: [...entry.periods] });
|
||||
continue;
|
||||
}
|
||||
existing.periods.push(...entry.periods);
|
||||
}
|
||||
|
||||
return Array.from(bySite.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Déclaré sur les types d'undici, pas sur ceux du DOM : le dépôt n'utilise que
|
||||
* `undici` côté serveur. Aligner les deux évite les `as unknown as` qui
|
||||
* désactivaient le contrôle de type à la frontière du transport.
|
||||
*/
|
||||
export type SiteManagerFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise<Response>;
|
||||
|
||||
export class UnifiSiteManagerApi {
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly fetchAsync: SiteManagerFetch,
|
||||
) {}
|
||||
|
||||
public async getSitesAsync(): Promise<SiteManagerSite[]> {
|
||||
const parsed = await this.requestAsync(
|
||||
"/v1/sites",
|
||||
siteManagerSiteListSchema,
|
||||
"Invalid Site Manager sites response",
|
||||
);
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
public async getHostsAsync(): Promise<SiteManagerHost[]> {
|
||||
const parsed = await this.requestAsync(
|
||||
"/v1/hosts",
|
||||
siteManagerHostListSchema,
|
||||
"Invalid Site Manager hosts response",
|
||||
);
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux granularités pour une seule fenêtre.
|
||||
*
|
||||
* La requête horaire couvre les HEALTH_WINDOW_HOURS heures de la bande, tous
|
||||
* les sites en une requête bornée — la variante non bornée pèse 216 Ko
|
||||
* (mesuré). Elle rapporte cependant jusqu'à près de quatre heures de retard,
|
||||
* d'où la seconde requête, à 5 minutes, sur les seules heures récentes.
|
||||
*
|
||||
* Les deux partent en parallèle : enchaînées, elles ajouteraient la latence de
|
||||
* l'une à celle de l'autre à chaque rafraîchissement.
|
||||
*/
|
||||
public async getIspMetricsAsync(
|
||||
sites: { siteId: string; hostId?: string }[],
|
||||
endingAt: Date,
|
||||
): Promise<IspMetricsEntry[]> {
|
||||
if (sites.length === 0) return [];
|
||||
|
||||
const endTimestamp = endingAt.toISOString();
|
||||
const beginTimestampOf = (hours: number) => new Date(endingAt.getTime() - hours * 3_600_000).toISOString();
|
||||
|
||||
const [hourlyResult, recentResult] = await Promise.allSettled([
|
||||
this.queryIspMetricsAsync("1h", sites, beginTimestampOf(HEALTH_WINDOW_HOURS), endTimestamp),
|
||||
this.queryIspMetricsAsync("5m", sites, beginTimestampOf(RECENT_METRICS_WINDOW_HOURS), endTimestamp),
|
||||
]);
|
||||
|
||||
// La fenêtre horaire porte toute la bande : son échec doit remonter.
|
||||
if (hourlyResult.status === "rejected") throw hourlyResult.reason;
|
||||
|
||||
// L'échec de la requête à 5 minutes est avalé À DESSEIN : elle n'apporte que
|
||||
// la fraîcheur des dernières heures. Sans elle, ces heures retombent en gris
|
||||
// — « non rapporté », ce qui est vrai — au lieu de perdre tout le widget
|
||||
// pour un enrichissement. Ne pas propager ce rejet.
|
||||
const recent = recentResult.status === "fulfilled" ? recentResult.value : [];
|
||||
|
||||
return mergeIspMetrics(hourlyResult.value, recent);
|
||||
}
|
||||
|
||||
private async queryIspMetricsAsync(
|
||||
granularity: "1h" | "5m",
|
||||
sites: { siteId: string; hostId?: string }[],
|
||||
beginTimestamp: string,
|
||||
endTimestamp: string,
|
||||
): Promise<IspMetricsEntry[]> {
|
||||
const response = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/${granularity}/query`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json", "Content-Type": "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
body: JSON.stringify({
|
||||
sites: sites.map((site) => ({ hostId: site.hostId, siteId: site.siteId, beginTimestamp, endTimestamp })),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
// Le schéma est identique aux deux granularités : `data.metrics[].periods[]`
|
||||
// avec `metricTime` et `data.wan` — vérifié sur la réponse réelle à 5 min.
|
||||
const parsed = await parseSiteManagerAsync(
|
||||
await response.json(),
|
||||
ispMetricsResponseSchema,
|
||||
"Invalid Site Manager ISP metrics response",
|
||||
);
|
||||
return parsed.data.metrics;
|
||||
}
|
||||
|
||||
public async getSdWanStatusesAsync(): Promise<{ configName: string; status: SdWanStatus["data"]["status"] }[]> {
|
||||
const configs = await this.requestAsync(
|
||||
"/v1/sd-wan-configs",
|
||||
sdWanConfigListSchema,
|
||||
"Invalid Site Manager SD-WAN configuration list",
|
||||
);
|
||||
|
||||
const statuses: { configName: string; status: SdWanStatus["data"]["status"] }[] = [];
|
||||
|
||||
for (const config of configs.data.slice(0, MAX_SD_WAN_CONFIGS)) {
|
||||
try {
|
||||
const parsed = await this.requestAsync(
|
||||
`/v1/sd-wan-configs/${encodeURIComponent(config.id)}/status`,
|
||||
sdWanStatusSchema,
|
||||
"Invalid Site Manager SD-WAN status",
|
||||
);
|
||||
statuses.push({ configName: config.name ?? config.id, status: parsed.data.status });
|
||||
} catch {
|
||||
// Une configuration illisible ne doit pas emporter les autres.
|
||||
}
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
private async requestAsync<TOutput>(path: string, schema: z.ZodType<TOutput>, message: string): Promise<TOutput> {
|
||||
const response = await this.fetchAsync(`${BASE_URL}${path}`, {
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
return await parseSiteManagerAsync(await response.json(), schema, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { fetch as platformFetch } from "undici";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { UnifiSiteManagerIntegration as UnifiSiteManagerIntegrationInterface } from "../interfaces/unifi-site-manager/unifi-site-manager-integration";
|
||||
import type { SiteManagerFetch } from "./unifi-site-manager-api";
|
||||
import { UnifiSiteManagerApi } from "./unifi-site-manager-api";
|
||||
import type {
|
||||
IspMetricsEntry,
|
||||
SiteManagerHost,
|
||||
UnifiSiteManagerOverview,
|
||||
UnifiSiteSdWanStatus,
|
||||
} from "./unifi-site-manager-types";
|
||||
import { buildSdWanIndex, mapSites, resolveHealthWindowEnd } from "./unifi-site-manager-types";
|
||||
|
||||
/**
|
||||
* `api.ui.com` présente un certificat public valide. On n'utilise donc PAS le
|
||||
* fetch à certificats de confiance de Homarr : il ajoute toutes les autorités
|
||||
* personnalisées téléversées par l'administrateur, ce qui en ferait des
|
||||
* ancrages valides pour le cloud d'Ubiquiti — et exposerait la clé, qui lit
|
||||
* tous les sites du compte.
|
||||
*/
|
||||
const fetchCloudAsync: SiteManagerFetch = async (url, init) => {
|
||||
const { timeout, ...rest } = init ?? {};
|
||||
return await platformFetch(url, {
|
||||
...rest,
|
||||
signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout),
|
||||
});
|
||||
};
|
||||
|
||||
const settled = <T>(result: PromiseSettledResult<T>, fallback: T): T =>
|
||||
result.status === "fulfilled" ? result.value : fallback;
|
||||
|
||||
export class UnifiSiteManagerIntegration extends Integration implements UnifiSiteManagerIntegrationInterface {
|
||||
public async getSiteOverviewAsync(): Promise<UnifiSiteManagerOverview> {
|
||||
return await this.buildOverviewAsync(fetchCloudAsync);
|
||||
}
|
||||
|
||||
// `input.fetchAsync` is ignored on purpose: it carries the administrator's
|
||||
// trusted-certificate bundle, and using it here would let that CA vouch for
|
||||
// `api.ui.com` — see the module-level comment on `fetchCloudAsync`.
|
||||
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
try {
|
||||
await this.createApi(fetchCloudAsync).getSitesAsync();
|
||||
} catch (error) {
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private createApi(fetchAsync: SiteManagerFetch) {
|
||||
return new UnifiSiteManagerApi(this.getSecretValue("siteManagerApiKey"), fetchAsync);
|
||||
}
|
||||
|
||||
private async buildOverviewAsync(fetchAsync: SiteManagerFetch): Promise<UnifiSiteManagerOverview> {
|
||||
const api = this.createApi(fetchAsync);
|
||||
const now = new Date();
|
||||
|
||||
const [sitesResult, hostsResult] = await Promise.allSettled([api.getSitesAsync(), api.getHostsAsync()]);
|
||||
|
||||
// La liste des sites porte tout : son échec doit remonter, sinon le widget
|
||||
// afficherait « aucun site » alors qu'il n'a rien pu lire.
|
||||
if (sitesResult.status === "rejected") throw sitesResult.reason;
|
||||
|
||||
const sites = sitesResult.value;
|
||||
const hosts = settled<SiteManagerHost[]>(hostsResult, []);
|
||||
|
||||
// Deux enrichissements, en parallèle. Leur échec laisse leur part à
|
||||
// l'inconnu — la fenêtre à « inconnu » plutôt qu'à « sain », le SD-WAN à
|
||||
// `null` — et n'emporte jamais la liste des sites.
|
||||
const [metricsResult, sdWanResult] = await Promise.allSettled([
|
||||
api.getIspMetricsAsync(
|
||||
sites.map((site) => ({ siteId: site.siteId, hostId: site.hostId })),
|
||||
now,
|
||||
),
|
||||
api.getSdWanStatusesAsync(),
|
||||
]);
|
||||
|
||||
const metrics = settled<IspMetricsEntry[]>(metricsResult, []);
|
||||
const sdWanBySite: Map<string, UnifiSiteSdWanStatus> = buildSdWanIndex(settled(sdWanResult, []));
|
||||
|
||||
// `now` drives the metrics REQUEST, but not the end of the health window:
|
||||
// the cloud's ISP metrics lag 25 to 35 minutes, so the current hour has no
|
||||
// measurement yet for the first half of every hour and its segment would be
|
||||
// permanently grey. The window therefore ends on the freshest hour any site
|
||||
// actually reported — one end for every row, so the table stays comparable
|
||||
// hour against hour. See `resolveHealthWindowEnd`.
|
||||
return { sites: mapSites(sites, hosts, metrics, sdWanBySite, resolveHealthWindowEnd(metrics, now)) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Séparé de `unifi-site-manager-types.ts` à dessein : ce module importe
|
||||
* `@homarr/common/server`, qui atteint `ioredis` puis `node:dns`. Le module de
|
||||
* types, lui, est réexporté vers des composants client et doit rester pur.
|
||||
*/
|
||||
export const parseSiteManagerAsync = async <TOutput>(
|
||||
value: unknown,
|
||||
schema: z.ZodType<TOutput>,
|
||||
message: string,
|
||||
): Promise<TOutput> => {
|
||||
const result = await schema.safeParseAsync(value);
|
||||
if (!result.success) {
|
||||
throw new ParseError(message, { cause: result.error });
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/** Ubiquiti ajoute des champs sans prévenir : tous les objets d'API sont tolérants. */
|
||||
|
||||
const wanMetricsSchema = z.looseObject({
|
||||
avgLatency: z.number().optional(),
|
||||
maxLatency: z.number().optional(),
|
||||
packetLoss: z.number().optional(),
|
||||
downtime: z.number().optional(),
|
||||
uptime: z.number().optional(),
|
||||
ispName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ispMetricsPeriodSchema = z.looseObject({
|
||||
metricTime: z.string(),
|
||||
data: z.looseObject({ wan: wanMetricsSchema.optional() }),
|
||||
});
|
||||
|
||||
export const ispMetricsEntrySchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
hostId: z.string().optional(),
|
||||
periods: z.array(ispMetricsPeriodSchema).default([]),
|
||||
});
|
||||
|
||||
export const ispMetricsResponseSchema = z.looseObject({
|
||||
data: z.looseObject({ metrics: z.array(ispMetricsEntrySchema).default([]) }),
|
||||
});
|
||||
|
||||
export const siteManagerSiteSchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
hostId: z.string().optional(),
|
||||
meta: z.looseObject({ desc: z.string().optional(), name: z.string().optional() }),
|
||||
statistics: z
|
||||
.looseObject({
|
||||
percentages: z.looseObject({ wanUptime: z.number().optional() }).optional(),
|
||||
ispInfo: z.looseObject({ name: z.string().optional() }).optional(),
|
||||
wans: z.record(z.string(), z.looseObject({ externalIp: z.string().optional() })).optional(),
|
||||
counts: z
|
||||
.looseObject({
|
||||
totalDevice: z.number().optional(),
|
||||
offlineDevice: z.number().optional(),
|
||||
pendingUpdateDevice: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const siteManagerSiteListSchema = z.looseObject({ data: z.array(siteManagerSiteSchema).default([]) });
|
||||
|
||||
export const siteManagerHostSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
reportedState: z
|
||||
.looseObject({
|
||||
deviceState: z.string().optional(),
|
||||
/**
|
||||
* `.nullable().optional()` et non `.nullish()` : « présent et explicitement
|
||||
* `null` » — le cloud affirme qu'aucune version n'est disponible — doit
|
||||
* rester distinguable de « absent », où il ne dit rien.
|
||||
*/
|
||||
firmwareUpdate: z.looseObject({ latestAvailableVersion: z.string().nullable().optional() }).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const siteManagerHostListSchema = z.looseObject({ data: z.array(siteManagerHostSchema).default([]) });
|
||||
|
||||
export const sdWanConfigSchema = z.looseObject({ id: z.string(), name: z.string().optional() });
|
||||
|
||||
export const sdWanConfigListSchema = z.looseObject({ data: z.array(sdWanConfigSchema).default([]) });
|
||||
|
||||
const sdWanConnectionSiteSchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
status: z
|
||||
.looseObject({
|
||||
connected: z.boolean().optional(),
|
||||
offline: z.boolean().optional(),
|
||||
ping: z.number().optional(),
|
||||
errors: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const sdWanStatusSchema = z.looseObject({
|
||||
data: z.looseObject({
|
||||
status: z.looseObject({
|
||||
connections: z.array(z.looseObject({ sites: z.array(sdWanConnectionSiteSchema).default([]) })).default([]),
|
||||
sites: z
|
||||
.array(
|
||||
z.looseObject({
|
||||
siteId: z.string(),
|
||||
errors: z.array(z.unknown()).default([]),
|
||||
warnings: z.array(z.unknown()).default([]),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SdWanStatus = z.infer<typeof sdWanStatusSchema>;
|
||||
|
||||
export type SiteManagerSite = z.infer<typeof siteManagerSiteSchema>;
|
||||
export type SiteManagerHost = z.infer<typeof siteManagerHostSchema>;
|
||||
export type IspMetricsEntry = z.infer<typeof ispMetricsEntrySchema>;
|
||||
export type IspMetricsPeriod = z.infer<typeof ispMetricsPeriodSchema>;
|
||||
|
||||
export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown";
|
||||
|
||||
export interface UnifiSiteHealthSegment {
|
||||
/** Début de la tranche horaire. */
|
||||
at: Date;
|
||||
state: UnifiSiteHealthState;
|
||||
}
|
||||
|
||||
export type UnifiSiteHealthSummary = Record<UnifiSiteHealthState, number>;
|
||||
|
||||
export interface UnifiSiteManagerSite {
|
||||
siteId: string;
|
||||
name: string;
|
||||
wanUptimePct: number | null;
|
||||
ispName: string | null;
|
||||
externalIps: string[];
|
||||
deviceCount: number;
|
||||
offlineDeviceCount: number;
|
||||
/** Firmwares d'ÉQUIPEMENTS en attente — distinct de la mise à jour de console. */
|
||||
pendingUpdateCount: number;
|
||||
/**
|
||||
* Mise à jour de la CONSOLE. `null` quand aucun host ne correspond ou que le
|
||||
* cloud ne se prononce pas — voir `resolveConsoleUpdate`.
|
||||
*/
|
||||
consoleUpdateAvailable: boolean | null;
|
||||
/** Toujours HEALTH_WINDOW_HOURS entrées, la plus ancienne en premier. */
|
||||
health: UnifiSiteHealthSegment[];
|
||||
summary: UnifiSiteHealthSummary;
|
||||
latencyMs: number | null;
|
||||
packetLossPct: number | null;
|
||||
/**
|
||||
* `null` couvre DEUX cas que ce contrat ne distingue pas : le site n'appartient
|
||||
* à aucune configuration SD-WAN, ou l'appel de statut a échoué. Ne pas en
|
||||
* déduire l'un des deux à l'affichage — le tiret dit « on ne sait pas ».
|
||||
*/
|
||||
sdWan: UnifiSiteSdWanStatus | null;
|
||||
}
|
||||
|
||||
export interface UnifiSiteSdWanStatus {
|
||||
configName: string;
|
||||
connected: boolean;
|
||||
offline: boolean;
|
||||
/** `null` quand le cloud rapporte `0` : « non mesuré », pas « zéro milliseconde ». */
|
||||
pingMs: number | null;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
export interface UnifiSiteManagerOverview {
|
||||
sites: UnifiSiteManagerSite[];
|
||||
}
|
||||
|
||||
export const HEALTH_WINDOW_HOURS = 24;
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Gravité décroissante. L'ordre compte : une coupure prime sur une perte. */
|
||||
export const deriveHealthState = (
|
||||
wan:
|
||||
| {
|
||||
downtime?: number;
|
||||
packetLoss?: number;
|
||||
}
|
||||
| undefined,
|
||||
): UnifiSiteHealthState => {
|
||||
if (wan === undefined) return "unknown";
|
||||
if (wan.downtime === undefined && wan.packetLoss === undefined) return "unknown";
|
||||
if ((wan.downtime ?? 0) > 0) return "down";
|
||||
if ((wan.packetLoss ?? 0) > 0) return "degraded";
|
||||
return "healthy";
|
||||
};
|
||||
|
||||
const SEVERITY: Record<UnifiSiteHealthState, number> = { unknown: 0, healthy: 1, degraded: 2, down: 3 };
|
||||
|
||||
const floorToHour = (value: number) => Math.floor(value / HOUR_MS) * HOUR_MS;
|
||||
|
||||
/**
|
||||
* Reconstitue une fenêtre pleine de HEALTH_WINDOW_HOURS tranches.
|
||||
*
|
||||
* Le cloud ne rapporte pas toutes les heures : une fenêtre de 24 h n'en a
|
||||
* renvoyé que 16 sur l'API réelle, avec des trous au milieu. Une tranche non
|
||||
* rapportée vaut `"unknown"` et jamais `"healthy"` — la peindre en vert
|
||||
* affirmerait un bon fonctionnement qui n'a pas été mesuré.
|
||||
*
|
||||
* `endingAt` est la dernière heure MESURÉE, pas maintenant : voir
|
||||
* `resolveHealthWindowEnd`. Les périodes hors fenêtre étant écartées ci-dessous,
|
||||
* reculer cette fin ne peut pas y faire entrer de données plus anciennes.
|
||||
*/
|
||||
export const buildHealthWindow = (periods: IspMetricsPeriod[], endingAt: Date): UnifiSiteHealthSegment[] => {
|
||||
const lastHour = floorToHour(endingAt.getTime());
|
||||
const firstHour = lastHour - (HEALTH_WINDOW_HOURS - 1) * HOUR_MS;
|
||||
|
||||
const byHour = new Map<number, UnifiSiteHealthState>();
|
||||
for (const period of periods) {
|
||||
const at = floorToHour(new Date(period.metricTime).getTime());
|
||||
if (Number.isNaN(at) || at < firstHour || at > lastHour) continue;
|
||||
|
||||
const state = deriveHealthState(period.data.wan);
|
||||
const existing = byHour.get(at);
|
||||
// Plusieurs mesures dans la même heure : on garde la pire.
|
||||
if (existing === undefined || SEVERITY[state] > SEVERITY[existing]) byHour.set(at, state);
|
||||
}
|
||||
|
||||
return Array.from({ length: HEALTH_WINDOW_HOURS }, (_, offset) => {
|
||||
const at = firstHour + offset * HOUR_MS;
|
||||
return { at: new Date(at), state: byHour.get(at) ?? "unknown" };
|
||||
});
|
||||
};
|
||||
|
||||
/** Le canal qui porte l'information quand la couleur ne passe pas. */
|
||||
export const summariseHealth = (segments: UnifiSiteHealthSegment[]): UnifiSiteHealthSummary => {
|
||||
const summary: UnifiSiteHealthSummary = { healthy: 0, degraded: 0, down: 0, unknown: 0 };
|
||||
for (const segment of segments) summary[segment.state] += 1;
|
||||
return summary;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fin de la fenêtre de santé : la dernière heure RÉELLEMENT rapportée, et non
|
||||
* maintenant.
|
||||
*
|
||||
* Les métriques ISP du cloud arrivent avec 25 à 35 minutes de retard (mesuré sur
|
||||
* le compte réel). Terminer la fenêtre sur l'heure courante réservait donc sa
|
||||
* dernière tranche à une heure qu'aucune mesure ne couvre encore : elle restait
|
||||
* grise pendant la première moitié de chaque heure. Honnêtement grise, et
|
||||
* totalement inutile — un vingt-quatrième de la barre ne disait jamais rien.
|
||||
*
|
||||
* Le maximum est GLOBAL, pris sur les périodes de TOUS les sites, et non site par
|
||||
* site : les lignes du tableau se lisent heure contre heure, et une fin propre à
|
||||
* chaque site ferait glisser sa fenêtre par rapport à celles de ses voisines.
|
||||
* Avec une fin unique, un site qui retarde vraiment sur les autres garde une
|
||||
* tranche grise à droite — c'est une information vraie, pas un artefact.
|
||||
*
|
||||
* Sans aucune période (compte vide, ou appel de métriques en échec), on retombe
|
||||
* sur `now` : la fenêtre reste pleine de HEALTH_WINDOW_HOURS tranches inconnues.
|
||||
*/
|
||||
export const resolveHealthWindowEnd = (metrics: IspMetricsEntry[], now: Date): Date => {
|
||||
let newest: number | undefined;
|
||||
|
||||
for (const entry of metrics) {
|
||||
for (const period of entry.periods) {
|
||||
const at = new Date(period.metricTime).getTime();
|
||||
if (Number.isNaN(at)) continue;
|
||||
if (newest === undefined || at > newest) newest = at;
|
||||
}
|
||||
}
|
||||
|
||||
return newest === undefined ? now : new Date(newest);
|
||||
};
|
||||
|
||||
const latestPeriod = (periods: IspMetricsPeriod[]): IspMetricsPeriod | undefined =>
|
||||
periods.reduce<IspMetricsPeriod | undefined>((newest, period) => {
|
||||
if (newest === undefined) return period;
|
||||
return new Date(period.metricTime).getTime() > new Date(newest.metricTime).getTime() ? period : newest;
|
||||
}, undefined);
|
||||
|
||||
/**
|
||||
* Indexe l'état des tunnels par site.
|
||||
*
|
||||
* `ping: 0` a été relevé sur un tunnel `connected` reliant deux sites
|
||||
* géographiquement distants — une latence nulle y est physiquement impossible.
|
||||
* Le champ signifie donc « non mesuré », et il devient `null` plutôt que d'être
|
||||
* propagé comme une performance.
|
||||
*
|
||||
* Un site peut apparaître dans plusieurs configurations : la première l'emporte,
|
||||
* et son nom accompagne l'état pour que la ligne reste interprétable.
|
||||
*/
|
||||
export const buildSdWanIndex = (
|
||||
configs: { configName: string; status: SdWanStatus["data"]["status"] }[],
|
||||
): Map<string, UnifiSiteSdWanStatus> => {
|
||||
const index = new Map<string, UnifiSiteSdWanStatus>();
|
||||
|
||||
for (const { configName, status } of configs) {
|
||||
const warningsBySite = new Map(status.sites.map((site) => [site.siteId, site]));
|
||||
|
||||
for (const connection of status.connections) {
|
||||
for (const site of connection.sites) {
|
||||
if (index.has(site.siteId)) continue;
|
||||
|
||||
const reported = site.status;
|
||||
const ping = reported?.ping ?? 0;
|
||||
|
||||
index.set(site.siteId, {
|
||||
configName,
|
||||
connected: reported?.connected ?? false,
|
||||
offline: reported?.offline ?? false,
|
||||
pingMs: ping > 0 ? ping : null,
|
||||
errorCount: reported?.errors.length ?? 0,
|
||||
warningCount: warningsBySite.get(site.siteId)?.warnings.length ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
};
|
||||
|
||||
/**
|
||||
* `false` exige un signal positif du cloud.
|
||||
*
|
||||
* `deviceState` n'est pas un booléen : le compte réel renvoie `"setup"` sur une
|
||||
* console et `"updateAvailable"` sur l'autre, et Ubiquiti peut en ajouter
|
||||
* d'autres. Comparer à la chaîne magique repliait donc tout le reste — `"setup"`,
|
||||
* une console hors ligne, un champ absent — sur `false`, que le widget affiche
|
||||
* « à jour » : dire d'un état qui ne parle pas du firmware qu'il n'y a pas de
|
||||
* mise à jour affirme une mesure qui n'a jamais été faite.
|
||||
*
|
||||
* Seul `firmwareUpdate.latestAvailableVersion` autorise `false` : explicitement
|
||||
* `null` sur la console à jour, porteur d'une version sur celle qui ne l'est pas.
|
||||
* Faute de l'un ou de l'autre, l'état reste inconnu.
|
||||
*/
|
||||
const resolveConsoleUpdate = (host: SiteManagerHost | undefined): boolean | null => {
|
||||
if (host === undefined) return null;
|
||||
|
||||
const reported = host.reportedState;
|
||||
if (reported?.deviceState === "updateAvailable") return true;
|
||||
|
||||
// `undefined` (champ ou objet absent) ne vaut pas `null` : seul `null` est une réponse.
|
||||
const latestAvailableVersion = reported?.firmwareUpdate?.latestAvailableVersion;
|
||||
if (typeof latestAvailableVersion === "string") return true;
|
||||
if (latestAvailableVersion === null) return false;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mapSites = (
|
||||
sites: SiteManagerSite[],
|
||||
hosts: SiteManagerHost[],
|
||||
metrics: IspMetricsEntry[],
|
||||
sdWanBySite: Map<string, UnifiSiteSdWanStatus>,
|
||||
/**
|
||||
* Dernière heure de la fenêtre de santé, commune à toutes les lignes. Ce n'est
|
||||
* PAS `now` : l'appelant la tient de `resolveHealthWindowEnd`, qui explique
|
||||
* pourquoi.
|
||||
*/
|
||||
healthWindowEndsAt: Date,
|
||||
): UnifiSiteManagerSite[] =>
|
||||
sites.map((site) => {
|
||||
const statistics = site.statistics;
|
||||
const counts = statistics?.counts;
|
||||
const host = site.hostId === undefined ? undefined : hosts.find((candidate) => candidate.id === site.hostId);
|
||||
const entry = metrics.find((candidate) => candidate.siteId === site.siteId);
|
||||
const periods = entry?.periods ?? [];
|
||||
const health = buildHealthWindow(periods, healthWindowEndsAt);
|
||||
const latest = latestPeriod(periods)?.data.wan;
|
||||
|
||||
return {
|
||||
siteId: site.siteId,
|
||||
name: site.meta.desc ?? site.meta.name ?? site.siteId,
|
||||
wanUptimePct: statistics?.percentages?.wanUptime ?? null,
|
||||
ispName: statistics?.ispInfo?.name ?? null,
|
||||
externalIps: Object.values(statistics?.wans ?? {})
|
||||
.map((wan) => wan.externalIp)
|
||||
.filter((ip): ip is string => ip !== undefined),
|
||||
deviceCount: counts?.totalDevice ?? 0,
|
||||
offlineDeviceCount: counts?.offlineDevice ?? 0,
|
||||
pendingUpdateCount: counts?.pendingUpdateDevice ?? 0,
|
||||
consoleUpdateAvailable: resolveConsoleUpdate(host),
|
||||
health,
|
||||
summary: summariseHealth(health),
|
||||
latencyMs: latest?.avgLatency ?? null,
|
||||
packetLossPct: latest?.packetLoss ?? null,
|
||||
sdWan: sdWanBySite.get(site.siteId) ?? null,
|
||||
};
|
||||
});
|
||||
@@ -37,9 +37,16 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
const systemInfo = await this.getSystemInformationAsync();
|
||||
|
||||
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
|
||||
const cpuCount = systemInfo.info.cpu.cores;
|
||||
const cpuCount = systemInfo.metrics.cpu.cpus.length;
|
||||
|
||||
// Each entry in cpus[] is one logical CPU (thread). Dividing the summed per-thread
|
||||
// utilization by the thread count (not physical cores) gives the correct average and
|
||||
// avoids over-reporting on SMT systems. Guard against zero to avoid Infinity/NaN.
|
||||
let cpuUtilizationNormalized = 0;
|
||||
if (cpuCount > 0) {
|
||||
cpuUtilizationNormalized = cpuUtilization / cpuCount;
|
||||
}
|
||||
|
||||
// We use "info" object instead of the stats since this is the exact amount the kernel sees, which is what Unraid displays.
|
||||
const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0);
|
||||
const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100);
|
||||
const uptime = dayjs(systemInfo.info.os.uptime);
|
||||
@@ -47,7 +54,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
return {
|
||||
version: systemInfo.info.os.release,
|
||||
cpuModelName: systemInfo.info.cpu.brand,
|
||||
cpuUtilization: cpuUtilization / cpuCount,
|
||||
cpuUtilization: cpuUtilizationNormalized,
|
||||
memUsedInBytes: usedMemory,
|
||||
memAvailableInBytes: totalMemory - usedMemory,
|
||||
uptime: dayjs().diff(uptime, "seconds"),
|
||||
@@ -139,7 +146,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
},
|
||||
memory {
|
||||
layout {
|
||||
size
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { getServerStatistics, init } from "@immich/sdk";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getAllTrustedCertificatesAsync: vi.fn(async () => []),
|
||||
getTrustedCertificateHostnamesAsync: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync", () => {
|
||||
test("x-api-key set via init() reaches the server", async () => {
|
||||
let receivedXApiKey: string | string[] | undefined;
|
||||
const server = createServer((req, res) => {
|
||||
receivedXApiKey = req.headers["x-api-key"];
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ usage: 1000, photos: 5, videos: 2 }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
|
||||
init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" });
|
||||
try {
|
||||
const stats = await getServerStatistics({
|
||||
fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(receivedXApiKey).toBe("sdk-configured-key");
|
||||
expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { UnifiOsSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const unifiOsRequestHandler = createIntegrationRequestHandler<UnifiOsSummary, "unifiOs", Record<string, never>>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getUnifiOsSummaryAsync();
|
||||
},
|
||||
cacheTtlMs: 5 * 1000,
|
||||
// Aligné sur l'intervalle du widget : un cache plus long qu'elle rendrait le
|
||||
// rafraîchissement rapide inopérant, la requête relisant la même entrée.
|
||||
// La console ne rafraîchit ses statistiques que toutes les 30 s (mesuré), donc
|
||||
// interroger plus vite gagne en réactivité, pas en résolution.
|
||||
// Le cache cloud reste à 5 min dans unifi-sm-api.ts : ses métriques n'évoluent
|
||||
// que par tranches de 5 min et accusent ~30 min de retard.
|
||||
// Une console momentanément injoignable doit afficher la dernière valeur connue
|
||||
// plutôt qu'une tuile d'erreur ; le widget signale la péremption.
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 10 * 60 * 1000,
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { UnifiSiteManagerOverview } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const unifiSiteManagerRequestHandler = createIntegrationRequestHandler<
|
||||
UnifiSiteManagerOverview,
|
||||
"unifiSiteManager",
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSiteOverviewAsync();
|
||||
},
|
||||
// Les compteurs cloud n'évoluent pas plus vite ; interroger davantage expose
|
||||
// à une limitation de débit sans rien gagner. Aligné sur l'intervalle du widget.
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 30 * 60 * 1000,
|
||||
});
|
||||
+1516
-1516
File diff suppressed because it is too large.
Load diff
@@ -1442,15 +1442,15 @@
|
||||
"iconPicker": {
|
||||
"label": "图标 URL",
|
||||
"description": "搜索库,粘贴图片URL,或上传文件。",
|
||||
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。",
|
||||
"headerLoading": "输入以搜索图标…",
|
||||
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标",
|
||||
"headerLoading": "按名称搜索图像",
|
||||
"clear": "清除图像",
|
||||
"uploadImage": "上传图片",
|
||||
"results": "图像搜索结果",
|
||||
"localImages": "你的映像",
|
||||
"svgIcons": "SVG 图标",
|
||||
"otherImages": "其他映像",
|
||||
"urlLoading": "",
|
||||
"urlLoading": "正在检查图片…",
|
||||
"urlReady": "图像已准备好使用",
|
||||
"urlError": "无法加载此图像。请检查URL,然后重试。",
|
||||
"noResults": "没有与“{search}”匹配的图标。",
|
||||
|
||||
+1702
-1702
File diff suppressed because it is too large.
Load diff
@@ -1442,7 +1442,7 @@
|
||||
"iconPicker": {
|
||||
"label": "Ikon-URL",
|
||||
"description": "Søg i biblioteket, indsæt en billed-URL, eller upload en fil.",
|
||||
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig.",
|
||||
"header": "Søg efter {countIcons} billeder efter navn",
|
||||
"headerLoading": "Søg billeder efter navn",
|
||||
"clear": "Ryd billede",
|
||||
"uploadImage": "Upload billede",
|
||||
@@ -3093,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Album ID",
|
||||
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Interval i sekunder",
|
||||
|
||||
+1652
-1652
File diff suppressed because it is too large.
Load diff
@@ -1442,8 +1442,8 @@
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"description": "Suchen Sie die Bibliothek, fügen Sie eine Bild-URL ein oder laden Sie eine Datei hoch.",
|
||||
"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…",
|
||||
"header": "Suche {countIcons} Bilder nach Namen",
|
||||
"headerLoading": "Bilder nach Namen suchen",
|
||||
"clear": "Bild löschen",
|
||||
"uploadImage": "Bild hochladen",
|
||||
"results": "Bildsuchergebnisse",
|
||||
@@ -2375,7 +2375,7 @@
|
||||
},
|
||||
"visibleStorageVolumes": {
|
||||
"label": "Sichtbare Speichervolumes",
|
||||
"description": "Nur verfügbar, wenn alle Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes zu zeigen. ",
|
||||
"description": "Nur verfügbar, wenn alle ausgewählten Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes anzuzeigen.",
|
||||
"placeholder": "Speichervolumen auswählen"
|
||||
},
|
||||
"defaultTab": {
|
||||
@@ -2485,7 +2485,7 @@
|
||||
"error": {
|
||||
"internalServerError": "Fehler beim Abrufen der Container Statistiken",
|
||||
"layoutSaveTitle": "Tabellenlayout nicht gespeichert",
|
||||
"layoutSaveMessage": "Die Docker-Tabelle konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden. "
|
||||
"layoutSaveMessage": "Das Docker-Tabellenlayout konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "Keine Docker-Container gefunden"
|
||||
@@ -3093,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Album ID",
|
||||
"description": "Dies ist eine eindeutige ID, die sich auf Ihr Immich Album bezieht. Sie können diese ID finden, indem Sie ein Album öffnen und die einzigartige Zeichenkette von der URL kopieren, zum Beispiel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "Dies ist eine eindeutige ID, die sich auf Ihr Immich Album bezieht. Sie können diese ID finden, indem Sie ein Album öffnen und die einzigartige Zeichenkette von der URL kopieren, zum Beispiel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Intervall in Sekunden",
|
||||
@@ -3163,8 +3163,8 @@
|
||||
"noColumns": "Spalten in Elementen auswählen",
|
||||
"noCommunications": "Kann keine Daten von der Integration laden",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"actionFailed": "Aktion fehlgeschlagen",
|
||||
"actionFailedMessage": "Diese Aktion konnte nicht abgeschlossen werden. Bitte versuche es erneut."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3452,7 +3452,7 @@
|
||||
"queued": ""
|
||||
},
|
||||
"type": {
|
||||
"movie": "",
|
||||
"movie": "Film",
|
||||
"episode": ""
|
||||
},
|
||||
"empty": {
|
||||
@@ -3812,7 +3812,7 @@
|
||||
},
|
||||
"resource": {
|
||||
"routers": "",
|
||||
"services": "",
|
||||
"services": "Dienste",
|
||||
"middlewares": ""
|
||||
},
|
||||
"status": {
|
||||
@@ -4098,13 +4098,13 @@
|
||||
"temp": "",
|
||||
"battery": "",
|
||||
"services": "Dienste",
|
||||
"uptime": "",
|
||||
"uptime": "Verfügbarkeit",
|
||||
"agent": "Agent"
|
||||
}
|
||||
},
|
||||
"beszelAlerts": {
|
||||
"name": "Beszel-Alarme",
|
||||
"description": "Zeige Beszels Alarmeinstellungen und -verlauf",
|
||||
"name": "Beszel-Meldungen",
|
||||
"description": "Zeige Beszels Benachrichtigungseinstellungen und -verlauf",
|
||||
"option": {
|
||||
"showHistory": {
|
||||
"label": "Alarmverlauf anzeigen"
|
||||
@@ -4173,12 +4173,12 @@
|
||||
"chart": {
|
||||
"cpu": {
|
||||
"title": "CPU Auslastung",
|
||||
"subtitle": "",
|
||||
"subtitle": "Durchschnittliche Systemweite CPU-Auslastung",
|
||||
"series": "CPU"
|
||||
},
|
||||
"memory": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"title": "Speicherverbrauch",
|
||||
"subtitle": "Genaue Auslastung zum erfassten Zeitpunkt",
|
||||
"series": "Belegt",
|
||||
"cache": ""
|
||||
},
|
||||
@@ -4189,7 +4189,7 @@
|
||||
},
|
||||
"diskIO": {
|
||||
"title": "Festplatten-E/A",
|
||||
"subtitle": "",
|
||||
"subtitle": "Durchsatz des Root-Dateisystems",
|
||||
"read": "Lesen",
|
||||
"write": "Schreiben"
|
||||
},
|
||||
@@ -6096,7 +6096,7 @@
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
"available": "",
|
||||
"available": "Verfügbar",
|
||||
"partiallyAvailable": "",
|
||||
"processing": "",
|
||||
"requested": "",
|
||||
|
||||
+1777
-1777
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Configure server settings"
|
||||
"subtitle": "Configure server settings."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
@@ -351,7 +351,7 @@
|
||||
"delete": {
|
||||
"label": "Permanently delete user",
|
||||
"description": "This deletes this user, including their preferences. This will not delete any boards. The user will not be notified.",
|
||||
"confirm": "Are you sure that you want to delete the user {username}, as well as their preferences"
|
||||
"confirm": "Are you sure that you want to delete the user {username}, as well as their preferences?"
|
||||
},
|
||||
"select": {
|
||||
"label": "Select a user",
|
||||
@@ -517,7 +517,7 @@
|
||||
"transfer": {
|
||||
"label": "Transfer group ownership",
|
||||
"description": "Transfer ownership of this group to another user.",
|
||||
"confirm": "Are you want to transfer the ownership of the group, {name} to {username}",
|
||||
"confirm": "Are you want to transfer the ownership of the group, {name} to {username}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": "Successfully transferred group {group} to {user}"
|
||||
@@ -536,7 +536,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"label": "Delete group",
|
||||
"description": "Once you delete this group, you can not revert the change. Please be careful",
|
||||
"description": "Once you delete this group, you can not revert the change. Please be careful.",
|
||||
"confirm": "Are you sure you want to delete the group, {name}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
@@ -1345,8 +1345,8 @@
|
||||
"description": "Cloud-native reverse proxy and load balancer"
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "What's Up Docker",
|
||||
"description": "Monitor which of your Docker containers have image updates available"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
@@ -1441,18 +1441,18 @@
|
||||
"here": "here",
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"description": "",
|
||||
"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": "Type to search icons…",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"localImages": "",
|
||||
"svgIcons": "",
|
||||
"otherImages": "",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"description": "Search the library, paste an image URL, or upload a file.",
|
||||
"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": "Type to search icons",
|
||||
"clear": "Clear image",
|
||||
"uploadImage": "Upload image",
|
||||
"results": "Image search results",
|
||||
"localImages": "Your images",
|
||||
"svgIcons": "SVG icons",
|
||||
"otherImages": "Other images",
|
||||
"urlLoading": "Checking image…",
|
||||
"urlReady": "Image is ready to use",
|
||||
"urlError": "This image could not be loaded. Check the URL and try again.",
|
||||
"noResults": "No icons match \"{search}\".",
|
||||
"error": "Couldn't load icons."
|
||||
},
|
||||
@@ -1512,7 +1512,7 @@
|
||||
"login": "Log-in",
|
||||
"homeBoard": "Your home board",
|
||||
"loggedOut": "Logged out",
|
||||
"updateAvailable": "New update available: {tag}"
|
||||
"updateAvailable": "{countUpdates} updates available: {tag}"
|
||||
}
|
||||
},
|
||||
"dangerZone": "Danger zone",
|
||||
@@ -1808,19 +1808,19 @@
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"name": "What's Up Docker",
|
||||
"description": "Show how many watched containers have updates available.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Monitored",
|
||||
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
|
||||
"updateAvailable": "Update available",
|
||||
"openReleaseNotes": "Open release notes",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Show title"
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
"label": "Show update ring"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
@@ -1834,8 +1834,8 @@
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Show container update list",
|
||||
"description": "Lists each monitored container that has an image update available"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1931,7 +1931,7 @@
|
||||
"label": "Display seconds"
|
||||
},
|
||||
"useCustomTimezone": {
|
||||
"label": " "
|
||||
"label": "Use a fixed timezone"
|
||||
},
|
||||
"timezone": {
|
||||
"label": "Time zone",
|
||||
@@ -2476,19 +2476,19 @@
|
||||
"label": "Invert sorting"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Column order"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Column widths"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch containers stats",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Table layout not saved",
|
||||
"layoutSaveMessage": "The Docker table layout could not be saved. Try moving or resizing the column again."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "No containers found"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2784,17 +2784,17 @@
|
||||
"description": "Disabling this will not work for Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Show bitrate",
|
||||
"description": "Show the current playback bitrate next to the location"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Show location",
|
||||
"description": "Show whether the stream is on your LAN or WAN"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"streams": "{count, plural, one {# active stream} other {# active streams}}",
|
||||
"totalBitrate": "Total: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Currently playing",
|
||||
@@ -2803,9 +2803,9 @@
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
"directPlay": "Direct play",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"directStream": "Direct stream",
|
||||
"transcodeVideo": "Transcoding video",
|
||||
"transcodeAudio": "Transcoding audio",
|
||||
"transcoding": "Transcoding",
|
||||
"paused": "Paused",
|
||||
"remaining": "{minutes} min left",
|
||||
@@ -3093,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Album ID",
|
||||
"description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Interval in seconds",
|
||||
@@ -3153,18 +3153,18 @@
|
||||
"description": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Column order"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Column widths"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "",
|
||||
"noCommunications": "",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "No downloads found",
|
||||
"actionFailed": "Action failed",
|
||||
"actionFailedMessage": "The action could not be completed. Please try again."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3179,7 +3179,7 @@
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"downSpeed": {
|
||||
"columnTitle": "",
|
||||
"columnTitle": "Down",
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"index": {
|
||||
@@ -3188,16 +3188,16 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "",
|
||||
"detailsTitle": ""
|
||||
"detailsTitle": "Item ID"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": ""
|
||||
},
|
||||
"name": {
|
||||
"columnTitle": ""
|
||||
"columnTitle": "Job name"
|
||||
},
|
||||
"progress": {
|
||||
"columnTitle": "",
|
||||
"columnTitle": "Progress",
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"ratio": {
|
||||
@@ -3217,13 +3217,13 @@
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"state": {
|
||||
"columnTitle": "",
|
||||
"columnTitle": "State",
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"time": {
|
||||
"columnTitle": "",
|
||||
"detailsTitle": "",
|
||||
"eta": ""
|
||||
"eta": "ETA"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "",
|
||||
@@ -3269,21 +3269,21 @@
|
||||
},
|
||||
"globalRatio": "",
|
||||
"contextMenu": {
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
"copyName": "Copy name",
|
||||
"copyId": "Copy ID",
|
||||
"clearFilters": "Clear filters"
|
||||
},
|
||||
"footer": {
|
||||
"filter": ""
|
||||
"filter": "Filter downloads"
|
||||
},
|
||||
"stats": {
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
"show": "Show global stats",
|
||||
"hide": "Hide global stats",
|
||||
"overallProgress": "Overall queue progress",
|
||||
"totalSize": "Total queue size",
|
||||
"clients": "{count, plural, one {# client} other {# clients}}"
|
||||
},
|
||||
"disconnected": ""
|
||||
"disconnected": "(disconnected)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "",
|
||||
@@ -3315,7 +3315,7 @@
|
||||
}
|
||||
},
|
||||
"mediaRequests-requestList": {
|
||||
"name": "",
|
||||
"name": "Media Requests List",
|
||||
"description": "",
|
||||
"option": {
|
||||
"linksTargetNewTab": {
|
||||
@@ -3337,15 +3337,15 @@
|
||||
"unknown": "",
|
||||
"pending": "",
|
||||
"processing": "",
|
||||
"requested": "",
|
||||
"requested": "Requested",
|
||||
"partiallyAvailable": "",
|
||||
"available": "",
|
||||
"blacklisted": "",
|
||||
"blacklisted": "Blacklisted",
|
||||
"deleted": ""
|
||||
},
|
||||
"status": {
|
||||
"pending": "",
|
||||
"approved": "",
|
||||
"approved": "Approved",
|
||||
"declined": "",
|
||||
"failed": "",
|
||||
"completed": ""
|
||||
@@ -3358,7 +3358,7 @@
|
||||
"option": {},
|
||||
"titles": {
|
||||
"stats": {
|
||||
"main": "",
|
||||
"main": "Media Stats",
|
||||
"approved": "",
|
||||
"pending": "",
|
||||
"processing": "",
|
||||
@@ -3402,15 +3402,15 @@
|
||||
"panel": {
|
||||
"statistics": {
|
||||
"empty": "",
|
||||
"transcodes": "",
|
||||
"transcodes": "Transcodes",
|
||||
"transcodesCount": "",
|
||||
"healthChecksCount": "",
|
||||
"filesCount": "",
|
||||
"savedSpace": "",
|
||||
"healthChecks": "",
|
||||
"videoCodecs": "",
|
||||
"videoContainers": "",
|
||||
"videoResolutions": ""
|
||||
"healthChecks": "Health checks",
|
||||
"videoCodecs": "Codecs",
|
||||
"videoContainers": "Containers",
|
||||
"videoResolutions": "Resolutions"
|
||||
},
|
||||
"workers": {
|
||||
"empty": "",
|
||||
@@ -3620,7 +3620,7 @@
|
||||
"option": {},
|
||||
"card": {
|
||||
"vpn": {
|
||||
"countConnected": ""
|
||||
"countConnected": "{count} connected"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3637,14 +3637,14 @@
|
||||
"name": ""
|
||||
},
|
||||
"wifi": {
|
||||
"name": ""
|
||||
"name": "Wi-Fi"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"label": ""
|
||||
},
|
||||
"guests": {
|
||||
"label": ""
|
||||
"label": "Guests"
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
@@ -3890,7 +3890,7 @@
|
||||
"option": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"gpu": "",
|
||||
"gpu": "GPU",
|
||||
"network": ""
|
||||
}
|
||||
},
|
||||
@@ -3908,7 +3908,7 @@
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"gpu": "",
|
||||
"network": "",
|
||||
"network": "NET",
|
||||
"up": "",
|
||||
"down": ""
|
||||
}
|
||||
@@ -4014,17 +4014,17 @@
|
||||
"label": "Show agent version"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Column order"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Column widths"
|
||||
}
|
||||
},
|
||||
"noRecords": "No systems found",
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Unable to save table layout",
|
||||
"layoutSaveMessage": "Your column order or widths could not be saved. Please try again."
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -4956,7 +4956,7 @@
|
||||
"title": "",
|
||||
"status": {
|
||||
"idle": "",
|
||||
"running": "",
|
||||
"running": "Running",
|
||||
"error": "",
|
||||
"disabled": ""
|
||||
},
|
||||
@@ -5422,7 +5422,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "",
|
||||
"confirm": "",
|
||||
"confirm": "Click again to remove permanently",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
@@ -5858,7 +5858,7 @@
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "",
|
||||
"placeholder": "Search for anything",
|
||||
"nothingFound": "",
|
||||
"error": {
|
||||
"fetch": ""
|
||||
|
||||
@@ -1015,6 +1015,8 @@
|
||||
"search": "Search",
|
||||
"mediaTranscoding": "Transcoding",
|
||||
"networkController": "Network",
|
||||
"unifiOs": "UniFi OS",
|
||||
"unifiSiteManager": "UniFi Site Manager",
|
||||
"releasesProvider": "Releases",
|
||||
"notifications": "Notifications",
|
||||
"firewall": "Firewall",
|
||||
@@ -1297,6 +1299,10 @@
|
||||
"label": "API Secret",
|
||||
"newLabel": "New API Secret"
|
||||
},
|
||||
"siteManagerApiKey": {
|
||||
"label": "Site Manager API Key",
|
||||
"newLabel": "New Site Manager API Key"
|
||||
},
|
||||
"githubAppId": {
|
||||
"label": "App Id",
|
||||
"newLabel": "New App Id"
|
||||
@@ -4217,6 +4223,84 @@
|
||||
"subtitle": "Network traffic of docker containers"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unifiOs": {
|
||||
"name": "UniFi OS",
|
||||
"description": "Network overview from a UniFi OS console",
|
||||
"option": {
|
||||
"showIsp": {
|
||||
"label": "Show ISP details",
|
||||
"description": "Requires a Site Manager API key"
|
||||
},
|
||||
"showDevices": {
|
||||
"label": "Show devices",
|
||||
"description": "Display how many devices are online"
|
||||
},
|
||||
"showFirmwareUpdates": {
|
||||
"label": "Show firmware updates",
|
||||
"description": "Display how many devices have a pending firmware update"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"internet": "Internet",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"latency": "{value} ms",
|
||||
"packetLoss": "{value} % loss",
|
||||
"measuredAt": "Measured at {time}",
|
||||
"stale": "Local data could not be refreshed, showing the last known values",
|
||||
"clients": "Clients",
|
||||
"clientBreakdown": "{total} · {wired} wired · {wireless} Wi-Fi",
|
||||
"devices": "Devices",
|
||||
"devicesOnline": "{online} / {total} online",
|
||||
"firmware": "Firmware",
|
||||
"firmwarePending": "{count} to update",
|
||||
"version": "Network {version}",
|
||||
"gatewayUptime": "Gateway up {uptime}"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Could not reach the UniFi OS console",
|
||||
"noData": "No data available"
|
||||
}
|
||||
},
|
||||
"unifiSiteManager": {
|
||||
"name": "UniFi Site Manager",
|
||||
"description": "Multi-site overview from a UniFi account",
|
||||
"option": {
|
||||
"showIsp": { "label": "Show ISP", "description": "Display the ISP name column" },
|
||||
"showExternalIp": { "label": "Show public IP", "description": "Display the public IP column" }
|
||||
},
|
||||
"column": {
|
||||
"site": "Site",
|
||||
"health": "Health (24h)",
|
||||
"wanUptime": "WAN uptime",
|
||||
"latency": "Latency",
|
||||
"isp": "ISP",
|
||||
"externalIp": "Public IP",
|
||||
"devices": "Devices",
|
||||
"updates": "Updates",
|
||||
"console": "Console",
|
||||
"sdWan": "SD-WAN"
|
||||
},
|
||||
"sdWan": {
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"offline": "Offline"
|
||||
},
|
||||
"health": {
|
||||
"healthy": "healthy",
|
||||
"degraded": "degraded",
|
||||
"down": "down",
|
||||
"unknown": "not reported"
|
||||
},
|
||||
"wanUptime": "{value} %",
|
||||
"latency": "{value} ms · {loss} % loss",
|
||||
"latencyOnly": "{value} ms",
|
||||
"devicesOnline": "{online} / {total}",
|
||||
"consoleUpdateAvailable": "Update available",
|
||||
"consoleUpToDate": "Up to date",
|
||||
"noSites": "No sites in this account",
|
||||
"error": { "internalServerError": "Could not reach the UniFi Site Manager API" }
|
||||
}
|
||||
},
|
||||
"widgetPreview": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1978
-1978
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Asetukset",
|
||||
"subtitle": "Määritä palvelinasetukset."
|
||||
"subtitle": "Määritä palvelimen asetukset."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integraatiot",
|
||||
@@ -110,7 +110,7 @@
|
||||
"message": "Jotain meni pieleen kojelautaasi määritettäessä. Yritä uudelleen."
|
||||
},
|
||||
"helpers": {
|
||||
"tooltip": "URL-apurit",
|
||||
"tooltip": "URL-avustajat",
|
||||
"description": "Nämä apurit pyrkivät arvaamaan palveluidesi URL-osoitteet. Jos tämä ei toimi, kirjoita osoite manuaalisesti."
|
||||
}
|
||||
},
|
||||
@@ -131,7 +131,7 @@
|
||||
"summary": "Homarr havaitse {apps} sovellusta, joista {integrations} löytyy sisäänrakennettu integraatio",
|
||||
"appBadge": "Sovellus",
|
||||
"appSectionLabel": "Ohjauspaneeliin lisättävät sovellukset ({count} valittu)",
|
||||
"scanning": "Dockeria tutkitaan...",
|
||||
"scanning": "Tutkitaan Dockeria...",
|
||||
"scanningDescription": "Tunnistetaan järjestelmässäsi suoritettavia kontteja"
|
||||
}
|
||||
},
|
||||
@@ -159,7 +159,7 @@
|
||||
},
|
||||
"invite": {
|
||||
"title": "Liity Homarriin",
|
||||
"subtitle": "Tervetuloa Homarriin! Luo itsellesi käyttäjätunnus",
|
||||
"subtitle": "Tervetuloa Homarriin! Luo käyttäjätunnuksesi",
|
||||
"description": "Sinut kutsui {username}"
|
||||
},
|
||||
"init": {
|
||||
@@ -218,7 +218,7 @@
|
||||
"action": {
|
||||
"login": {
|
||||
"label": "Kirjaudu",
|
||||
"labelWith": "Kirjaudu {{provider}}-tunnuksella",
|
||||
"labelWith": "Kirjaudu palvelulla: {provider}",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Kirjautuminen onnistui",
|
||||
@@ -302,7 +302,7 @@
|
||||
"changeDdgBangs": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": "DuckDuckGo Bangs -komentojen tila vaihdettiin"
|
||||
"message": "DuckDuckGon Bangs-komentojen tila vaihdettiin"
|
||||
},
|
||||
"error": {
|
||||
"message": "DuckDuckGo Bangs -komentojen tilaa ei voitu vaihtaa"
|
||||
@@ -397,7 +397,7 @@
|
||||
},
|
||||
"use-all": {
|
||||
"label": "Sovellusten käyttö",
|
||||
"description": "Sallii jäsenten lisätä kaikkia sovelluksia paneeleihinsa."
|
||||
"description": "Sallii jäsenten lisätä kaikkia sovelluksia paneeleihinsa"
|
||||
},
|
||||
"modify-all": {
|
||||
"label": "Sovellusten muokkaus",
|
||||
@@ -414,19 +414,19 @@
|
||||
"item": {
|
||||
"create": {
|
||||
"label": "Paneelien luonti",
|
||||
"description": "Sallii jäsenten luoda paneeleita."
|
||||
"description": "Sallii jäsenten luoda paneeleita"
|
||||
},
|
||||
"view-all": {
|
||||
"label": "Paneelien tarkastelu",
|
||||
"description": "Sallii jäsenten tarkastella kaikkia paneeleita."
|
||||
"description": "Sallii jäsenten tarkastella kaikkia paneeleita"
|
||||
},
|
||||
"modify-all": {
|
||||
"label": "Paneelien muokkaus",
|
||||
"description": "Sallii jäsenten muokata kaikkia paneeleita (pois lukien käyttöoikeudet ja vaaravyöhyke)."
|
||||
"description": "Sallii jäsenten muokata kaikkia paneeleita (pois lukien käyttöoikeudet ja vaaravyöhyke)"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "Paneelien hallinta",
|
||||
"description": "Sallii jäsenten tarkastella, muokata ja poistaa kaikkia paneeleita (mukaan lukien käyttöoikeudet ja vaaravyöhyke)."
|
||||
"description": "Sallii jäsenten tarkastella, muokata ja poistaa kaikkia paneeleita (mukaan lukien käyttöoikeudet ja vaaravyöhyke)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -996,7 +996,7 @@
|
||||
"grid": {
|
||||
"widgetCount": "{count} widgettiä",
|
||||
"noWidgets": "Widgettejä ei ole",
|
||||
"detected": "Havaittu Dockerin kautta"
|
||||
"detected": "Havaittu Dockerilla"
|
||||
},
|
||||
"category": {
|
||||
"dnsHole": "DNS",
|
||||
@@ -1228,7 +1228,7 @@
|
||||
},
|
||||
"tooManyRequests": {
|
||||
"title": "",
|
||||
"message": "Pyyntöjä oli liikaa. Pyyntöjäsi todennäköisesti rajoitetaan tai sinut estetään kohdejärjestelmässä."
|
||||
"message": "Pyyntöjä oli liikaa. Pyyntöjäsi todennäköisesti rajoitetaan tai sinut estetään kohdejärjestelmässä"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1254,8 +1254,8 @@
|
||||
"newLabel": "Uusi käyttäjätunnus"
|
||||
},
|
||||
"apiKey": {
|
||||
"label": "Rajapinnan avain",
|
||||
"newLabel": "Uusi rajapinnan avain"
|
||||
"label": "API-avain",
|
||||
"newLabel": "Uusi API-avain"
|
||||
},
|
||||
"password": {
|
||||
"label": "Salasana",
|
||||
@@ -1442,15 +1442,15 @@
|
||||
"iconPicker": {
|
||||
"label": "Kuvakkeen URL-osoite",
|
||||
"description": "Etsi kirjastosta, liitä kuvan URL-osoite tai lataa tiedosto.",
|
||||
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta.",
|
||||
"headerLoading": "Kirjoita hakeaksesi kuvakkeita…",
|
||||
"header": "Etsi {countIcons} kuvaa nimen mukaan",
|
||||
"headerLoading": "Hae kuvia nimen mukaan",
|
||||
"clear": "Tyhjennä kuva",
|
||||
"uploadImage": "Lataa kuva",
|
||||
"results": "Haun tulokset",
|
||||
"localImages": "Omat kuvat",
|
||||
"svgIcons": "SVG kuvakkeet",
|
||||
"otherImages": "Muut kuvat",
|
||||
"urlLoading": "",
|
||||
"urlLoading": "Tarkistetaan kuvaa…",
|
||||
"urlReady": "Kuva on valmis käytettäväksi",
|
||||
"urlError": "Kuvaa ei voitu ladata. Tarkista URL-osoite ja yritä uudelleen.",
|
||||
"noResults": "Ei kuvakkeita haulla \"{search}\".",
|
||||
@@ -2285,7 +2285,7 @@
|
||||
},
|
||||
"showCurrentWindSpeed": {
|
||||
"label": "Näytä nykyinen tuulen nopeus",
|
||||
"description": "Vain nykyiselle säälle."
|
||||
"description": "Vain nykyiselle säälle"
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": "Käytä tuulen nopeudelle mph-yksikköä"
|
||||
@@ -2476,19 +2476,19 @@
|
||||
"label": "Käänteinen järjestys"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden järjestys"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden leveydet"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Taulukon asettelua ei tallennettu",
|
||||
"layoutSaveMessage": "Docker-taulukon asettelua ei voitu tallentaa. Yritä siirtää tai muuttaa sarakkeen kokoa uudelleen."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "Kontteja ei löytynyt"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2784,17 +2784,17 @@
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Näytä bittinopeus",
|
||||
"description": "Näytä nykyinen toiston bittinopeus sijainnin vieressä"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Näytä sijainti",
|
||||
"description": "Näytä, onko suoratoisto LAN- vai WAN-verkossa."
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"streams": "{count, plural, one {# aktiivinen suoratoisto} other {# aktiivista suoratoistoa}}",
|
||||
"totalBitrate": "Yhteensä: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
@@ -2803,9 +2803,9 @@
|
||||
"id": "",
|
||||
"status": "Tila",
|
||||
"directPlay": "Muuntamaton toisto",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"directStream": "Suora suoratoisto",
|
||||
"transcodeVideo": "Videon transkoodaus",
|
||||
"transcodeAudio": "Äänen transkoodaus",
|
||||
"transcoding": "Transkoodaus",
|
||||
"paused": "Keskeytetty",
|
||||
"remaining": "{minutes} min jäljellä",
|
||||
@@ -3150,21 +3150,21 @@
|
||||
},
|
||||
"limitPerIntegration": {
|
||||
"label": "Integraation elementtien määrärajoitus",
|
||||
"description": "Tämä rajoittaa näytettävien elementtien määrää integraatiokohtaisesti, ei kokonaisvaltaisesti."
|
||||
"description": "Tämä rajoittaa näytettävien elementtien määrää integraatiokohtaisesti, ei kokonaisvaltaisesti"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden järjestys"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden leveydet"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "Valitse kohteiden sarakkeet",
|
||||
"noCommunications": "Integraatiolta ei voida noutaa tietoja",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "Latauksia ei löytynyt",
|
||||
"actionFailed": "Toiminto epäonnistui",
|
||||
"actionFailedMessage": "Toimintoa ei voitu suorittaa. Yritä uudelleen."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3188,7 +3188,7 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "Tunniste",
|
||||
"detailsTitle": ""
|
||||
"detailsTitle": "Kohteen ID"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "Integraatio"
|
||||
@@ -3223,7 +3223,7 @@
|
||||
"time": {
|
||||
"columnTitle": "Valmistumisaika",
|
||||
"detailsTitle": "",
|
||||
"eta": ""
|
||||
"eta": "Arvioitu aika"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "Tyyppi",
|
||||
@@ -3269,21 +3269,21 @@
|
||||
},
|
||||
"globalRatio": "Kokonaissuhde",
|
||||
"contextMenu": {
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
"copyName": "Kopioi nimi",
|
||||
"copyId": "Kopioi ID",
|
||||
"clearFilters": "Tyhjennä suodattimet"
|
||||
},
|
||||
"footer": {
|
||||
"filter": ""
|
||||
"filter": "Suodata latauksia"
|
||||
},
|
||||
"stats": {
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
"show": "Näytä globaalit tilastot",
|
||||
"hide": "Piilota globaalit tilastot",
|
||||
"overallProgress": "Jonon kokonaisedistyminen",
|
||||
"totalSize": "Jonon kokonaiskoko",
|
||||
"clients": "{count, plural, one {# asiakas} other {# asiakasta}}"
|
||||
},
|
||||
"disconnected": ""
|
||||
"disconnected": "(ei yhteydessä)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "",
|
||||
@@ -3745,7 +3745,7 @@
|
||||
"unhealthy": "",
|
||||
"ok": "",
|
||||
"fail": "",
|
||||
"noSectionsEnabled": "Aktivoi ainakin yksi osio widgetin asetuksista.",
|
||||
"noSectionsEnabled": "Ota ainakin yksi osio käyttöön widgetin asetuksista.",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -4014,17 +4014,17 @@
|
||||
"label": "Näytä agentin versio"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden järjestys"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden leveydet"
|
||||
}
|
||||
},
|
||||
"noRecords": "Järjestelmiä ei löytynyt",
|
||||
"error": {
|
||||
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Taulukon asettelua ei voitu tallentaa",
|
||||
"layoutSaveMessage": "Sarakkeiden järjestystä tai leveyksiä ei voitu tallentaa. Yritä uudelleen."
|
||||
},
|
||||
"column": {
|
||||
"system": "Järjestelmä",
|
||||
@@ -4423,7 +4423,7 @@
|
||||
},
|
||||
"itemRadius": {
|
||||
"label": "Kohteen säde",
|
||||
"description": "Muuttaa paneelin korttien kulmien pyöristystä.",
|
||||
"description": "Muuttaa paneelin korttien pyöreyttä",
|
||||
"option": {
|
||||
"xs": "Erittäin pieni",
|
||||
"sm": "Pieni",
|
||||
@@ -4968,7 +4968,7 @@
|
||||
"label": "Analytiikka"
|
||||
},
|
||||
"ping": {
|
||||
"label": "Ping-koestukset"
|
||||
"label": "Pingit"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5333,7 +5333,7 @@
|
||||
"refresh": {
|
||||
"lastUpdated": "Viimeisin päivitys {when}"
|
||||
},
|
||||
"search": "Suodata {count} konttia...",
|
||||
"search": "Suodata {count} konttia",
|
||||
"selected": "{selectCount}/{totalCount} kontista valittu",
|
||||
"footer": "Yhteensä {count} konttia",
|
||||
"totalCpu": "CPU yhteensä: {cpu}%",
|
||||
@@ -5422,7 +5422,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Poista",
|
||||
"confirm": "",
|
||||
"confirm": "Napsauta uudelleen poistaaksesi pysyvästi",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
@@ -5964,14 +5964,14 @@
|
||||
"title": "Asetukset",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Valitse muutettava asetus.",
|
||||
"title": "Valitse muutettava asetus",
|
||||
"backHint": "Palaa takaisin askelpalauttimella tai painamalla Takaisin-painiketta",
|
||||
"backAction": "Palaa takaisin"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"error": {
|
||||
"message": "Asetusta ei voitu päivittää."
|
||||
"message": "Asetusta ei voitu päivittää"
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
@@ -6055,10 +6055,10 @@
|
||||
}
|
||||
},
|
||||
"pingIconsEnabled": {
|
||||
"label": "Käytä ping-kuvakkeita",
|
||||
"label": "Käytä ping-ikoneita",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Käytä ping-kuvakkeita"
|
||||
"title": "Käytä ping-ikoneita"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6237,7 +6237,7 @@
|
||||
"label": "Hallinnoi Dockeria"
|
||||
},
|
||||
"manageApi": {
|
||||
"label": "Swagger-rajapinta"
|
||||
"label": "Swagger API"
|
||||
},
|
||||
"manageLog": {
|
||||
"label": "Tarkastele lokeja"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"start": {
|
||||
"title": "Bienvenue sur Homarr",
|
||||
"subtitle": "Commençons par configurer votre instance Homarr.",
|
||||
"description": "Pour commencer, veuillez selectionner comment vous souhaitez configurer votre instance Homarr.",
|
||||
"description": "Pour commencer, veuillez sélectionner comment vous souhaitez configurer votre instance Homarr.",
|
||||
"action": {
|
||||
"scratch": "Partir de zéro",
|
||||
"importOldmarr": "Importer depuis Homarr avant la 1.0",
|
||||
@@ -129,7 +129,7 @@
|
||||
"detected": "Docker a détecté {count} services",
|
||||
"detectedBreakdown": "Intégrations {integrations} · Applications {apps}",
|
||||
"summary": "Les applications {apps} ont été détectées par Homarr, dont {integrations} ont des intégrations intégrées",
|
||||
"appBadge": "App",
|
||||
"appBadge": "Application",
|
||||
"appSectionLabel": "Applications à ajouter au tableau de bord ({count} sélectionné)",
|
||||
"scanning": "Analyse de Docker...",
|
||||
"scanningDescription": "Recherche de conteneurs en cours d'exécution sur votre système"
|
||||
@@ -604,7 +604,7 @@
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"search": "Trouver une application",
|
||||
"search": "Trouver une app",
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "Applications",
|
||||
@@ -740,36 +740,36 @@
|
||||
"unit": "Unité",
|
||||
"mappings": "Correspondances",
|
||||
"tablePath": "Chemin du tableau JSON",
|
||||
"tablePathHint": "",
|
||||
"tablePathHint": "Chemin vers le tableau dans la réponse, par exemple $.data.items",
|
||||
"columns": "Colonnes",
|
||||
"header": "En-tête",
|
||||
"items": "Objets",
|
||||
"gridColumns": "Nombre de colonnes",
|
||||
"cardStyle": "Style des cartes",
|
||||
"bars": "Barres",
|
||||
"valuePath": "",
|
||||
"maxPath": "",
|
||||
"valuePath": "Chemin de la valeur JSON",
|
||||
"maxPath": "Chemin de la valeur max JSON",
|
||||
"showPercentage": "Afficher le pourcentage",
|
||||
"barSize": "Taille de la barre",
|
||||
"goodValues": "Bonnes valeurs (séparées par des virgules)",
|
||||
"dotSize": "Taille des points",
|
||||
"valueSize": "Taille",
|
||||
"labelPosition": "Position du libellé",
|
||||
"kvLayout": "",
|
||||
"striped": "",
|
||||
"kvLayout": "Mise en page",
|
||||
"striped": "Lignes en couleur alternées",
|
||||
"compact": "Mode Compact",
|
||||
"maxHeight": "",
|
||||
"maxHeight": "Hauteur maximale (px)",
|
||||
"color": "Couleur",
|
||||
"buttonLabel": "",
|
||||
"buttonColor": "",
|
||||
"buttonLabel": "Libellé du bouton",
|
||||
"buttonColor": "Couleur du bouton",
|
||||
"confirmText": "Texte de confirmation",
|
||||
"confirmTextHint": "",
|
||||
"confirmTextHint": "Si défini, une popup de confirmation apparaîtra avant d'exécuter la requête",
|
||||
"successMessage": "Message de réussite",
|
||||
"successMessageHint": "",
|
||||
"successMessageHint": "Message affiché après une exécution réussie",
|
||||
"enabled": "Activé",
|
||||
"template": {
|
||||
"label": "Template JSX",
|
||||
"description": ""
|
||||
"description": "Modèle JSX Mantine. Accédez aux champs de réponse de l'API via les liaisons de données (par ex. data.name). Utilisez uniquement les composants autorisés. Interdit : constructor, __proto__, eval, Function, window, document."
|
||||
}
|
||||
},
|
||||
"placeholder": {
|
||||
@@ -779,23 +779,23 @@
|
||||
"exampleUnit": "°C",
|
||||
"iconUrl": "https://...",
|
||||
"url": "https://api.example.com/api/v1/status",
|
||||
"headerName": "",
|
||||
"headerName": "X-API-Key",
|
||||
"jsonPath": "$.data.value",
|
||||
"tablePath": "",
|
||||
"columnJsonPath": "",
|
||||
"valuePath": "",
|
||||
"maxPath": "",
|
||||
"goodValues": "",
|
||||
"tablePath": "$.data.items",
|
||||
"columnJsonPath": "$.name",
|
||||
"valuePath": "$.data.used",
|
||||
"maxPath": "$.data.total",
|
||||
"goodValues": "en ligne, vrai,",
|
||||
"color": "bleu"
|
||||
},
|
||||
"cardStyleOption": {
|
||||
"filled": "",
|
||||
"outline": "",
|
||||
"subtle": ""
|
||||
"filled": "Rempli",
|
||||
"outline": "Contour",
|
||||
"subtle": "Discret"
|
||||
},
|
||||
"layoutOption": {
|
||||
"list": "",
|
||||
"grid": ""
|
||||
"list": "Liste",
|
||||
"grid": "Grille"
|
||||
},
|
||||
"sizeOption": {
|
||||
"sm": "Petit",
|
||||
@@ -815,7 +815,7 @@
|
||||
"displayTypeDescription": {
|
||||
"singleValue": "Afficher une valeur extraite avec une étiquette et une unité",
|
||||
"keyValue": "Afficher plusieurs valeurs étiquetées côte à côte",
|
||||
"table": "",
|
||||
"table": "Afficher les lignes de données dans un format de tableau",
|
||||
"statGrid": "",
|
||||
"progressBars": "",
|
||||
"statusIndicator": "Points d'état colorés montrant en ligne/hors ligne ou l'état de santé",
|
||||
@@ -860,14 +860,14 @@
|
||||
"title": "Aperçu",
|
||||
"test": "Tester",
|
||||
"hint": "Cliquez sur Test pour prévisualiser la réponse API avec votre configuration actuelle.",
|
||||
"rawResponse": ""
|
||||
"rawResponse": "Réponse de JSON brute"
|
||||
},
|
||||
"notification": {
|
||||
"created": "Widget \"{name}\" créé avec succès.",
|
||||
"updated": "Widget \"{name}\" mis à jour avec succès.",
|
||||
"deleted": "Widget \"{name}\" supprimé.",
|
||||
"imported": "Widget importé avec succès.",
|
||||
"aiPromptCopied": "",
|
||||
"aiPromptCopied": "Prompt IA a été copiée dans le presse-papiers",
|
||||
"aiPromptDescription": "",
|
||||
"aiPromptCopy": "Copier dans le presse-papiers",
|
||||
"aiPromptNoResponse": "",
|
||||
@@ -1168,15 +1168,15 @@
|
||||
},
|
||||
"invalidUrl": {
|
||||
"title": "URL invalide",
|
||||
"message": "L'URL est invalide."
|
||||
"message": "L'URL est invalide"
|
||||
},
|
||||
"secretNotDefined": {
|
||||
"title": "Identifiants manquants",
|
||||
"message": "Les informations d'identification n'ont pas toutes été fournies"
|
||||
"message": "Tous les identifiants n'ont pas été fournis"
|
||||
},
|
||||
"invalidCredentials": {
|
||||
"title": "Informations d'identification incorrectes.",
|
||||
"message": "Les informations d'identification ne sont pas valides"
|
||||
"title": "Les identifiants sont invalides",
|
||||
"message": "Les identifiants sont invalides"
|
||||
},
|
||||
"commonError": {
|
||||
"title": "Échec de la connexion",
|
||||
@@ -1290,12 +1290,12 @@
|
||||
"newLabel": "Nouvelle clé API (secrète)"
|
||||
},
|
||||
"patchmonApiKey": {
|
||||
"label": "",
|
||||
"label": "Clé API",
|
||||
"newLabel": "Nouvelle clé API"
|
||||
},
|
||||
"patchmonApiSecret": {
|
||||
"label": "Clé secrète d'API",
|
||||
"newLabel": ""
|
||||
"newLabel": "Nouveau secret API"
|
||||
},
|
||||
"githubAppId": {
|
||||
"label": "Identifiant de l'application",
|
||||
@@ -1321,12 +1321,12 @@
|
||||
"full": "Accès intégral à l'intégration"
|
||||
},
|
||||
"audiobookshelf": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "Étagère de livres audio",
|
||||
"description": "Serveur de livres audio et de podcasts auto-hébergés"
|
||||
},
|
||||
"navidrome": {
|
||||
"name": "Navidrome",
|
||||
"description": ""
|
||||
"description": "Serveur de streaming de musique auto-hébergé avec compatibilité avec l'API Subsonic"
|
||||
},
|
||||
"uptimeKuma": {
|
||||
"name": "Uptime Kuma",
|
||||
@@ -1349,15 +1349,15 @@
|
||||
"description": "Surveiller lequel de vos conteneurs Docker a des mises à jour d'images disponibles"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"name": "Paperless-ngx",
|
||||
"description": ""
|
||||
},
|
||||
"patchmon": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "PatchMon",
|
||||
"description": "Gestion et surveillance des correctifs pour les hôtes Linux"
|
||||
},
|
||||
"bazarr": {
|
||||
"name": "",
|
||||
"name": "Bazarr",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
@@ -1440,10 +1440,10 @@
|
||||
},
|
||||
"here": "ici",
|
||||
"iconPicker": {
|
||||
"label": "URL de l'icône :",
|
||||
"label": "URL de l'icône ",
|
||||
"description": "Recherchez dans la bibliothèque, collez une URL d'image, ou téléchargez un fichier.",
|
||||
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous.",
|
||||
"headerLoading": "Tapez pour rechercher les icônes…",
|
||||
"header": "Rechercher les images {countIcons} par nom",
|
||||
"headerLoading": "Rechercher des images par nom",
|
||||
"clear": "Effacer l'image",
|
||||
"uploadImage": "Charger une image",
|
||||
"results": "Résultats de la recherche d'images",
|
||||
@@ -1642,10 +1642,10 @@
|
||||
"actions": "Actions"
|
||||
},
|
||||
"status": {
|
||||
"success": "",
|
||||
"error": "",
|
||||
"loading": "",
|
||||
"idle": ""
|
||||
"success": "OK",
|
||||
"error": "Erreur",
|
||||
"loading": "Chargement",
|
||||
"idle": "Inactif"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -1799,7 +1799,7 @@
|
||||
}
|
||||
},
|
||||
"archiveTeamWarrior": {
|
||||
"name": "",
|
||||
"name": "ArchiveTeam Warrior",
|
||||
"description": "",
|
||||
"option": {
|
||||
"showBroadcastMessage": {
|
||||
@@ -1953,14 +1953,14 @@
|
||||
"description": "Utiliser ISO 8601 pour formater la date (cela remplacera les autres options)"
|
||||
},
|
||||
"showWeather": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher la météo",
|
||||
"description": "Afficher une petite icône météo animée et la température dans le coin de la carte"
|
||||
},
|
||||
"weatherLocation": {
|
||||
"label": ""
|
||||
"label": "Lieu de la météo"
|
||||
},
|
||||
"isWeatherFormatFahrenheit": {
|
||||
"label": ""
|
||||
"label": "Utiliser les degrés Fahrenheit"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1990,7 +1990,7 @@
|
||||
"noStation": "Aucune station sélectionnée",
|
||||
"option": {
|
||||
"baseUrl": {
|
||||
"label": ""
|
||||
"label": "URL Search.ch"
|
||||
},
|
||||
"station": {
|
||||
"label": "Station"
|
||||
@@ -2022,8 +2022,8 @@
|
||||
"clear": "Effacer la mise en forme",
|
||||
"heading": "Titre {level}",
|
||||
"align": "Aligner le texte : {position}",
|
||||
"directionLtr": "",
|
||||
"directionRtl": "",
|
||||
"directionLtr": "Définir la direction du texte de gauche à droite",
|
||||
"directionRtl": "Définir la direction du texte de droite à gauche",
|
||||
"blockquote": "Citation",
|
||||
"horizontalLine": "Ligne horizontale",
|
||||
"bulletList": "Liste à puces",
|
||||
@@ -2093,7 +2093,7 @@
|
||||
"edit": "Modifier",
|
||||
"save": "Sauvegarder",
|
||||
"cancel": "Annuler",
|
||||
"readOnlyViewer": "",
|
||||
"readOnlyViewer": "Vous avez un accès en lecture seule à cette note. La modification est désactivée.",
|
||||
"saveForbidden": "Vous n'êtes pas autorisé à modifier cette note.",
|
||||
"saveFailed": "Impossible d'enregistrer cette note. Veuillez réessayer."
|
||||
},
|
||||
@@ -2308,13 +2308,13 @@
|
||||
"description": "À quoi devrait ressembler la date"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "{currentWindSpeed} km/h",
|
||||
"currentWindSpeed": "{currentWindSpeed} {unit}",
|
||||
"dailyForecast": {
|
||||
"sunrise": "Lever du Soleil",
|
||||
"sunset": "Coucher du soleil",
|
||||
"maxWindSpeed": "Vitesse maximale du vent : {maxWindSpeed} km/h",
|
||||
"maxWindGusts": "Rafales de vent maximales : {maxWindGusts} km/h",
|
||||
"humidity": ""
|
||||
"maxWindSpeed": "Vitesse maximale du vent : {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Rafales de vent maximales : {maxWindGusts} {unit}",
|
||||
"humidity": "Humidité : {humidity}%"
|
||||
},
|
||||
"kind": {
|
||||
"clear": "Clair",
|
||||
@@ -2374,9 +2374,9 @@
|
||||
"label": "Afficher les infos sur le système de fichiers"
|
||||
},
|
||||
"visibleStorageVolumes": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": ""
|
||||
"label": "Volumes de stockage visibles",
|
||||
"description": "Uniquement disponible lorsque toutes les intégrations sélectionnées sont Synology. Laissez vide pour afficher tous les volumes de stockage détectés.",
|
||||
"placeholder": "Sélectionnez les volumes de stockage"
|
||||
},
|
||||
"defaultTab": {
|
||||
"label": "Onglet par défaut"
|
||||
@@ -2398,7 +2398,7 @@
|
||||
"loadAverage": "Charge moyenne :",
|
||||
"minute": "1 minute",
|
||||
"minutes": "{count} minutes",
|
||||
"used": "Utilisé ",
|
||||
"used": "Utilisé",
|
||||
"available": "Disponible",
|
||||
"lastSeen": "Dernière mise à jour de statut : {lastSeen}"
|
||||
},
|
||||
@@ -2435,7 +2435,7 @@
|
||||
"cpu": "Coeurs",
|
||||
"memory": "Mémoire",
|
||||
"storage": "Stockage",
|
||||
"uptime": "Disponibilité",
|
||||
"uptime": "Disponible",
|
||||
"haState": "État HA",
|
||||
"storageType": {
|
||||
"local": "Stockage local",
|
||||
@@ -2458,7 +2458,7 @@
|
||||
"description": "Statistiques de vos conteneurs (Ce widget ne peut être ajouté qu'avec les privilèges d'administrateur)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
"label": "Colonnes à afficher"
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": "Activer le tri des éléments"
|
||||
@@ -2476,19 +2476,19 @@
|
||||
"label": "Inverser le tri"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Ordre des colonnes"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Largeurs des colonnes"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Impossible de récupérer les statistiques des conteneurs",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveTitle": "Disposition du tableau non sauvegardée",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "Aucun conteneur trouvé"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2525,9 +2525,9 @@
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"name": "VPN",
|
||||
"description": "Aperçu de vos connexions VPN",
|
||||
"serviceUnavailable": "VPN indisponible",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
@@ -2784,31 +2784,31 @@
|
||||
"description": "Désactiver cette option ne fonctionnera pas pour Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"label": "Afficher le débit",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"label": "Afficher la localisation",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"totalBitrate": "Total : {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "En cours de lecture",
|
||||
"user": "Utilisateur",
|
||||
"name": "Nom",
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
"status": "Statut",
|
||||
"directPlay": "Lecture directe",
|
||||
"directStream": "Flux direct",
|
||||
"transcodeVideo": "Transcodage vidéo",
|
||||
"transcodeAudio": "Transcodage audio",
|
||||
"transcoding": "Transcodage",
|
||||
"paused": "En pause",
|
||||
"remaining": "{minutes} min restantes",
|
||||
"metadata": {
|
||||
"title": "Statistiques détaillées",
|
||||
"video": {
|
||||
@@ -2834,111 +2834,111 @@
|
||||
"description": "Aperçu de votre archive de documents Paperless-ngx",
|
||||
"documentsTotal": "Nombre total de documents",
|
||||
"documentsInbox": "Boîte de réception",
|
||||
"correspondents": "",
|
||||
"tags": "",
|
||||
"documentTypes": "",
|
||||
"inboxRatio": "",
|
||||
"inbox": "",
|
||||
"total": "",
|
||||
"correspondents": "Correspondants",
|
||||
"tags": "Tags",
|
||||
"documentTypes": "Types de documents",
|
||||
"inboxRatio": "Ratio de la boîte de réception",
|
||||
"inbox": "boîte de réception",
|
||||
"total": "total",
|
||||
"option": {
|
||||
"showInboxRatio": {
|
||||
"label": "Afficher le ratio de la boîte de réception",
|
||||
"description": ""
|
||||
"description": "Afficher le rapport inbox/total des documents dans la section Héros"
|
||||
},
|
||||
"showInboxRing": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher l'anneau de la boîte de réception",
|
||||
"description": "Afficher un anneau de progression à côté du ratio de la boîte de réception"
|
||||
},
|
||||
"showDocumentsTotal": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher le total des documents",
|
||||
"description": "Afficher le nombre total de documents dans votre archive"
|
||||
},
|
||||
"showDocumentsInbox": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher le compteur de la boîte de réception",
|
||||
"description": "Afficher le nombre de documents non traités dans la boîte de réception"
|
||||
},
|
||||
"showCorrespondents": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les correspondants",
|
||||
"description": "Afficher le nombre total de correspondants"
|
||||
},
|
||||
"showTags": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les tags",
|
||||
"description": "Afficher le nombre total de tags"
|
||||
},
|
||||
"showDocumentTypes": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les types de documents",
|
||||
"description": "Afficher le nombre total de types de documents"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "Échec de la récupération des données Paperless-ngx"
|
||||
}
|
||||
},
|
||||
"patchmon": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"totalHosts": "",
|
||||
"hostsNeedingUpdates": "",
|
||||
"securityUpdates": "",
|
||||
"upToDateHosts": "",
|
||||
"hostsWithSecurityUpdates": "",
|
||||
"recentUpdates24h": "",
|
||||
"totalOutdatedPackages": "",
|
||||
"totalRepos": "",
|
||||
"osDistribution": "",
|
||||
"compliance": "",
|
||||
"complianceRatio": "",
|
||||
"lastUpdated": "",
|
||||
"name": "PatchMon",
|
||||
"description": "Aperçu de l'état des correctifs des hôtes depuis PatchMon",
|
||||
"title": "Statistiques PatchMon",
|
||||
"totalHosts": "Total des hôtes",
|
||||
"hostsNeedingUpdates": "Mises à jour requises",
|
||||
"securityUpdates": "Mises à jour de sécurité",
|
||||
"upToDateHosts": "Hôtes à jour",
|
||||
"hostsWithSecurityUpdates": "Hôtes avec mises à jour de sécurité",
|
||||
"recentUpdates24h": "Mises à jour (24h)",
|
||||
"totalOutdatedPackages": "Paquets obsolètes",
|
||||
"totalRepos": "Dépôts",
|
||||
"osDistribution": "Répartition des OS",
|
||||
"compliance": "Conformité des correctifs",
|
||||
"complianceRatio": "{upToDate} / {total}",
|
||||
"lastUpdated": "Mis à jour {time}",
|
||||
"option": {
|
||||
"showTotalHosts": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher le nombre total d'hôtes",
|
||||
"description": "Afficher le nombre total d'hôtes surveillés"
|
||||
},
|
||||
"showHostsNeedingUpdates": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les hôtes ayant besoin de mises à jour",
|
||||
"description": "Afficher les hôtes avec au moins un paquet obsolète"
|
||||
},
|
||||
"showSecurityUpdates": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les mises à jour de sécurité",
|
||||
"description": "Afficher le nombre total de mises à jour de sécurité disponibles"
|
||||
},
|
||||
"showUpToDateHosts": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les hôtes à jour",
|
||||
"description": "Afficher les hôtes avec aucun paquet obsolète"
|
||||
},
|
||||
"showHostsWithSecurityUpdates": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les hôtes avec les mises à jour de sécurité",
|
||||
"description": "Afficher les hôtes nécessitant des correctifs de sécurité"
|
||||
},
|
||||
"showRecentUpdates24h": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les mises à jour récentes (24h)",
|
||||
"description": "Afficher le nombre de mises à jour appliquées au cours des dernières 24 heures"
|
||||
},
|
||||
"showTotalOutdatedPackages": {
|
||||
"label": "",
|
||||
"label": "Afficher les paquets obsolètes",
|
||||
"description": ""
|
||||
},
|
||||
"showTotalRepos": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les dépôts",
|
||||
"description": "Afficher le nombre de dépôts actifs en cours de surveillance"
|
||||
},
|
||||
"enableThresholdColors": {
|
||||
"label": "",
|
||||
"label": "Activer les couleurs de seuil",
|
||||
"description": ""
|
||||
},
|
||||
"useCustomThresholds": {
|
||||
"label": "",
|
||||
"label": "Seuils avancés",
|
||||
"description": ""
|
||||
},
|
||||
"thresholdMode": {
|
||||
"option": {
|
||||
"absolute": "",
|
||||
"percent": ""
|
||||
"percent": "Pourcentage du total des hôtes"
|
||||
}
|
||||
},
|
||||
"hostsNeedingUpdatesThresholdMode": {
|
||||
"label": "",
|
||||
"label": "Nécessite le mode de mise à jour de seuil",
|
||||
"description": ""
|
||||
},
|
||||
"hostsNeedingUpdatesWarningAt": {
|
||||
@@ -2958,7 +2958,7 @@
|
||||
"description": ""
|
||||
},
|
||||
"securityUpdatesCriticalAt": {
|
||||
"label": "",
|
||||
"label": "Seuil critique de mise à jour de sécurité",
|
||||
"description": ""
|
||||
},
|
||||
"hostsWithSecurityUpdatesThresholdMode": {
|
||||
@@ -2998,33 +2998,33 @@
|
||||
"description": ""
|
||||
},
|
||||
"showOsDistribution": {
|
||||
"label": "",
|
||||
"label": "Afficher la répartition des OS",
|
||||
"description": ""
|
||||
},
|
||||
"osDistributionLimit": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"3": "",
|
||||
"5": "",
|
||||
"10": "",
|
||||
"all": ""
|
||||
"3": "Top 3",
|
||||
"5": "Top 5",
|
||||
"10": "Top 10",
|
||||
"all": "Tout"
|
||||
}
|
||||
},
|
||||
"showOsVersion": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher la version de l'OS",
|
||||
"description": "Ajouter la version de l'OS à chaque étiquette de distribution lorsque disponible"
|
||||
},
|
||||
"showComplianceHero": {
|
||||
"label": "",
|
||||
"label": "Afficher la jauge de conformité",
|
||||
"description": ""
|
||||
},
|
||||
"osDisplayMode": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "Mode d'affichage de l'OS",
|
||||
"description": "Choisir comment afficher la répartition des systèmes d'exploitation",
|
||||
"option": {
|
||||
"bars": "",
|
||||
"donut": ""
|
||||
"bars": "Barres horizontales",
|
||||
"donut": "Graphique en anneau"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3033,19 +3033,19 @@
|
||||
}
|
||||
},
|
||||
"bazarr": {
|
||||
"name": "",
|
||||
"name": "Bazarr",
|
||||
"description": "",
|
||||
"episodes": "",
|
||||
"movies": "",
|
||||
"providers": "",
|
||||
"episodes": "Épisodes manquants",
|
||||
"movies": "Films manquants",
|
||||
"providers": "Problèmes de fournisseurs",
|
||||
"status": "",
|
||||
"option": {
|
||||
"showMissingEpisodes": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les épisodes manquants",
|
||||
"description": "Afficher le nombre d'épisodes avec les sous-titres manquants"
|
||||
},
|
||||
"showMissingMovies": {
|
||||
"label": "",
|
||||
"label": "Afficher les films manquants",
|
||||
"description": ""
|
||||
},
|
||||
"showProviderIssues": {
|
||||
@@ -3093,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Identifiant de l'album",
|
||||
"description": "C'est l'identifiant unique qui correspond à l'album Immich. Vous pouvez le trouver en ouvrant un album et copiant l'identifiant unique depuis l'URL, par exemple 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "C'est l'identifiant unique qui correspond à l'album Immich. Vous pouvez le trouver en ouvrant un album et copiant l'identifiant unique depuis l'URL, par exemple 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Intervalle en secondes",
|
||||
@@ -3153,18 +3153,18 @@
|
||||
"description": "Cela limitera le nombre d'éléments affichés par intégration, pas globalement"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Ordre des colonnes"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Largeur des colonnes"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "Sélectionner les colonnes dans les éléments",
|
||||
"noCommunications": "Impossible de charger les données à partir de l'intégration",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "Aucun téléchargement trouvé",
|
||||
"actionFailed": "Action échouée",
|
||||
"actionFailedMessage": "Cette action n'a pas pu être complétée. Veuillez ressayer."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3358,7 +3358,7 @@
|
||||
"option": {},
|
||||
"titles": {
|
||||
"stats": {
|
||||
"main": "Statistiques des médias",
|
||||
"main": "Stats des médias",
|
||||
"approved": "Déjà approuvé",
|
||||
"pending": "En attente de validation",
|
||||
"processing": "En cours de traitement",
|
||||
@@ -3428,7 +3428,7 @@
|
||||
"file": "Fichier",
|
||||
"size": "Taille",
|
||||
"transcode": "Transcodeur",
|
||||
"healthCheck": "Bilan de santé"
|
||||
"healthCheck": "Contrôle de santé"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3441,26 +3441,26 @@
|
||||
"label": ""
|
||||
},
|
||||
"showQueued": {
|
||||
"label": ""
|
||||
"label": "Afficher les éléments en attente"
|
||||
},
|
||||
"pageSize": {
|
||||
"label": ""
|
||||
"label": "Éléments par page"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"missing": "",
|
||||
"queued": ""
|
||||
"queued": "En file d’attente"
|
||||
},
|
||||
"type": {
|
||||
"movie": "",
|
||||
"episode": ""
|
||||
"movie": "Film",
|
||||
"episode": "Épisode"
|
||||
},
|
||||
"empty": {
|
||||
"missing": "",
|
||||
"queued": "",
|
||||
"noTabsEnabled": ""
|
||||
"missing": "Aucun élément manquant",
|
||||
"queued": "Aucun élément en file d'attente",
|
||||
"noTabsEnabled": "Activer au moins un onglet dans les paramètres du widget"
|
||||
},
|
||||
"timeLeft": ""
|
||||
"timeLeft": "Temps restant"
|
||||
},
|
||||
"rssFeed": {
|
||||
"name": "Flux RSS",
|
||||
@@ -3594,9 +3594,9 @@
|
||||
"not-found": "Non trouvé",
|
||||
"pre-release": "Pré-publication",
|
||||
"archived": "Archivé",
|
||||
"forked": "",
|
||||
"forked": "Forké",
|
||||
"starsCount": "Étoiles",
|
||||
"forksCount": "",
|
||||
"forksCount": "Forks",
|
||||
"issuesCount": "Problèmes ouverts",
|
||||
"markViewed": "Marquer comme vu(s)",
|
||||
"openProjectPage": "Ouvrir la page du projet",
|
||||
@@ -3707,7 +3707,7 @@
|
||||
"noItems": "Aucune notification à afficher.",
|
||||
"option": {
|
||||
"hideLogos": {
|
||||
"label": ""
|
||||
"label": "Masquer les logos"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3751,7 +3751,7 @@
|
||||
}
|
||||
},
|
||||
"uptimeKuma": {
|
||||
"name": "",
|
||||
"name": "Uptime Kuma",
|
||||
"description": "",
|
||||
"option": {
|
||||
"showAverageUptime": {
|
||||
@@ -3812,7 +3812,7 @@
|
||||
},
|
||||
"resource": {
|
||||
"routers": "",
|
||||
"services": "",
|
||||
"services": "Services",
|
||||
"middlewares": ""
|
||||
},
|
||||
"status": {
|
||||
@@ -4030,15 +4030,15 @@
|
||||
"system": "",
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"disk": "",
|
||||
"disk": "Disque",
|
||||
"gpu": "",
|
||||
"loadAvg": "",
|
||||
"net": "",
|
||||
"temp": "",
|
||||
"temp": "Temp",
|
||||
"battery": "",
|
||||
"services": "",
|
||||
"uptime": "",
|
||||
"agent": ""
|
||||
"agent": "Agent"
|
||||
}
|
||||
},
|
||||
"beszelSystemGrid": {
|
||||
@@ -4090,7 +4090,7 @@
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"memory": "Mémoire",
|
||||
"disk": "",
|
||||
"gpu": "",
|
||||
"loadAvg": "",
|
||||
@@ -4117,8 +4117,8 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"history": "",
|
||||
"resolved": "",
|
||||
"history": "Historique",
|
||||
"resolved": "Résolu",
|
||||
"status": {
|
||||
"triggered": "",
|
||||
"ok": ""
|
||||
@@ -4173,12 +4173,12 @@
|
||||
"chart": {
|
||||
"cpu": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"series": ""
|
||||
"subtitle": "Utilisation moyenne du CPU à l'échelle du système",
|
||||
"series": "CPU"
|
||||
},
|
||||
"memory": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"title": "Utilisation Mémoire",
|
||||
"subtitle": "Utilisation précise au moment enregistré",
|
||||
"series": "",
|
||||
"cache": ""
|
||||
},
|
||||
@@ -4189,7 +4189,7 @@
|
||||
},
|
||||
"diskIO": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"subtitle": "Débit du système de fichiers root",
|
||||
"read": "",
|
||||
"write": ""
|
||||
},
|
||||
@@ -5480,7 +5480,7 @@
|
||||
"title": "Capacité",
|
||||
"resource": {
|
||||
"reserved": "Réservé",
|
||||
"used": "Utilisé "
|
||||
"used": "Utilisé"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
@@ -5766,7 +5766,7 @@
|
||||
"label": "Médias"
|
||||
},
|
||||
"apps": {
|
||||
"label": "Applications",
|
||||
"label": "Apps",
|
||||
"new": {
|
||||
"label": "Nouveau"
|
||||
},
|
||||
@@ -5861,7 +5861,7 @@
|
||||
"placeholder": "Rechercher quelque chose",
|
||||
"nothingFound": "Aucun résultat",
|
||||
"error": {
|
||||
"fetch": "Une erreur s'est produite lors de la récupération des données."
|
||||
"fetch": "Une erreur s'est produite lors de la récupération des données"
|
||||
},
|
||||
"mode": {
|
||||
"appIntegrationBoard": {
|
||||
@@ -6544,7 +6544,7 @@
|
||||
},
|
||||
"editMode": {
|
||||
"title": "Mode édition",
|
||||
"description": "Cliquez ici pour entrer en mode édition. Vous pouvez ensuite ajouter des widgets, des applications et des sections — faites glisser pour repositionner, redimensionnez avec la poignée d'angle. Cliquez à nouveau pour enregistrer vos modifications."
|
||||
"description": "Cliquez ici pour entrer en mode édition. Vous pourrez ensuite ajouter des widgets, des applications et des sections — faites glisser pour repositionner, redimensionnez avec la poignée d'angle. Cliquez à nouveau pour enregistrer vos modifications."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres du tableau de bord",
|
||||
|
||||
@@ -1345,8 +1345,8 @@
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "וואטס אפ דוקר",
|
||||
"description": "עקוב אחר אילו מקונטיינרי הדוקר שלך כוללים עדכוני תמונה זמינים"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
@@ -1441,18 +1441,18 @@
|
||||
"here": "כאן",
|
||||
"iconPicker": {
|
||||
"label": "קישור של הסמל",
|
||||
"description": "",
|
||||
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} .",
|
||||
"headerLoading": "",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"description": "חפש בספרייה, הדבק כתובת URL של תמונה, או העלה קובץ.",
|
||||
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} ",
|
||||
"headerLoading": "חפש תמונות לפי שם",
|
||||
"clear": "נקה תמונה",
|
||||
"uploadImage": "העלה תמונה",
|
||||
"results": "תוצאות חיפוש תמונות",
|
||||
"localImages": "התמונות שלך",
|
||||
"svgIcons": "",
|
||||
"svgIcons": "סמלי SVG",
|
||||
"otherImages": "תמונות אחרות",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"urlLoading": "בודק תמונה…",
|
||||
"urlReady": "התמונה מוכנה לשימוש",
|
||||
"urlError": "לא ניתן היה לטעון את התמונה. בדוק את כתובת ה-URL ונסה שוב.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1808,19 +1808,19 @@
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"name": "וואטס אפ דוקר",
|
||||
"description": "הצג כמה מהמכולות הנצפות עם עדכונים זמינים.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "מנוטר",
|
||||
"updatesAvailable": "{count, plural, one {# עדכון זמין} two {# עדכונים זמינים} many {# עדכונים זמינים} other {# עדכונים זמינים}}",
|
||||
"updateAvailable": "עדכון זמין",
|
||||
"openReleaseNotes": "פתח הערות שחרור",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": ""
|
||||
"label": "הצג כותרת"
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
"label": "הצג טבעת עדכון"
|
||||
},
|
||||
"layout": {
|
||||
"label": "פריסה",
|
||||
@@ -1834,8 +1834,8 @@
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "הצג רשימת עדכוני מכולות",
|
||||
"description": "מפרט כל מכולה מנוטרת שיש עדכון תמונה זמין עבורה"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2641,7 +2641,7 @@
|
||||
"today": "היום",
|
||||
"24h": "24 השעות האחרונות",
|
||||
"7d": "7 הימים האחרונים",
|
||||
"30d": "",
|
||||
"30d": "30 הימים האחרונים",
|
||||
"month": "",
|
||||
"lastMonth": ""
|
||||
}
|
||||
|
||||
+1572
-1572
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1442,8 +1442,8 @@
|
||||
"iconPicker": {
|
||||
"label": "Icona URL",
|
||||
"description": "Cerca nella libreria, incolla un URL di immagine, o carica un file.",
|
||||
"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…",
|
||||
"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",
|
||||
"clear": "Cancella immagine",
|
||||
"uploadImage": "Carica immagine",
|
||||
"results": "Risultati della ricerca immagini",
|
||||
@@ -2070,7 +2070,7 @@
|
||||
}
|
||||
},
|
||||
"anchorNote": {
|
||||
"name": "Nota Anchor",
|
||||
"name": "Ancora Nota",
|
||||
"description": "Visualizza una nota selezionata da Ancoraggio",
|
||||
"option": {
|
||||
"noteId": {
|
||||
@@ -2308,12 +2308,12 @@
|
||||
"description": "Come dovrebbe apparire la data"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "{currentWindSpeed} km/h",
|
||||
"currentWindSpeed": "{currentWindSpeed} {unit}",
|
||||
"dailyForecast": {
|
||||
"sunrise": "Alba",
|
||||
"sunset": "Tramonto",
|
||||
"maxWindSpeed": "Max velocità vento: {maxWindSpeed} km/h",
|
||||
"maxWindGusts": "Max raffiche di vento: {maxWindGusts} km/h",
|
||||
"maxWindSpeed": "Velocità massima del vento: {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Raffiche di vento massime: {maxWindGusts} {unit}",
|
||||
"humidity": "Umidità: {humidity}%"
|
||||
},
|
||||
"kind": {
|
||||
@@ -3093,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "ID Album",
|
||||
"description": "Si tratta di un ID univoco che si riferisce al tuo album Immich. Puoi trovare questo id aprendo un album e copiando la stringa univoca dall'URL, ad esempio 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "Si tratta di un ID univoco che si riferisce al tuo album Immich. Puoi trovare questo id aprendo un album e copiando la stringa univoca dall'URL, ad esempio 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Intervallo in secondi",
|
||||
@@ -3254,7 +3254,7 @@
|
||||
},
|
||||
"client": {
|
||||
"pause": "Metti in pausa il client",
|
||||
"resume": "Ripristina il client"
|
||||
"resume": "Riprendi il client"
|
||||
},
|
||||
"item": {
|
||||
"pause": "Metti in pausa l'elemento",
|
||||
@@ -5398,7 +5398,7 @@
|
||||
"label": "Arresta",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containers arrestati",
|
||||
"title": "Containers fermati",
|
||||
"message": "I containers sono stati arrestati con successo"
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1442,7 +1442,7 @@
|
||||
"iconPicker": {
|
||||
"label": "アイコン URL",
|
||||
"description": "ライブラリを検索し、画像の URL を貼り付けるか、ファイルをアップロードします。",
|
||||
"header": "アイコンをフィルタするために、名前またはオブジェクトを入力してください... Homarrは {countIcons} アイコンを検索します。",
|
||||
"header": "{countIcons} 画像を名前で検索",
|
||||
"headerLoading": "名前で画像を検索",
|
||||
"clear": "画像をクリア",
|
||||
"uploadImage": "画像をアップロード",
|
||||
@@ -1450,7 +1450,7 @@
|
||||
"localImages": "あなたの画像",
|
||||
"svgIcons": "SVGアイコン",
|
||||
"otherImages": "その他の画像",
|
||||
"urlLoading": "",
|
||||
"urlLoading": "画像を確認中…",
|
||||
"urlReady": "画像を使用する準備ができました",
|
||||
"urlError": "この画像を読み込めませんでした。URLを確認して再度お試し下さい。",
|
||||
"noResults": "",
|
||||
@@ -2535,7 +2535,7 @@
|
||||
"description": "",
|
||||
"option": {
|
||||
"showBattery": {
|
||||
"label": ""
|
||||
"label": "バッテリーを表示"
|
||||
},
|
||||
"showLoad": {
|
||||
"label": ""
|
||||
@@ -2641,7 +2641,7 @@
|
||||
"today": "今日",
|
||||
"24h": "過去24時間",
|
||||
"7d": "過去7日間",
|
||||
"30d": "",
|
||||
"30d": "過去30日間",
|
||||
"month": "",
|
||||
"lastMonth": ""
|
||||
}
|
||||
@@ -6501,7 +6501,7 @@
|
||||
"skip": "",
|
||||
"manage": {
|
||||
"welcome": {
|
||||
"title": "",
|
||||
"title": "Homarrへようこそ!",
|
||||
"description": ""
|
||||
},
|
||||
"boardsList": {
|
||||
|
||||
@@ -1042,20 +1042,20 @@
|
||||
},
|
||||
"unknown": {
|
||||
"title": "알 수 없는 오류",
|
||||
"description": ""
|
||||
"description": "알 수 없는 오류가 발생했습니다. 자세한 내용을 보려면 아래 원인을 펼치세요"
|
||||
},
|
||||
"parse": {
|
||||
"title": "파싱 오류",
|
||||
"description": ""
|
||||
"description": "응답을 파싱할 수 없습니다. URL이 서비스의 기본 URL을 가리키는지 확인하세요."
|
||||
},
|
||||
"authorization": {
|
||||
"title": "인증 오류",
|
||||
"description": ""
|
||||
"description": "요청이 승인되지 않았습니다. 자격 증명이 올바르고 충분한 권한으로 구성되어 있는지 확인하세요."
|
||||
},
|
||||
"statusCode": {
|
||||
"title": "응답 오류",
|
||||
"description": "",
|
||||
"otherDescription": "",
|
||||
"description": "예상치 못한 {statusCode} ({reason}) 응답을 <url></url>에서 받았습니다. URL이 통합의 기본 URL을 가리키는지 확인하세요.",
|
||||
"otherDescription": "예상치 못한 {statusCode} 응답을 <url></url>에서 받았습니다. URL이 통합의 기본 URL을 가리키는지 확인하세요.",
|
||||
"reason": {
|
||||
"badRequest": "잘못된 요청",
|
||||
"notFound": "찾을 수 없음",
|
||||
@@ -1345,8 +1345,8 @@
|
||||
"description": "클라우드 네이티브 리버스 프록시 및 로드 밸런서"
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "왓츠업 도커",
|
||||
"description": "Docker 컨테이너 중 이미지 업데이트가 가능한 것을 모니터링합니다."
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
@@ -1441,18 +1441,18 @@
|
||||
"here": "여기",
|
||||
"iconPicker": {
|
||||
"label": "아이콘 URL",
|
||||
"description": "",
|
||||
"header": "이름 또는 주제를 입력해 아이콘을 필터링하세요... Homarr가 {countIcons}에서 아이콘을 검색합니다.",
|
||||
"headerLoading": "아이콘을 검색하려면 입력…",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"description": "라이브러리에서 검색하거나, 이미지 URL을 붙여넣거나, 파일을 업로드하세요.",
|
||||
"header": "이름 또는 주제를 입력해 아이콘을 필터링하세요... Homarr가 {countIcons}에서 아이콘을 검색합니다",
|
||||
"headerLoading": "아이콘을 검색하려면 입력",
|
||||
"clear": "이미지 지우기",
|
||||
"uploadImage": "이미지 업로드",
|
||||
"results": "이미지 검색 결과",
|
||||
"localImages": "내 이미지",
|
||||
"svgIcons": "",
|
||||
"svgIcons": "SVG 아이콘",
|
||||
"otherImages": "기타 이미지",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"urlLoading": "이미지 확인 중…",
|
||||
"urlReady": "이미지 사용 준비 완료",
|
||||
"urlError": "이 이미지는 불러올 수 없습니다. URL을 확인하고 다시 시도하십시오.",
|
||||
"noResults": "\"{search}\"에 일치하는 아이콘이 없습니다.",
|
||||
"error": "아이콘을 불러올 수 없습니다."
|
||||
},
|
||||
@@ -1808,19 +1808,19 @@
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"name": "왓츠업 도커",
|
||||
"description": "모니터링 중인 컨테이너에서 사용 가능한 업데이트 수를 표시합니다.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "모니터링 중",
|
||||
"updatesAvailable": "{count, plural, other {업데이트 #개 사용 가능}}",
|
||||
"updateAvailable": "업데이트 사용 가능",
|
||||
"openReleaseNotes": "릴리스 노트 열기",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": ""
|
||||
"label": "제목 표시"
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
"label": "업데이트 링 표시"
|
||||
},
|
||||
"layout": {
|
||||
"label": "레이아웃",
|
||||
@@ -1834,8 +1834,8 @@
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "컨테이너 업데이트 목록 표시",
|
||||
"description": "이미지 업데이트가 있는 각 모니터링 컨테이너를 나열합니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1984,16 +1984,16 @@
|
||||
}
|
||||
},
|
||||
"timetable": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"noStation": "",
|
||||
"name": "시간표",
|
||||
"description": "역의 출발 시간을 표시합니다",
|
||||
"title": "{station} 출발",
|
||||
"noStation": "선택한 역이 없습니다",
|
||||
"option": {
|
||||
"baseUrl": {
|
||||
"label": "Search.ch URL"
|
||||
},
|
||||
"station": {
|
||||
"label": ""
|
||||
"label": "역"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2061,7 +2061,7 @@
|
||||
},
|
||||
"placeholder": "노트 작성 시작",
|
||||
"dismiss": {
|
||||
"title": "변경사항 삭제",
|
||||
"title": "변경사항을 버리시겠습니까??",
|
||||
"message": "노트북에 저장되지 않은 변경 사항이 있습니다. 삭제하시겠습니까?",
|
||||
"action": {
|
||||
"discard": "저장하지 않음",
|
||||
@@ -2070,32 +2070,32 @@
|
||||
}
|
||||
},
|
||||
"anchorNote": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Anchor 노트",
|
||||
"description": "Anchor에서 선택한 노트를 표시합니다",
|
||||
"option": {
|
||||
"noteId": {
|
||||
"label": ""
|
||||
"label": "노트"
|
||||
},
|
||||
"showTitle": {
|
||||
"label": ""
|
||||
"label": "제목 표시"
|
||||
},
|
||||
"showUpdatedAt": {
|
||||
"label": ""
|
||||
"label": "마지막 업데이트 표시"
|
||||
}
|
||||
},
|
||||
"empty": "",
|
||||
"emptyContent": "",
|
||||
"notFound": "",
|
||||
"noNotes": "",
|
||||
"integrationRequired": "",
|
||||
"updatedAt": "",
|
||||
"untitled": "",
|
||||
"edit": "",
|
||||
"save": "",
|
||||
"cancel": "",
|
||||
"readOnlyViewer": "",
|
||||
"saveForbidden": "",
|
||||
"saveFailed": ""
|
||||
"empty": "표시할 노트를 선택하세요",
|
||||
"emptyContent": "이 노트에는 내용이 없습니다",
|
||||
"notFound": "노트를 찾을 수 없습니다",
|
||||
"noNotes": "노트를 찾을 수 없습니다",
|
||||
"integrationRequired": "먼저 Anchor 통합을 선택하세요",
|
||||
"updatedAt": "{date}에 업데이트됨",
|
||||
"untitled": "제목 없음",
|
||||
"edit": "편집",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"readOnlyViewer": "이 노트에 대한 뷰어 권한이 있습니다. 편집이 비활성화되었습니다.",
|
||||
"saveForbidden": "이 노트를 편집할 권한이 없습니다.",
|
||||
"saveFailed": "이 노트를 저장하지 못했습니다. 다시 시도하세요."
|
||||
},
|
||||
"iframe": {
|
||||
"name": "iFrame",
|
||||
@@ -2455,7 +2455,7 @@
|
||||
},
|
||||
"dockerContainers": {
|
||||
"name": "Docker 상태",
|
||||
"description": "",
|
||||
"description": "컨테이너 통계 (이 위젯은 관리자 권한으로만 추가할 수 있습니다)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": "표시할 열"
|
||||
@@ -2464,7 +2464,7 @@
|
||||
"label": "항목 정렬 활성화"
|
||||
},
|
||||
"defaultSort": {
|
||||
"label": "",
|
||||
"label": "기본 정렬에 사용되는 열",
|
||||
"option": {
|
||||
"name": "이름",
|
||||
"state": "상태",
|
||||
@@ -2476,52 +2476,52 @@
|
||||
"label": "역순 정렬"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "열 순서"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "열 너비"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "컨테이너 상태 불러오기 실패",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "테이블 레이아웃이 저장되지 않았습니다",
|
||||
"layoutSaveMessage": "Docker 테이블 레이아웃을 저장할 수 없습니다. 열을 다시 이동하거나 크기를 조정해 보세요."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "컨테이너 없음"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Coolify",
|
||||
"description": "서버와 애플리케이션을 포함한 Coolify 인스턴스 개요",
|
||||
"option": {
|
||||
"showServers": {
|
||||
"label": ""
|
||||
"label": "서버 표시"
|
||||
},
|
||||
"showApplications": {
|
||||
"label": ""
|
||||
"label": "애플리케이션 표시"
|
||||
},
|
||||
"showServices": {
|
||||
"label": ""
|
||||
"label": "서비스 표시"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"servers": "",
|
||||
"applications": "",
|
||||
"services": ""
|
||||
"servers": "서버를 찾을 수 없습니다",
|
||||
"applications": "애플리케이션을 찾을 수 없습니다",
|
||||
"services": "서비스를 찾을 수 없습니다"
|
||||
},
|
||||
"footer": {
|
||||
"updated": ""
|
||||
"updated": "{when}에 업데이트됨"
|
||||
},
|
||||
"error": {
|
||||
"noIntegration": "",
|
||||
"internalServerError": ""
|
||||
"noIntegration": "구성된 Coolify 통합이 없습니다",
|
||||
"internalServerError": "Coolify 데이터를 가져오지 못했습니다"
|
||||
},
|
||||
"server": {
|
||||
"buildServer": ""
|
||||
"buildServer": "빌드"
|
||||
},
|
||||
"instances": {
|
||||
"title": ""
|
||||
"title": "인스턴스"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
@@ -2562,85 +2562,85 @@
|
||||
}
|
||||
},
|
||||
"tracearr": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Tracearr",
|
||||
"description": "상세 스트림 및 트랜스코딩 데이터로 강화된 미디어 서버 모니터링",
|
||||
"option": {
|
||||
"showStreams": {
|
||||
"label": ""
|
||||
"label": "활성 스트림 표시"
|
||||
},
|
||||
"showStats": {
|
||||
"label": ""
|
||||
"label": "통계 요약 표시"
|
||||
},
|
||||
"showRecentActivity": {
|
||||
"label": ""
|
||||
"label": "최근 활동 표시"
|
||||
},
|
||||
"showViolations": {
|
||||
"label": ""
|
||||
"label": "위반 사항 표시"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"activeStreams": "",
|
||||
"totalUsers": "",
|
||||
"transcodes": "",
|
||||
"bandwidth": ""
|
||||
"activeStreams": "활성 스트림",
|
||||
"totalUsers": "총 사용자",
|
||||
"transcodes": "트랜스코드",
|
||||
"bandwidth": "대역폭"
|
||||
},
|
||||
"streams": {
|
||||
"empty": "",
|
||||
"user": "",
|
||||
"media": "",
|
||||
"state": "",
|
||||
"quality": "",
|
||||
"device": "",
|
||||
"progress": "",
|
||||
"empty": "활성 스트림이 없습니다",
|
||||
"user": "사용자",
|
||||
"media": "미디어",
|
||||
"state": "상태",
|
||||
"quality": "품질",
|
||||
"device": "장치",
|
||||
"progress": "진행률",
|
||||
"videoDecision": {
|
||||
"directPlay": "",
|
||||
"transcode": "",
|
||||
"directStream": ""
|
||||
"directPlay": "직접 재생",
|
||||
"transcode": "트랜스코딩",
|
||||
"directStream": "직접 스트림"
|
||||
}
|
||||
},
|
||||
"recentActivity": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"watched": "",
|
||||
"partial": ""
|
||||
"title": "최근 활동",
|
||||
"empty": "최근 활동이 없습니다",
|
||||
"watched": "시청함",
|
||||
"partial": "부분"
|
||||
},
|
||||
"violations": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"rule": "",
|
||||
"user": ""
|
||||
"title": "위반 사항",
|
||||
"empty": "최근 위반 사항이 없습니다",
|
||||
"rule": "규칙",
|
||||
"user": "사용자"
|
||||
},
|
||||
"noSectionsEnabled": "",
|
||||
"noSectionsEnabled": "활성화된 섹션이 없습니다",
|
||||
"error": {
|
||||
"noIntegration": "",
|
||||
"internalServerError": ""
|
||||
"noIntegration": "구성된 Tracearr 통합이 없습니다",
|
||||
"internalServerError": "Tracearr 데이터를 가져오지 못했습니다"
|
||||
}
|
||||
},
|
||||
"umami": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"visitors": "",
|
||||
"active": "",
|
||||
"pageviews": "",
|
||||
"visits": "",
|
||||
"bounceRate": "",
|
||||
"avgDuration": "",
|
||||
"name": "Umami Analytics",
|
||||
"description": "Umami 분석 인스턴스의 방문자 통계를 표시합니다",
|
||||
"visitors": "방문자",
|
||||
"active": "활성",
|
||||
"pageviews": "페이지뷰",
|
||||
"visits": "방문",
|
||||
"bounceRate": "이탈률",
|
||||
"avgDuration": "평균",
|
||||
"option": {
|
||||
"websiteId": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": "",
|
||||
"loading": "",
|
||||
"noWebsites": "",
|
||||
"configureIntegrationFirst": ""
|
||||
"label": "웹사이트",
|
||||
"description": "Umami 인스턴스에서 추적하는 웹사이트 선택",
|
||||
"placeholder": "웹사이트 선택",
|
||||
"loading": "웹사이트 불러오는 중...",
|
||||
"noWebsites": "웹사이트를 찾을 수 없습니다",
|
||||
"configureIntegrationFirst": "먼저 Umami 통합을 연결하세요"
|
||||
},
|
||||
"timeFrame": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "시간 범위",
|
||||
"description": "방문자 데이터를 표시할 기간",
|
||||
"option": {
|
||||
"today": "",
|
||||
"24h": "",
|
||||
"7d": "",
|
||||
"today": "오늘",
|
||||
"24h": "지난 24시간",
|
||||
"7d": "지난 7일",
|
||||
"30d": "지난 30일",
|
||||
"month": "이번 달",
|
||||
"lastMonth": "지난 달"
|
||||
@@ -2784,17 +2784,17 @@
|
||||
"description": "비활성화 옵션은 plex에서는 지원되지 않음"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "비트레이트 표시",
|
||||
"description": "위치 옆에 현재 재생 비트레이트를 표시합니다"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "위치 표시",
|
||||
"description": "스트림이 LAN 또는 WAN에 있는지 표시합니다"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"streams": "{count, plural, other {# 활성 스트림}}",
|
||||
"totalBitrate": "총: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "현재 재생 중",
|
||||
@@ -2803,9 +2803,9 @@
|
||||
"id": "아이디",
|
||||
"status": "상태",
|
||||
"directPlay": "직접 재생",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"directStream": "직접 스트림",
|
||||
"transcodeVideo": "비디오 트랜스코딩",
|
||||
"transcodeAudio": "오디오 트랜스코딩",
|
||||
"transcoding": "트랜스코딩",
|
||||
"paused": "일시 중지됨",
|
||||
"remaining": "남은 시간 {minutes}분",
|
||||
@@ -3153,18 +3153,18 @@
|
||||
"description": "통합당 표시되는 항목 수를 제한하여 전체 항목이 표시되지 않습니다"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "열 순서"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "열 너비"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "항목에서 열 선택",
|
||||
"noCommunications": "통합에서 데이터를 불러올 수 없습니다",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "다운로드 없음",
|
||||
"actionFailed": "작업 실패",
|
||||
"actionFailedMessage": "작업을 완료할 수 없습니다. 다시 시도해 주세요."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3188,7 +3188,7 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "아이디",
|
||||
"detailsTitle": ""
|
||||
"detailsTitle": "항목 ID"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "통합"
|
||||
@@ -3223,7 +3223,7 @@
|
||||
"time": {
|
||||
"columnTitle": "완료 시간",
|
||||
"detailsTitle": "완료/완료 후 경과 시간",
|
||||
"eta": ""
|
||||
"eta": "남은 시간"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "유형",
|
||||
@@ -3269,49 +3269,49 @@
|
||||
},
|
||||
"globalRatio": "전체 비율",
|
||||
"contextMenu": {
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
"copyName": "이름 복사",
|
||||
"copyId": "ID 복사",
|
||||
"clearFilters": "필터 지우기"
|
||||
},
|
||||
"footer": {
|
||||
"filter": ""
|
||||
"filter": "다운로드 필터"
|
||||
},
|
||||
"stats": {
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
"show": "전체 통계 표시",
|
||||
"hide": "전체 통계 숨기기",
|
||||
"overallProgress": "전체 대기열 진행률",
|
||||
"totalSize": "전체 대기열 크기",
|
||||
"clients": "{count, plural, other {# 클라이언트}}"
|
||||
},
|
||||
"disconnected": ""
|
||||
"disconnected": "(연결 끊김)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "미디어 릴리스",
|
||||
"description": "다른 통합에서 새로 추가된 미디어나 예정된 릴리스를 표시합니다",
|
||||
"option": {
|
||||
"layout": {
|
||||
"label": "",
|
||||
"label": "레이아웃",
|
||||
"option": {
|
||||
"backdrop": {
|
||||
"label": ""
|
||||
"label": "배경"
|
||||
},
|
||||
"poster": {
|
||||
"label": ""
|
||||
"label": "포스터"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showDescriptionTooltip": {
|
||||
"label": ""
|
||||
"label": "설명 도구 설명 표시"
|
||||
},
|
||||
"showType": {
|
||||
"label": ""
|
||||
"label": "미디어 유형 배지 표시"
|
||||
},
|
||||
"showSource": {
|
||||
"label": ""
|
||||
"label": "소스 통합 표시"
|
||||
}
|
||||
},
|
||||
"length": {
|
||||
"duration": ""
|
||||
"duration": "{length}분"
|
||||
}
|
||||
},
|
||||
"mediaRequests-requestList": {
|
||||
@@ -3337,11 +3337,11 @@
|
||||
"unknown": "알 수 없음",
|
||||
"pending": "대기 중",
|
||||
"processing": "처리 중",
|
||||
"requested": "",
|
||||
"requested": "요청됨",
|
||||
"partiallyAvailable": "부분",
|
||||
"available": "사용 가능",
|
||||
"blacklisted": "",
|
||||
"deleted": ""
|
||||
"blacklisted": "블랙리스트",
|
||||
"deleted": "삭제됨"
|
||||
},
|
||||
"status": {
|
||||
"pending": "대기 중",
|
||||
@@ -3479,10 +3479,10 @@
|
||||
"label": "게시물 수 제한"
|
||||
},
|
||||
"hideDescription": {
|
||||
"label": ""
|
||||
"label": "설명 숨기기"
|
||||
},
|
||||
"showPosterImage": {
|
||||
"label": ""
|
||||
"label": "포스터 이미지 표시"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3506,7 +3506,7 @@
|
||||
"label": "세부 정보 표시"
|
||||
},
|
||||
"showOnlyIcon": {
|
||||
"label": ""
|
||||
"label": "아이콘만 표시"
|
||||
},
|
||||
"topReleases": {
|
||||
"label": "최상위 릴리스",
|
||||
@@ -3515,18 +3515,18 @@
|
||||
"repositories": {
|
||||
"label": "저장소",
|
||||
"addRepository": {
|
||||
"label": ""
|
||||
"label": "저장소 추가"
|
||||
},
|
||||
"importRepositories": {
|
||||
"label": "",
|
||||
"loading": "",
|
||||
"noImagesFound": "",
|
||||
"listFoundImages": "",
|
||||
"listAlreadyImportedImages": "",
|
||||
"allImagesAlreadyImported": "",
|
||||
"onlyAdminCanImport": "",
|
||||
"selectAll": "",
|
||||
"deselectAll": ""
|
||||
"label": "Docker에서 가져오기",
|
||||
"loading": "Docker 이미지 불러오는 중",
|
||||
"noImagesFound": "Docker 이미지를 찾을 수 없습니다",
|
||||
"listFoundImages": "발견된 이미지 목록",
|
||||
"listAlreadyImportedImages": "이미 가져온 이미지 목록",
|
||||
"allImagesAlreadyImported": "모든 이미지를 이미 가져왔습니다",
|
||||
"onlyAdminCanImport": "관리자만 Docker에서 가져올 수 있습니다",
|
||||
"selectAll": "모두 선택",
|
||||
"deselectAll": "모두 선택 해제"
|
||||
},
|
||||
"provider": {
|
||||
"label": "제공자"
|
||||
@@ -3572,15 +3572,15 @@
|
||||
}
|
||||
},
|
||||
"importForm": {
|
||||
"title": ""
|
||||
"title": "Docker에서 가져오기"
|
||||
},
|
||||
"example": {
|
||||
"label": "예시"
|
||||
},
|
||||
"invalid": "잘못된 저장소 정의, 값을 확인하십시오",
|
||||
"noProvider": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
"label": "제공자 없음",
|
||||
"tooltip": "제공자를 파싱할 수 없습니다. 이미지를 가져온 후 수동으로 설정하세요"
|
||||
},
|
||||
"tokens": {
|
||||
"label": "공급자 토큰",
|
||||
@@ -3598,20 +3598,20 @@
|
||||
"starsCount": "별",
|
||||
"forksCount": "포크",
|
||||
"issuesCount": "열린 이슈",
|
||||
"markViewed": "",
|
||||
"markViewed": "본 것으로 표시",
|
||||
"openProjectPage": "프로젝트 페이지 열기",
|
||||
"openReleasePage": "릴리스 페이지 열기",
|
||||
"releaseDescription": "릴리스 설명",
|
||||
"projectDescription": "",
|
||||
"projectDescription": "프로젝트 설명",
|
||||
"created": "생성됨",
|
||||
"error": {
|
||||
"label": "오류",
|
||||
"messages": {
|
||||
"invalidIdentifier": "",
|
||||
"noMatchingVersion": "",
|
||||
"noReleasesFound": "",
|
||||
"invalidIdentifier": "잘못된 식별자",
|
||||
"noMatchingVersion": "일치하는 버전을 찾을 수 없습니다",
|
||||
"noReleasesFound": "릴리스를 찾을 수 없습니다",
|
||||
"noProviderSelected": "선택한 공급자가 없습니다",
|
||||
"noProviderResponse": "",
|
||||
"noProviderResponse": "제공자로부터 응답이 없습니다",
|
||||
"unexpected": "예기치 않은 공급자 오류"
|
||||
}
|
||||
}
|
||||
@@ -3673,31 +3673,31 @@
|
||||
}
|
||||
},
|
||||
"firewall": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "방화벽 모니터링",
|
||||
"description": "방화벽 요약을 표시합니다",
|
||||
"tab": {
|
||||
"system": "",
|
||||
"interfaces": ""
|
||||
"system": "시스템",
|
||||
"interfaces": "인터페이스"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "방화벽에서 데이터를 가져올 수 없습니다"
|
||||
},
|
||||
"option": {
|
||||
"interfaces": ""
|
||||
"interfaces": "표시할 네트워크 인터페이스"
|
||||
},
|
||||
"widget": {
|
||||
"fwname": "",
|
||||
"version": "",
|
||||
"versiontitle": "",
|
||||
"cputitle": "",
|
||||
"memorytitle": "",
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"fwname": "이름",
|
||||
"version": "버전",
|
||||
"versiontitle": "버전",
|
||||
"cputitle": "CPU 사용량",
|
||||
"memorytitle": "메모리 사용량",
|
||||
"cpu": "CPU",
|
||||
"memory": "메모리",
|
||||
"interfaces": {
|
||||
"name": "",
|
||||
"trans": "",
|
||||
"recv": "",
|
||||
"title": ""
|
||||
"name": "이름",
|
||||
"trans": "전송됨",
|
||||
"recv": "수신됨",
|
||||
"title": "네트워크 인터페이스"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3712,42 +3712,42 @@
|
||||
}
|
||||
},
|
||||
"speedtestTracker": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Speedtest Tracker",
|
||||
"description": "Speedtest Tracker 인스턴스의 속도 테스트 결과를 표시합니다.",
|
||||
"option": {
|
||||
"showLatestResult": {
|
||||
"label": ""
|
||||
"label": "최신 결과 표시"
|
||||
},
|
||||
"showStats": {
|
||||
"label": ""
|
||||
"label": "평균 표시"
|
||||
},
|
||||
"showRecentResults": {
|
||||
"label": ""
|
||||
"label": "최근 결과 표시"
|
||||
},
|
||||
"showPingGraph": {
|
||||
"label": ""
|
||||
"label": "핑 그래프 표시"
|
||||
}
|
||||
},
|
||||
"latestResult": "",
|
||||
"averages": "",
|
||||
"tests": "",
|
||||
"recentResults": "",
|
||||
"recentResultsSpeed": "",
|
||||
"recentResultsPing": "",
|
||||
"download": "",
|
||||
"upload": "",
|
||||
"ping": "",
|
||||
"status": "",
|
||||
"avgDownload": "",
|
||||
"avgUpload": "",
|
||||
"avgPing": "",
|
||||
"healthy": "",
|
||||
"unhealthy": "",
|
||||
"ok": "",
|
||||
"fail": "",
|
||||
"noSectionsEnabled": "",
|
||||
"latestResult": "최신 결과",
|
||||
"averages": "평균",
|
||||
"tests": "테스트 {count}개",
|
||||
"recentResults": "최근 결과",
|
||||
"recentResultsSpeed": "속도",
|
||||
"recentResultsPing": "핑",
|
||||
"download": "다운로드",
|
||||
"upload": "업로드",
|
||||
"ping": "핑",
|
||||
"status": "상태",
|
||||
"avgDownload": "평균 다운로드",
|
||||
"avgUpload": "평균 업로드",
|
||||
"avgPing": "평균 핑",
|
||||
"healthy": "정상",
|
||||
"unhealthy": "비정상",
|
||||
"ok": "정상",
|
||||
"fail": "실패",
|
||||
"noSectionsEnabled": "위젯 옵션에서 하나 이상의 섹션을 활성화하세요.",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "속도 테스트 데이터를 가져오지 못했습니다"
|
||||
}
|
||||
},
|
||||
"uptimeKuma": {
|
||||
@@ -4014,17 +4014,17 @@
|
||||
"label": "에이전트 버전 표시"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "열 순서"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "열 너비"
|
||||
}
|
||||
},
|
||||
"noRecords": "시스템을 찾을 수 없음",
|
||||
"error": {
|
||||
"internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "테이블 레이아웃을 저장할 수 없습니다",
|
||||
"layoutSaveMessage": "열 순서나 너비를 저장할 수 없습니다. 다시 시도해 주세요."
|
||||
},
|
||||
"column": {
|
||||
"system": "시스템",
|
||||
@@ -4656,7 +4656,7 @@
|
||||
"authorization": "권한"
|
||||
},
|
||||
"heroBanner": {
|
||||
"title": "",
|
||||
"title": "다시 오신 것을 환영합니다",
|
||||
"subtitle": "{app} 보드"
|
||||
}
|
||||
},
|
||||
@@ -4922,7 +4922,7 @@
|
||||
"title": "사용자",
|
||||
"enableGravatar": {
|
||||
"label": "Gravatar 활성화",
|
||||
"description": ""
|
||||
"description": "사용자 지정 아바타가 설정되지 않고 이메일이 구성된 경우 Libravatar/Gravatar의 사용자 아바타로 대체됩니다"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
@@ -4958,7 +4958,7 @@
|
||||
"idle": "대기",
|
||||
"running": "실행 중",
|
||||
"error": "오류",
|
||||
"disabled": ""
|
||||
"disabled": "비활성화됨"
|
||||
},
|
||||
"job": {
|
||||
"iconsUpdater": {
|
||||
@@ -5300,8 +5300,8 @@
|
||||
"subtitle": "{count}명이 여러 언어로 번역 기여"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "스폰서",
|
||||
"subtitle": "재정적으로 지원 중인 {count}명"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "라이브러리",
|
||||
@@ -5315,12 +5315,12 @@
|
||||
"action": "행동"
|
||||
},
|
||||
"action": {
|
||||
"toggleBoardEdit": "",
|
||||
"toggleColorScheme": "",
|
||||
"saveNotebook": "",
|
||||
"openSpotlight": ""
|
||||
"toggleBoardEdit": "보드 편집 모드 전환",
|
||||
"toggleColorScheme": "라이트/다크 모드 전환",
|
||||
"saveNotebook": "노트북 저장 (노트북 위젯 내에서만)",
|
||||
"openSpotlight": "검색 열기"
|
||||
},
|
||||
"note": ""
|
||||
"note": "팁: Mod는 Ctrl 키와 macOS의 ⌘ 키를 모두 의미합니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5422,7 +5422,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "제거",
|
||||
"confirm": "",
|
||||
"confirm": "영구적으로 제거하려면 다시 클릭하세요",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "컨테이너 제거됨",
|
||||
|
||||
+1578
-1578
File diff suppressed because it is too large.
Load diff
+1706
-1706
File diff suppressed because it is too large.
Load diff
@@ -1442,7 +1442,7 @@
|
||||
"iconPicker": {
|
||||
"label": "Icoon-URL",
|
||||
"description": "Zoek in de bibliotheek, plak een afbeelding-URL of upload een bestand.",
|
||||
"header": "Typ naam of objecten om te filteren op iconen… Homarr doorzoekt {countIcons} iconen voor je.",
|
||||
"header": "Zoek {countIcons} afbeeldingen op naam",
|
||||
"headerLoading": "Afbeeldingen zoeken op naam",
|
||||
"clear": "Verwijder afbeelding",
|
||||
"uploadImage": "Afbeelding uploaden",
|
||||
@@ -3452,7 +3452,7 @@
|
||||
"queued": ""
|
||||
},
|
||||
"type": {
|
||||
"movie": "",
|
||||
"movie": "Film",
|
||||
"episode": ""
|
||||
},
|
||||
"empty": {
|
||||
|
||||
@@ -1809,7 +1809,7 @@
|
||||
},
|
||||
"wud": {
|
||||
"name": "Hva passer opp",
|
||||
"description": "Vis hvor mange sette containere som er tilgjengelige",
|
||||
"description": "Vis hvor mange sette containere som er tilgjengelige.",
|
||||
"title": "Hva passer opp",
|
||||
"monitored": "Overvåket",
|
||||
"updatesAvailable": "{count, plural, one {# oppdatering tilgjengelig} other {# oppdateringer tilgjengelig}}",
|
||||
@@ -2801,7 +2801,7 @@
|
||||
"user": "Bruker",
|
||||
"name": "Navn",
|
||||
"id": "Id",
|
||||
"status": "Status:",
|
||||
"status": "Status",
|
||||
"directPlay": "Direkte avspilling",
|
||||
"directStream": "Direkte strøm",
|
||||
"transcodeVideo": "Konverterer video",
|
||||
@@ -2883,7 +2883,7 @@
|
||||
"securityUpdates": "Sikkerhetsoppdateringer",
|
||||
"upToDateHosts": "Oppdaterte verter",
|
||||
"hostsWithSecurityUpdates": "Verter med sikkerhetsoppdateringer",
|
||||
"recentUpdates24h": "Oppdateringer (2,4 timer)",
|
||||
"recentUpdates24h": "Oppdateringer (24 timer)",
|
||||
"totalOutdatedPackages": "Utdaterte pakker",
|
||||
"totalRepos": "Depoter",
|
||||
"osDistribution": "OS distribusjon",
|
||||
@@ -3093,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Album ID",
|
||||
"description": "Dette er en unik ID som refererer til ditt Immich-album. Du kan finne denne ID-en ved å åpne et album og kopiere den unike strengen fra URL-en, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "Dette er en unik ID som refererer til ditt Immich-album. Du kan finne denne ID-en ved å åpne et album og kopiere den unike strengen fra URL-en, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Intervall i sekunder",
|
||||
@@ -4034,7 +4034,7 @@
|
||||
"gpu": "GPU",
|
||||
"loadAvg": "Last snitt",
|
||||
"net": "Netto",
|
||||
"temp": "Temp.",
|
||||
"temp": "Temp",
|
||||
"battery": "Flaggermus",
|
||||
"services": "Tjenester",
|
||||
"uptime": "Oppetid",
|
||||
@@ -4095,7 +4095,7 @@
|
||||
"gpu": "GPU",
|
||||
"loadAvg": "Last snitt",
|
||||
"net": "Netto",
|
||||
"temp": "Temp.",
|
||||
"temp": "Temp",
|
||||
"battery": "Flaggermus",
|
||||
"services": "Tjenester",
|
||||
"uptime": "Oppetid",
|
||||
@@ -5126,7 +5126,7 @@
|
||||
"availableTools": {
|
||||
"title": "Tilgjengelige verktøy",
|
||||
"description": "{count} verktøy tilgjengelig. Utvid en kategori for å se enkelte verktøy og tilhørende beskrivelser.",
|
||||
"columnType": "Type:",
|
||||
"columnType": "Type",
|
||||
"columnName": "Verktøy",
|
||||
"columnDescription": "Beskrivelse"
|
||||
},
|
||||
@@ -5134,7 +5134,7 @@
|
||||
"title": "Feilsøking",
|
||||
"missingHeader": {
|
||||
"title": "\"Mangler ApiKey header\"",
|
||||
"description": "Klienten din sender ikke headeren {header} . Verifiser {section} -delen i MCP-konfigurasjonen din er riktig. Noen klienter krever omstart etter konfigurasjonsendringer."
|
||||
"description": "Klienten din sender ikke headeren {header}. Verifiser {section}-delen i MCP-konfigurasjonen din er riktig. Noen klienter krever omstart etter konfigurasjonsendringer."
|
||||
},
|
||||
"invalidFormat": {
|
||||
"title": "\"Ugyldig ApiKey format\"",
|
||||
@@ -6378,7 +6378,7 @@
|
||||
"header": {
|
||||
"season": "Sesong",
|
||||
"episodes": "Episoder",
|
||||
"status": "Status:"
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"requested": "Forespurt"
|
||||
|
||||
@@ -1442,8 +1442,8 @@
|
||||
"iconPicker": {
|
||||
"label": "adres URL ikony",
|
||||
"description": "Przeszukaj bibliotekę, wklej adres URL obrazu lub wgraj plik.",
|
||||
"header": "Wpisz nazwę lub obiekty do filtrowania ikon... Homarr przeszuka dla Ciebie ikony {countIcons}.",
|
||||
"headerLoading": "Wpisz, aby wyszukać ikony…",
|
||||
"header": "Szukaj zdjęć {countIcons} według nazwy",
|
||||
"headerLoading": "Szukaj obrazów według nazwy",
|
||||
"clear": "Wyczyść obraz",
|
||||
"uploadImage": "Prześlij obraz",
|
||||
"results": "Wyniki wyszukiwania obrazów",
|
||||
@@ -1892,7 +1892,7 @@
|
||||
}
|
||||
},
|
||||
"showToggleAllButtons": {
|
||||
"label": "Pokaż \"pokaż wszystkie przyciski\" przycisk"
|
||||
"label": "Pokaż przyciski „przełącz wszystkie\""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -2312,8 +2312,8 @@
|
||||
"dailyForecast": {
|
||||
"sunrise": "Wschód słońca",
|
||||
"sunset": "Zachód słońca",
|
||||
"maxWindSpeed": "Maksymalna prędkość wiatru: {maxWindSpeed} km/h",
|
||||
"maxWindGusts": "Maksymalne porywy wiatru: {maxWindGusts} km/h",
|
||||
"maxWindSpeed": "Maksymalna prędkość wiatru: {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Maksymalne porywy wiatru: {maxWindGusts} {unit}",
|
||||
"humidity": "Wilgotność: {humidity}%"
|
||||
},
|
||||
"kind": {
|
||||
@@ -2348,7 +2348,7 @@
|
||||
}
|
||||
},
|
||||
"healthMonitoring": {
|
||||
"name": "Monitorowanie zdrowia systemu",
|
||||
"name": "Monitorowanie zdrowia systemu",
|
||||
"description": "Wyświetla informacje o stanie Twojego systemu(ów).",
|
||||
"tab": {
|
||||
"system": "System",
|
||||
@@ -2395,7 +2395,7 @@
|
||||
"memoryAvailable": "Dostępna pamięć {memoryAvailable} GB ({percent}%)",
|
||||
"version": "Wersja: {version}",
|
||||
"uptime": "Czas pracy: {months} mies., {days} dni, {hours} godzin, {minutes} minut",
|
||||
"loadAverage": "Średnie obciążenie",
|
||||
"loadAverage": "Średnie obciążenie:",
|
||||
"minute": "1 minuta",
|
||||
"minutes": "{count} minut",
|
||||
"used": "Używane",
|
||||
@@ -2719,7 +2719,7 @@
|
||||
"query": "Miasto / kod pocztowy",
|
||||
"latitude": "Szerokość geograficzna (Latitude)",
|
||||
"longitude": "Długość geograficzna (Longitude)",
|
||||
"disabledTooltip": "Podaj miasto lub kod pocztowy.",
|
||||
"disabledTooltip": "Podaj miasto lub kod pocztowy",
|
||||
"unknownLocation": "Nieznana lokalizacja",
|
||||
"search": "Szukaj",
|
||||
"table": {
|
||||
@@ -3150,7 +3150,7 @@
|
||||
},
|
||||
"limitPerIntegration": {
|
||||
"label": "Limit elementów na integrację",
|
||||
"description": "To ograniczy liczbę wyświetlanych elementów dla każdej integracji, a nie globalnie."
|
||||
"description": "To ograniczy liczbę wyświetlanych elementów dla każdej integracji, a nie globalnie"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Kolejność kolumn"
|
||||
@@ -3496,11 +3496,11 @@
|
||||
},
|
||||
"staleReleaseWithin": {
|
||||
"label": "Nieaktualne wydanie w ciągu",
|
||||
"description": "Przykład użycia: 1w (1 tydzień), 10M (10 miesięcy). Obsługiwane jednostki: h (godziny), d (dni), w (tygodnie), M (miesiące), y (lata). Pozostaw puste, aby nie wyróżniać nieaktualnych wydań"
|
||||
"description": "Przykład użycia: 1w (1 tydzień), 10M (10 miesięcy). Obsługiwane jednostki: h (godziny), d (dni), w (tygodnie), M (miesiące), y (lata). Pozostaw puste, aby nie wyróżniać nieaktualnych wydań."
|
||||
},
|
||||
"showOnlyHighlighted": {
|
||||
"label": "Pokaż tylko wyróżnione",
|
||||
"description": "„Pokaż tylko nowe lub nieaktualne wydania. Zgodnie z powyższymi ustawieniami.”"
|
||||
"description": "„Pokaż tylko nowe lub nieaktualne wydania. Zgodnie z powyższymi ustawieniami.”."
|
||||
},
|
||||
"showDetails": {
|
||||
"label": "Pokaż szczegóły"
|
||||
@@ -3580,7 +3580,7 @@
|
||||
"invalid": "Nieprawidłowa definicja repozytorium, sprawdź wartości",
|
||||
"noProvider": {
|
||||
"label": "Brak dostawcy",
|
||||
"tooltip": "Nie udało się rozpoznać dostawcy. Ustaw go ręcznie po zaimportowaniu obrazów."
|
||||
"tooltip": "Nie udało się rozpoznać dostawcy. Ustaw go ręcznie po zaimportowaniu obrazów"
|
||||
},
|
||||
"tokens": {
|
||||
"label": "Tokeny dostawcy",
|
||||
@@ -3609,7 +3609,7 @@
|
||||
"messages": {
|
||||
"invalidIdentifier": "Nieprawidłowy identyfikator",
|
||||
"noMatchingVersion": "Nie znaleziono pasującej wersji",
|
||||
"noReleasesFound": "Nie znaleziono wydań!",
|
||||
"noReleasesFound": "Nie znaleziono wydań",
|
||||
"noProviderSelected": "Nie wybrano dostawcy",
|
||||
"noProviderResponse": "Brak odpowiedzi od dostawcy",
|
||||
"unexpected": "Nieoczekiwany błąd dostawcy"
|
||||
@@ -3625,10 +3625,10 @@
|
||||
},
|
||||
"error": {
|
||||
"integrationsDisconnected": "Brak dostępnych danych, wszystkie integracje rozłączone",
|
||||
"unknownContentOption": "Nieznana opcja zawartości dla widżetu podsumowania kontrolera sieci:"
|
||||
"unknownContentOption": "Nieznana opcja zawartości dla widżetu podsumowania kontrolera sieci: "
|
||||
},
|
||||
"name": "Podsumowanie kontrolera sieci",
|
||||
"description": "Wyświetla podsumowanie kontrolera sieci (np. UniFi Controller)."
|
||||
"description": "Wyświetla podsumowanie kontrolera sieci (np. UniFi Controller)"
|
||||
},
|
||||
"networkControllerStatus": {
|
||||
"card": {
|
||||
@@ -3662,7 +3662,7 @@
|
||||
},
|
||||
"error": {
|
||||
"integrationsDisconnected": "Brak dostępnych danych, wszystkie integracje są rozłączone",
|
||||
"unknownContentOption": "Nieznana opcja zawartości dla widżetu statusu sieci:"
|
||||
"unknownContentOption": "Nieznana opcja zawartości dla widżetu statusu sieci: "
|
||||
},
|
||||
"name": "Stan sieci",
|
||||
"description": "Wyświetl podłączone urządzenia w sieci"
|
||||
@@ -3713,7 +3713,7 @@
|
||||
},
|
||||
"speedtestTracker": {
|
||||
"name": "Speedtest Tracker",
|
||||
"description": "Wyświetla wyniki testów prędkości z Twojej instancji Speedtest Tracker",
|
||||
"description": "Wyświetla wyniki testów prędkości z Twojej instancji Speedtest Tracker.",
|
||||
"option": {
|
||||
"showLatestResult": {
|
||||
"label": "Pokaż ostatni wynik"
|
||||
@@ -3745,7 +3745,7 @@
|
||||
"unhealthy": "Zły",
|
||||
"ok": "Ok",
|
||||
"fail": "Błąd",
|
||||
"noSectionsEnabled": "Włącz co najmniej jedną sekcję w opcjach widgetu",
|
||||
"noSectionsEnabled": "Włącz co najmniej jedną sekcję w opcjach widgetu.",
|
||||
"error": {
|
||||
"internalServerError": "Nie udało się pobrać danych testu prędkości"
|
||||
}
|
||||
@@ -4232,7 +4232,7 @@
|
||||
"action": {
|
||||
"duplicate": {
|
||||
"title": "Duplikuj tablicę",
|
||||
"message": "Spowoduje to zduplikowanie tablicy {name} wraz ze wszystkimi jej treściami. Jeśli widżety odwołują się do integracji, których nie możesz używać, zostaną usunięte",
|
||||
"message": "Spowoduje to zduplikowanie tablicy {name} wraz ze wszystkimi jej treściami. Jeśli widżety odwołują się do integracji, których nie możesz używać, zostaną usunięte.",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Tablica została zduplikowana",
|
||||
@@ -4269,7 +4269,7 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "Import nie powiódł się",
|
||||
"message": "Nie udało się zaimportować tablicy. Sprawdź logi, aby uzyskać więcej informacji."
|
||||
"message": "Nie udało się zaimportować tablicy. Sprawdź logi, aby uzyskać więcej informacji"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -4281,11 +4281,11 @@
|
||||
"label": "Aplikacje",
|
||||
"avoidDuplicates": {
|
||||
"label": "Unikaj duplikatów",
|
||||
"description": "Ignoruje aplikacje, dla których już istnieje aplikacja z tym samym adresem href."
|
||||
"description": "Ignoruje aplikacje, dla których już istnieje aplikacja z tym samym adresem href"
|
||||
},
|
||||
"onlyImportApps": {
|
||||
"label": "Import tylko aplikacje",
|
||||
"description": "Dodaje tylko aplikacje, tablica musi zostać odtworzona ręcznie."
|
||||
"description": "Dodaje tylko aplikacje, tablica musi zostać odtworzona ręcznie"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
@@ -4416,14 +4416,14 @@
|
||||
},
|
||||
"disableStatus": {
|
||||
"label": "Wyłącz status aplikacji",
|
||||
"description": "Wyłącza sprawdzanie statusu wszystkich aplikacji na tej tablicy."
|
||||
"description": "Wyłącza sprawdzanie statusu wszystkich aplikacji na tej tablicy"
|
||||
},
|
||||
"columnCount": {
|
||||
"label": "Liczba kolumn"
|
||||
},
|
||||
"itemRadius": {
|
||||
"label": "Promień elementu",
|
||||
"description": "Zmienia zaokrąglenie kafelków na Twojej tablicy.",
|
||||
"description": "Zmienia zaokrąglenie kafelków na Twojej tablicy",
|
||||
"option": {
|
||||
"xs": "Bardzo mały",
|
||||
"sm": "Mały",
|
||||
@@ -4537,11 +4537,11 @@
|
||||
"title": "Witamy w Homarr",
|
||||
"description": "Elegancki, nowoczesny pulpit nawigacyjny, który daje Ci dostęp do wszystkich Twoich aplikacji i usług w zasięgu ręki.",
|
||||
"link": "Utwórz swoją pierwszą tablicę",
|
||||
"notice": "Aby ta strona zniknęła, utwórz tablicę i ustaw ją jako tablicę główną."
|
||||
"notice": "Aby ta strona zniknęła, utwórz tablicę i ustaw ją jako tablicę główną"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Tablica nie znaleziona",
|
||||
"description": "Wskazana tablica nie została znaleziona lub nie masz do niej dostępu.”",
|
||||
"description": "Wskazana tablica nie została znaleziona lub nie masz do niej dostępu.”.",
|
||||
"link": "Wyświetl wszystkie tablice",
|
||||
"notice": "Sprawdź link lub skontaktuj się z administratorem, jeśli uważasz, że powinien być dostępny"
|
||||
},
|
||||
@@ -4550,7 +4550,7 @@
|
||||
"admin": {
|
||||
"description": "Nie ustawiłeś jeszcze tablicy głównej dla serwera.",
|
||||
"link": "Skonfiguruj tablicę główną dla całego serwera",
|
||||
"notice": "Aby ta strona zniknęła dla wszystkich użytkowników, ustaw tablicę główną dla serwera\n\n"
|
||||
"notice": "Aby ta strona zniknęła dla wszystkich użytkowników, ustaw tablicę główną dla serwera"
|
||||
},
|
||||
"user": {
|
||||
"description": "Nie ustawiłeś jeszcze tablicy głównej.",
|
||||
@@ -4560,7 +4560,7 @@
|
||||
"anonymous": {
|
||||
"description": "Administrator serwera nie ustawił jeszcze tablicy głównej.",
|
||||
"link": "Pokaż tablice publiczne",
|
||||
"notice": "Aby ta strona zniknęła, poproś administratora serwera o ustawienie tablicy głównej dla serwera."
|
||||
"notice": "Aby ta strona zniknęła, poproś administratora serwera o ustawienie tablicy głównej dla serwera"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4588,7 +4588,7 @@
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Nie znaleziono",
|
||||
"text": "Nie znaleziono żądanego zasobu."
|
||||
"text": "Nie znaleziono żądanego zasobu"
|
||||
},
|
||||
"error": {
|
||||
"title": "Nie można załadować tej strony",
|
||||
@@ -4719,7 +4719,7 @@
|
||||
},
|
||||
"user": {
|
||||
"back": "Powrót do użytkowników",
|
||||
"fieldsDisabledExternalProvider": "Niektóre pola są zablokowane, ponieważ są zarządzane przez zewnętrznego dostawcę uwierzytelniania",
|
||||
"fieldsDisabledExternalProvider": "Niektóre pola są zablokowane, ponieważ są zarządzane przez zewnętrznego dostawcę uwierzytelniania.",
|
||||
"setting": {
|
||||
"general": {
|
||||
"title": "Ogólne",
|
||||
|
||||
@@ -682,7 +682,7 @@
|
||||
},
|
||||
"create": {
|
||||
"title": "Criar aplicativo",
|
||||
"description": "Criar um aplicativo",
|
||||
"description": "Criar um novo aplicativo ",
|
||||
"action": "Abrir criação de aplicativo"
|
||||
},
|
||||
"add": "Adicione um aplicativo"
|
||||
@@ -769,7 +769,7 @@
|
||||
"enabled": "Habilitado",
|
||||
"template": {
|
||||
"label": "Template JSX",
|
||||
"description": ""
|
||||
"description": "Modelo JSX do Mantine. Acesse os campos da resposta da API por meio de ligações de dados (por exemplo, data.name). Use apenas componentes da lista de permissões. Proibido: constructor, __proto__, eval, Function, window, document."
|
||||
}
|
||||
},
|
||||
"placeholder": {
|
||||
@@ -864,8 +864,8 @@
|
||||
},
|
||||
"notification": {
|
||||
"created": "Widget \"{name}\" criado com sucesso.",
|
||||
"updated": "Widget \"{name}atualizado com sucesso.",
|
||||
"deleted": "Widget \"{name}excluído.",
|
||||
"updated": "Widget \"{name}\" atualizado com sucesso.",
|
||||
"deleted": "Widget \"{name}\" excluído.",
|
||||
"imported": "Widget importado com sucesso.",
|
||||
"aiPromptCopied": "Prompt de IA copiado para a área de transferência",
|
||||
"aiPromptDescription": "Copie um prompt pré-construído para colar qualquer chatbot de AI (ChatGPT, Claude, Gemini...). Ele inclui o esquema completo de widget e a sua resposta de API para que a AI possa gerar uma configuração JSON pronta para importar para você.",
|
||||
@@ -876,7 +876,7 @@
|
||||
"updateError": "Falha ao atualizar o widget. Suas alterações não foram salvas.",
|
||||
"importError": "Falha ao importar o widget. Certifique-se de que o arquivo seja um widget personalizado válido exportado em JSON.",
|
||||
"exportError": "Falha ao exportar o widget.",
|
||||
"duplicated": "Widget \"{name}duplicado.",
|
||||
"duplicated": "Widget \"{name}\" duplicado.",
|
||||
"duplicateError": "Falha ao duplicar o widget.",
|
||||
"toggleError": "Falha ao alternar o status do widget.",
|
||||
"deleteError": "Falha ao excluir o widget.",
|
||||
@@ -1442,8 +1442,8 @@
|
||||
"iconPicker": {
|
||||
"label": "URL do ícone",
|
||||
"description": "Procure na biblioteca, cole uma URL da imagem ou envie um arquivo.",
|
||||
"header": "Digite o nome ou os objetos para filtrar os ícones... O Homarr pesquisará {countIcons} ícones para você.",
|
||||
"headerLoading": "Digite para pesquisar ícones…",
|
||||
"header": "Digite o nome ou os objetos para filtrar os ícones... O Homarr pesquisará {countIcons} ícones para você",
|
||||
"headerLoading": "Digite para pesquisar ícones",
|
||||
"clear": "Limpar imagem",
|
||||
"uploadImage": "Upload de imagem",
|
||||
"results": "Resultados da busca por imagem",
|
||||
@@ -2563,7 +2563,7 @@
|
||||
},
|
||||
"tracearr": {
|
||||
"name": "Tracearr",
|
||||
"description": "Monitoramento avançado de servidor de mídia com dados detalhados de transmissão e transcodificação\n",
|
||||
"description": "Monitoramento avançado de servidor de mídia com dados detalhados de transmissão e transcodificação",
|
||||
"option": {
|
||||
"showStreams": {
|
||||
"label": "Exibir transmissões ativas"
|
||||
@@ -2739,7 +2739,7 @@
|
||||
},
|
||||
"integration": {
|
||||
"noData": "Nenhuma integração encontrada",
|
||||
"description": "Clique <here></here< para criar uma nova integração"
|
||||
"description": "Clique <here></here> para criar uma nova integração"
|
||||
},
|
||||
"app": {
|
||||
"noData": "Nenhum aplicativo encontrado",
|
||||
@@ -2772,7 +2772,7 @@
|
||||
},
|
||||
"error": {
|
||||
"noUrl": "Nenhum URL de vídeo fornecido",
|
||||
"forYoutubeUseIframe": "Para vídeos do Youtube, use a opção de iFrame"
|
||||
"forYoutubeUseIframe": "Para vídeos do Youtube, use a opção de iframe"
|
||||
}
|
||||
},
|
||||
"mediaServer": {
|
||||
@@ -3093,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "ID do Álbum",
|
||||
"description": "Esta é uma identificação única que se refere ao seu álbum Immich. Você pode encontrar essa identificação abrindo um álbum e copiando a string única da URL, por exemplo 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "Esta é uma identificação única que se refere ao seu álbum Immich. Você pode encontrar essa identificação abrindo um álbum e copiando a string única da URL, por exemplo 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Intervalo em segundos",
|
||||
|
||||
+1549
-1549
File diff suppressed because it is too large.
Load diff
@@ -51,7 +51,7 @@
|
||||
}
|
||||
},
|
||||
"tokenModal": {
|
||||
"title": "Introdu token-ul de import",
|
||||
"title": "Introdu codul de import",
|
||||
"field": {
|
||||
"token": {
|
||||
"label": "Token-ul",
|
||||
@@ -540,7 +540,7 @@
|
||||
"confirm": "Sunteţi sigur că doriţi să ştergeţi grupul {name}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "Grupul {name} a fost șters cu succes"
|
||||
},
|
||||
"error": {
|
||||
"message": "Imposibil de șters grupul {name}"
|
||||
@@ -769,7 +769,7 @@
|
||||
"enabled": "Activat",
|
||||
"template": {
|
||||
"label": "Şablon JSX",
|
||||
"description": ""
|
||||
"description": "Șablon Mantine JSX. Accesați câmpurile răspunsului API prin legături de date (de ex. data.name). Utilizați numai componente permise. Interzis: constructor, __proto__, eval, Function, window, document."
|
||||
}
|
||||
},
|
||||
"placeholder": {
|
||||
@@ -1507,7 +1507,7 @@
|
||||
"switchToLightMode": "Comutați la modul lumină",
|
||||
"management": "Gestionare",
|
||||
"docker": "Doctor",
|
||||
"preferences": "Preferințele dvs.",
|
||||
"preferences": "Preferințele dvs",
|
||||
"logout": "Deconectare",
|
||||
"login": "Utilizator",
|
||||
"homeBoard": "Tabloul de bord",
|
||||
@@ -1875,7 +1875,7 @@
|
||||
},
|
||||
"dnsHoleControls": {
|
||||
"name": "Controale DNS",
|
||||
"description": "Controlați PiHole sau AdGuard din planșa dvs.",
|
||||
"description": "Controlați PiHole sau AdGuard din planșa dvs",
|
||||
"option": {
|
||||
"layout": {
|
||||
"label": "Aspect",
|
||||
@@ -4035,7 +4035,7 @@
|
||||
"loadAvg": "Încărcare medie",
|
||||
"net": "Rețea",
|
||||
"temp": "Temp",
|
||||
"battery": "Bat.",
|
||||
"battery": "Bat",
|
||||
"services": "Servicii",
|
||||
"uptime": "Uptime",
|
||||
"agent": "Agent"
|
||||
@@ -4096,7 +4096,7 @@
|
||||
"loadAvg": "Încărcare medie",
|
||||
"net": "Rețea",
|
||||
"temp": "Temp",
|
||||
"battery": "Bat.",
|
||||
"battery": "Bat",
|
||||
"services": "Servicii",
|
||||
"uptime": "Uptime",
|
||||
"agent": "Agent"
|
||||
@@ -4334,7 +4334,7 @@
|
||||
"label": "URL imagine logo"
|
||||
},
|
||||
"faviconImageUrl": {
|
||||
"label": "URL imagine fichar@@0icon"
|
||||
"label": "URL imagine favicon"
|
||||
},
|
||||
"backgroundImageUrl": {
|
||||
"label": "URL-ul imaginii de fundal",
|
||||
@@ -5134,7 +5134,7 @@
|
||||
"title": "Depanare",
|
||||
"missingHeader": {
|
||||
"title": "\"Lipsă header ApiKey",
|
||||
"description": "Clientul dvs. nu trimite headerul {header} . Verificați dacă secțiunea {section} din configurația MCP este corectă. Unii clienți necesită repornirea după modificarea configurării."
|
||||
"description": "Clientul dvs. nu trimite headerul {header}. Verificați dacă secțiunea {section} din configurația MCP este corectă. Unii clienți necesită repornirea după modificarea configurării."
|
||||
},
|
||||
"invalidFormat": {
|
||||
"title": "\"Format ApiKey nevalid\"",
|
||||
@@ -6255,7 +6255,7 @@
|
||||
"label": "Tablou principal"
|
||||
},
|
||||
"preferences": {
|
||||
"label": "Preferințele dvs."
|
||||
"label": "Preferințele dvs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1346,7 +1346,7 @@
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Sledujte, ktoré z vašich Docker kontajnerov majú k dispozícii aktualizácie obrazov."
|
||||
"description": "Sledujte, ktoré z vašich Docker kontajnerov majú k dispozícii aktualizácie obrazov"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
@@ -1442,7 +1442,7 @@
|
||||
"iconPicker": {
|
||||
"label": "URL ikony",
|
||||
"description": "Vyhľadajte v knižnici, prilepte URL obrázka alebo nahrajte súbor.",
|
||||
"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": "Vyhľadať obrázky podľa názvu",
|
||||
"clear": "Odstrániť obrázok",
|
||||
"uploadImage": "Nahrať obrázok",
|
||||
@@ -1835,7 +1835,7 @@
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Zobraziť zoznam aktualizácií kontajnerov",
|
||||
"description": "Uvádza každý sledovaný kontajner, ktorý má dostupnú aktualizáciu obrazu."
|
||||
"description": "Uvádza každý sledovaný kontajner, ktorý má dostupnú aktualizáciu obrazu"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2308,12 +2308,12 @@
|
||||
"description": "Ako by mal dátum vyzerať"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "{currentWindSpeed} km/h",
|
||||
"currentWindSpeed": "{currentWindSpeed} {unit}",
|
||||
"dailyForecast": {
|
||||
"sunrise": "Východ slnka",
|
||||
"sunset": "Západ slnka",
|
||||
"maxWindSpeed": "Maximálna rýchlosť vetra: {maxWindSpeed} km/h",
|
||||
"maxWindGusts": "Maximálne nárazy vetra: {maxWindGusts} km/h",
|
||||
"maxWindSpeed": "Maximálna rýchlosť vetra: {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Maximálne nárazy vetra: {maxWindGusts} {unit}",
|
||||
"humidity": ""
|
||||
},
|
||||
"kind": {
|
||||
|
||||
+1886
-1886
File diff suppressed because it is too large.
Load diff
+1752
-1752
File diff suppressed because it is too large.
Load diff
@@ -1346,7 +1346,7 @@
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Docker konteynerlerinizden hangilerinde imaj güncellemeleri olduğunu izleyin."
|
||||
"description": "Docker konteynerlerinizden hangilerinde imaj güncellemeleri olduğunu izleyin"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
@@ -1442,8 +1442,8 @@
|
||||
"iconPicker": {
|
||||
"label": "Simge URL'si",
|
||||
"description": "Kütüphanede arayın, bir görsel URL'si yapıştırın veya bir dosya yükleyin.",
|
||||
"header": "Simgeleri filtrelemek için isim veya nesne yazın... Homarr sizin için {countIcons} simge arasında arama yapacaktır.",
|
||||
"headerLoading": "Simge aramak için yazın…",
|
||||
"header": "Simgeleri filtrelemek için isim veya nesne yazın... Homarr sizin için {countIcons} simge arasında arama yapacaktır",
|
||||
"headerLoading": "Simge aramak için yazın",
|
||||
"clear": "Görseli temizle",
|
||||
"uploadImage": "Görsel yükle",
|
||||
"results": "Görsel arama sonuçları",
|
||||
|
||||
@@ -682,7 +682,7 @@
|
||||
},
|
||||
"create": {
|
||||
"title": "Створити новий додаток",
|
||||
"description": "Створити новий додаток",
|
||||
"description": "Створити новий додаток ",
|
||||
"action": "Відкрийте створення додатку"
|
||||
},
|
||||
"add": "Додати додаток"
|
||||
@@ -868,7 +868,7 @@
|
||||
"deleted": "Віджет \"{name}\" видалено.",
|
||||
"imported": "Віджет успішно імпортовано.",
|
||||
"aiPromptCopied": "Запит ШІ скопійовано до буферу обміну",
|
||||
"aiPromptDescription": "Скопіюйте запит на вставку в будь-який AI чатбот (ChatGPT, Claude, Gemini ...). Вона включає в себе повну схему віджетів і вашу API відповідь, щоб ШІ могла генерувати конфігурацію AI для вашого коректного імпортування.",
|
||||
"aiPromptDescription": "Скопіюйте запит на вставку в будь-який AI чатбот (ChatGPT, Claude, Gemini...). Вона включає в себе повну схему віджетів і вашу API відповідь, щоб ШІ могла генерувати конфігурацію AI для вашого коректного імпортування.",
|
||||
"aiPromptCopy": "Скопіювати в буфер обміну",
|
||||
"aiPromptNoResponse": "Порада: Спочатку запустіть тест, щоб включити відповідь API в командний рядок для отримання кращих результатів.",
|
||||
"aiPromptCopyError": "Не вдалося скопіювати запит до буфера обміну. Можливо, ваш браузер не підтримує цю функцію.",
|
||||
@@ -1075,7 +1075,7 @@
|
||||
},
|
||||
"alert": {
|
||||
"permission": {
|
||||
"title": "Недостатньо прав доступу.",
|
||||
"title": "Недостатньо прав доступу",
|
||||
"message": "Вам не дозволено довіряти або завантажувати сертифікати. Зверніться до адміністратора, щоб завантажити необхідний кореневий сертифікат."
|
||||
},
|
||||
"hostnameMismatch": {
|
||||
@@ -1228,7 +1228,7 @@
|
||||
},
|
||||
"tooManyRequests": {
|
||||
"title": "Занадто багато запитів за певний час",
|
||||
"message": "Було забагато запитів. Ймовірно, цільова система обмежує кількість запитів або відхиляє їх."
|
||||
"message": "Було забагато запитів. Ймовірно, цільова система обмежує кількість запитів або відхиляє їх"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1298,7 +1298,7 @@
|
||||
"newLabel": "Новий cекрет API"
|
||||
},
|
||||
"githubAppId": {
|
||||
"label": " ID додатку",
|
||||
"label": "ID додатку",
|
||||
"newLabel": "Новий ID додатку"
|
||||
},
|
||||
"githubInstallationId": {
|
||||
@@ -1442,8 +1442,8 @@
|
||||
"iconPicker": {
|
||||
"label": "URL іконки",
|
||||
"description": "Знайдіть бібліотеку, вставте URL-адресу зображення або завантажте файл.",
|
||||
"header": "Введіть назву або об'єкти для фільтрації значків... Homarr виконає пошук серед {countIcons} значків для вас.",
|
||||
"headerLoading": "Введіть для пошуку іконок…",
|
||||
"header": "Шукати зображення {countIcons} за назвою",
|
||||
"headerLoading": "Пошук зображень за назвою",
|
||||
"clear": "Очистити зображення",
|
||||
"uploadImage": "Вивантажити зображення",
|
||||
"results": "Результати пошуку зображень",
|
||||
@@ -1512,7 +1512,7 @@
|
||||
"login": "Логін",
|
||||
"homeBoard": "Ваша домашня дошка",
|
||||
"loggedOut": "Ви вийшли",
|
||||
"updateAvailable": "Доступні оновлення {countUpdates} : {tag}"
|
||||
"updateAvailable": "{countUpdates} оновлень доступно: {tag}"
|
||||
}
|
||||
},
|
||||
"dangerZone": "Небезпечна зона",
|
||||
@@ -2308,11 +2308,11 @@
|
||||
"description": "Як має виглядати дата"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "{currentWindSpeed} км/год",
|
||||
"currentWindSpeed": "{currentWindSpeed} {unit}",
|
||||
"dailyForecast": {
|
||||
"sunrise": "Схід сонця",
|
||||
"sunset": "Захід сонця",
|
||||
"maxWindSpeed": "Максимальна швидкість вітру: {maxWindSpeed} км/год",
|
||||
"maxWindSpeed": "Максимальна швидкість вітру: {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Максимальна швидкість вітру: {maxWindGusts} {unit}",
|
||||
"humidity": "Вологість: {humidity}%"
|
||||
},
|
||||
@@ -2476,19 +2476,19 @@
|
||||
"label": "Інверсія сортування"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Порядок стовпців"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Ширина стовпців"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Не вдалося отримати статистику контейнерів",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Макет таблиці не збережено",
|
||||
"layoutSaveMessage": "Не вдалося зберегти макет таблиці Docker. Спробуйте ще раз перемістити або змінити розмір стовпця."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "Не знайдено жодних контейнерів"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2781,20 +2781,20 @@
|
||||
"option": {
|
||||
"showOnlyPlaying": {
|
||||
"label": "Показувати тільки поточне відтворення",
|
||||
"description": "Вимкнення цієї опції не працюватиме для Plex."
|
||||
"description": "Вимкнення цієї опції не працюватиме для Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Показати бітрейт",
|
||||
"description": "Показувати поточний бітрейт відтворення поруч із розташуванням"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Показати розташування",
|
||||
"description": "Показувати, чи перебуває потік у вашій локальній або глобальній мережі."
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"streams": "{count, plural, one {# активна трансляція} few {# активні трансляції} many {# активних трансляцій} other {# активних трансляцій}}",
|
||||
"totalBitrate": "Всього: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Зараз відтворюється",
|
||||
@@ -2803,9 +2803,9 @@
|
||||
"id": "Id",
|
||||
"status": "Статус",
|
||||
"directPlay": "Пряме відтворення",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"directStream": "Прямий потік",
|
||||
"transcodeVideo": "Транскодування відео",
|
||||
"transcodeAudio": "Транскодування аудіо",
|
||||
"transcoding": "Транскодування",
|
||||
"paused": "Призупинено",
|
||||
"remaining": "Залишилося {minutes} хв",
|
||||
@@ -2979,7 +2979,7 @@
|
||||
},
|
||||
"upToDateHostsWarningAt": {
|
||||
"label": "Поріг попередження для актуальних хостів",
|
||||
"description": "Вмикається жовтий колір, коли значення дорівнює або перевищує цей поріг, але нижче повного покриття."
|
||||
"description": "Вмикається жовтий колір, коли значення дорівнює або перевищує цей поріг, але нижче повного покриття"
|
||||
},
|
||||
"upToDateHostsCriticalAt": {
|
||||
"label": "Критичний поріг для оновлених хостів",
|
||||
@@ -2995,7 +2995,7 @@
|
||||
},
|
||||
"totalOutdatedPackagesCriticalAt": {
|
||||
"label": "Критичний поріг застарілих пакетів",
|
||||
"description": "Вмикається червоний коли значення досягає цього порогу"
|
||||
"description": "Вмикається червоний, коли значення досягає цього порогу"
|
||||
},
|
||||
"showOsDistribution": {
|
||||
"label": "Показати розподіл ОС",
|
||||
@@ -3150,21 +3150,21 @@
|
||||
},
|
||||
"limitPerIntegration": {
|
||||
"label": "Обмежити елементи за інтегрування",
|
||||
"description": "Це обмежить кількість елементів, що відображаються для кожної інтеграції, а не глобально."
|
||||
"description": "Це обмежить кількість елементів, що відображаються для кожної інтеграції, а не глобально"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Порядок стовпців"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Ширина стовпців"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "Вибір стовпців в елементах",
|
||||
"noCommunications": "Не вдалося завантажити дані з інтеграції",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "Не знайдено жодних завантажень",
|
||||
"actionFailed": "Дію не виконано",
|
||||
"actionFailedMessage": "Не вдалося виконати дію. Спробуйте ще раз."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3188,7 +3188,7 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "Id",
|
||||
"detailsTitle": ""
|
||||
"detailsTitle": "ID елемента"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "Інтеграція"
|
||||
@@ -3223,7 +3223,7 @@
|
||||
"time": {
|
||||
"columnTitle": "Час завершення",
|
||||
"detailsTitle": "Час з/до завершення",
|
||||
"eta": ""
|
||||
"eta": "Залишилося"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "Тип",
|
||||
@@ -3269,21 +3269,21 @@
|
||||
},
|
||||
"globalRatio": "Загальний рейтинг",
|
||||
"contextMenu": {
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
"copyName": "Копіювати назву",
|
||||
"copyId": "Копіювати ID",
|
||||
"clearFilters": "Очистити фільтри"
|
||||
},
|
||||
"footer": {
|
||||
"filter": ""
|
||||
"filter": "Фільтрувати завантаження"
|
||||
},
|
||||
"stats": {
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
"show": "Показати загальну статистику",
|
||||
"hide": "Сховати загальну статистику",
|
||||
"overallProgress": "Загальний прогрес черги",
|
||||
"totalSize": "Загальний розмір черги",
|
||||
"clients": "{count, plural, one {# клієнт} few {# клієнти} many {# клієнтів} other {# клієнтів}}"
|
||||
},
|
||||
"disconnected": ""
|
||||
"disconnected": "(відключено)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "Медіа випуски",
|
||||
@@ -3580,7 +3580,7 @@
|
||||
"invalid": "Неправильне визначення репозиторію, перевірте значення",
|
||||
"noProvider": {
|
||||
"label": "Без постачальника",
|
||||
"tooltip": "Не вдалося проаналізувати постачальника, встановіть його вручну після імпорту зображень."
|
||||
"tooltip": "Не вдалося проаналізувати постачальника, встановіть його вручну після імпорту зображень"
|
||||
},
|
||||
"tokens": {
|
||||
"label": "Токени постачальника",
|
||||
@@ -3610,7 +3610,7 @@
|
||||
"invalidIdentifier": "Неприпустимий ідентифікатор",
|
||||
"noMatchingVersion": "Відповідної версії не знайдено",
|
||||
"noReleasesFound": "Результатів не знайдено",
|
||||
"noProviderSelected": "Постачальника не обрано.",
|
||||
"noProviderSelected": "Постачальника не обрано",
|
||||
"noProviderResponse": "Немає відповіді від провайдера",
|
||||
"unexpected": "Неочікувана помилка провайдера"
|
||||
}
|
||||
@@ -3625,7 +3625,7 @@
|
||||
},
|
||||
"error": {
|
||||
"integrationsDisconnected": "Дані недоступні, всі інтеграції відключені",
|
||||
"unknownContentOption": "Невідомий вміст для віджета зведення мережевого контролера:"
|
||||
"unknownContentOption": "Невідомий вміст для віджета зведення мережевого контролера: "
|
||||
},
|
||||
"name": "Зведення мережного контролера",
|
||||
"description": "Відображає зведену інформацію про мережевий контролер (наприклад, UniFi Controller)"
|
||||
@@ -3735,7 +3735,7 @@
|
||||
"recentResultsSpeed": "Швидкість",
|
||||
"recentResultsPing": "Пінг",
|
||||
"download": "Завантажити",
|
||||
"upload": "Вивантаж.",
|
||||
"upload": "Вивантаж",
|
||||
"ping": "Пінг",
|
||||
"status": "Статус",
|
||||
"avgDownload": "Середнє завантаження",
|
||||
@@ -4014,17 +4014,17 @@
|
||||
"label": "Показати версію агента"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Порядок стовпців"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Ширина стовпців"
|
||||
}
|
||||
},
|
||||
"noRecords": "Системи не знайдені",
|
||||
"error": {
|
||||
"internalServerError": "Не вдається підключитися до Beszel. Перевірте параметри інтеграції.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Не вдалося зберегти макет таблиці",
|
||||
"layoutSaveMessage": "Не вдалося зберегти порядок або ширину стовпців. Спробуйте ще раз."
|
||||
},
|
||||
"column": {
|
||||
"system": "Система",
|
||||
@@ -4887,11 +4887,11 @@
|
||||
"text": "Не індексувати сайт у пошукових системах і не показувати його в жодному результаті пошуку"
|
||||
},
|
||||
"noFollow": {
|
||||
"title": "Не переходити (No follow)",
|
||||
"title": "Не переходити",
|
||||
"text": "Не переходьте за посиланнями під час індексації. Якщо вимкнути це, то сканери намагатимуться переходити за всіма посиланнями на Homarr."
|
||||
},
|
||||
"noTranslate": {
|
||||
"title": "Не перекладати (No translate)",
|
||||
"title": "Не перекладати",
|
||||
"text": "Якщо мова сайту не є тією, яку користувач, імовірно, захоче прочитати, Google покаже посилання на переклад у результатах пошуку"
|
||||
},
|
||||
"noSiteLinksSearchBox": {
|
||||
@@ -4922,7 +4922,7 @@
|
||||
"title": "Користувачі",
|
||||
"enableGravatar": {
|
||||
"label": "Увімкнути Gravatar",
|
||||
"description": "Повертається до аватарів користувачів з Libravatar/Gravatar, якщо не встановлено власний аватар, а налаштовано електронну адресу."
|
||||
"description": "Повертається до аватарів користувачів з Libravatar/Gravatar, якщо не встановлено власний аватар, а налаштовано електронну адресу"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
@@ -5301,7 +5301,7 @@
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "Спонсори",
|
||||
"subtitle": "{count} , що підтримує проект фінансово"
|
||||
"subtitle": "{count}, що підтримує проект фінансово"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Бібліотеки",
|
||||
@@ -5320,7 +5320,7 @@
|
||||
"saveNotebook": "Зберегти блокнот (тільки всередині віджету)",
|
||||
"openSpotlight": "Відкрити пошук"
|
||||
},
|
||||
"note": "Порада: Mod стосується як клавіші Ctrl, так і клавіші ⌘ у macOS."
|
||||
"note": "Порада: Mod стосується як клавіші Ctrl, так і клавіші ⌘ у macOS"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5335,9 +5335,9 @@
|
||||
},
|
||||
"search": "Пошук контейнерів ({count})",
|
||||
"selected": "Вибрано {selectCount} із {totalCount} контейнерів",
|
||||
"footer": "Всього {count} контейнерів ",
|
||||
"footer": "Всього {count} контейнерів",
|
||||
"totalCpu": "Всього ЦП: {cpu}%",
|
||||
"totalMemory": "Всього Оперативної Пам'яті: {cpu}%"
|
||||
"totalMemory": "Всього оперативної пам'яті: {memory}"
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
@@ -5422,7 +5422,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Видалити",
|
||||
"confirm": "",
|
||||
"confirm": "Натисніть ще раз, щоб видалити остаточно",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Контейнери видалено",
|
||||
|
||||
+1715
-1715
File diff suppressed because it is too large.
Load diff
@@ -1345,8 +1345,8 @@
|
||||
"description": "雲原生反向代理和負載平衡器"
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "What's Up Docker",
|
||||
"description": "監控您的 Docker 容器中哪些有可用的映像更新"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
@@ -1441,18 +1441,18 @@
|
||||
"here": "此處",
|
||||
"iconPicker": {
|
||||
"label": "Icon 網址",
|
||||
"description": "",
|
||||
"description": "搜尋圖庫、貼上圖片網址或上傳檔案。",
|
||||
"header": "輸入名稱或物件來篩選 icons,Homarr 會自動搜尋 {countIcons}",
|
||||
"headerLoading": "輸入以搜尋圖示…",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"headerLoading": "輸入以搜尋圖示",
|
||||
"clear": "清除圖片",
|
||||
"uploadImage": "上傳圖片",
|
||||
"results": "圖片搜尋結果",
|
||||
"localImages": "你的圖像",
|
||||
"svgIcons": "",
|
||||
"svgIcons": "SVG 圖示",
|
||||
"otherImages": "其他圖像",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"urlLoading": "正在檢查圖片…",
|
||||
"urlReady": "圖片已可使用",
|
||||
"urlError": "無法載入此圖片。請檢查網址後再試一次。",
|
||||
"noResults": "沒有符合 \"{search}\" 的圖示。",
|
||||
"error": "無法載入圖示。"
|
||||
},
|
||||
@@ -1808,19 +1808,19 @@
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"name": "What's Up Docker",
|
||||
"description": "顯示有多少受監控的容器有可用更新。",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "已監控",
|
||||
"updatesAvailable": "{count, plural, other {# 個可用更新}}",
|
||||
"updateAvailable": "有可用更新",
|
||||
"openReleaseNotes": "開啟發佈說明",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": ""
|
||||
"label": "顯示標題"
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
"label": "顯示更新環"
|
||||
},
|
||||
"layout": {
|
||||
"label": "顯示布局",
|
||||
@@ -1834,8 +1834,8 @@
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "顯示容器更新清單",
|
||||
"description": "列出每個具有可用映像更新的受監控容器"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2308,12 +2308,12 @@
|
||||
"description": "日期應該是什麼樣式"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "{currentWindSpeed} 公里/時",
|
||||
"currentWindSpeed": "{currentWindSpeed} {unit}",
|
||||
"dailyForecast": {
|
||||
"sunrise": "日出",
|
||||
"sunset": "日落",
|
||||
"maxWindSpeed": "最大風速:{maxWindSpeed} 公里/時",
|
||||
"maxWindGusts": "最大陣風:{maxWindGusts} 公里/時",
|
||||
"maxWindSpeed": "最大風速:{maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "最大陣風:{maxWindGusts} {unit}",
|
||||
"humidity": "濕度: {humidity}%"
|
||||
},
|
||||
"kind": {
|
||||
@@ -2476,19 +2476,19 @@
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "欄位順序"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "欄位寬度"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "表格版面配置未儲存",
|
||||
"layoutSaveMessage": "無法儲存 Docker 表格版面配置。請再次移動或調整欄位大小。"
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "找不到任何容器"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2784,17 +2784,17 @@
|
||||
"description": "停用此功能將無法在 Plex 上運作"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "顯示位元速率",
|
||||
"description": "在地點旁顯示目前的播放位元速率"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "顯示位置",
|
||||
"description": "顯示串流位於 LAN 或 WAN"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"streams": "{count, plural, other {# 個使用中的串流}}",
|
||||
"totalBitrate": "總計:{bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "目前播放中",
|
||||
@@ -2803,9 +2803,9 @@
|
||||
"id": "ID",
|
||||
"status": "狀態",
|
||||
"directPlay": "直接播放",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"directStream": "直接串流",
|
||||
"transcodeVideo": "轉碼影片",
|
||||
"transcodeAudio": "轉碼音訊",
|
||||
"transcoding": "轉碼中",
|
||||
"paused": "已暫停",
|
||||
"remaining": "剩餘 {minutes} 分鐘",
|
||||
@@ -3153,18 +3153,18 @@
|
||||
"description": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "欄位順序"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "欄位寬度"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "於項目中選擇列",
|
||||
"noCommunications": "無法從集成中加載數據",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "找不到下載項目",
|
||||
"actionFailed": "操作失敗",
|
||||
"actionFailedMessage": "無法完成此操作,請再試一次。"
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3188,7 +3188,7 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "ID",
|
||||
"detailsTitle": ""
|
||||
"detailsTitle": "項目 ID"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "集成"
|
||||
@@ -3223,7 +3223,7 @@
|
||||
"time": {
|
||||
"columnTitle": "完成時間",
|
||||
"detailsTitle": "剩餘完成時間",
|
||||
"eta": ""
|
||||
"eta": "剩餘時間"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "類型",
|
||||
@@ -3269,21 +3269,21 @@
|
||||
},
|
||||
"globalRatio": "全局速率",
|
||||
"contextMenu": {
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
"copyName": "複製名稱",
|
||||
"copyId": "複製 ID",
|
||||
"clearFilters": "清除篩選器"
|
||||
},
|
||||
"footer": {
|
||||
"filter": ""
|
||||
"filter": "篩選下載項目"
|
||||
},
|
||||
"stats": {
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
"show": "顯示全域統計資料",
|
||||
"hide": "隱藏全域統計資料",
|
||||
"overallProgress": "整體佇列進度",
|
||||
"totalSize": "佇列總大小",
|
||||
"clients": "{count, plural, other {# 個用戶端}}"
|
||||
},
|
||||
"disconnected": ""
|
||||
"disconnected": "(斷線)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "",
|
||||
@@ -4014,17 +4014,17 @@
|
||||
"label": "顯示代理版本"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "欄位順序"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "欄位寬度"
|
||||
}
|
||||
},
|
||||
"noRecords": "找不到系統",
|
||||
"error": {
|
||||
"internalServerError": "無法連線到 Beszel。請檢查整合設定。",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "無法儲存表格版面配置",
|
||||
"layoutSaveMessage": "無法儲存您的欄位順序或寬度。請再試一次。"
|
||||
},
|
||||
"column": {
|
||||
"system": "系統",
|
||||
@@ -4876,7 +4876,7 @@
|
||||
"title": "分析",
|
||||
"general": {
|
||||
"title": "發送匿名分析",
|
||||
"text": "Homarr 使用開源軟體 Umami 發送匿名分析,他從不收集任何個人資料,因此完全符合 GDPR 和 CCPA 要求,我們鼓勵啟用分析,因為可以幫助我們的團隊識別問題,並確認待辦事項的優先級別"
|
||||
"text": "Homarr 使用開源軟體 Umami 發送匿名分析,他從不收集任何個人資料,因此完全符合 GDPR 和 CCPA 要求,我們鼓勵啟用分析,因為可以幫助我們的團隊識別問題,並確認待辦事項的優先級別。"
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
@@ -5422,7 +5422,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "移除",
|
||||
"confirm": "",
|
||||
"confirm": "再次點擊以永久移除",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "容器已移除",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconBrandMinecraft,
|
||||
IconBrowser,
|
||||
IconBuildingBank,
|
||||
IconBuildingBroadcastTower,
|
||||
IconBusStop,
|
||||
IconCalendar,
|
||||
IconChartAreaLine,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
IconReportSearch,
|
||||
IconRocket,
|
||||
IconRoute,
|
||||
IconRouter,
|
||||
IconRss,
|
||||
IconServer2,
|
||||
IconShieldCheck,
|
||||
@@ -75,6 +77,8 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
minecraftServerStatus: IconBrandMinecraft,
|
||||
networkControllerSummary: IconTopologyFull,
|
||||
networkControllerStatus: IconTopologyFull,
|
||||
unifiOs: IconRouter,
|
||||
unifiSiteManager: IconBuildingBroadcastTower,
|
||||
rssFeed: IconRss,
|
||||
bookmarks: IconBookmark,
|
||||
indexerManager: IconReportSearch,
|
||||
|
||||
@@ -55,6 +55,8 @@ import * as timetable from "./timetable";
|
||||
import * as traefik from "./traefik";
|
||||
import * as tracearr from "./tracearr";
|
||||
import * as umami from "./umami";
|
||||
import * as unifiOs from "./unifi-os";
|
||||
import * as unifiSiteManager from "./unifi-site-manager";
|
||||
import * as ups from "./ups";
|
||||
import * as vpn from "./vpn";
|
||||
import * as beszelSystemTable from "./beszel-system-table";
|
||||
@@ -131,6 +133,8 @@ export const widgetImports = {
|
||||
traefik,
|
||||
customApi,
|
||||
wud,
|
||||
unifiOs,
|
||||
unifiSiteManager,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -43,6 +43,8 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||
mediaMissing: () => import("./media-missing") as Promise<WidgetModule>,
|
||||
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
|
||||
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
|
||||
unifiOs: () => import("./unifi-os") as Promise<WidgetModule>,
|
||||
unifiSiteManager: () => import("./unifi-site-manager") as Promise<WidgetModule>,
|
||||
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
|
||||
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
|
||||
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
|
||||
@@ -105,6 +107,8 @@ const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>>
|
||||
networkControllerSummary: () => import("./network-controller/summary/component") as Promise<WidgetComponentModule>,
|
||||
networkControllerStatus: () =>
|
||||
import("./network-controller/network-status/component") as Promise<WidgetComponentModule>,
|
||||
unifiOs: () => import("./unifi-os/component") as Promise<WidgetComponentModule>,
|
||||
unifiSiteManager: () => import("./unifi-site-manager/component") as Promise<WidgetComponentModule>,
|
||||
rssFeed: () => import("./rssFeed/component") as Promise<WidgetComponentModule>,
|
||||
bookmarks: () => import("./bookmarks/component") as Promise<WidgetComponentModule>,
|
||||
bazarr: () => import("./bazarr/component") as Promise<WidgetComponentModule>,
|
||||
|
||||
@@ -25,6 +25,8 @@ export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "unifiOs"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "unifiSiteManager"]], intervalSeconds: 300 },
|
||||
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
|
||||
] as const;
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCircleFilled } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationConnected } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { ThroughputChart, ThroughputLegend } from "./throughput-chart";
|
||||
import type { ThroughputPoint } from "./throughput-history";
|
||||
import { appendThroughputPoint } from "./throughput-history";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const EMPTY_VALUE = "—";
|
||||
|
||||
/**
|
||||
* Le request-handler met les compteurs locaux en cache 5 s et le widget se
|
||||
* rafraîchit toutes les 5 s : en régime normal la donnée a moins de 10 s.
|
||||
* 30 s laissent six cycles de marge avant de crier au loup ; au-delà, la console
|
||||
* n'a pas répondu et `fallbackToStaleOnError` sert la dernière valeur connue —
|
||||
* ce que l'utilisateur doit voir, sans quoi la source périmée paraîtrait plus
|
||||
* fraîche que le bloc cloud, qui affiche son âge.
|
||||
*/
|
||||
const LOCAL_STALE_AFTER_MS = 30_000;
|
||||
|
||||
/** Identité stable : évite de relancer l'effet du hook à chaque rendu. */
|
||||
const NEVER_UPDATED = new Date(0);
|
||||
|
||||
const formatBitsPerSecond = (bps: number): string => {
|
||||
const units = ["b/s", "Kb/s", "Mb/s", "Gb/s"];
|
||||
let value = bps;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1000 && unitIndex < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
|
||||
return days > 0 ? `${days}d ${hours}h` : `${hours}h`;
|
||||
};
|
||||
|
||||
export default function UnifiOsWidget({ integrationIds, options }: WidgetComponentProps<"unifiOs">) {
|
||||
const t = useI18n();
|
||||
const { data: results = [], dataUpdatedAt } = clientApi.widget.unifiOs.summary.useQuery({ integrationIds });
|
||||
|
||||
const summary = useMemo(() => results[0]?.summary ?? null, [results]);
|
||||
const updatedAt = results[0]?.updatedAt ?? NEVER_UPDATED;
|
||||
const isLocalFresh = useIntegrationConnected(updatedAt, { timeout: LOCAL_STALE_AFTER_MS });
|
||||
|
||||
/* L'API ne fournit que l'instantané et Homarr ne stocke aucun historique :
|
||||
on l'accumule ici, comme le fait le widget systemResources. La série
|
||||
repart donc vide à chaque rechargement de page — c'est assumé. */
|
||||
const [throughputHistory, setThroughputHistory] = useState<ThroughputPoint[]>([]);
|
||||
|
||||
const previousIntegrationIds = useRef(integrationIds);
|
||||
useEffect(() => {
|
||||
if (previousIntegrationIds.current === integrationIds) return;
|
||||
previousIntegrationIds.current = integrationIds;
|
||||
setThroughputHistory([]);
|
||||
}, [integrationIds]);
|
||||
|
||||
const lastRecordedAt = useRef(dataUpdatedAt);
|
||||
useEffect(() => {
|
||||
if (dataUpdatedAt === lastRecordedAt.current) return;
|
||||
lastRecordedAt.current = dataUpdatedAt;
|
||||
|
||||
const throughput = results[0]?.summary.wan.throughput ?? null;
|
||||
setThroughputHistory((previous) => appendThroughputPoint(previous, throughput));
|
||||
}, [dataUpdatedAt, results]);
|
||||
|
||||
if (summary === null) {
|
||||
return (
|
||||
<Box h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("widget.unifiOs.error.noData")}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const wanColor = summary.wan.state === "online" ? "green" : summary.wan.state === "offline" ? "red" : "gray";
|
||||
const isp = summary.wan.isp;
|
||||
|
||||
// No `justify="space-between"` on the outer stack: the Internet card flexes to
|
||||
// take the leftover height, so there is no slack left to distribute —
|
||||
// distributing it is exactly what piled empty space between the bottom rows.
|
||||
return (
|
||||
<Stack h="100%" p="sm" gap="xs">
|
||||
{/* The site name is not decorative: a console can host several sites and
|
||||
the widget only shows one. Naming it makes that choice visible. */}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconCircleFilled size={8} color={`var(--mantine-color-${wanColor}-6)`} />
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
|
||||
{summary.site.name}
|
||||
</Text>
|
||||
{/* Second rôle du badge de fraîcheur (§8) : une console injoignable est
|
||||
servie depuis le cache périmé. Sans ce signal, les compteurs locaux
|
||||
paraîtraient plus frais que le bloc cloud, qui affiche son âge.
|
||||
Gris et discret : il ne doit pas concurrencer le bandeau WAN. */}
|
||||
{!isLocalFresh && (
|
||||
<Tooltip label={t("widget.unifiOs.card.stale")}>
|
||||
<Badge size="xs" variant="light" color="gray" style={{ flexShrink: 0 }}>
|
||||
{dayjs(updatedAt).fromNow()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* The one block that grows; the header, the stats rows and the footer keep
|
||||
their natural size. No `minHeight: 0` here on purpose: a flex item's
|
||||
automatic minimum is its content, so on a tile too short for everything
|
||||
the card stops shrinking at its own content and the tile scrolls, as it
|
||||
already did, rather than the card being squeezed and spilling the graph
|
||||
over the rows below it. */}
|
||||
<Stack
|
||||
gap={4}
|
||||
p="xs"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
background: "var(--mantine-color-default-hover)",
|
||||
}}
|
||||
>
|
||||
{/* The rates ride the header row, right-aligned opposite the title, so
|
||||
they read as a heading and detach from the area below them. */}
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconCircleFilled size={10} color={`var(--mantine-color-${wanColor}-6)`} />
|
||||
<Text fw={600} size="sm">
|
||||
{t("widget.unifiOs.card.internet")}
|
||||
</Text>
|
||||
</Group>
|
||||
{summary.wan.throughput === null ? (
|
||||
<Text size="sm" ff="monospace">
|
||||
{EMPTY_VALUE}
|
||||
</Text>
|
||||
) : (
|
||||
<ThroughputLegend
|
||||
upLabel={formatBitsPerSecond(summary.wan.throughput.txBps)}
|
||||
downLabel={formatBitsPerSecond(summary.wan.throughput.rxBps)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Le graphe est rendu dès la première mesure : avec un seul point il
|
||||
reste vide, ce qui évite un saut de mise en page au second — et une
|
||||
aire vide n'affirme rien, contrairement à une ligne plate. */}
|
||||
{summary.wan.throughput !== null && (
|
||||
<ThroughputChart
|
||||
history={throughputHistory}
|
||||
formatRate={formatBitsPerSecond}
|
||||
upName={t("widget.unifiOs.card.up")}
|
||||
downName={t("widget.unifiOs.card.down")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bloc ISP masqué quand il n'y a pas de donnée cloud (§8) : sans clé
|
||||
Site Manager, cloud injoignable, ou aucun site correspondant au MAC
|
||||
du gateway. Une absence que l'intégration a positivement établie ne
|
||||
se déguise pas en valeur inconnue. */}
|
||||
{options.showIsp && isp !== null && (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{isp.name}
|
||||
{isp.externalIp !== null ? ` · ${isp.externalIp}` : ""}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{/* Le cloud omet la latence pour les périodes de coupure : afficher
|
||||
un tiret plutôt qu'un « 0 ms » qui rassurerait à tort. */}
|
||||
<Text size="xs">
|
||||
{isp.latencyMs === null ? EMPTY_VALUE : t("widget.unifiOs.card.latency", { value: `${isp.latencyMs}` })}
|
||||
</Text>
|
||||
{isp.packetLossPct !== null && (
|
||||
<Text size="xs" c={isp.packetLossPct > 0 ? "yellow" : undefined}>
|
||||
{t("widget.unifiOs.card.packetLoss", { value: `${isp.packetLossPct}` })}
|
||||
</Text>
|
||||
)}
|
||||
{/* The cloud metrics behind this badge lag ~27 minutes: it must
|
||||
never look live, hence the relative-time badge instead of a
|
||||
bare number. */}
|
||||
<Tooltip
|
||||
label={t("widget.unifiOs.card.measuredAt", {
|
||||
time: dayjs(isp.measuredAt).format("HH:mm"),
|
||||
})}
|
||||
>
|
||||
<Badge size="xs" variant="light" color="yellow">
|
||||
{dayjs(isp.measuredAt).fromNow()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("widget.unifiOs.card.clients")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{summary.clients === null
|
||||
? EMPTY_VALUE
|
||||
: t("widget.unifiOs.card.clientBreakdown", {
|
||||
total: `${summary.clients.total}`,
|
||||
wired: `${summary.clients.wired}`,
|
||||
wireless: `${summary.clients.wireless}`,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{options.showDevices && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("widget.unifiOs.card.devices")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c={summary.devices !== null && summary.devices.offline > 0 ? "red" : undefined}>
|
||||
{summary.devices === null
|
||||
? EMPTY_VALUE
|
||||
: t("widget.unifiOs.card.devicesOnline", {
|
||||
online: `${summary.devices.online}`,
|
||||
total: `${summary.devices.total}`,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{options.showFirmwareUpdates && (summary.devices === null || summary.devices.firmwareUpdatable > 0) && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("widget.unifiOs.card.firmware")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c={summary.devices !== null ? "yellow" : undefined}>
|
||||
{summary.devices === null
|
||||
? EMPTY_VALUE
|
||||
: t("widget.unifiOs.card.firmwarePending", { count: `${summary.devices.firmwareUpdatable}` })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t("widget.unifiOs.card.version", { version: summary.site.applicationVersion })}
|
||||
</Text>
|
||||
{(summary.devices === null || summary.gateway !== null) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.unifiOs.card.gatewayUptime", {
|
||||
uptime: summary.gateway?.uptimeSec != null ? formatUptime(summary.gateway.uptimeSec) : EMPTY_VALUE,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IconRouter, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("unifiOs", {
|
||||
icon: IconRouter,
|
||||
refetchInterval: 5,
|
||||
supportedIntegrations: ["unifiOs"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showIsp: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
showDevices: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
showFirmwareUpdates: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
}));
|
||||
},
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.unifiOs.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user