Compare commits
129
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 | ||
|
|
73a742154e | ||
|
|
993a267e0c | ||
|
|
f1c49d1fbf | ||
|
|
95c66c61eb | ||
|
|
ec2f8c6c2c | ||
|
|
05ba57c2fa | ||
|
|
ddbf05524b | ||
|
|
df438b4cd2 | ||
|
|
a1f91eb385 | ||
|
|
8d5ffb2dd9 | ||
|
|
56e6b23af6 | ||
|
|
5488bf4d18 | ||
|
|
621ea07549 | ||
|
|
f64c01456b | ||
|
|
56198c3f02 | ||
|
|
ca1bb6f341 | ||
|
|
bf6b6663cd | ||
|
|
5c2d86767f | ||
|
|
7c75642d4d | ||
|
|
7652eb8c1c | ||
|
|
e761c10f32 | ||
|
|
2513e8454f | ||
|
|
f6137b2dcc | ||
|
|
425dd90871 | ||
|
|
527e911eef | ||
|
|
ff2cdecc86 | ||
|
|
742bde1d27 | ||
|
|
64febbce12 | ||
|
|
98bb1aecf8 | ||
|
|
06c489bd48 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Comprehensive Homarr codebase architecture and conventions reference
|
||||
alwaysApply: true
|
||||
name: codebase-context
|
||||
description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code.
|
||||
---
|
||||
|
||||
# Homarr Codebase Context
|
||||
@@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
```text
|
||||
homarr/
|
||||
├── apps/
|
||||
│ ├── nextjs/ # Main Next.js application (port 3000)
|
||||
@@ -66,8 +66,6 @@ homarr/
|
||||
│ ├── analytics/ # Server-side analytics (Umami)
|
||||
│ ├── server-settings/ # Server setting keys/types
|
||||
│ ├── settings/ # User-facing settings UI context
|
||||
│ ├── old-import/ # Legacy Homarr import
|
||||
│ ├── old-schema/ # Legacy Homarr zod schemas
|
||||
│ └── cli/ # Node CLI for ops (brocli)
|
||||
├── tooling/
|
||||
│ ├── typescript/ # Base tsconfig
|
||||
@@ -79,11 +77,11 @@ homarr/
|
||||
|
||||
## Three Runtime Services
|
||||
|
||||
| Service | Port | Role |
|
||||
|---------|------|------|
|
||||
| Next.js | 3000 | Main web app (SSR + API routes) |
|
||||
| WebSocket | 3001 | tRPC subscriptions via `ws` |
|
||||
| Tasks | 3002 | Cron job runner + management API via Fastify |
|
||||
| Service | Port | Role |
|
||||
| --------- | ---- | -------------------------------------------- |
|
||||
| Next.js | 3000 | Main web app (SSR + API routes) |
|
||||
| WebSocket | 3001 | tRPC subscriptions via `ws` |
|
||||
| Tasks | 3002 | Cron job runner + management API via Fastify |
|
||||
|
||||
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
|
||||
|
||||
@@ -112,6 +110,7 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service
|
||||
### Multi-Database Support
|
||||
|
||||
Drizzle schemas exist in three parallel implementations:
|
||||
|
||||
- `packages/db/schema/sqlite.ts`
|
||||
- `packages/db/schema/mysql.ts`
|
||||
- `packages/db/schema/postgresql.ts`
|
||||
@@ -121,6 +120,7 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
|
||||
### Widget System
|
||||
|
||||
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
|
||||
|
||||
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
|
||||
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
|
||||
- Loads dynamically via `next/dynamic`
|
||||
@@ -133,6 +133,7 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat
|
||||
### Cron Job System
|
||||
|
||||
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
|
||||
|
||||
- Uses `createCronJob` with a cron expression
|
||||
- Publishes results to Redis channels
|
||||
- Widget subscriptions consume those channels via tRPC subscriptions
|
||||
@@ -160,6 +161,7 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o
|
||||
## Environment Variables
|
||||
|
||||
Key env vars (from `.env.example`):
|
||||
|
||||
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
|
||||
- `DB_URL`: Database connection string
|
||||
- `AUTH_SECRET`: NextAuth secret
|
||||
@@ -191,16 +193,3 @@ Key env vars (from `.env.example`):
|
||||
- Drag-and-drop via `@dnd-kit/*`
|
||||
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
|
||||
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
|
||||
|
||||
## Documentation (`apps/docs`)
|
||||
|
||||
Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`.
|
||||
|
||||
- Run with `pnpm dev:docs` from root
|
||||
- Build with `pnpm turbo build --filter=@homarr/docs`
|
||||
- Uses Tailwind CSS 3 for custom pages (not Mantine)
|
||||
- Imports `@homarr/definitions` for type-safe integration/widget metadata
|
||||
- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/`
|
||||
- Integration docs: `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
|
||||
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
|
||||
- Strict link checking: broken links/anchors cause build failure
|
||||
+142
-73
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: datatable-migration
|
||||
description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets.
|
||||
---
|
||||
|
||||
# Migrating Tables from mantine-react-table to mantine-datatable
|
||||
|
||||
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
|
||||
@@ -7,10 +12,12 @@ This document captures every pattern, fix, and decision from the downloads widge
|
||||
Current `mantine-react-table` consumers that could be migrated:
|
||||
|
||||
**Widgets (in `packages/widgets/`):**
|
||||
|
||||
- `docker/component.tsx` — Docker container table
|
||||
- `media-server/component.tsx` — Media server sessions
|
||||
|
||||
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
|
||||
|
||||
- `tools/docker/docker-table.tsx`
|
||||
- `tools/tasks/_components/tasks-table.tsx`
|
||||
- `tools/api/components/api-keys.tsx`
|
||||
@@ -19,12 +26,12 @@ Current `mantine-react-table` consumers that could be migrated:
|
||||
- `tools/kubernetes/` — 8 Kubernetes resource tables
|
||||
|
||||
**Modals:**
|
||||
|
||||
- `packages/modals-collection/src/search-engines/request-media-modal.tsx`
|
||||
|
||||
**Shared hook to remove once all migrated:**
|
||||
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
|
||||
|
||||
---
|
||||
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
|
||||
|
||||
## 1. Core Migration: mantine-react-table → mantine-datatable
|
||||
|
||||
@@ -67,6 +74,7 @@ const columns: DataTableColumn<MyRow>[] = [
|
||||
```
|
||||
|
||||
Key differences:
|
||||
|
||||
- `accessorKey` → `accessor`
|
||||
- `header` → `title`
|
||||
- `size` → `width`
|
||||
@@ -118,8 +126,6 @@ const sortedData = useMemo(() => {
|
||||
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Making Tables Transparent (Widget Context)
|
||||
|
||||
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
|
||||
@@ -144,13 +150,21 @@ Create a CSS file alongside the component:
|
||||
|
||||
/* Hover: translucent instead of opaque */
|
||||
.my-table .mantine-datatable-table tbody tr:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-default-hover) 40%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Headers: frosted glass effect */
|
||||
.my-table th {
|
||||
white-space: nowrap;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-body) 60%,
|
||||
transparent
|
||||
) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@@ -172,14 +186,16 @@ Create a CSS file alongside the component:
|
||||
|
||||
/* Row expansion area */
|
||||
.my-table .mantine-datatable-row-expansion-cell {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-body) 40%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
```
|
||||
|
||||
Import it: `import "./styles.css";` and apply via `className="my-table"`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Responsive Column Visibility
|
||||
|
||||
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
|
||||
@@ -195,11 +211,38 @@ interface SizeConfig {
|
||||
}
|
||||
|
||||
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
|
||||
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
|
||||
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
|
||||
{
|
||||
maxWidth: 300,
|
||||
config: {
|
||||
fontSize: "xs",
|
||||
iconSize: 12,
|
||||
cellPadding: 2,
|
||||
showSpeedColumns: false,
|
||||
showTimeColumn: false,
|
||||
showStateColumn: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
maxWidth: 500,
|
||||
config: {
|
||||
fontSize: "xs",
|
||||
iconSize: 14,
|
||||
cellPadding: 4,
|
||||
showSpeedColumns: false,
|
||||
showTimeColumn: true,
|
||||
showStateColumn: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
|
||||
const DEFAULT_SIZE_CONFIG: SizeConfig = {
|
||||
fontSize: "sm",
|
||||
iconSize: 14,
|
||||
cellPadding: 4,
|
||||
showSpeedColumns: true,
|
||||
showTimeColumn: true,
|
||||
showStateColumn: true,
|
||||
};
|
||||
|
||||
function getSizeConfig(width: number): SizeConfig {
|
||||
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
|
||||
@@ -215,7 +258,9 @@ const size = useMemo(() => getSizeConfig(width), [width]);
|
||||
Then use a visibility check lookup table for columns:
|
||||
|
||||
```typescript
|
||||
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
|
||||
const columnVisibilityChecks: Partial<
|
||||
Record<string, (ctx: ColumnContext) => boolean>
|
||||
> = {
|
||||
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
||||
downSpeed: (ctx) => ctx.size.showSpeedColumns,
|
||||
time: (ctx) => ctx.size.showTimeColumn,
|
||||
@@ -230,8 +275,6 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Column Persistence (Drag/Resize → Server)
|
||||
|
||||
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
|
||||
@@ -263,15 +306,6 @@ Even hidden options need translation keys or the translation test fails:
|
||||
"widget.mywidget.option.columnWidths.label": "Column widths"
|
||||
```
|
||||
|
||||
### Old-import mappings
|
||||
|
||||
Add to `packages/old-import/src/widgets/options.ts`:
|
||||
|
||||
```typescript
|
||||
columnOrder: () => undefined,
|
||||
columnWidths: () => undefined,
|
||||
```
|
||||
|
||||
### Dynamic storeKey
|
||||
|
||||
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
|
||||
@@ -285,8 +319,13 @@ const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted
|
||||
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
|
||||
|
||||
```typescript
|
||||
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
|
||||
useDataTableColumns<MyRow>({ key: storeKey, columns });
|
||||
const {
|
||||
effectiveColumns,
|
||||
columnsOrder,
|
||||
columnsWidth,
|
||||
setColumnsOrder,
|
||||
setMultipleColumnWidths,
|
||||
} = useDataTableColumns<MyRow>({ key: storeKey, columns });
|
||||
|
||||
const lastStoreKey = useRef(storeKey);
|
||||
const hydrated = useRef(false);
|
||||
@@ -300,11 +339,22 @@ useEffect(() => {
|
||||
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
|
||||
if (Object.keys(savedWidths).length > 0) {
|
||||
setMultipleColumnWidths(
|
||||
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
|
||||
Object.entries(savedWidths).map(([accessor, w]) => ({
|
||||
accessor,
|
||||
width: w,
|
||||
})),
|
||||
);
|
||||
}
|
||||
requestAnimationFrame(() => { hydrated.current = true; });
|
||||
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
|
||||
requestAnimationFrame(() => {
|
||||
hydrated.current = true;
|
||||
});
|
||||
}, [
|
||||
storeKey,
|
||||
savedOrder,
|
||||
savedWidths,
|
||||
setColumnsOrder,
|
||||
setMultipleColumnWidths,
|
||||
]);
|
||||
```
|
||||
|
||||
### Persist localStorage → server
|
||||
@@ -317,22 +367,26 @@ const prevWidths = useRef(columnsWidth);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated.current) return;
|
||||
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
|
||||
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
|
||||
const orderChanged =
|
||||
JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
|
||||
const widthsChanged =
|
||||
JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
|
||||
prevOrder.current = columnsOrder;
|
||||
prevWidths.current = columnsWidth;
|
||||
|
||||
if (!orderChanged && !widthsChanged) return;
|
||||
|
||||
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
|
||||
if (orderChanged)
|
||||
persistOption({ columnOrder: JSON.stringify(columnsOrder) });
|
||||
if (widthsChanged) {
|
||||
const widthMap: Record<string, number> = {};
|
||||
for (const entry of columnsWidth) {
|
||||
const key = Object.keys(entry)[0];
|
||||
if (!key) continue;
|
||||
const w = entry[key as keyof typeof entry];
|
||||
if (typeof w === "number") widthMap[key] = w;
|
||||
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
|
||||
const v = entry[key as keyof typeof entry];
|
||||
if (typeof v === "number") widthMap[key] = v;
|
||||
else if (typeof v === "string" && v.endsWith("px"))
|
||||
widthMap[key] = parseInt(v, 10);
|
||||
}
|
||||
persistOption({ columnWidths: JSON.stringify(widthMap) });
|
||||
}
|
||||
@@ -341,8 +395,6 @@ useEffect(() => {
|
||||
|
||||
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
|
||||
|
||||
---
|
||||
|
||||
## 5. Context Menu (Right-Click)
|
||||
|
||||
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
|
||||
@@ -374,15 +426,41 @@ const handleContextMenu = useCallback(
|
||||
The menu component uses `Portal` for correct z-index:
|
||||
|
||||
```tsx
|
||||
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
|
||||
function RowContextMenu({
|
||||
state,
|
||||
onClose,
|
||||
}: {
|
||||
state: ContextMenuState;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Portal>
|
||||
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
|
||||
<Menu
|
||||
opened
|
||||
onClose={onClose}
|
||||
closeOnItemClick={false}
|
||||
position="right-start"
|
||||
offset={0}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
|
||||
<Box
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}}
|
||||
/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
/* action */ onClose();
|
||||
}}
|
||||
>
|
||||
Action
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Portal>
|
||||
@@ -392,8 +470,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
|
||||
|
||||
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Row Expansion
|
||||
|
||||
```typescript
|
||||
@@ -413,8 +489,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
|
||||
|
||||
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
|
||||
|
||||
---
|
||||
|
||||
## 7. Common Pitfalls & Fixes
|
||||
|
||||
### Status indicator dots
|
||||
@@ -475,8 +549,11 @@ Always wrap in try/catch:
|
||||
```typescript
|
||||
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try { return JSON.parse(value) as T; }
|
||||
catch { return fallback; }
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -494,20 +571,19 @@ If the widget has configurable default sort, sync it:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
|
||||
setSortStatus({
|
||||
columnAccessor: options.defaultSort,
|
||||
direction: defaultSortDirection,
|
||||
});
|
||||
}, [options.defaultSort, defaultSortDirection]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility
|
||||
|
||||
- All `ActionIcon` components need `aria-label`
|
||||
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
|
||||
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
- Every mutation needs an `onError` callback with `showErrorNotification`
|
||||
@@ -518,12 +594,14 @@ useEffect(() => {
|
||||
```typescript
|
||||
const mutationOptions = {
|
||||
onSettled: () => void utils.myQuery.invalidate(),
|
||||
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
||||
onError: () =>
|
||||
showErrorNotification({
|
||||
title: t("errors.actionFailed"),
|
||||
message: t("errors.actionFailedMessage"),
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. i18n
|
||||
|
||||
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
|
||||
@@ -534,8 +612,6 @@ const mutationOptions = {
|
||||
- Errors: `t("errors.{errorKey}")`
|
||||
- Hidden options still need `.label` translation keys
|
||||
|
||||
---
|
||||
|
||||
## 11. Edit Mode
|
||||
|
||||
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
|
||||
@@ -550,8 +626,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
<DataTable style={{ pointerEvents: tablePointerEvents }} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. DataTable Props Reference (Commonly Used)
|
||||
|
||||
```typescript
|
||||
@@ -590,8 +664,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Layout Stability
|
||||
|
||||
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
|
||||
@@ -600,8 +672,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
|
||||
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
|
||||
|
||||
---
|
||||
|
||||
## 14. Checklist for Each Migration
|
||||
|
||||
1. Replace imports (`mantine-react-table` → `mantine-datatable`)
|
||||
@@ -610,13 +680,12 @@ if (isEditMode) rowContextMenuHandler = undefined;
|
||||
4. Add CSS file for transparent backgrounds (if widget context)
|
||||
5. Add responsive column visibility (if widget context)
|
||||
6. Add hidden options + hydration logic (if column persistence needed)
|
||||
7. Add old-import mappings for new options
|
||||
8. Add translation keys (including hidden option labels)
|
||||
9. Add context menu if row actions exist
|
||||
10. Add row expansion if detail view exists
|
||||
11. Add error handling on all mutations
|
||||
12. Add accessibility attributes
|
||||
13. Test at multiple widget widths
|
||||
14. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
15. Run `pnpm turbo typecheck` — catches old-import mismatches
|
||||
16. Run translation spec — catches missing keys
|
||||
7. Add translation keys (including hidden option labels)
|
||||
8. Add context menu if row actions exist
|
||||
9. Add row expansion if detail view exists
|
||||
10. Add error handling on all mutations
|
||||
11. Add accessibility attributes
|
||||
12. Test at multiple widget widths
|
||||
13. Update docs in `apps/docs/docs/widgets/` if applicable
|
||||
14. Run `pnpm turbo typecheck`
|
||||
15. Run translation spec — catches missing keys
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: documentation-sync
|
||||
description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern.
|
||||
---
|
||||
|
||||
# Documentation Sync
|
||||
|
||||
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
|
||||
|
||||
## What triggers a doc update
|
||||
|
||||
| Code change | Doc location |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
|
||||
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
|
||||
| Changed API surface | `apps/docs/docs/management/api.mdx` |
|
||||
| New/changed env vars | `apps/docs/docs/advanced/` |
|
||||
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
|
||||
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
|
||||
| UI/UX changes | Relevant getting-started or management pages |
|
||||
| New cron job | `apps/docs/docs/management/tasks.mdx` |
|
||||
| Changed permissions | `apps/docs/docs/management/users.mdx` |
|
||||
|
||||
## Integration doc pattern
|
||||
|
||||
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
|
||||
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
|
||||
- Import metadata from `@homarr/definitions` where possible
|
||||
|
||||
## Widget doc pattern
|
||||
|
||||
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
|
||||
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
|
||||
|
||||
## Development
|
||||
|
||||
- Run docs locally: `pnpm dev:docs` from root
|
||||
- Build docs: `pnpm turbo build --filter=@homarr/docs`
|
||||
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
|
||||
- Docusaurus strict mode throws on broken links — fix any broken references before committing
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
description: How to expose tRPC procedures as MCP tools for AI clients
|
||||
globs: packages/api/src/router/**/*.ts
|
||||
alwaysApply: false
|
||||
name: mcp-integration
|
||||
description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip.
|
||||
---
|
||||
|
||||
# MCP Integration Guide
|
||||
# MCP Integration
|
||||
|
||||
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
|
||||
|
||||
Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool.
|
||||
|
||||
## Adding MCP to a procedure
|
||||
|
||||
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
|
||||
@@ -16,7 +17,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha
|
||||
// Read-only tool (query)
|
||||
myProcedure: protectedProcedure
|
||||
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
|
||||
.input(z.object({ limit: z.number().default(50) }))
|
||||
.input(z.object({ limit: z.number().default(10) }))
|
||||
.query(async ({ ctx, input }) => { ... })
|
||||
|
||||
// Write tool (mutation)
|
||||
@@ -38,23 +39,27 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()`
|
||||
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
|
||||
|
||||
**Must include:**
|
||||
|
||||
- What the tool returns or does
|
||||
- Which integrations/services it works with (if applicable)
|
||||
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
|
||||
- Permission requirements (if non-obvious)
|
||||
|
||||
**Good:**
|
||||
```
|
||||
|
||||
```text
|
||||
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
```
|
||||
|
||||
```text
|
||||
"Get calendar events"
|
||||
```
|
||||
|
||||
**For tools that return permission fields**, explain what they mean:
|
||||
```
|
||||
|
||||
```text
|
||||
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
|
||||
```
|
||||
|
||||
@@ -77,11 +82,13 @@ export const mcpRouter = createTRPCRouter({
|
||||
## What to expose vs. what to skip
|
||||
|
||||
**Expose:**
|
||||
|
||||
- Data queries (list, search, get by ID)
|
||||
- Actions users would ask an AI to do (toggle, create, delete, approve)
|
||||
- Status checks (health, stats, summaries)
|
||||
|
||||
**Skip:**
|
||||
|
||||
- Subscriptions (WebSocket-only, not supported by MCP)
|
||||
- Internal procedures (session management, onboarding steps)
|
||||
- File upload/download procedures
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
description: Keep documentation in sync with code changes
|
||||
globs:
|
||||
- packages/definitions/src/**
|
||||
- packages/widgets/src/**
|
||||
- packages/integrations/src/**
|
||||
- packages/api/src/**
|
||||
- apps/nextjs/src/**
|
||||
- apps/docs/**
|
||||
---
|
||||
|
||||
# Documentation Sync Rule
|
||||
|
||||
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
|
||||
|
||||
## What triggers a doc update
|
||||
|
||||
| Code change | Doc location |
|
||||
|---|---|
|
||||
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
|
||||
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
|
||||
| Changed API surface | `apps/docs/docs/management/api.mdx` |
|
||||
| New/changed env vars | `apps/docs/docs/advanced/` |
|
||||
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
|
||||
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
|
||||
| UI/UX changes | Relevant getting-started or management pages |
|
||||
| New cron job | `apps/docs/docs/management/tasks.mdx` |
|
||||
| Changed permissions | `apps/docs/docs/management/users.mdx` |
|
||||
|
||||
## Integration doc pattern
|
||||
|
||||
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
|
||||
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
|
||||
- Import metadata from `@homarr/definitions` where possible
|
||||
|
||||
## Widget doc pattern
|
||||
|
||||
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
|
||||
|
||||
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
|
||||
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
|
||||
|
||||
## Development
|
||||
|
||||
- Run docs locally: `pnpm dev:docs` from root
|
||||
- Build docs: `pnpm turbo build --filter=@homarr/docs`
|
||||
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
|
||||
- Docusaurus strict mode throws on broken links — fix any broken references before committing
|
||||
@@ -33,6 +33,11 @@ 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
|
||||
- 1.72.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: |
|
||||
|
||||
+2
-1
@@ -71,7 +71,8 @@ apps/nextjs/public/images/background.png
|
||||
|
||||
# personal agent config
|
||||
.opencode
|
||||
.agents/
|
||||
.agents/*
|
||||
!.agents/skills/
|
||||
skills-lock.json
|
||||
|
||||
# docusaurus
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mantine": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mantine/mcp-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,16 @@ When modifying user-facing code, you MUST also update the corresponding document
|
||||
- Lint: oxlint (not ESLint)
|
||||
- Format: oxfmt (not Prettier)
|
||||
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
|
||||
- Mantine: use the `mantine` MCP server (configured in `.mcp.json`; tools `get_item_doc` / `get_item_props` / `search_docs`) for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox` for selects, the polymorphic `component` prop, `@mantine/hooks`) over hand-rolled equivalents. Check `packages/ui/` for existing conventions first.
|
||||
|
||||
## MCP servers
|
||||
|
||||
`.mcp.json` at the repo root declares project-scoped MCP servers (the standard Claude Code layout; other tools can import the same `mcpServers` block). See the `mantine` entry there for the Mantine v9 server, and the upstream docs at https://mantine.dev/guides/llms/ for per-tool setup (OpenCode: `.opencode/opencode.json`, Codex: `.codex/config.toml`). Only keyless server definitions belong in `.mcp.json`; anything needing secrets stays in local dotfiles.
|
||||
|
||||
## Agent Skills
|
||||
|
||||
Portable agent skills live in `.agents/skills/` (the Agent Skills open standard). Cursor, Codex, and OpenCode read `.agents/skills/` natively. Claude Code does not scan it, so `.claude/skills/` is a symlink back to `.agents/skills/`. Install skills at `.agents/skills/<name>/SKILL.md` — that single copy is used everywhere. See `.agents/skills/` for available skills (documentation-sync, mcp-integration, codebase-context, datatable-migration).
|
||||
|
||||
- Icons: @tabler/icons-react
|
||||
- Docs app can import from `@homarr/definitions` for shared types
|
||||
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
## [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)
|
||||
|
||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||
|
||||
@@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw
|
||||
apps/docs/docs/integrations/uptime-kuma @ajnart
|
||||
packages/integrations/src/uptime-kuma @ajnart
|
||||
|
||||
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
|
||||
apps/docs/docs/integrations/whats-up-docker @tiimonn
|
||||
apps/docs/docs/widgets/whats-up-docker @tiimonn
|
||||
packages/api/src/router/widgets/wud.ts @tiimonn
|
||||
packages/integrations/src/wud @tiimonn
|
||||
packages/request-handler/src/wud.ts @tiimonn
|
||||
packages/widgets/src/wud @tiimonn
|
||||
|
||||
# Traefik - by @Nerdy-Technician
|
||||
apps/docs/docs/integrations/traefik @Nerdy-Technician
|
||||
apps/docs/docs/widgets/traefik @Nerdy-Technician
|
||||
|
||||
+2
-2
@@ -55,8 +55,8 @@ pnpm typecheck
|
||||
docs/
|
||||
├── getting-started/ # Installation guides, glossary, prerequisites
|
||||
├── management/ # Boards, apps, users, integrations, settings, tasks, API
|
||||
├── integrations/ # 55 integration guides (one folder each)
|
||||
├── widgets/ # 42 widget guides (one folder each)
|
||||
├── integrations/ # 56 integration guides (one folder each)
|
||||
├── widgets/ # 43 widget guides (one folder each)
|
||||
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
|
||||
└── community/ # FAQ, donate, license, translations, get-in-touch
|
||||
```
|
||||
|
||||
@@ -9,17 +9,19 @@ tags:
|
||||
---
|
||||
|
||||
## Icon picker
|
||||
|
||||
Icons in Homarr are automatically requested from multiple sources:
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
|
||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
||||
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
|
||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
||||
|
||||
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
|
||||
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
|
||||
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
|
||||
|
||||
### Finding Missing Icons
|
||||
|
||||
@@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
|
||||
|
||||
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
|
||||
|
||||
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
|
||||
|
||||
### Using external icons
|
||||
|
||||
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
|
||||
### Uploading a local image
|
||||
|
||||
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
|
||||
@@ -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.74.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 |
|
||||
|
||||
@@ -27,6 +27,8 @@ import Alert from '@theme/Admonition';
|
||||
]}
|
||||
/>
|
||||
|
||||
The storage values shown by these widgets come from each pool's root ZFS dataset, so they already account for RAIDZ parity overhead and dataset limits and match what `zfs list` and the TrueNAS web interface report. When a pool cannot report its dataset space, Homarr falls back to the pool's physical values, which read higher on RAIDZ.
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { wudIntegration } from ".";
|
||||
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
|
||||
|
||||
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
|
||||
|
||||
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: [],
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
|
||||
"Enter that username and password in Homarr — Basic authentication is handled automatically",
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const wudIntegration: IntegrationDefinition = {
|
||||
name: "What's Up Docker",
|
||||
description:
|
||||
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
path: "../../integrations/whats-up-docker",
|
||||
};
|
||||
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
|
||||
### Icon URL
|
||||
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -52,14 +52,21 @@ The certificates are stored in the `/appdata/trusted-certificates` directory whi
|
||||
This means you can automate the update of the certificates by simply replacing or adding a new file in the directory.
|
||||
|
||||
## Obtaining certificates
|
||||
|
||||
Every integration has its own way of obtaining certificates.
|
||||
Generally it is recommended to search for the documentation of the integration you are using.
|
||||
Most of the time you can find the certificate in the file system of the integration.
|
||||
The certificate file needs to be uploaded to Homarr. To first get the file on your computer you can either:
|
||||
- use `scp user@host:/path/to/file ./certificate.pem`
|
||||
- use `cat /path/to/file` to print the certificate. Then recreate the file on your machine.
|
||||
|
||||
Below you can find instructions for some of the integrations.
|
||||
|
||||
### Pi-hole (v6)
|
||||
|
||||
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
|
||||
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
|
||||
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
|
||||
The certificate can be found in the path `/etc/pihole/tls_ca.crt`.
|
||||
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
|
||||
|
||||
### Proxmox
|
||||
By default, Proxmox VE uses a node certificate signed by the cluster. You only need to configure Homarr with the certificate of a single node.
|
||||
The certificate can be found in any node on the path `/etc/pve/pve-root-ca.pem`.
|
||||
You can learn more about certificate management in Proxmox [here](https://pve.proxmox.com/wiki/Certificate_Management).
|
||||
@@ -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} />
|
||||
@@ -43,6 +43,7 @@ import temperature from './img/temperature.png';
|
||||
### Interactions
|
||||
|
||||
- Toggle entity state by clicking on the entity (only when clickable enabled).
|
||||
- After a successful toggle, the displayed state refreshes immediately.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
@@ -64,4 +65,4 @@ import retrieveEntityId from './img/retrieve-entity-id.png';
|
||||
|
||||
<div>
|
||||
<img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,4 +47,7 @@ import withDisplayname from './img/with-displayname.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
|
||||
|
||||
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "Displays how many watched containers have image updates available."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { wudWidget } from ".";
|
||||
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
|
||||
|
||||
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
|
||||
|
||||
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: wudIntegration,
|
||||
note: "Displays monitored container and available-update counts",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={wudWidget.configuration} />
|
||||
@@ -0,0 +1,37 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconBrandDocker } from "@tabler/icons-react";
|
||||
|
||||
export const wudWidget: WidgetDefinition = {
|
||||
icon: IconBrandDocker,
|
||||
name: "What's Up Docker",
|
||||
description: "Displays how many watched containers have image updates available.",
|
||||
path: "../../widgets/whats-up-docker",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show title",
|
||||
description: "Displays the icon and \"What's Up Docker\" label above the stats",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show update ring",
|
||||
description: "Displays a ring showing the percentage of monitored containers with a pending update",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
|
||||
values: { type: "select", options: ["Horizontal", "Vertical"] },
|
||||
defaultValue: "Horizontal",
|
||||
},
|
||||
{
|
||||
name: "Show container update list",
|
||||
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
suggestedSearch={mode === "create" ? form.values.name : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ export const integrationSecretIcons = {
|
||||
opnsenseApiSecret: IconPassword,
|
||||
patchmonApiKey: IconKey,
|
||||
patchmonApiSecret: IconPassword,
|
||||
siteManagerApiKey: IconKey,
|
||||
githubAppId: IconCode,
|
||||
githubInstallationId: IconPlug,
|
||||
privateKey: IconKey,
|
||||
|
||||
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
{...form.getInputProps("iconUrl")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Suspense, use, useEffect, useState } from "react";
|
||||
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
import { UpdateIndicator } from "./update";
|
||||
|
||||
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
|
||||
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
|
||||
|
||||
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
|
||||
const loadUserAvatarMenu = () => {
|
||||
if (userAvatarMenuPromise) return userAvatarMenuPromise;
|
||||
|
||||
const promise = import("~/components/user-avatar-menu");
|
||||
userAvatarMenuPromise = promise;
|
||||
void promise.catch(() => {
|
||||
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
|
||||
|
||||
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
|
||||
const { UserAvatarMenu } = use(loadUserAvatarMenu());
|
||||
return <UserAvatarMenu {...props} />;
|
||||
};
|
||||
|
||||
interface UserButtonClientProps {
|
||||
avatar: ReactNode;
|
||||
isAdmin: boolean;
|
||||
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
|
||||
|
||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
||||
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||
const [isMenuMounted, setIsMenuMounted] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const session = useSession();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
|
||||
staleTime: 60 * 60 * 1_000,
|
||||
});
|
||||
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||
const openMenu = () => {
|
||||
setIsMenuMounted(true);
|
||||
setIsMenuOpen(true);
|
||||
};
|
||||
const renderButton = (onClick: () => void, isLoading = false) => (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
onFocus={preloadUserAvatarMenu}
|
||||
onMouseEnter={preloadUserAvatarMenu}
|
||||
onPointerDown={preloadUserAvatarMenu}
|
||||
aria-busy={isLoading || undefined}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
<Box pos="relative" style={{ display: "inline-flex" }}>
|
||||
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
|
||||
{isLoading && (
|
||||
<Loader
|
||||
aria-label="Loading user menu"
|
||||
size={18}
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
style={{ transform: "translate(-50%, -50%)" }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</UpdateIndicator>
|
||||
</UnstyledButton>
|
||||
);
|
||||
const button = renderButton(openMenu);
|
||||
const loadingButton = renderButton(() => undefined, true);
|
||||
|
||||
if (!isMenuMounted) return button;
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary }) =>
|
||||
renderButton(() => {
|
||||
resetErrorBoundary();
|
||||
openMenu();
|
||||
})
|
||||
}
|
||||
>
|
||||
<Suspense fallback={loadingButton}>
|
||||
<LazyUserAvatarMenu
|
||||
availableUpdates={visibleUpdates}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
opened={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
{button}
|
||||
</LazyUserAvatarMenu>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
{avatar}
|
||||
</UpdateIndicator>
|
||||
</UnstyledButton>
|
||||
</UserAvatarMenu>
|
||||
);
|
||||
};
|
||||
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
isDockerEnabled?: boolean;
|
||||
opened: boolean;
|
||||
onOpenChange: (opened: boolean) => void;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({
|
||||
children,
|
||||
availableUpdates,
|
||||
isDockerEnabled,
|
||||
opened,
|
||||
onOpenChange,
|
||||
}: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const session = useSession();
|
||||
|
||||
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
|
||||
@@ -429,6 +429,13 @@
|
||||
<br/>
|
||||
<p align="center">Uptime<br/>Kuma</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/whats-up-docker" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg" alt="What's Up Docker" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">What's<br/>Up<br/>Docker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
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 |
|
||||
@@ -45,7 +45,10 @@ describe("Custom JSX custom widgets", () => {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
|
||||
const loginNavigation = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
|
||||
timeout: 60_000,
|
||||
waitUntil: "commit",
|
||||
});
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await loginNavigation;
|
||||
|
||||
|
||||
@@ -7,42 +7,6 @@ import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
|
||||
+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.74.0",
|
||||
"version": "1.77.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, like } from "@homarr/db";
|
||||
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import { iconsFindSchema } from "@homarr/validation/icons";
|
||||
|
||||
@@ -15,38 +15,41 @@ export const iconsRouter = createTRPCRouter({
|
||||
})
|
||||
.input(iconsFindSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const term = input.searchText?.toLowerCase().trim() ?? "";
|
||||
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
|
||||
|
||||
let whereCondition = undefined;
|
||||
if (term.length > 0) {
|
||||
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
|
||||
}
|
||||
const term = normalizeImageName(input.searchText ?? "");
|
||||
const repositories = await ctx.db.query.iconRepositories.findMany({
|
||||
orderBy: (table, { asc, sql }) => [sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
|
||||
with: {
|
||||
icons: {
|
||||
columns: { id: true, name: true, url: true },
|
||||
orderBy: (table, { asc, sql }) => [
|
||||
sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`,
|
||||
asc(table.name),
|
||||
],
|
||||
limit: term.length === 0 ? input.limitPerGroup : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
icons: await ctx.db.query.iconRepositories.findMany({
|
||||
with: {
|
||||
icons: {
|
||||
columns: { id: true, name: true, url: true },
|
||||
where: whereCondition,
|
||||
orderBy: (table, { asc, sql }) => {
|
||||
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
||||
const nameAsc = asc(table.name);
|
||||
if (term.length === 0) {
|
||||
return [svgFirst, nameAsc];
|
||||
}
|
||||
const tier = sql`CASE
|
||||
WHEN LOWER(${table.name}) = ${term} THEN 0
|
||||
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
|
||||
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
|
||||
ELSE 3
|
||||
END`;
|
||||
return [tier, svgFirst, nameAsc];
|
||||
},
|
||||
limit: input.limitPerGroup,
|
||||
},
|
||||
},
|
||||
}),
|
||||
icons:
|
||||
term.length === 0
|
||||
? repositories
|
||||
: repositories.map((repository) => ({
|
||||
...repository,
|
||||
icons: repository.icons
|
||||
.flatMap((icon) => {
|
||||
const rank = getImageMatchRank(term, icon.name || icon.url);
|
||||
return rank === null ? [] : [{ icon, rank }];
|
||||
})
|
||||
.toSorted((a, b) =>
|
||||
a.rank !== b.rank
|
||||
? a.rank - b.rank
|
||||
: Number(b.icon.name.toLowerCase().endsWith(".svg")) -
|
||||
Number(a.icon.name.toLowerCase().endsWith(".svg")) || a.icon.name.localeCompare(b.icon.name),
|
||||
)
|
||||
.slice(0, input.limitPerGroup)
|
||||
.map(({ icon }) => icon),
|
||||
})),
|
||||
countIcons: await ctx.db.$count(icons),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { iconRepositories, icons } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { iconsRouter } from "../icons";
|
||||
|
||||
describe("findIcons", () => {
|
||||
test.each(["homeassistant", "home_assistant", "home assistant.png"])(
|
||||
"matches normalized search %s before applying the group limit",
|
||||
async (searchText) => {
|
||||
const db = createDb();
|
||||
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await db.insert(iconRepositories).values({ id: "repository", slug: "dashboard-icons" });
|
||||
await db.insert(icons).values([
|
||||
{
|
||||
id: "irrelevant",
|
||||
name: "000-unrelated.svg",
|
||||
url: "https://example.com/000-unrelated.svg",
|
||||
checksum: "irrelevant",
|
||||
iconRepositoryId: "repository",
|
||||
},
|
||||
{
|
||||
id: "home-assistant",
|
||||
name: "home-assistant.svg",
|
||||
url: "https://example.com/home-assistant.svg",
|
||||
checksum: "home-assistant",
|
||||
iconRepositoryId: "repository",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.findIcons({ searchText, limitPerGroup: 1 });
|
||||
|
||||
expect(result.icons[0]?.icons).toEqual([
|
||||
{
|
||||
id: "home-assistant",
|
||||
name: "home-assistant.svg",
|
||||
url: "https://example.com/home-assistant.svg",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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)),
|
||||
@@ -44,4 +46,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
@@ -41,6 +42,13 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
const client = await createIntegrationAsync(integration);
|
||||
const success = await client.triggerToggleAsync(input.entityId);
|
||||
|
||||
if (!success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Home Assistant failed to toggle the entity",
|
||||
});
|
||||
}
|
||||
|
||||
return success;
|
||||
}),
|
||||
executeAutomation: protectedProcedure
|
||||
@@ -55,6 +63,13 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
.input(z.object({ automationId: z.string() }))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.triggerAutomationAsync(input.automationId);
|
||||
const success = await client.triggerAutomationAsync(input.automationId);
|
||||
|
||||
if (!success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Home Assistant failed to trigger the automation",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const wudRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "wud"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
return {
|
||||
stats: 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";
|
||||
|
||||
@@ -28,3 +28,34 @@ export const bestMatch = <TItem extends Record<string, unknown>>(
|
||||
return best;
|
||||
}, null);
|
||||
};
|
||||
|
||||
export const normalizeImageName = (value: string) => {
|
||||
const fileName = value.split(/[?#]/, 1)[0]?.split("/").pop() ?? value;
|
||||
const withoutExtension = fileName.replace(/\.(?:avif|gif|jpe?g|png|svg|webp)$/i, "");
|
||||
|
||||
return withoutExtension
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/&/g, " and ")
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
export const getImageMatchRank = (normalizedSearch: string, candidateValue: string): number | null => {
|
||||
const candidate = normalizeImageName(candidateValue);
|
||||
if (!candidate) return null;
|
||||
|
||||
const compactSearch = normalizedSearch.replaceAll(" ", "");
|
||||
const compactCandidate = candidate.replaceAll(" ", "");
|
||||
if (candidate === normalizedSearch) return 0;
|
||||
if (compactCandidate === compactSearch) return 1;
|
||||
if (candidate.startsWith(`${normalizedSearch} `)) return 2;
|
||||
|
||||
const searchTokens = normalizedSearch.split(" ");
|
||||
const candidateTokens = candidate.split(" ");
|
||||
if (searchTokens.every((term) => candidateTokens.some((token) => token === term))) return 3;
|
||||
if (searchTokens.every((term) => candidateTokens.some((token) => token.startsWith(term)))) return 4;
|
||||
if (candidate.includes(normalizedSearch)) return 5;
|
||||
return null;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { bestMatch, capitalize } from "../string";
|
||||
import { bestMatch, capitalize, getImageMatchRank, normalizeImageName } from "../string";
|
||||
|
||||
const capitalizeTestCases = [
|
||||
["hello", "Hello"],
|
||||
@@ -54,3 +54,27 @@ describe("bestMatch should find the best match in an array of options", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("image name matching", () => {
|
||||
test.each([
|
||||
["home-assistant.svg", "home assistant"],
|
||||
["https://example.com/Home_Assistant.PNG?v=1", "home assistant"],
|
||||
["jelly.fin.webp", "jelly fin"],
|
||||
["Caf\u00e9.svg", "cafe"],
|
||||
["\u6771\u4eac.svg", "\u6771\u4eac"],
|
||||
["\u0939\u093f\u0928\u094d\u0926\u0940.svg", "\u0939\u093f\u0928\u094d\u0926\u0940"],
|
||||
])("normalizes %s", (value, expected) => {
|
||||
expect(normalizeImageName(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["homeassistant", "home-assistant.svg", 1],
|
||||
["home assistant", "home_assistant.png", 0],
|
||||
["home assistant", "Home Assistant.webp", 0],
|
||||
["\u6771\u4eac", "\u6771\u4eac.svg", 0],
|
||||
["\u0939\u093f\u0928\u094d\u0926\u0940", "\u0939\u093f\u0928\u094d\u0926\u0940.png", 0],
|
||||
["home assistant", "unrelated.svg", null],
|
||||
])("ranks %s against %s", (search, candidate, expected) => {
|
||||
expect(getImageMatchRank(normalizeImageName(search), candidate)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
||||
import { withTimeoutAsync } from "./timeout";
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: TrustedCertificateHostname[],
|
||||
): typeof checkServerIdentity => {
|
||||
|
||||
@@ -210,6 +210,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/unifi-controller"
|
||||
| "/docs/integrations/unraid"
|
||||
| "/docs/integrations/uptime-kuma"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/management/api"
|
||||
| "/docs/management/apps"
|
||||
| "/docs/management/backup"
|
||||
@@ -276,6 +277,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/whats-up-docker"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
| "/docs/integrations/sabnzbd"
|
||||
@@ -330,6 +332,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/integrations/traefik"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/app"
|
||||
@@ -382,4 +385,5 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/beszel-alerts"
|
||||
| "/docs/widgets/beszel-system-stats"
|
||||
| "/docs/widgets/traefik"
|
||||
| "/docs/widgets/custom-api";
|
||||
| "/docs/widgets/custom-api"
|
||||
| "/docs/widgets/whats-up-docker";
|
||||
@@ -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",
|
||||
@@ -54,5 +56,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
beszel: "beszel",
|
||||
traefik: "traefik",
|
||||
archiveTeamWarrior: null,
|
||||
wud: "whats-up-docker",
|
||||
mock: null,
|
||||
};
|
||||
@@ -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",
|
||||
@@ -55,4 +57,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
wud: "whats-up-docker",
|
||||
};
|
||||
@@ -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"]],
|
||||
@@ -468,6 +485,14 @@ export const integrationDefs = {
|
||||
category: ["archiving"],
|
||||
documentationUrl: null,
|
||||
},
|
||||
wud: {
|
||||
name: "What's Up Docker",
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"),
|
||||
defaultPort: 3000,
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
name: "Mock",
|
||||
@@ -585,6 +610,8 @@ export const integrationCategories = [
|
||||
"uptimeMonitoring",
|
||||
"subtitleManager",
|
||||
"reverseProxy",
|
||||
"unifiOs",
|
||||
"unifiSiteManager",
|
||||
] as const;
|
||||
|
||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||
@@ -23,8 +23,12 @@ 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"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
firewall: getIntegrationKindsByCategory("firewall"),
|
||||
notifications: getIntegrationKindsByCategory("notifications"),
|
||||
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
|
||||
@@ -50,6 +54,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
traefik: ["traefik"],
|
||||
wud: ["wud"],
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
|
||||
@@ -21,6 +21,8 @@ export const widgetKinds = [
|
||||
"minecraftServerStatus",
|
||||
"networkControllerSummary",
|
||||
"networkControllerStatus",
|
||||
"unifiOs",
|
||||
"unifiSiteManager",
|
||||
"rssFeed",
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
@@ -53,6 +55,7 @@ export const widgetKinds = [
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"wud",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
|
||||
@@ -1,3 +1,89 @@
|
||||
[data-combobox-selected="true"] .iconCard {
|
||||
border-color: var(--mantine-primary-color-6);
|
||||
.dropdown {
|
||||
min-width: min(32rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.options {
|
||||
max-height: min(28rem, 60vh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.iconGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
|
||||
}
|
||||
|
||||
.iconOption {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
height: 3.25rem;
|
||||
padding: var(--mantine-spacing-xs);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.iconOption:hover,
|
||||
.iconOption[data-combobox-selected="true"] {
|
||||
border-color: var(--mantine-primary-color-6);
|
||||
background: var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.iconOption:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.formatBadge,
|
||||
.sectionBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
background: var(--mantine-color-red-9);
|
||||
color: var(--mantine-color-white);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.formatBadge {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 0.2rem;
|
||||
padding: 0.15rem 0.2rem;
|
||||
}
|
||||
|
||||
.sectionBadge {
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.urlPreview {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.previewFrame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
flex: 0 0 3rem;
|
||||
}
|
||||
|
||||
.previewLoader {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.iconOption {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -4,31 +4,84 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vi
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
import type { Root } from "react-dom/client";
|
||||
import type { IconGroup } from "./icon-picker.utils";
|
||||
|
||||
const { findIconsQuery } = vi.hoisted(() => ({
|
||||
findIconsQuery: vi.fn((_query: { searchText: string }) => ({
|
||||
data: { icons: [], countIcons: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
const { findIconsQuery, refetch, uploadSelection, sessionPermissions } = vi.hoisted(() => ({
|
||||
findIconsQuery: vi.fn(),
|
||||
refetch: vi.fn(),
|
||||
uploadSelection: vi.fn(),
|
||||
sessionPermissions: { current: [] as string[] },
|
||||
}));
|
||||
|
||||
interface QueryData {
|
||||
icons: IconGroup[];
|
||||
countIcons: number;
|
||||
}
|
||||
|
||||
const emptyResult: QueryData = { icons: [], countIcons: 11_000 };
|
||||
let queryResult: {
|
||||
data?: QueryData;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
|
||||
clientApi: {
|
||||
icon: {
|
||||
findIcons: {
|
||||
useQuery: (input: { searchText: string; limitPerGroup: number }, options: { enabled: boolean }) => {
|
||||
findIconsQuery(input, options);
|
||||
return { ...queryResult, refetch };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: { user: { permissions: [] } } }),
|
||||
useSession: () => ({ data: { user: { permissions: sessionPermissions.current } } }),
|
||||
}));
|
||||
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}:${Object.values(params).join(",")}` : key,
|
||||
}));
|
||||
vi.mock("../upload-media/upload-media", () => ({
|
||||
UploadMedia: ({
|
||||
children,
|
||||
onSuccess,
|
||||
}: {
|
||||
children: (props: object) => unknown;
|
||||
onSuccess: (medias: unknown) => void;
|
||||
}) =>
|
||||
children({
|
||||
loading: false,
|
||||
onClick: () => {
|
||||
uploadSelection();
|
||||
onSuccess([{ id: "uploaded", url: "/api/user-medias/uploaded" }]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
|
||||
|
||||
import { IconPicker } from "./icon-picker";
|
||||
|
||||
const directIconUrl = "https://cdn.example.com/shopify.svg";
|
||||
const directImageUrl = "https://cdn.example.com/shopify.svg";
|
||||
|
||||
const iconGroups = [
|
||||
{
|
||||
id: "remote",
|
||||
slug: "dashboard-icons",
|
||||
icons: [
|
||||
{ id: "png", name: "shopify.png", url: "https://cdn.example.com/shopify.png" },
|
||||
{ id: "svg", name: "shopify.svg", url: "https://cdn.example.com/shopify.svg" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
slug: "local",
|
||||
icons: [{ id: "local-icon", name: "my-shop.png", url: "/api/user-medias/local-icon" }],
|
||||
},
|
||||
];
|
||||
|
||||
const setInputValue = (input: HTMLInputElement, value: string) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
@@ -37,6 +90,18 @@ const setInputValue = (input: HTMLInputElement, value: string) => {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
const pressKey = (input: HTMLInputElement, key: string, init?: KeyboardEventInit) => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }));
|
||||
};
|
||||
|
||||
const focusInput = async (input: HTMLInputElement) => {
|
||||
await act(async () => input.focus());
|
||||
};
|
||||
|
||||
const advanceDebounce = async () => {
|
||||
await act(async () => vi.advanceTimersByTimeAsync(300));
|
||||
};
|
||||
|
||||
describe("IconPicker", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -45,9 +110,9 @@ describe("IconPicker", () => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
value: (media: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
media,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
@@ -56,54 +121,186 @@ describe("IconPicker", () => {
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: () => undefined, writable: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
findIconsQuery.mockClear();
|
||||
refetch.mockClear();
|
||||
uploadSelection.mockClear();
|
||||
sessionPermissions.current = [];
|
||||
queryResult = { data: emptyResult, isLoading: false, isFetching: false, isError: false };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.unmount();
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.querySelectorAll("[data-combobox-dropdown]").forEach((element) => element.remove());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const renderPicker = async (onChange: (value: string) => void) => {
|
||||
const renderPicker = async (
|
||||
onChange: (value: string) => void,
|
||||
props: Partial<React.ComponentProps<typeof IconPicker>> = {},
|
||||
) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
|
||||
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange, ...props })));
|
||||
});
|
||||
const input = container.querySelector("input");
|
||||
if (!input) throw new Error("Icon picker input was not rendered");
|
||||
return input;
|
||||
};
|
||||
|
||||
test("keeps a direct URL visible without searching the icon repositories", async () => {
|
||||
test("clears an existing value without restoring it", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
const input = await renderPicker(onChange, { value: directImageUrl });
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(input, directIconUrl);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
await act(async () => setInputValue(input, ""));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(directIconUrl);
|
||||
expect(input.value).toBe(directIconUrl);
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("keeps icon names in repository search mode", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
test.each([{ metaKey: true }, { ctrlKey: true }])(
|
||||
"clears the entire value with a line-delete shortcut",
|
||||
async (modifier) => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange, { value: directImageUrl });
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(input, "shopify");
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
await act(async () => pressKey(input, "Backspace", modifier));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(input.value).toBe("shopify");
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(input.value).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
test("debounces repository searches", async () => {
|
||||
const input = await renderPicker(vi.fn());
|
||||
findIconsQuery.mockClear();
|
||||
|
||||
await act(async () => setInputValue(input, "shopify"));
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(false);
|
||||
|
||||
await advanceDebounce();
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
|
||||
});
|
||||
|
||||
test("pre-searches the suggested app name when opened", async () => {
|
||||
const input = await renderPicker(vi.fn(), { suggestedSearch: "Jellyfin" });
|
||||
|
||||
await focusInput(input);
|
||||
expect(input.value).toBe("Jellyfin");
|
||||
await advanceDebounce();
|
||||
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "Jellyfin")).toBe(true);
|
||||
});
|
||||
|
||||
test("shows local images before SVG and raster library images", async () => {
|
||||
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
|
||||
const input = await renderPicker(vi.fn());
|
||||
|
||||
await focusInput(input);
|
||||
const content = document.body.textContent ?? "";
|
||||
|
||||
expect(document.body.querySelector('[role="listbox"]')?.getAttribute("aria-label")).toBe("iconPicker.results");
|
||||
expect(content.indexOf("iconPicker.localImages")).toBeLessThan(content.indexOf("iconPicker.svgIcons"));
|
||||
expect(content.indexOf("iconPicker.svgIcons")).toBeLessThan(content.indexOf("iconPicker.otherImages"));
|
||||
});
|
||||
|
||||
test("selects a library image with a pointer", async () => {
|
||||
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
await focusInput(input);
|
||||
|
||||
const option = document.body.querySelector<HTMLElement>(
|
||||
'[data-combobox-option][value="/api/user-medias/local-icon"]',
|
||||
);
|
||||
if (!option) throw new Error("Local image option was not rendered");
|
||||
await act(async () => option.click());
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
|
||||
expect(input.value).toBe("/api/user-medias/local-icon");
|
||||
});
|
||||
|
||||
test("selects a library image with the keyboard", async () => {
|
||||
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
await focusInput(input);
|
||||
|
||||
await act(async () => pressKey(input, "ArrowDown"));
|
||||
await act(async () => pressKey(input, "Enter"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
|
||||
});
|
||||
|
||||
test("keeps a direct URL visible and reports a successful preview", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
|
||||
await act(async () => setInputValue(input, directImageUrl));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(directImageUrl);
|
||||
expect(input.value).toBe(directImageUrl);
|
||||
expect(findIconsQuery.mock.calls.at(-1)?.[1]).toMatchObject({ enabled: false });
|
||||
expect(document.body.textContent).toContain("iconPicker.urlLoading");
|
||||
|
||||
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
|
||||
if (!preview) throw new Error("Direct URL preview was not rendered");
|
||||
await act(async () => preview.dispatchEvent(new Event("load")));
|
||||
expect(document.body.textContent).toContain("iconPicker.urlReady");
|
||||
});
|
||||
|
||||
test("keeps a failed direct URL editable and shows an error", async () => {
|
||||
const input = await renderPicker(vi.fn());
|
||||
await act(async () => setInputValue(input, "https://invalid.example/image.png"));
|
||||
|
||||
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
|
||||
if (!preview) throw new Error("Direct URL preview was not rendered");
|
||||
await act(async () => preview.dispatchEvent(new Event("error")));
|
||||
|
||||
expect(input.value).toBe("https://invalid.example/image.png");
|
||||
expect(document.body.textContent).toContain("iconPicker.urlError");
|
||||
});
|
||||
|
||||
test("restores the committed value when an unfinished search is cancelled", async () => {
|
||||
const input = await renderPicker(vi.fn(), { value: directImageUrl });
|
||||
|
||||
await act(async () => setInputValue(input, "unfinished search"));
|
||||
await act(async () => pressKey(input, "Escape"));
|
||||
|
||||
expect(input.value).toBe(directImageUrl);
|
||||
});
|
||||
|
||||
test("offers retry when the repository search fails", async () => {
|
||||
queryResult = { data: undefined, isLoading: false, isFetching: false, isError: true };
|
||||
const input = await renderPicker(vi.fn());
|
||||
await focusInput(input);
|
||||
|
||||
const retry = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "action.tryAgain",
|
||||
);
|
||||
if (!retry) throw new Error("Retry button was not rendered");
|
||||
await act(async () => retry.click());
|
||||
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("selects an uploaded local image when the user has permission", async () => {
|
||||
sessionPermissions.current = ["media-upload"];
|
||||
const onChange = vi.fn();
|
||||
await renderPicker(onChange);
|
||||
|
||||
const upload = container.querySelector<HTMLButtonElement>('[aria-label="iconPicker.uploadImage"]');
|
||||
if (!upload) throw new Error("Upload button was not rendered");
|
||||
await act(async () => upload.click());
|
||||
|
||||
expect(uploadSelection).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith("/api/user-medias/uploaded");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import type { FocusEventHandler, KeyboardEventHandler } from "react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CloseButton,
|
||||
Combobox,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Indicator,
|
||||
InputBase,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCloud,
|
||||
IconMoodEmpty,
|
||||
IconPhoto,
|
||||
IconPhotoOff,
|
||||
IconSearchOff,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -29,6 +38,7 @@ import { supportedLanguages } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UploadMedia } from "../upload-media/upload-media";
|
||||
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
|
||||
import classes from "./icon-picker.module.css";
|
||||
|
||||
interface IconPickerProps {
|
||||
@@ -39,9 +49,12 @@ interface IconPickerProps {
|
||||
onBlur?: FocusEventHandler;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
suggestedSearch?: string | null;
|
||||
withAsterisk?: boolean;
|
||||
}
|
||||
|
||||
type PreviewState = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export const IconPicker = ({
|
||||
value: propsValue,
|
||||
onChange,
|
||||
@@ -51,188 +64,403 @@ export const IconPicker = ({
|
||||
withAsterisk = true,
|
||||
label,
|
||||
placeholder,
|
||||
suggestedSearch,
|
||||
}: IconPickerProps) => {
|
||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
}, [value]);
|
||||
|
||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange, defaultValue: "" });
|
||||
const [draft, setDraft] = useState(propsValue ?? "");
|
||||
const [hasEdited, setHasEdited] = useState(false);
|
||||
const [keyboardIndex, setKeyboardIndex] = useState(-1);
|
||||
const [previewState, setPreviewState] = useState<PreviewState>("idle");
|
||||
const { data: session } = useSession();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
||||
const [debouncedDraft] = useDebouncedValue(draft, 250);
|
||||
|
||||
const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : "";
|
||||
const directUrl = isDirectImageUrl(draft) ? draft.trim() : null;
|
||||
const isCommittedValueDraft = debouncedDraft === (value ?? "");
|
||||
const searchText = directUrl !== null || isCommittedValueDraft ? "" : debouncedDraft.trim();
|
||||
const canUpload = session?.user.permissions.includes("media-upload") ?? false;
|
||||
|
||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText },
|
||||
{ placeholderData: (prev) => prev },
|
||||
const query = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText, limitPerGroup: 24 },
|
||||
{ enabled: directUrl === null, placeholderData: (previous) => previous },
|
||||
);
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
|
||||
const groups = data?.icons.map((group) => {
|
||||
const options = group.icons.map((item) => (
|
||||
<Combobox.Option
|
||||
key={item.id}
|
||||
value={item.url}
|
||||
p={0}
|
||||
h="calc(2*var(--mantine-spacing-sm)+25px)"
|
||||
w="calc(2*var(--mantine-spacing-sm)+25px)"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
setValue(item.url);
|
||||
setQuery("");
|
||||
combobox.closeDropdown();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} />
|
||||
</Box>
|
||||
</Card>
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
</Combobox.Option>
|
||||
));
|
||||
useEffect(() => {
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Paper p="xs" key={group.slug} pt={2}>
|
||||
<Text mb={8} size="sm" fw="bold">
|
||||
{group.slug}
|
||||
</Text>
|
||||
<Flex gap={8} wrap={"wrap"}>
|
||||
{options}
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
useEffect(() => {
|
||||
setPreviewState(directUrl ? "loading" : "idle");
|
||||
}, [directUrl]);
|
||||
|
||||
const inputValue = query || value || "";
|
||||
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
||||
const placeholderText =
|
||||
placeholder ||
|
||||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
||||
tCommon("iconPicker.headerLoading");
|
||||
const sections = useMemo(
|
||||
() => arrangeIconPickerSections(query.data?.icons ?? [], searchText),
|
||||
[query.data?.icons, searchText],
|
||||
);
|
||||
const orderedOptions = useMemo(
|
||||
() => [...sections.local, ...sections.svg, ...sections.other],
|
||||
[sections.local, sections.svg, sections.other],
|
||||
);
|
||||
const totalOptions = sections.local.length + sections.svg.length + sections.other.length;
|
||||
|
||||
const renderDropdown = () => {
|
||||
if (isError) {
|
||||
const commitValue = (nextValue: string) => {
|
||||
startTransition(() => {
|
||||
setValue(nextValue);
|
||||
setDraft(nextValue);
|
||||
setHasEdited(false);
|
||||
setKeyboardIndex(-1);
|
||||
});
|
||||
};
|
||||
|
||||
const clearValue = () => {
|
||||
commitValue("");
|
||||
combobox.openDropdown("keyboard");
|
||||
};
|
||||
|
||||
const openWithSuggestedSearch = () => {
|
||||
if (!draft && !value && !hasEdited && suggestedSearch?.trim()) {
|
||||
setDraft(suggestedSearch.trim());
|
||||
}
|
||||
combobox.openDropdown();
|
||||
};
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
clearValue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
combobox.closeDropdown("keyboard");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!directUrl && orderedOptions.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
||||
event.preventDefault();
|
||||
combobox.openDropdown("keyboard");
|
||||
setKeyboardIndex((current) => {
|
||||
const next =
|
||||
event.key === "ArrowDown"
|
||||
? current >= orderedOptions.length - 1
|
||||
? 0
|
||||
: current + 1
|
||||
: current <= 0
|
||||
? orderedOptions.length - 1
|
||||
: current - 1;
|
||||
combobox.selectOption(next);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (keyboardIndex >= 0 && orderedOptions[keyboardIndex]) {
|
||||
event.preventDefault();
|
||||
commitValue(orderedOptions[keyboardIndex].url);
|
||||
combobox.closeDropdown("keyboard");
|
||||
} else if (isImageSource(draft)) {
|
||||
event.preventDefault();
|
||||
commitValue(draft.trim());
|
||||
combobox.closeDropdown("keyboard");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderIconOptions = (icons: typeof sections.local, offset: number) => (
|
||||
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6}>
|
||||
{icons.map((icon, index) => (
|
||||
<Tooltip key={icon.id} label={`${icon.name} · ${icon.repositorySlug}`} openDelay={500}>
|
||||
<Combobox.Option
|
||||
value={icon.url}
|
||||
aria-label={icon.name}
|
||||
active={icon.url === value || keyboardIndex === offset + index}
|
||||
className={classes.iconOption}
|
||||
>
|
||||
<Image src={icon.url} alt="" w={30} h={30} fit="contain" fallbackSrc="/logo/logo.png" />
|
||||
{isSvgImage(icon.url) && <span className={classes.formatBadge}>SVG</span>}
|
||||
</Combobox.Option>
|
||||
</Tooltip>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
const renderSearchResults = () => {
|
||||
if (query.isError) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconSearchOff size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.error")}</Text>
|
||||
<Button size="xs" variant="default" onClick={() => refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<PickerMessage
|
||||
icon={<IconSearchOff size={22} />}
|
||||
title={tCommon("iconPicker.error")}
|
||||
action={
|
||||
<Button size="xs" variant="default" onClick={() => query.refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return Array(15)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6} p="xs">
|
||||
{Array.from({ length: 12 }, (_, index) => (
|
||||
<Skeleton key={index} height={52} radius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
if (totalOptions > 0) {
|
||||
return <Stack gap={4}>{groups}</Stack>;
|
||||
|
||||
if (totalOptions === 0) {
|
||||
return (
|
||||
<PickerMessage
|
||||
icon={<IconMoodEmpty size={22} />}
|
||||
title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconMoodEmpty size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
||||
<Stack gap="sm" p="xs">
|
||||
{sections.local.length > 0 && (
|
||||
<PickerSection icon={<IconPhoto size={15} />} label={tCommon("iconPicker.localImages")}>
|
||||
{renderIconOptions(sections.local, 0)}
|
||||
</PickerSection>
|
||||
)}
|
||||
{sections.svg.length > 0 && (
|
||||
<PickerSection label={tCommon("iconPicker.svgIcons")} badge="SVG">
|
||||
{renderIconOptions(sections.svg, sections.local.length)}
|
||||
</PickerSection>
|
||||
)}
|
||||
{sections.other.length > 0 && (
|
||||
<PickerSection icon={<IconCloud size={15} />} label={tCommon("iconPicker.otherImages")}>
|
||||
{renderIconOptions(sections.other, sections.local.length + sections.svg.length)}
|
||||
</PickerSection>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const leftSection = (() => {
|
||||
if (directUrl && previewState === "loading") return <Loader size={15} />;
|
||||
if (directUrl && previewState === "error") return <IconPhotoOff size={17} color="var(--mantine-color-red-6)" />;
|
||||
if (shouldShowPreview(value)) {
|
||||
return (
|
||||
<Image
|
||||
src={value}
|
||||
alt=""
|
||||
w={22}
|
||||
h={22}
|
||||
fit="contain"
|
||||
onLoad={() => directUrl && setPreviewState("ready")}
|
||||
onError={() => directUrl && setPreviewState("error")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <IconPhoto size={17} stroke={1.5} />;
|
||||
})();
|
||||
|
||||
const placeholderText =
|
||||
placeholder ??
|
||||
(query.data
|
||||
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
|
||||
: tCommon("iconPicker.headerLoading"));
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal>
|
||||
<Combobox.Target>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<InputBase
|
||||
flex={1}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={leftSection}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
const trimmedValue = nextValue.trim();
|
||||
setQuery(nextValue);
|
||||
if (isDirectIconUrl(trimmedValue)) {
|
||||
setValue(trimmedValue);
|
||||
combobox.closeDropdown();
|
||||
} else {
|
||||
combobox.openDropdown();
|
||||
}
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setQuery("");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
placeholder={placeholderText}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
startTransition(() => {
|
||||
setValue(first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal
|
||||
resetSelectionOnOptionHover
|
||||
onOptionSubmit={(url) => {
|
||||
commitValue(url);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target withExpandedAttribute>
|
||||
<InputBase
|
||||
flex={1}
|
||||
leftSection={leftSection}
|
||||
rightSection={
|
||||
<Group gap={2} wrap="nowrap">
|
||||
{canUpload && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
commitValue(first.url);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<Tooltip label={tCommon("iconPicker.uploadImage")}>
|
||||
<ActionIcon
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
loading={loading}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
aria-label={tCommon("iconPicker.uploadImage")}
|
||||
>
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
{(draft || value) && (
|
||||
<CloseButton
|
||||
size="sm"
|
||||
aria-label={tCommon("iconPicker.clear")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={clearValue}
|
||||
/>
|
||||
)}
|
||||
{query.isFetching && !directUrl ? <Loader size={14} /> : <Combobox.Chevron />}
|
||||
</Group>
|
||||
}
|
||||
rightSectionWidth={canUpload ? 86 : 56}
|
||||
rightSectionPointerEvents="all"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
setDraft(nextValue);
|
||||
setHasEdited(true);
|
||||
setKeyboardIndex(-1);
|
||||
combobox.openDropdown("keyboard");
|
||||
combobox.updateSelectedOptionIndex();
|
||||
|
||||
if (!nextValue) {
|
||||
setValue("");
|
||||
} else if (isImageSource(nextValue)) {
|
||||
setValue(nextValue.trim());
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={openWithSuggestedSearch}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
openWithSuggestedSearch();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
}}
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
description={tCommon("iconPicker.description")}
|
||||
styles={{
|
||||
description: { color: "var(--mantine-color-text)" },
|
||||
input: { color: "var(--mantine-color-text)" },
|
||||
error: { color: "light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2))" },
|
||||
}}
|
||||
placeholder={placeholderText}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{renderDropdown()}
|
||||
<Combobox.Dropdown className={classes.dropdown}>
|
||||
<Combobox.Options className={classes.options} aria-label={tCommon("iconPicker.results")}>
|
||||
{directUrl ? (
|
||||
<Paper p="sm" m="xs" className={classes.urlPreview}>
|
||||
<Group wrap="nowrap" align="center">
|
||||
<Box className={classes.previewFrame}>
|
||||
{previewState === "loading" && <Loader size={18} className={classes.previewLoader} />}
|
||||
{previewState === "error" ? (
|
||||
<ThemeIcon variant="light" color="red" size={40}>
|
||||
<IconPhotoOff size={21} />
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<Image
|
||||
src={directUrl}
|
||||
alt=""
|
||||
w={40}
|
||||
h={40}
|
||||
fit="contain"
|
||||
data-testid="direct-url-preview"
|
||||
onLoad={() => setPreviewState("ready")}
|
||||
onError={() => setPreviewState("error")}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex={1} miw={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{previewState === "error"
|
||||
? tCommon("iconPicker.urlError")
|
||||
: previewState === "ready"
|
||||
? tCommon("iconPicker.urlReady")
|
||||
: tCommon("iconPicker.urlLoading")}
|
||||
</Text>
|
||||
<Text size="xs" truncate>
|
||||
{directUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
{previewState === "ready" && (
|
||||
<ThemeIcon variant="light" color="green" size="sm" aria-hidden>
|
||||
<IconCheck size={14} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
renderSearchResults()
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
||||
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||
interface PickerSectionProps {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
badge?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PickerSection = ({ label, icon, badge, children }: PickerSectionProps) => (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6} px={2}>
|
||||
{icon}
|
||||
<Text size="xs" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{badge && <span className={classes.sectionBadge}>{badge}</span>}
|
||||
</Group>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
interface PickerMessageProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
const PickerMessage = ({ icon, title, action }: PickerMessageProps) => (
|
||||
<Stack align="center" gap="xs" p="lg" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size="lg">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text size="sm">{title}</Text>
|
||||
{action}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
||||
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
||||
if (!value) return false;
|
||||
return !localizationPathRegex.test(value);
|
||||
};
|
||||
|
||||
const isDirectIconUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { IconGroup } from "./icon-picker.utils";
|
||||
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
|
||||
|
||||
const groups = [
|
||||
{
|
||||
id: "remote",
|
||||
slug: "dashboard-icons",
|
||||
icons: [
|
||||
{ id: "long", name: "home-assistant-alt.svg", url: "https://cdn.example.com/home-assistant-alt.svg" },
|
||||
{ id: "raster", name: "Home Assistant.png", url: "https://cdn.example.com/home-assistant.png" },
|
||||
{ id: "svg", name: "home-assistant.svg", url: "https://cdn.example.com/home-assistant.svg?v=1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
slug: "local",
|
||||
icons: [{ id: "local", name: "Home Assistant.png", url: "/api/user-medias/home-assistant" }],
|
||||
},
|
||||
] as IconGroup[];
|
||||
|
||||
describe("icon picker utilities", () => {
|
||||
test("arranges local images before remote SVG and raster images", () => {
|
||||
const result = arrangeIconPickerSections(groups, "home assistant");
|
||||
|
||||
expect(result.local.map((icon) => icon.id)).toEqual(["local"]);
|
||||
expect(result.svg.map((icon) => icon.id)).toEqual(["svg", "long"]);
|
||||
expect(result.other.map((icon) => icon.id)).toEqual(["raster"]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["https://example.com/icon.png", true],
|
||||
["http://localhost:3000/icon", true],
|
||||
["ftp://example.com/icon.png", false],
|
||||
["javascript:alert(1)", false],
|
||||
["example.com/icon.png", false],
|
||||
])("validates direct image URLs", (value, expected) => {
|
||||
expect(isDirectImageUrl(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test("accepts internal image paths without treating search text as a source", () => {
|
||||
expect(isImageSource("/api/user-medias/123")).toBe(true);
|
||||
expect(isImageSource("shopify")).toBe(false);
|
||||
});
|
||||
|
||||
test("recognizes SVG URLs with query strings case-insensitively", () => {
|
||||
expect(isSvgImage("https://example.com/ICON.SVG?raw=1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
|
||||
|
||||
type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
export type IconGroup = RouterOutput["icon"]["findIcons"]["icons"][number];
|
||||
export type PickerIcon = IconGroup["icons"][number] & { repositorySlug: string };
|
||||
|
||||
export interface IconPickerSections {
|
||||
local: PickerIcon[];
|
||||
svg: PickerIcon[];
|
||||
other: PickerIcon[];
|
||||
}
|
||||
|
||||
export const arrangeIconPickerSections = (iconGroups: IconGroup[], searchTerm = ""): IconPickerSections => {
|
||||
const sections: IconPickerSections = { local: [], svg: [], other: [] };
|
||||
|
||||
for (const group of iconGroups) {
|
||||
for (const icon of group.icons) {
|
||||
const pickerIcon = { ...icon, repositorySlug: group.slug };
|
||||
if (group.slug === "local") {
|
||||
sections.local.push(pickerIcon);
|
||||
} else if (isSvgImage(icon.url)) {
|
||||
sections.svg.push(pickerIcon);
|
||||
} else {
|
||||
sections.other.push(pickerIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSearch = normalizeImageName(searchTerm);
|
||||
if (normalizedSearch) {
|
||||
const compareMatches = (a: PickerIcon, b: PickerIcon) => {
|
||||
const aRank = getImageMatchRank(normalizedSearch, a.name || a.url) ?? Number.MAX_SAFE_INTEGER;
|
||||
const bRank = getImageMatchRank(normalizedSearch, b.name || b.url) ?? Number.MAX_SAFE_INTEGER;
|
||||
return aRank - bRank || a.name.localeCompare(b.name);
|
||||
};
|
||||
return {
|
||||
local: sections.local.toSorted(compareMatches),
|
||||
svg: sections.svg.toSorted(compareMatches),
|
||||
other: sections.other.toSorted(compareMatches),
|
||||
};
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
export const isDirectImageUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isImageSource = (value: string) => isDirectImageUrl(value) || value.trim().startsWith("/");
|
||||
|
||||
export const isSvgImage = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value, "http://homarr.local");
|
||||
return url.pathname.toLowerCase().endsWith(".svg");
|
||||
} catch {
|
||||
return value.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".svg") ?? false;
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@ export * from "./new-app/_app-new-form";
|
||||
export * from "./new-app/_form";
|
||||
|
||||
export * from "./icon-picker/icon-picker";
|
||||
export * from "./new-app/icon-matcher";
|
||||
|
||||
export * from "./upload-media/upload-media";
|
||||
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
import { IconPicker } from "../icon-picker/icon-picker";
|
||||
import { findBestIconMatch } from "./icon-matcher";
|
||||
|
||||
type FormType = z.infer<typeof appManageSchema>;
|
||||
|
||||
@@ -81,9 +79,6 @@ export const AppForm = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialValuesKey]);
|
||||
|
||||
// Debounce the name value with 200ms delay
|
||||
const [debouncedName] = useDebouncedValue(form.values.name, 200);
|
||||
|
||||
const shouldCreateAnother = useRef(false);
|
||||
const handleSubmit = (values: FormType) => {
|
||||
const redirect = !shouldCreateAnother.current;
|
||||
@@ -130,29 +125,13 @@ export const AppForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-select icon based on app name with debounced search
|
||||
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
|
||||
{
|
||||
searchText: debouncedName,
|
||||
},
|
||||
{
|
||||
enabled: debouncedName.length > 3,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedName && !form.values.iconUrl && iconsData?.icons) {
|
||||
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
|
||||
if (bestMatch) {
|
||||
form.setFieldValue("iconUrl", bestMatch);
|
||||
}
|
||||
}
|
||||
}, [debouncedName, iconsData]);
|
||||
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
{...form.getInputProps("iconUrl")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
<Textarea
|
||||
{...form.getInputProps("description")}
|
||||
label={t("app.field.description.label")}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
|
||||
type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
type IconGroupsOutput = RouterOutput["icon"]["findIcons"]["icons"];
|
||||
|
||||
export const findBestIconMatch = (searchTerm: string, iconGroups: IconGroupsOutput): string | null => {
|
||||
const nameLower = searchTerm.toLowerCase();
|
||||
const allIcons = iconGroups.flatMap((group) => group.icons);
|
||||
|
||||
const getIconPriority = (iconUrl: string) => {
|
||||
const fileName = iconUrl.toLowerCase().split("/").pop()?.split(".")[0];
|
||||
if (!fileName) return -1;
|
||||
|
||||
const isSvg = iconUrl.endsWith(".svg");
|
||||
const isExactMatch = fileName === nameLower;
|
||||
|
||||
if (isExactMatch) return isSvg ? 0 : 1;
|
||||
if (fileName.includes(nameLower)) return isSvg ? 2 : 3;
|
||||
return -1;
|
||||
};
|
||||
|
||||
for (let priority = 0; priority <= 3; priority++) {
|
||||
const match = allIcons.find((icon) => getIconPriority(icon.url) === priority);
|
||||
if (match) return match.url;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -53,7 +53,10 @@ 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";
|
||||
|
||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||
@@ -108,6 +111,8 @@ export const integrationCreators = {
|
||||
emby: EmbyIntegration,
|
||||
nextcloud: NextcloudIntegration,
|
||||
unifiController: UnifiControllerIntegration,
|
||||
unifiOs: UnifiOsIntegration,
|
||||
unifiSiteManager: UnifiSiteManagerIntegration,
|
||||
opnsense: OPNsenseIntegration,
|
||||
ical: ICalIntegration,
|
||||
ntfy: NTFYIntegration,
|
||||
@@ -133,6 +138,7 @@ export const integrationCreators = {
|
||||
beszel: BeszelIntegration,
|
||||
bazarr: BazarrIntegration,
|
||||
traefik: TraefikIntegration,
|
||||
wud: WudIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
|
||||
*/
|
||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||
headers: this.getAuthHeaders(),
|
||||
headers: {
|
||||
...this.getAuthHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// General integrations
|
||||
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
|
||||
export { WudIntegration } from "./wud/wud-integration";
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||
@@ -91,11 +92,15 @@ export type {
|
||||
ArchiveTeamWarriorItem,
|
||||
ArchiveTeamWarriorStatus,
|
||||
} from "./archive-team-warrior/archive-team-warrior-types";
|
||||
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
|
||||
|
||||
// Schemas
|
||||
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>;
|
||||
}
|
||||
@@ -85,6 +85,11 @@ export class SynologyClient {
|
||||
if (volumesFromV2.length > 0) {
|
||||
return volumesFromV2;
|
||||
}
|
||||
|
||||
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
|
||||
if (legacyVolumesFromV2.length > 0) {
|
||||
return legacyVolumesFromV2;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
|
||||
integrationId: this.integrationId,
|
||||
|
||||
@@ -78,6 +78,7 @@ export const synologyStorageV2VolumeSchema = z
|
||||
|
||||
export const synologyStorageV2DataSchema = z.object({
|
||||
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
|
||||
vol_info: z.array(synologyVolumeInfoSchema).optional(),
|
||||
});
|
||||
|
||||
export const synologyUtilizationCpuSchema = z.object({
|
||||
|
||||
@@ -426,6 +426,60 @@ describe("SynologyIntegration.getSystemInfoAsync", () => {
|
||||
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
|
||||
});
|
||||
|
||||
test("uses vol_info from storage_v2 without falling back to legacy storage", async () => {
|
||||
mockSuccessfulFlow({
|
||||
storageV2: {
|
||||
success: true,
|
||||
data: {
|
||||
vol_info: [
|
||||
{
|
||||
name: "volume_1",
|
||||
used_size: "4000000000",
|
||||
total_size: "10000000000",
|
||||
status: "normal",
|
||||
},
|
||||
{
|
||||
name: "volume_2",
|
||||
used_size: "5000000000",
|
||||
total_size: "20000000000",
|
||||
status: "attention",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toEqual([
|
||||
{
|
||||
deviceName: "volume_1",
|
||||
used: "4000000000",
|
||||
available: "6000000000",
|
||||
percentage: 40,
|
||||
},
|
||||
{
|
||||
deviceName: "volume_2",
|
||||
used: "5000000000",
|
||||
available: "15000000000",
|
||||
percentage: 25,
|
||||
},
|
||||
]);
|
||||
|
||||
const legacyStorageRequests = mockFetch.mock.calls.filter(([url]) => {
|
||||
const urlString = toUrlString(url);
|
||||
|
||||
return (
|
||||
getApiFromUrl(urlString) === "SYNO.Core.System" &&
|
||||
getMethodFromUrl(urlString) === "info" &&
|
||||
getTypeFromUrl(urlString) === "storage"
|
||||
);
|
||||
});
|
||||
|
||||
expect(legacyStorageRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("continues when optional tier-2 calls fail", async () => {
|
||||
mockSuccessfulFlow({ skipOptional: true });
|
||||
const integration = createIntegration();
|
||||
|
||||
@@ -59,10 +59,12 @@ const ws = vi.hoisted(() => {
|
||||
}
|
||||
|
||||
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
|
||||
// A responder returning an Error mimics a method the server rejects, e.g. an unsupported filter.
|
||||
const body = result instanceof Error ? { error: { message: result.message } } : { result };
|
||||
const envelope =
|
||||
payload.jsonrpc === "2.0"
|
||||
? { jsonrpc: "2.0", id: payload.id, result }
|
||||
: { msg: "result", id: payload.id, result };
|
||||
? { jsonrpc: "2.0", id: payload.id, ...body }
|
||||
: { msg: "result", id: payload.id, ...body };
|
||||
this.respond(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
@@ -91,7 +93,10 @@ const ws = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const logs = vi.hoisted(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }));
|
||||
|
||||
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => logs }));
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||
@@ -148,6 +153,14 @@ const happyResponder = (method: string) => {
|
||||
];
|
||||
case "pool.query":
|
||||
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
|
||||
case "pool.dataset.query":
|
||||
return [
|
||||
{
|
||||
id: "tank",
|
||||
used: { parsed: 200 },
|
||||
available: { parsed: 300 },
|
||||
},
|
||||
];
|
||||
case "interface.query":
|
||||
return [{ id: "eth0", name: "eth0" }];
|
||||
case "reporting.netdata_get_data":
|
||||
@@ -159,6 +172,11 @@ const happyResponder = (method: string) => {
|
||||
|
||||
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
|
||||
|
||||
// `pool.query` reports 500 allocated of a 1000 byte pool, leaving 500 free.
|
||||
const physicalFallbackFileSystem = [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }];
|
||||
|
||||
const ERROR_MESSAGE_LIMIT = 200;
|
||||
|
||||
let integrationCounter = 0;
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
|
||||
new TrueNasIntegration({
|
||||
@@ -180,6 +198,7 @@ describe("TrueNasIntegration", () => {
|
||||
ws.constructedUrls.length = 0;
|
||||
ws.failures.length = 0;
|
||||
ws.setResponder(happyResponder);
|
||||
logs.warn.mockClear();
|
||||
});
|
||||
|
||||
test("fetches and maps system info over the JSON-RPC API", async () => {
|
||||
@@ -191,6 +210,18 @@ describe("TrueNasIntegration", () => {
|
||||
// JSON-RPC does not use the legacy "connect" session handshake.
|
||||
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
|
||||
|
||||
const datasetPayload = ws.sentPayloads.find((payload) => payload.method === "pool.dataset.query");
|
||||
expect(datasetPayload).toMatchObject({
|
||||
params: [
|
||||
[["id", "in", ["tank"]]],
|
||||
{
|
||||
extra: {
|
||||
properties: ["used", "available"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
version: "TrueNAS-SCALE-24.10",
|
||||
cpuModelName: "Intel Xeon",
|
||||
@@ -204,12 +235,131 @@ describe("TrueNasIntegration", () => {
|
||||
loadAverage: null,
|
||||
gpu: [],
|
||||
network: { up: 20_000, down: 10_000 },
|
||||
// `available` is the free space left on the pool (free = 500), not its total size.
|
||||
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
||||
// `available` and `used` come from the root dataset, not the physical pool.
|
||||
fileSystem: [{ deviceName: "tank", available: "300", used: "200", percentage: 40 }],
|
||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the root dataset is not returned", async () => {
|
||||
ws.setResponder((method) => (method === "pool.dataset.query" ? [] : happyResponder(method)));
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
// Dropping the pool would also hide its health status.
|
||||
expect(result.smart).toMatchObject([{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" }]);
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the root dataset reports no usable values", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? [{ id: "tank", used: { parsed: null }, available: null }]
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the root dataset omits the requested properties", async () => {
|
||||
// Datasets that cannot report space, e.g. while locked, come back without `used` and `available`.
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query" ? [{ id: "tank", locked: true }] : happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the dataset response fails schema validation", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query" ? { unexpected: "not an array" } : happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
// A malformed payload must not take the rest of the system info down with it.
|
||||
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the dataset query is rejected", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? new Error("[EINVAL] filters: Value error, Invalid operation: in")
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
// A rejected dataset query must not take the rest of the system info down with it.
|
||||
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
|
||||
});
|
||||
|
||||
test("logs the truncated rejection reason rather than the generic wrapper message", async () => {
|
||||
// TrueNAS answers a rejected method with its Python traceback, which runs to several kilobytes.
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? new Error(`[EINVAL] query-filters ${"x".repeat(5000)}`)
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
await integration.getSystemInfoAsync();
|
||||
|
||||
const logged = logs.warn.mock.calls.find(([message]) => String(message).includes("Could not retrieve"));
|
||||
const reason = String((logged?.[1] as { error?: string } | undefined)?.error);
|
||||
// The decorator would otherwise surface only "An unknown error occured while executing Integration method".
|
||||
expect(reason).toContain("[EINVAL] query-filters");
|
||||
expect(reason).toHaveLength(ERROR_MESSAGE_LIMIT + 1); // truncated, plus the ellipsis
|
||||
});
|
||||
|
||||
test("keeps reporting a pool that is unhealthy but has no usable dataset space", async () => {
|
||||
ws.setResponder((method) => {
|
||||
if (method === "pool.query")
|
||||
return [
|
||||
{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 },
|
||||
{ name: "backup", status: "DEGRADED", healthy: false, free: 10, size: 100, allocated: 90 },
|
||||
];
|
||||
if (method === "pool.dataset.query") return [{ id: "tank", used: { parsed: 200 }, available: { parsed: 300 } }];
|
||||
return happyResponder(method);
|
||||
});
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.smart).toMatchObject([
|
||||
{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" },
|
||||
{ deviceName: "backup", healthy: false, overallStatus: "DEGRADED" },
|
||||
]);
|
||||
expect(result.fileSystem).toMatchObject([
|
||||
{ deviceName: "tank", available: "300", used: "200", percentage: 40 },
|
||||
{ deviceName: "backup", available: "10", used: "90", percentage: 90 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports zero usage for an empty root dataset", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? [{ id: "tank", used: { parsed: 0 }, available: { parsed: 0 } }]
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem[0]?.percentage).toBe(0);
|
||||
});
|
||||
|
||||
test("authenticates with an API key when one is configured", async () => {
|
||||
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
|
||||
|
||||
|
||||
@@ -15,6 +15,20 @@ const logger = createLogger({ module: "trueNasIntegration" });
|
||||
|
||||
const NETWORK_MULTIPLIER = 100;
|
||||
|
||||
const ERROR_MESSAGE_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* The error decorator replaces a rejected request with a generic message and moves the actionable
|
||||
* reason to `cause`. TrueNAS puts its middleware traceback in there, several kilobytes of it, so the
|
||||
* reason is truncated before it reaches a log line that repeats on every poll.
|
||||
*/
|
||||
const describeRequestError = (error: unknown) => {
|
||||
const cause = error instanceof Error ? error.cause : undefined;
|
||||
const reason = cause instanceof Error ? cause.message : error instanceof Error ? error.message : String(error);
|
||||
|
||||
return reason.length > ERROR_MESSAGE_LIMIT ? `${reason.slice(0, ERROR_MESSAGE_LIMIT)}…` : reason;
|
||||
};
|
||||
|
||||
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||
private client?: TrueNasClient;
|
||||
|
||||
@@ -49,9 +63,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
memUsedInBytes,
|
||||
fileSystem: datasets.map((dataset) => ({
|
||||
deviceName: dataset.name,
|
||||
available: `${dataset.free}`, // free space left on the pool
|
||||
used: `${dataset.allocated}`,
|
||||
percentage: (dataset.allocated / dataset.size) * 100,
|
||||
available: `${dataset.available}`,
|
||||
used: `${dataset.used}`,
|
||||
percentage:
|
||||
dataset.used + dataset.available === 0 ? 0 : (dataset.used / (dataset.used + dataset.available)) * 100,
|
||||
})),
|
||||
availablePkgUpdates: 0,
|
||||
network: {
|
||||
@@ -107,9 +122,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
.map((pool) => {
|
||||
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
|
||||
return {
|
||||
...pool,
|
||||
name: pool.name,
|
||||
status: pool.status,
|
||||
healthy: pool.healthy,
|
||||
allocated: pool.allocated,
|
||||
size: pool.size,
|
||||
free: pool.free,
|
||||
};
|
||||
}
|
||||
@@ -117,12 +133,76 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
return null;
|
||||
})
|
||||
.filter((pool) => pool !== null);
|
||||
|
||||
logger.debug("Retrieved pools", {
|
||||
url: this.integration.url,
|
||||
totalCount: result.length,
|
||||
activeCount: activePools.length,
|
||||
});
|
||||
return activePools;
|
||||
|
||||
if (activePools.length === 0) return [];
|
||||
|
||||
const usableSpaceByPool = await this.getUsableSpaceByPoolAsync(activePools.map((pool) => pool.name));
|
||||
|
||||
return activePools.map((pool) => {
|
||||
const usableSpace = usableSpaceByPool.get(pool.name);
|
||||
if (!usableSpace) {
|
||||
logger.warn("Falling back to physical pool space, the root dataset reported no usable space", {
|
||||
url: this.integration.url,
|
||||
pool: pool.name,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: pool.name,
|
||||
status: pool.status,
|
||||
healthy: pool.healthy,
|
||||
used: usableSpace?.used ?? pool.allocated,
|
||||
available: usableSpace?.available ?? pool.free,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the usable space of each pool's root ZFS dataset, keyed by pool name. Unlike the physical
|
||||
* `pool.query` values, these account for RAIDZ parity overhead and dataset limits, matching `zfs list`.
|
||||
* See https://github.com/homarr-labs/homarr/issues/6566
|
||||
*
|
||||
* A pool is left out when its usable space is unavailable, so the caller keeps reporting the pool with
|
||||
* its physical values instead of dropping it, which would also hide its health status:
|
||||
* - request rejected → method or `extra.properties` unsupported, or the API key lacks the dataset role
|
||||
* - response rejected → the payload does not match the expected shape
|
||||
* - dataset missing → the pool's root dataset was not returned by the query
|
||||
* - property absent → the dataset does not report `used` / `available`, e.g. while locked
|
||||
*/
|
||||
private async getUsableSpaceByPoolAsync(poolNames: string[]) {
|
||||
const usableSpaceByPool = new Map<string, { used: number; available: number }>();
|
||||
|
||||
try {
|
||||
const datasets = await this.requestAsync("pool.dataset.query", [
|
||||
[["id", "in", poolNames]],
|
||||
{
|
||||
extra: {
|
||||
properties: ["used", "available"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
for (const dataset of await poolDatasetSchema.parseAsync(datasets)) {
|
||||
const used = dataset.used?.parsed;
|
||||
const available = dataset.available?.parsed;
|
||||
if (typeof used !== "number" || typeof available !== "number") continue;
|
||||
|
||||
usableSpaceByPool.set(dataset.id, { used, available });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Could not retrieve root dataset space, continuing with physical pool space", {
|
||||
url: this.integration.url,
|
||||
error: describeRequestError(error),
|
||||
});
|
||||
}
|
||||
|
||||
return usableSpaceByPool;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,6 +343,19 @@ const poolSchema = z.array(
|
||||
}),
|
||||
);
|
||||
|
||||
// Properties are absent or unparsed for datasets that do not report them, e.g. while locked,
|
||||
// so they are optional here and the pool falls back to its physical values instead of failing
|
||||
// the whole request, which would also drop cpu, memory and network data
|
||||
const poolDatasetPropertySchema = z.object({ parsed: z.number().min(0).nullish() }).nullish();
|
||||
|
||||
const poolDatasetSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
used: poolDatasetPropertySchema,
|
||||
available: poolDatasetPropertySchema,
|
||||
}),
|
||||
);
|
||||
|
||||
const reportingNetDataSchema = z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user