Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbe13f5d39 | ||
|
|
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,10 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.76.2
|
||||
- 1.76.1
|
||||
- 1.76.0
|
||||
- 1.75.0
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
- 1.72.0
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
name: Homarr v2 beta feedback
|
||||
description: Report a specific bug, unexpected behavior, or improvement for the Homarr v2 beta
|
||||
title: "feedback(v2): "
|
||||
labels: ["needs triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for testing Homarr v2. It will become the default when it is ready, and every detailed report helps us get there.
|
||||
|
||||
We cannot guess what you wanted from “this looks wrong.” Tell us exactly which feature is affected, what happened, why it is wrong for your use case, and what you expected instead. Add screenshots or a short recording whenever possible.
|
||||
|
||||
**Not useful:** “The board looks wrong.”
|
||||
|
||||
**Useful:** “The container changes from four columns to three after I move a widget at 1440 × 900 and 100% browser zoom. I expected it to keep four columns.”
|
||||
|
||||
- type: dropdown
|
||||
id: feedback-type
|
||||
attributes:
|
||||
label: Feedback type
|
||||
options:
|
||||
- Bug
|
||||
- Upgrade or migration
|
||||
- Usability or design
|
||||
- Performance
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Homarr v2 version or image tag
|
||||
description: Copy the version from the About page or provide the exact Docker image tag you tested.
|
||||
placeholder: ghcr.io/homarr-labs/homarr-test:v2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: affected-area
|
||||
attributes:
|
||||
label: Affected page, feature, or widget
|
||||
description: Name the exact page, widget, integration, container, sidebar, or action involved.
|
||||
placeholder: "Board editor: moving a widget into a container"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: behavior
|
||||
attributes:
|
||||
label: What happened, why is it wrong, and what should happen instead?
|
||||
description: Describe the actual result, why it does not work for you, and the exact result you expected.
|
||||
placeholder: |
|
||||
What happened:
|
||||
Why this is wrong for my use case:
|
||||
What I expected instead:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Exact steps to reproduce
|
||||
description: Start from the page you opened and list every click, keypress, drag, setting, and value needed to reproduce it.
|
||||
placeholder: |
|
||||
1. Open...
|
||||
2. Set...
|
||||
3. Drag or click...
|
||||
4. Observe...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: visual-evidence
|
||||
attributes:
|
||||
label: Screenshots or screen recordings
|
||||
description: Drag screenshots or a recording into this field. Include the full page for context and a close-up of the problem. Blur secrets and personal data. If you cannot add visuals, explain why.
|
||||
placeholder: Drag screenshots or a recording here, or explain why none are available.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Device and Homarr environment
|
||||
description: Provide every detail that could affect the result.
|
||||
placeholder: |
|
||||
Device type and model:
|
||||
Operating system and version:
|
||||
Browser and version:
|
||||
Installation method (Docker, Unraid, Kubernetes, other):
|
||||
Database (SQLite, MySQL, PostgreSQL):
|
||||
Reverse proxy and version, if used:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: board-context
|
||||
attributes:
|
||||
label: Board and display context
|
||||
description: Include the layout details even when you are not sure they matter.
|
||||
placeholder: |
|
||||
Display resolution:
|
||||
Browser zoom:
|
||||
Desktop or mobile layout:
|
||||
Board column count:
|
||||
Approximate number of items:
|
||||
Container or sidebar nesting:
|
||||
Relevant board or widget settings:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Logs and additional information
|
||||
description: Add relevant browser console errors, Homarr logs, integration details, or anything else that may help. Remove secrets and personal information first.
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I added screenshots or a recording where possible, or explained why I could not.
|
||||
required: true
|
||||
- label: I described both what happened and the exact result I expected.
|
||||
required: true
|
||||
- label: I searched the existing issues for the same feedback.
|
||||
required: true
|
||||
- label: I removed passwords, API keys, tokens, and other private information.
|
||||
required: true
|
||||
@@ -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,11 @@
|
||||
## [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
|
||||
```
|
||||
|
||||
@@ -11,6 +11,12 @@ tags:
|
||||
import Link from '@docusaurus/Link';
|
||||
import { IconArrowRight } from '@tabler/icons-react';
|
||||
|
||||
:::info Homarr v2 beta is available
|
||||
|
||||
We've been working on Homarr's v2 update for a while now. There is no fixed release date yet, it will be ready when it's ready. We **need your help** and feedback to help us become confident enough to release it for everyone. Read [what is new](https://v2.preview.homarr.dev/blog/2026/09/03/homarr-2.0), browse the [v2 documentation](https://v2.preview.homarr.dev/docs/getting-started/) and [Workshop](https://v2.preview.homarr.dev/workshop), or try the [hosted preview](https://app-v2.preview.homarr.dev). If anything breaks or does not feel right, [tell us on Discord](https://discord.com/invite/aCsmEV5RgA) in the dedicated v2 channel for the fastest response, or [open a dedicated GitHub issue](https://github.com/homarr-labs/homarr/issues/new?template=v2_beta_feedback.yml). SQLite administrators can download a full backup directly from the beta announcement before changing images.
|
||||
|
||||
:::
|
||||
|
||||
Before you try to install Homarr, please review if your device meets the requirements.
|
||||
Installing on an unsupported device may lead to unstable behaviour and no support will be provided.
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,17 +1,21 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { JSX, PropsWithChildren } from "react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { cookies } from "next/headers";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { v2BetaAnnouncementCookieKey } from "@homarr/definitions";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { MainHeaderContent } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { V2BetaDashboardShell } from "~/components/layout/v2-beta-announcement/v2-beta-dashboard-shell";
|
||||
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
||||
import { env } from "~/env";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
@@ -42,6 +46,7 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}>) => {
|
||||
const resolvedParams = await params;
|
||||
const sessionPromise = auth();
|
||||
const cookiesPromise = cookies();
|
||||
const initialBoardPromise = getInitialBoard(resolvedParams).then(
|
||||
(board) => ({ status: "fulfilled", board }) as const,
|
||||
(error: unknown) => ({ status: "rejected", error }) as const,
|
||||
@@ -58,11 +63,12 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
|
||||
const [session, initialBoardResult, colorScheme, shouldRunBoardTour, cookieStore] = await Promise.all([
|
||||
sessionPromise,
|
||||
initialBoardPromise,
|
||||
colorSchemePromise,
|
||||
shouldRunBoardTourPromise,
|
||||
cookiesPromise,
|
||||
]);
|
||||
if (initialBoardResult.status === "rejected") {
|
||||
const { error } = initialBoardResult;
|
||||
@@ -83,6 +89,11 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
throw error;
|
||||
}
|
||||
const initialBoard = initialBoardResult.board;
|
||||
const viewerIdentity = session?.user.id ?? "anonymous";
|
||||
const viewerHash = createHash("sha256").update(viewerIdentity).digest("hex").slice(0, 16);
|
||||
const dismissalCookieName = `${v2BetaAnnouncementCookieKey}.${viewerHash}`;
|
||||
const isAnnouncementDismissed = cookieStore.get(dismissalCookieName)?.value === "dismissed";
|
||||
const canExportBackup = dbEnv.DRIVER === "better-sqlite3" && Boolean(session?.user.permissions.includes("admin"));
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
@@ -91,14 +102,20 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
<V2BetaDashboardShell
|
||||
canExportBackup={canExportBackup}
|
||||
dismissalCookieName={dismissalCookieName}
|
||||
initiallyDismissed={isAnnouncementDismissed}
|
||||
header={
|
||||
<MainHeaderContent
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</V2BetaDashboardShell>
|
||||
</BoardTourGate>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
|
||||
+4
-65
@@ -1,49 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
|
||||
import { Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconDatabaseExport } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
|
||||
import { BackupExportButton } from "~/components/backup";
|
||||
|
||||
export const BackupExportCard = () => {
|
||||
const t = useScopedI18n("management.page.tool.backup.export");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/backup/export");
|
||||
if (!response.ok) {
|
||||
const errorMessage = await response.json().then(
|
||||
(data) => data.error,
|
||||
() => `Server returned ${response.status}`,
|
||||
);
|
||||
throw new Error(errorMessage ?? t("error"));
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename =
|
||||
response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setShowConfirm(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
@@ -55,33 +20,7 @@ export const BackupExportCard = () => {
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showConfirm ? (
|
||||
<DangerousActionConfirmation
|
||||
title={t("confirm.title")}
|
||||
warningTitle={t("confirm.warningTitle")}
|
||||
warningBody={t("confirm.warningBody")}
|
||||
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
|
||||
submitLabel={t("confirm.submit")}
|
||||
submitIcon={<IconDownload size={16} />}
|
||||
cancelLabel={t("confirm.cancel")}
|
||||
onConfirm={handleExport}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
disabled={loading}
|
||||
color="orange"
|
||||
/>
|
||||
) : (
|
||||
<Group>
|
||||
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
|
||||
{t("button")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
<BackupExportButton />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Stack } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconDownload } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
||||
|
||||
export const BackupExportButton = () => {
|
||||
const t = useScopedI18n("management.page.tool.backup.export");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/backup/export");
|
||||
if (!response.ok) {
|
||||
const errorMessage = await response.json().then(
|
||||
(data) => data.error,
|
||||
() => `Server returned ${response.status}`,
|
||||
);
|
||||
throw new Error(errorMessage ?? t("error"));
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename =
|
||||
response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setShowConfirm(false);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showConfirm ? (
|
||||
<DangerousActionConfirmation
|
||||
title={t("confirm.title")}
|
||||
warningTitle={t("confirm.warningTitle")}
|
||||
warningBody={t("confirm.warningBody")}
|
||||
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
|
||||
submitLabel={t("confirm.submit")}
|
||||
submitIcon={<IconDownload size={16} />}
|
||||
cancelLabel={t("confirm.cancel")}
|
||||
onConfirm={handleExport}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
disabled={loading}
|
||||
color="orange"
|
||||
/>
|
||||
) : (
|
||||
<Group>
|
||||
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
|
||||
{t("button")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { BackupExportButton } from "./backup-export-button";
|
||||
export { DatabaseRestoreFlow } from "./database-restore-flow";
|
||||
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
||||
export type { BackupAnalysis, RestoreStep } from "./types";
|
||||
@@ -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,
|
||||
|
||||
@@ -19,25 +19,31 @@ interface Props {
|
||||
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
return (
|
||||
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
|
||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <ClientBurger />}
|
||||
<UnstyledButton component={Link} href="/">
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<TourTarget id="board-search">
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<LazySpotlight />
|
||||
<MainHeaderContent logo={logo} actions={actions} hasNavigation={hasNavigation} />
|
||||
</AppShellHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export const MainHeaderContent = ({ logo, actions, hasNavigation = true }: Props) => (
|
||||
<>
|
||||
<Group h={60} gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <ClientBurger />}
|
||||
<UnstyledButton component={Link} href="/">
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<TourTarget id="board-search">
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<LazySpotlight />
|
||||
</>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -10,11 +10,13 @@ import { navigationCollapsedAtom } from "./header/burger";
|
||||
interface ClientShellProps {
|
||||
hasHeader?: boolean;
|
||||
hasNavigation?: boolean;
|
||||
headerHeight?: number;
|
||||
}
|
||||
|
||||
export const ClientShell = ({
|
||||
hasHeader = true,
|
||||
hasNavigation = true,
|
||||
headerHeight = 60,
|
||||
children,
|
||||
}: PropsWithChildren<ClientShellProps>) => {
|
||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||
@@ -23,7 +25,7 @@ export const ClientShell = ({
|
||||
return (
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
header={hasHeader ? { height: 60 } : undefined}
|
||||
header={hasHeader ? { height: headerHeight } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const v2BetaBlogUrl = "https://v2.preview.homarr.dev/blog/2026/09/03/homarr-2.0";
|
||||
export const v2BetaDocsUrl = "https://v2.preview.homarr.dev/docs/getting-started/";
|
||||
export const v2BetaPreviewUrl = "https://app-v2.preview.homarr.dev";
|
||||
export const v2BetaWorkshopUrl = "https://v2.preview.homarr.dev/workshop";
|
||||
export const v2BetaFeedbackUrl = "https://github.com/homarr-labs/homarr/issues/new?template=v2_beta_feedback.yml";
|
||||
export const v2BetaDiscussionUrl = "https://github.com/homarr-labs/homarr/issues/6600";
|
||||
export const discordInviteUrl = "https://discord.com/invite/aCsmEV5RgA";
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
.banner {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
height: 52px;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-white);
|
||||
background: var(--mantine-color-red-9);
|
||||
border-bottom: 1px solid rgb(255 255 255 / 18%);
|
||||
}
|
||||
|
||||
.banner::after {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
right: -0.35rem;
|
||||
bottom: -1.75rem;
|
||||
color: rgb(255 255 255 / 9%);
|
||||
font-size: 5.5rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
content: "v2";
|
||||
}
|
||||
|
||||
.copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.modalHero {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-white);
|
||||
background: var(--mantine-color-red-9);
|
||||
}
|
||||
|
||||
.modalHero::after {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
right: -0.75rem;
|
||||
bottom: -1.5rem;
|
||||
color: rgb(255 255 255 / 8%);
|
||||
font-size: 12rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
content: "v2";
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
height: 100%;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.diffViewer {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diffHeader {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.diffCode {
|
||||
margin: 0;
|
||||
padding-block: var(--mantine-spacing-sm);
|
||||
overflow-x: auto;
|
||||
color: light-dark(var(--mantine-color-dark-8), var(--mantine-color-gray-2));
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.diffLine_context,
|
||||
.diffLine_added,
|
||||
.diffLine_removed {
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding-inline: var(--mantine-spacing-md);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.diffLine_added {
|
||||
color: light-dark(var(--mantine-color-green-9), var(--mantine-color-green-3));
|
||||
background: light-dark(var(--mantine-color-green-0), rgb(47 158 68 / 15%));
|
||||
}
|
||||
|
||||
.diffLine_removed {
|
||||
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-3));
|
||||
background: light-dark(var(--mantine-color-red-0), rgb(250 82 82 / 15%));
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Accordion,
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
getDefaultZIndex,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowsMove,
|
||||
IconBook2,
|
||||
IconBrandCss3,
|
||||
IconBrandDiscord,
|
||||
IconBrandGithub,
|
||||
IconDatabaseExport,
|
||||
IconExternalLink,
|
||||
IconHeartHandshake,
|
||||
IconRobot,
|
||||
IconRocket,
|
||||
IconTerminal2,
|
||||
IconWand,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { setClientCookie } from "@homarr/common";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
|
||||
import { BackupExportButton } from "~/components/backup";
|
||||
import {
|
||||
discordInviteUrl,
|
||||
v2BetaBlogUrl,
|
||||
v2BetaDiscussionUrl,
|
||||
v2BetaDocsUrl,
|
||||
v2BetaFeedbackUrl,
|
||||
v2BetaPreviewUrl,
|
||||
v2BetaWorkshopUrl,
|
||||
} from "./constants";
|
||||
import classes from "./v2-beta-announcement.module.css";
|
||||
|
||||
const diffLineClassNames = {
|
||||
added: classes.diffLine_added,
|
||||
context: classes.diffLine_context,
|
||||
removed: classes.diffLine_removed,
|
||||
} as const;
|
||||
|
||||
const composeDiffLines = [
|
||||
{ kind: "context", value: " services:" },
|
||||
{ kind: "context", value: " homarr:" },
|
||||
{ kind: "added", value: "+ image: ghcr.io/homarr-labs/homarr-test:v2" },
|
||||
{ kind: "removed", value: "- image: ghcr.io/homarr-labs/homarr:latest" },
|
||||
{ kind: "context", value: " ports:" },
|
||||
{ kind: "context", value: ' - "7575:7575"' },
|
||||
{ kind: "context", value: " volumes:" },
|
||||
{ kind: "context", value: " - homarr-v2:/appdata" },
|
||||
{ kind: "context", value: " environment:" },
|
||||
{ kind: "context", value: " SECRET_ENCRYPTION_KEY: <SECRET_KEY>" },
|
||||
{ kind: "added", value: "+ WORKSHOP_API_URL: https://v2.preview.homarr.dev/ # Only during the beta" },
|
||||
{ kind: "context", value: "" },
|
||||
{ kind: "context", value: " volumes:" },
|
||||
{ kind: "context", value: " homarr-v2:" },
|
||||
] as const;
|
||||
|
||||
interface V2BetaAnnouncementProps {
|
||||
canExportBackup: boolean;
|
||||
dismissalCookieName: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const V2BetaAnnouncement = ({ canExportBackup, dismissalCookieName, onDismiss }: V2BetaAnnouncementProps) => {
|
||||
const { openModal } = useModalAction(V2BetaAnnouncementModal);
|
||||
|
||||
const dismiss = () => {
|
||||
setClientCookie(dismissalCookieName, "dismissed", {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: window.location.protocol === "https:",
|
||||
});
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="aside" className={classes.banner} aria-label="Homarr v2 beta announcement">
|
||||
<Group h="100%" justify="space-between" gap="sm" wrap="nowrap" px="md">
|
||||
<Box className={classes.copy}>
|
||||
<Text fw={750} size="sm" truncate>
|
||||
We've been cooking, Homarr v2 beta is here
|
||||
</Text>
|
||||
<Text className={classes.subtitle} visibleFrom="sm" size="xs" truncate>
|
||||
Custom Widgets & CSS workshop, new drag-and-drop, new container system, side-bars are back, new widgets
|
||||
and so much more, come check it out and help us make v2 ready for everyone
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button variant="white" color="red" size="compact-sm" onClick={() => openModal({ canExportBackup })}>
|
||||
Explore v2
|
||||
</Button>
|
||||
<ActionIcon variant="subtle" color="white" onClick={dismiss} aria-label="Dismiss the v2 beta announcement">
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface V2BetaAnnouncementModalProps {
|
||||
canExportBackup: boolean;
|
||||
}
|
||||
|
||||
const V2BetaAnnouncementModal = createModal<V2BetaAnnouncementModalProps>(({ innerProps }) => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Paper className={classes.modalHero} p={{ base: "lg", sm: "xl" }} radius="md">
|
||||
<Stack gap="sm" maw={760}>
|
||||
<Title order={2}>Help us get Homarr v2 ready</Title>
|
||||
<Text>
|
||||
We've been working on Homarr's v2 update for a while now. There is no fixed release date yet, it
|
||||
will be ready when it's ready. We{" "}
|
||||
<Text span inherit fw={700}>
|
||||
need your help
|
||||
</Text>{" "}
|
||||
and feedback to help us become confident enough to release it for everyone.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FeatureCard icon={<IconBrandCss3 size={21} />} title="Workshop">
|
||||
Share and discover Custom Widgets and CSS made by the Homarr community.
|
||||
</FeatureCard>
|
||||
<FeatureCard icon={<IconArrowsMove size={21} />} title="Complete drag-and-drop rewrite">
|
||||
Move, resize, multi-select, and arrange your dashboard with a board editor rebuilt from the ground up.
|
||||
</FeatureCard>
|
||||
<FeatureCard icon={<IconWand size={21} />} title="Custom Widgets v2">
|
||||
We think you can make almost any widget with live previews, API requests, options, actions, and validation.
|
||||
</FeatureCard>
|
||||
<FeatureCard icon={<IconRobot size={21} />} title="Assistant">
|
||||
Assistant can directly make a Custom Widget for you. Try giving it the API docs for your latest selfhosted
|
||||
tool and asking it to build a custom widget for it.
|
||||
</FeatureCard>
|
||||
</SimpleGrid>
|
||||
|
||||
<Accordion variant="separated" radius="md">
|
||||
<Accordion.Item value="installation">
|
||||
<Accordion.Control
|
||||
icon={
|
||||
<ThemeIcon variant="light" color="red" radius="md" size="lg">
|
||||
<IconTerminal2 size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
aria-label="Test Homarr v2 on your server"
|
||||
>
|
||||
<Box>
|
||||
<Text fw={750}>Test Homarr v2 on your server</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
Back up this instance, then update your Docker Compose file.
|
||||
</Text>
|
||||
</Box>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<V2BetaInstallInstructions canExportBackup={innerProps.canExportBackup} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<Alert variant="light" color="red" icon={<IconHeartHandshake size={22} />} title="Your testing is essential">
|
||||
<Text size="sm">
|
||||
Everyone uses Homarr in a different way. If anything breaks or does not feel right, the easiest way to get it
|
||||
fixed as soon as possible is to{" "}
|
||||
<Anchor
|
||||
href={discordInviteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
c="inherit"
|
||||
fz="sm"
|
||||
fw={700}
|
||||
underline="always"
|
||||
>
|
||||
tell us about it on Discord
|
||||
</Anchor>{" "}
|
||||
in our dedicated v2 channel. You can also{" "}
|
||||
<Anchor
|
||||
href={v2BetaFeedbackUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
c="inherit"
|
||||
fz="sm"
|
||||
fw={700}
|
||||
underline="always"
|
||||
>
|
||||
open a dedicated GitHub issue
|
||||
</Anchor>
|
||||
{"."} Every report matters.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
<Button
|
||||
component="a"
|
||||
href={v2BetaPreviewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
leftSection={<IconRocket size={18} />}
|
||||
>
|
||||
Try the hosted preview
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={v2BetaWorkshopUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="light"
|
||||
size="md"
|
||||
leftSection={<IconBrandCss3 size={18} />}
|
||||
>
|
||||
Open Workshop
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={v2BetaDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
size="md"
|
||||
leftSection={<IconBook2 size={18} />}
|
||||
>
|
||||
Read the v2 docs
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={v2BetaBlogUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
size="md"
|
||||
rightSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
See everything new
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={discordInviteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
size="md"
|
||||
leftSection={<IconBrandDiscord size={18} />}
|
||||
>
|
||||
Join us on Discord
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={v2BetaDiscussionUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
size="md"
|
||||
rightSection={<IconExternalLink size={15} />}
|
||||
>
|
||||
Follow v2 development
|
||||
</Button>
|
||||
</SimpleGrid>
|
||||
|
||||
<Button
|
||||
component="a"
|
||||
href={v2BetaFeedbackUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="light"
|
||||
color="red"
|
||||
size="md"
|
||||
leftSection={<IconBrandGithub size={18} />}
|
||||
>
|
||||
Report anything that breaks or feels wrong
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: "Homarr v2 beta",
|
||||
size: 1000,
|
||||
yOffset: 128,
|
||||
zIndex: getDefaultZIndex("modal") + 2,
|
||||
});
|
||||
|
||||
interface V2BetaInstallInstructionsProps {
|
||||
canExportBackup: boolean;
|
||||
}
|
||||
|
||||
const V2BetaInstallInstructions = ({ canExportBackup }: V2BetaInstallInstructionsProps) => (
|
||||
<Stack gap="lg">
|
||||
{canExportBackup ? (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm">
|
||||
<IconDatabaseExport size={22} />
|
||||
<Title order={4}>Back up this Homarr instance</Title>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
Download a full backup before testing v2. The archive contains sensitive data, so keep it somewhere safe.
|
||||
</Text>
|
||||
<BackupExportButton />
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Alert color="blue" title="Back up your database before continuing">
|
||||
Direct backup downloads are available here to Homarr administrators using SQLite. If you use MySQL or
|
||||
PostgreSQL, back up the database with its own tools before testing v2.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>Update your Docker Compose file</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Replace the current image and add the beta-only Workshop URL. Use the example below as a starting point.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Paper withBorder radius="md" className={classes.diffViewer}>
|
||||
<Group gap="xs" px="md" py="sm" className={classes.diffHeader}>
|
||||
<IconTerminal2 size={17} />
|
||||
<Text fw={700} size="sm">
|
||||
docker-compose.yml
|
||||
</Text>
|
||||
</Group>
|
||||
<Box component="pre" className={classes.diffCode} aria-label="Docker Compose changes for Homarr v2 beta">
|
||||
{composeDiffLines.map((line, index) => (
|
||||
<Box component="span" className={diffLineClassNames[line.kind]} key={`${line.kind}-${index}`}>
|
||||
{line.value || " "}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
Replace <code><SECRET_KEY></code> with your existing encryption key. The Workshop URL is only required while
|
||||
the beta uses the preview Workshop.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
interface FeatureCardProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const FeatureCard = ({ icon, title, children }: FeatureCardProps) => (
|
||||
<Paper className={classes.featureCard} withBorder p="md" radius="md">
|
||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="red" radius="md" size="lg">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={750} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { AppShellHeader, AppShellMain } from "@mantine/core";
|
||||
|
||||
import { ClientShell } from "../shell";
|
||||
import { V2BetaAnnouncement } from "./v2-beta-announcement";
|
||||
|
||||
interface V2BetaDashboardShellProps {
|
||||
canExportBackup: boolean;
|
||||
dismissalCookieName: string;
|
||||
initiallyDismissed: boolean;
|
||||
header: ReactNode;
|
||||
}
|
||||
|
||||
export const V2BetaDashboardShell = ({
|
||||
canExportBackup,
|
||||
dismissalCookieName,
|
||||
initiallyDismissed,
|
||||
header,
|
||||
children,
|
||||
}: PropsWithChildren<V2BetaDashboardShellProps>) => {
|
||||
const [isAnnouncementVisible, setIsAnnouncementVisible] = useState(!initiallyDismissed);
|
||||
const headerHeight = isAnnouncementVisible ? 112 : 60;
|
||||
|
||||
return (
|
||||
<ClientShell hasNavigation={false} headerHeight={headerHeight}>
|
||||
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
|
||||
{isAnnouncementVisible && (
|
||||
<V2BetaAnnouncement
|
||||
canExportBackup={canExportBackup}
|
||||
dismissalCookieName={dismissalCookieName}
|
||||
onDismiss={() => setIsAnnouncementVisible(false)}
|
||||
/>
|
||||
)}
|
||||
{header}
|
||||
</AppShellHeader>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.74.0",
|
||||
"version": "1.76.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -44,4 +44,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,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";
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const colorSchemeCookieKey = "homarr.color-scheme";
|
||||
export const localeCookieKey = "homarr.locale";
|
||||
export const v2BetaAnnouncementCookieKey = "homarr.v2-beta-announcement";
|
||||
@@ -332,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"
|
||||
@@ -384,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";
|
||||
@@ -54,5 +54,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
beszel: "beszel",
|
||||
traefik: "traefik",
|
||||
archiveTeamWarrior: null,
|
||||
wud: "whats-up-docker",
|
||||
mock: null,
|
||||
};
|
||||
@@ -55,4 +55,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
wud: "whats-up-docker",
|
||||
};
|
||||
@@ -468,6 +468,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",
|
||||
|
||||
@@ -24,7 +24,9 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
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 +52,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
traefik: ["traefik"],
|
||||
wud: ["wud"],
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
|
||||
@@ -53,6 +53,7 @@ export const widgetKinds = [
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"wud",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import { UmamiIntegration } from "../umami/umami-integration";
|
||||
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-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>(
|
||||
@@ -133,6 +134,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,6 +92,7 @@ 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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -37,9 +37,16 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
const systemInfo = await this.getSystemInformationAsync();
|
||||
|
||||
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
|
||||
const cpuCount = systemInfo.info.cpu.cores;
|
||||
const cpuCount = systemInfo.metrics.cpu.cpus.length;
|
||||
|
||||
// Each entry in cpus[] is one logical CPU (thread). Dividing the summed per-thread
|
||||
// utilization by the thread count (not physical cores) gives the correct average and
|
||||
// avoids over-reporting on SMT systems. Guard against zero to avoid Infinity/NaN.
|
||||
let cpuUtilizationNormalized = 0;
|
||||
if (cpuCount > 0) {
|
||||
cpuUtilizationNormalized = cpuUtilization / cpuCount;
|
||||
}
|
||||
|
||||
// We use "info" object instead of the stats since this is the exact amount the kernel sees, which is what Unraid displays.
|
||||
const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0);
|
||||
const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100);
|
||||
const uptime = dayjs(systemInfo.info.os.uptime);
|
||||
@@ -47,7 +54,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
return {
|
||||
version: systemInfo.info.os.release,
|
||||
cpuModelName: systemInfo.info.cpu.brand,
|
||||
cpuUtilization: cpuUtilization / cpuCount,
|
||||
cpuUtilization: cpuUtilizationNormalized,
|
||||
memUsedInBytes: usedMemory,
|
||||
memAvailableInBytes: totalMemory - usedMemory,
|
||||
uptime: dayjs().diff(uptime, "seconds"),
|
||||
@@ -139,7 +146,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
},
|
||||
memory {
|
||||
layout {
|
||||
size
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment node
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
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 type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { WudIntegration } from "../wud-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://wud.example.com";
|
||||
const TEST_USERNAME = "user";
|
||||
const TEST_PASSWORD = "p@ss";
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const sampleContainersResponse = [
|
||||
{
|
||||
id: "1",
|
||||
name: "homeassistant",
|
||||
displayName: "Home Assistant",
|
||||
updateAvailable: true,
|
||||
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
|
||||
image: { tag: { value: "2023.12.0" } },
|
||||
updateKind: { remoteValue: "2024.1.0" },
|
||||
},
|
||||
{ id: "2", name: "traefik", updateAvailable: false },
|
||||
{
|
||||
id: "3",
|
||||
name: "grafana",
|
||||
updateAvailable: true,
|
||||
image: { tag: { value: "10.0.0" } },
|
||||
result: { tag: "10.1.0" },
|
||||
},
|
||||
];
|
||||
|
||||
const sampleUpdates = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Home Assistant",
|
||||
currentVersion: "2023.12.0",
|
||||
newVersion: "2024.1.0",
|
||||
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "grafana",
|
||||
currentVersion: "10.0.0",
|
||||
newVersion: "10.1.0",
|
||||
link: null,
|
||||
},
|
||||
];
|
||||
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
|
||||
new WudIntegration({
|
||||
id: "test-wud",
|
||||
name: "Test WUD",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
const createIntegrationWithBasicAuth = () =>
|
||||
createIntegration([
|
||||
{ kind: "username", value: TEST_USERNAME },
|
||||
{ kind: "password", value: TEST_PASSWORD },
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("WudIntegration getStatsAsync", () => {
|
||||
test("computes total and available-update counts from the containers list", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats).toStrictEqual({ totalContainers: 3, updatesAvailable: 2, updates: sampleUpdates });
|
||||
});
|
||||
|
||||
test("returns zero counts for an empty containers list", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats).toStrictEqual({ totalContainers: 0, updatesAvailable: 0, updates: [] });
|
||||
});
|
||||
|
||||
test("maps container updates using displayName, falling back to result.tag and null link", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats.updates).toStrictEqual(sampleUpdates);
|
||||
});
|
||||
|
||||
test("throws ParseError when API response is not valid JSON", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response("not-json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
|
||||
if (!(error instanceof IntegrationParseError)) return false;
|
||||
|
||||
const cause = error.cause;
|
||||
return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response");
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ParseError when API response does not match schema", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ invalid: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
|
||||
if (!(error instanceof IntegrationParseError)) return false;
|
||||
|
||||
const cause = error.cause;
|
||||
return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response");
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when API returns an error", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WudIntegration authentication", () => {
|
||||
test("sends no Authorization header when no credentials are configured", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await createIntegration().getStatsAsync();
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sends a Basic auth header when username and password are configured", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await createIntegrationWithBasicAuth().getStatsAsync();
|
||||
|
||||
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
|
||||
const [url, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(String(url)).toContain("/api/containers");
|
||||
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
|
||||
expect(requestInit?.timeout).toBe(10_000);
|
||||
});
|
||||
|
||||
test("does not send credentials over a non-HTTPS URL", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const integration = new WudIntegration({
|
||||
id: "test-wud-http",
|
||||
name: "Test WUD HTTP",
|
||||
url: "http://wud.example.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: TEST_USERNAME },
|
||||
{ kind: "password", value: TEST_PASSWORD },
|
||||
],
|
||||
});
|
||||
|
||||
await integration.getStatsAsync();
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WudIntegration testing endpoint", () => {
|
||||
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
|
||||
|
||||
const invokeTesting = (integration: WudIntegration, input: IntegrationTestingInput) => {
|
||||
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
|
||||
return testing(input);
|
||||
};
|
||||
|
||||
const makeInput = (response: Response): IntegrationTestingInput =>
|
||||
({
|
||||
fetchAsync: vi.fn().mockResolvedValue(response),
|
||||
}) as unknown as IntegrationTestingInput;
|
||||
|
||||
test("returns success when /api/app responds 200", async () => {
|
||||
const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }));
|
||||
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("returns a status-code failure when /api/app responds non-OK", async () => {
|
||||
const input = makeInput(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("passes the Basic auth header through to fetchAsync when configured", async () => {
|
||||
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
|
||||
const input = makeInput(response);
|
||||
|
||||
await invokeTesting(createIntegrationWithBasicAuth(), input);
|
||||
|
||||
const fetchAsyncMock = vi.mocked(input.fetchAsync);
|
||||
const init = fetchAsyncMock.mock.calls[0]?.[1];
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
|
||||
expect(headers.Authorization).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { ParseError, ResponseError } from "@homarr/common/server";
|
||||
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 { WudStats } from "./wud-types";
|
||||
import { mapWudStats, parseWudContainersResponseAsync } from "./wud-types";
|
||||
|
||||
const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class WudIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/app"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
try {
|
||||
await response.json();
|
||||
} catch (error) {
|
||||
return TestConnectionError.ParseResult(
|
||||
new ParseError("Invalid WUD app response", {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getStatsAsync(): Promise<WudStats> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/containers"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
timeout: CONTAINERS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const containers = await parseWudContainersResponseAsync(response);
|
||||
|
||||
return mapWudStats(containers);
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> | undefined {
|
||||
if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.url("/api/app").protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const WUD_CONTAINERS_PARSE_ERROR_MESSAGE = "Invalid WUD containers response";
|
||||
|
||||
export const wudContainerSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
displayName: z.string().nullable().optional(),
|
||||
updateAvailable: z.boolean(),
|
||||
link: z.string().nullable().optional(),
|
||||
image: z
|
||||
.object({
|
||||
tag: z
|
||||
.object({
|
||||
value: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
updateKind: z
|
||||
.object({
|
||||
remoteValue: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
result: z
|
||||
.object({
|
||||
tag: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const wudContainersResponseSchema = z.array(wudContainerSchema);
|
||||
|
||||
export interface WudContainerUpdate {
|
||||
id: string;
|
||||
name: string;
|
||||
currentVersion: string | null;
|
||||
newVersion: string | null;
|
||||
link: string | null;
|
||||
}
|
||||
|
||||
export interface WudStats {
|
||||
totalContainers: number;
|
||||
updatesAvailable: number;
|
||||
updates: WudContainerUpdate[];
|
||||
}
|
||||
|
||||
export const parseWudContainersResponseAsync = async (response: {
|
||||
json: () => Promise<unknown>;
|
||||
}): Promise<z.infer<typeof wudContainersResponseSchema>> => {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await response.json();
|
||||
} catch (error) {
|
||||
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = await wudContainersResponseSchema.safeParseAsync(json);
|
||||
if (!parseResult.success) {
|
||||
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, { cause: parseResult.error });
|
||||
}
|
||||
|
||||
return parseResult.data;
|
||||
};
|
||||
|
||||
const mapContainerUpdate = (container: z.infer<typeof wudContainerSchema>): WudContainerUpdate => ({
|
||||
id: container.id,
|
||||
name: container.displayName ?? container.name,
|
||||
currentVersion: container.image?.tag?.value ?? null,
|
||||
newVersion: container.updateKind?.remoteValue ?? container.result?.tag ?? null,
|
||||
link: container.link ?? null,
|
||||
});
|
||||
|
||||
export const mapWudStats = (containers: z.infer<typeof wudContainersResponseSchema>): WudStats => ({
|
||||
totalContainers: containers.length,
|
||||
updatesAvailable: containers.filter((container) => container.updateAvailable).length,
|
||||
updates: containers.filter((container) => container.updateAvailable).map(mapContainerUpdate),
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { HomeAssistantIntegration } from "../src";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const integration = new HomeAssistantIntegration({
|
||||
id: "home-assistant",
|
||||
name: "Home Assistant",
|
||||
url: "http://home-assistant.local:8123",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [{ kind: "apiKey", value: "secret" }],
|
||||
});
|
||||
|
||||
describe("HomeAssistantIntegration actions", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue({ ok: true } as never);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["toggle", () => integration.triggerToggleAsync("light.desk"), "/api/services/homeassistant/toggle", "light.desk"],
|
||||
[
|
||||
"automation",
|
||||
() => integration.triggerAutomationAsync("automation.turn_on_lights"),
|
||||
"/api/services/automation/trigger",
|
||||
"automation.turn_on_lights",
|
||||
],
|
||||
])("sends the %s service call as JSON", async (_action, trigger, path, entityId) => {
|
||||
await expect(trigger()).resolves.toBe(true);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(new URL(`http://home-assistant.local:8123${path}`), {
|
||||
headers: {
|
||||
Authorization: "Bearer secret",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ entity_id: entityId }),
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { getServerStatistics, init } from "@immich/sdk";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getAllTrustedCertificatesAsync: vi.fn(async () => []),
|
||||
getTrustedCertificateHostnamesAsync: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync", () => {
|
||||
test("x-api-key set via init() reaches the server", async () => {
|
||||
let receivedXApiKey: string | string[] | undefined;
|
||||
const server = createServer((req, res) => {
|
||||
receivedXApiKey = req.headers["x-api-key"];
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ usage: 1000, photos: 5, videos: 2 }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
|
||||
init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" });
|
||||
try {
|
||||
const stats = await getServerStatistics({
|
||||
fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(receivedXApiKey).toBe("sdk-configured-key");
|
||||
expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
|
||||
|
||||
export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
||||
SystemHealthMonitoring,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
|
||||
@@ -18,4 +18,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
|
||||
|
||||
return result.data.state;
|
||||
},
|
||||
// The widget already polls every 30 seconds and refetches after toggles.
|
||||
cacheTtlMs: 0,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { WudStats } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const wudStatsRequestHandler = createIntegrationRequestHandler<WudStats, "wud", Record<string, never>>({
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
},
|
||||
});
|
||||
+1563
-1517
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1344,6 +1344,10 @@
|
||||
"name": "crwdns5753:0crwdne5753:0",
|
||||
"description": "crwdns5755:0crwdne5755:0"
|
||||
},
|
||||
"wud": {
|
||||
"name": "crwdns6193:0crwdne6193:0",
|
||||
"description": "crwdns6195:0crwdne6195:0"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "crwdns5003:0crwdne5003:0",
|
||||
"description": "crwdns5005:0crwdne5005:0"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "crwdns552:0crwdne552:0",
|
||||
"iconPicker": {
|
||||
"label": "crwdns554:0crwdne554:0",
|
||||
"header": "crwdns556:0{countIcons}crwdne556:0",
|
||||
"headerLoading": "crwdns5681:0crwdne5681:0",
|
||||
"description": "crwdns6225:0crwdne6225:0",
|
||||
"header": "crwdns6227:0{countIcons}crwdne6227:0",
|
||||
"headerLoading": "crwdns6229:0crwdne6229:0",
|
||||
"clear": "crwdns6231:0crwdne6231:0",
|
||||
"uploadImage": "crwdns6233:0crwdne6233:0",
|
||||
"results": "crwdns6235:0crwdne6235:0",
|
||||
"localImages": "crwdns6237:0crwdne6237:0",
|
||||
"svgIcons": "crwdns6239:0crwdne6239:0",
|
||||
"otherImages": "crwdns6241:0crwdne6241:0",
|
||||
"urlLoading": "crwdns6243:0crwdne6243:0",
|
||||
"urlReady": "crwdns6245:0crwdne6245:0",
|
||||
"urlError": "crwdns6247:0crwdne6247:0",
|
||||
"noResults": "crwdns5683:0{search}crwdne5683:0",
|
||||
"error": "crwdns5685:0crwdne5685:0"
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "crwdns6197:0crwdne6197:0",
|
||||
"description": "crwdns6199:0crwdne6199:0",
|
||||
"title": "crwdns6201:0crwdne6201:0",
|
||||
"monitored": "crwdns6203:0crwdne6203:0",
|
||||
"updatesAvailable": "crwdns6205:0count={count}crwdne6205:0",
|
||||
"updateAvailable": "crwdns6207:0crwdne6207:0",
|
||||
"openReleaseNotes": "crwdns6209:0crwdne6209:0",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "crwdns6211:0crwdne6211:0"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "crwdns6213:0crwdne6213:0"
|
||||
},
|
||||
"layout": {
|
||||
"label": "crwdns6215:0crwdne6215:0",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "crwdns6217:0crwdne6217:0"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "crwdns6219:0crwdne6219:0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "crwdns6221:0crwdne6221:0",
|
||||
"description": "crwdns6223:0crwdne6223:0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "crwdns768:0crwdne768:0",
|
||||
"description": "crwdns770:0crwdne770:0",
|
||||
|
||||
+1750
-1704
File diff suppressed because it is too large.
Load diff
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Hvad Er Opad Docker",
|
||||
"description": "Overvåg hvilke af dine Docker-containere der er tilgængelige billedopdateringer"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumenthåndteringssystem til arkivering og organisering af scannede dokumenter"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "her",
|
||||
"iconPicker": {
|
||||
"label": "Ikon-URL",
|
||||
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig.",
|
||||
"headerLoading": "",
|
||||
"description": "Søg i biblioteket, indsæt en billed-URL, eller upload en fil.",
|
||||
"header": "Søg efter {countIcons} billeder efter navn",
|
||||
"headerLoading": "Søg billeder efter navn",
|
||||
"clear": "Ryd billede",
|
||||
"uploadImage": "Upload billede",
|
||||
"results": "Billede søgeresultater",
|
||||
"localImages": "Dine billeder",
|
||||
"svgIcons": "SVG ikoner",
|
||||
"otherImages": "Andre billeder",
|
||||
"urlLoading": "Kontrollerer billede…",
|
||||
"urlReady": "Billedet er klar til brug",
|
||||
"urlError": "Dette billede kunne ikke indlæses. Tjek URL'en og prøv igen.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Hvad Er Opad Docker",
|
||||
"description": "Vis hvor mange overvågede containere der er tilgængelige opdateringer.",
|
||||
"title": "Hvad Er Opad Docker",
|
||||
"monitored": "Overvåget",
|
||||
"updatesAvailable": "{count, plural, one {# opdatering tilgængelig} other {# opdateringer tilgængelige}}",
|
||||
"updateAvailable": "Opdatering tilgængelig",
|
||||
"openReleaseNotes": "Åbn udgivelsesnoter",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Vis titel"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Vis opdateringsring"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horisontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Vis liste over container-opdatering",
|
||||
"description": "Lister hver overvåget beholder, der har en billedopdatering tilgængelig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Oversigt",
|
||||
"description": "Viser oversigten over dit DNS-hul",
|
||||
@@ -3047,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Album ID",
|
||||
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Interval i sekunder",
|
||||
|
||||
+1674
-1628
File diff suppressed because it is too large.
Load diff
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-nativer Reverse-Proxy und Lastverteiler"
|
||||
},
|
||||
"wud": {
|
||||
"name": "Was ist Docker hoch",
|
||||
"description": "Überwachen Sie, welche Docker-Container Bildupdates verfügbar sind"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "Hier",
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.",
|
||||
"headerLoading": "Tippe um die Symbole zu durchsuchen…",
|
||||
"description": "Suchen Sie die Bibliothek, fügen Sie eine Bild-URL ein oder laden Sie eine Datei hoch.",
|
||||
"header": "Suche {countIcons} Bilder nach Namen",
|
||||
"headerLoading": "Bilder nach Namen suchen",
|
||||
"clear": "Bild löschen",
|
||||
"uploadImage": "Bild hochladen",
|
||||
"results": "Bildsuchergebnisse",
|
||||
"localImages": "Ihre Bilder",
|
||||
"svgIcons": "SVG-Symbole",
|
||||
"otherImages": "Andere Bilder",
|
||||
"urlLoading": "Bild wird überprüft…",
|
||||
"urlReady": "Bild ist bereit zur Verwendung",
|
||||
"urlError": "Dieses Bild konnte nicht geladen werden. Überprüfen Sie die URL und versuchen Sie es erneut.",
|
||||
"noResults": "Keine Symbole stimmen mit \"{search}\" überein.",
|
||||
"error": "Konnte Symbole nicht laden."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Was ist Docker hoch",
|
||||
"description": "Zeigt an, wie viele überwachte Container über Updates verfügen.",
|
||||
"title": "Was ist Docker hoch",
|
||||
"monitored": "Überwacht",
|
||||
"updatesAvailable": "{count, plural, one {# Update verfügbar} other {# Updates verfügbar}}",
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"openReleaseNotes": "Versionshinweise öffnen",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Titel anzeigen"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Update-Ring anzeigen"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Ansicht",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Container-Update-Liste anzeigen",
|
||||
"description": "Listet jeden überwachten Container auf, der eine Bildaktualisierung zur Verfügung hat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Zusammenfassung",
|
||||
"description": "Zeigt die Zusammenfassung deines DNS Holes an",
|
||||
@@ -2329,7 +2375,7 @@
|
||||
},
|
||||
"visibleStorageVolumes": {
|
||||
"label": "Sichtbare Speichervolumes",
|
||||
"description": "Nur verfügbar, wenn alle Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes zu zeigen. ",
|
||||
"description": "Nur verfügbar, wenn alle ausgewählten Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes anzuzeigen.",
|
||||
"placeholder": "Speichervolumen auswählen"
|
||||
},
|
||||
"defaultTab": {
|
||||
@@ -2439,7 +2485,7 @@
|
||||
"error": {
|
||||
"internalServerError": "Fehler beim Abrufen der Container Statistiken",
|
||||
"layoutSaveTitle": "Tabellenlayout nicht gespeichert",
|
||||
"layoutSaveMessage": "Die Docker-Tabelle konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden. "
|
||||
"layoutSaveMessage": "Das Docker-Tabellenlayout konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "Keine Docker-Container gefunden"
|
||||
@@ -2739,10 +2785,10 @@
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "Zeigt die Bitrate an",
|
||||
"description": ""
|
||||
"description": "Zeige die aktuelle Wiedergabe-Bitrate neben dem Ort an"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"label": "Standort anzeigen",
|
||||
"description": "Zeigt an, ob der Stream über das LAN oder WAN läuft"
|
||||
}
|
||||
},
|
||||
@@ -2758,8 +2804,8 @@
|
||||
"status": "Status",
|
||||
"directPlay": "Direkte Wiedergabe",
|
||||
"directStream": "Direkter Stream",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcodeVideo": "Video wird transkodiert",
|
||||
"transcodeAudio": "Audio wird transkodiert",
|
||||
"transcoding": "Transkodierung",
|
||||
"paused": "Pausiert",
|
||||
"remaining": "{minutes} Min. verbleibend",
|
||||
@@ -2966,8 +3012,8 @@
|
||||
}
|
||||
},
|
||||
"showOsVersion": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Betriebssystem-Version anzeigen",
|
||||
"description": "Füge die Betriebssystemversion jedem Verteilungsetikett hinzu, sofern verfügbar"
|
||||
},
|
||||
"showComplianceHero": {
|
||||
"label": "",
|
||||
@@ -3047,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Album ID",
|
||||
"description": "Dies ist eine eindeutige ID, die sich auf Ihr Immich Album bezieht. Sie können diese ID finden, indem Sie ein Album öffnen und die einzigartige Zeichenkette von der URL kopieren, zum Beispiel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "Dies ist eine eindeutige ID, die sich auf Ihr Immich Album bezieht. Sie können diese ID finden, indem Sie ein Album öffnen und die einzigartige Zeichenkette von der URL kopieren, zum Beispiel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Intervall in Sekunden",
|
||||
@@ -3117,8 +3163,8 @@
|
||||
"noColumns": "Spalten in Elementen auswählen",
|
||||
"noCommunications": "Kann keine Daten von der Integration laden",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"actionFailed": "Aktion fehlgeschlagen",
|
||||
"actionFailedMessage": "Diese Aktion konnte nicht abgeschlossen werden. Bitte versuche es erneut."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3406,7 +3452,7 @@
|
||||
"queued": ""
|
||||
},
|
||||
"type": {
|
||||
"movie": "",
|
||||
"movie": "Film",
|
||||
"episode": ""
|
||||
},
|
||||
"empty": {
|
||||
@@ -3766,7 +3812,7 @@
|
||||
},
|
||||
"resource": {
|
||||
"routers": "",
|
||||
"services": "",
|
||||
"services": "Dienste",
|
||||
"middlewares": ""
|
||||
},
|
||||
"status": {
|
||||
@@ -4052,16 +4098,16 @@
|
||||
"temp": "",
|
||||
"battery": "",
|
||||
"services": "Dienste",
|
||||
"uptime": "",
|
||||
"uptime": "Verfügbarkeit",
|
||||
"agent": "Agent"
|
||||
}
|
||||
},
|
||||
"beszelAlerts": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Beszel-Meldungen",
|
||||
"description": "Zeige Beszels Benachrichtigungseinstellungen und -verlauf",
|
||||
"option": {
|
||||
"showHistory": {
|
||||
"label": ""
|
||||
"label": "Alarmverlauf anzeigen"
|
||||
},
|
||||
"maxHistoryItems": {
|
||||
"label": ""
|
||||
@@ -4072,9 +4118,9 @@
|
||||
"internalServerError": ""
|
||||
},
|
||||
"history": "Verlauf",
|
||||
"resolved": "",
|
||||
"resolved": "Gelöst",
|
||||
"status": {
|
||||
"triggered": "",
|
||||
"triggered": "Ausgelöst",
|
||||
"ok": "Ok"
|
||||
}
|
||||
},
|
||||
@@ -4083,43 +4129,43 @@
|
||||
"description": "",
|
||||
"option": {
|
||||
"systemId": {
|
||||
"label": "",
|
||||
"label": "System",
|
||||
"description": ""
|
||||
},
|
||||
"timePeriod": {
|
||||
"label": ""
|
||||
"label": "Zeitraum"
|
||||
},
|
||||
"showCpu": {
|
||||
"label": ""
|
||||
"label": "CPU-Diagramm anzeigen"
|
||||
},
|
||||
"showMemory": {
|
||||
"label": ""
|
||||
"label": "RAM-Diagramm anzeigen"
|
||||
},
|
||||
"showDisk": {
|
||||
"label": ""
|
||||
"label": "Festplatten-Diagramm anzeigen"
|
||||
},
|
||||
"showDiskIO": {
|
||||
"label": ""
|
||||
"label": "Festplatten-E/A-Diagramm anzeigen"
|
||||
},
|
||||
"showNetwork": {
|
||||
"label": ""
|
||||
"label": "Netzwerk-Diagramm anzeigen"
|
||||
},
|
||||
"showDockerCpu": {
|
||||
"label": ""
|
||||
"label": "Docker-CPU-Diagramm anzeigen"
|
||||
},
|
||||
"showDockerMemory": {
|
||||
"label": ""
|
||||
"label": "Docker-RAM-Diagramm anzeigen"
|
||||
},
|
||||
"showDockerNetwork": {
|
||||
"label": ""
|
||||
"label": "Docker-Netzwerk-Diagramm anzeigen"
|
||||
}
|
||||
},
|
||||
"selectSystem": "System auswählen",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
"noSystems": "Keine Systeme gefunden"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"internalServerError": "Keine Verbindung zu Beszel möglich. Überprüfe deine Integrations-Einstellungen.",
|
||||
"liveConnectionFailed": "",
|
||||
"switchToHistorical": "",
|
||||
"systemNotFound": ""
|
||||
@@ -4127,48 +4173,48 @@
|
||||
"chart": {
|
||||
"cpu": {
|
||||
"title": "CPU Auslastung",
|
||||
"subtitle": "",
|
||||
"subtitle": "Durchschnittliche Systemweite CPU-Auslastung",
|
||||
"series": "CPU"
|
||||
},
|
||||
"memory": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"title": "Speicherverbrauch",
|
||||
"subtitle": "Genaue Auslastung zum erfassten Zeitpunkt",
|
||||
"series": "Belegt",
|
||||
"cache": ""
|
||||
},
|
||||
"disk": {
|
||||
"title": "Festplattenbelegung",
|
||||
"subtitle": "",
|
||||
"series": ""
|
||||
"series": "Root"
|
||||
},
|
||||
"diskIO": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"read": "",
|
||||
"write": ""
|
||||
"title": "Festplatten-E/A",
|
||||
"subtitle": "Durchsatz des Root-Dateisystems",
|
||||
"read": "Lesen",
|
||||
"write": "Schreiben"
|
||||
},
|
||||
"storage": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"series": ""
|
||||
"title": "Speicher",
|
||||
"subtitle": "Festplattenverbrauch pro Dateisystem",
|
||||
"series": "Belegt"
|
||||
},
|
||||
"network": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"sent": "",
|
||||
"recv": ""
|
||||
"title": "Bandbreite",
|
||||
"subtitle": "Netzwerkverkehr öffentlicher Schnittstellen",
|
||||
"sent": "Gesendet",
|
||||
"recv": "Empfangen"
|
||||
},
|
||||
"dockerCpu": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Docker-CPU-Nutzung",
|
||||
"subtitle": "Durchschnittliche CPU-Nutzung der Container"
|
||||
},
|
||||
"dockerMemory": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Docker-RAM-Nutzung",
|
||||
"subtitle": "RAM-Nutzung der Docker-Container"
|
||||
},
|
||||
"dockerNetwork": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Docker-Netzwerk-E/A",
|
||||
"subtitle": "Netzwerkverkehr der Docker-Container"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4545,7 +4591,7 @@
|
||||
"text": "Konnte die angeforderte Ressource nicht finden"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"title": "Diese Seite konnte nicht geladen werden",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
@@ -5110,7 +5156,7 @@
|
||||
"copy": "",
|
||||
"oneClick": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"subtitle": "OAuth wird verwendet. Authentifiziere dich bei der ersten Nutzung im Browser",
|
||||
"vsCode": "In VS Code installieren",
|
||||
"cursor": "In Cursor installieren",
|
||||
"copyUrl": "MCP URL kopieren",
|
||||
@@ -5130,7 +5176,7 @@
|
||||
"button": "Sicherung herunterladen",
|
||||
"error": "Export fehlgeschlagen",
|
||||
"confirm": {
|
||||
"title": "",
|
||||
"title": "Export sensibler Daten",
|
||||
"warningTitle": "Dieser Export enthält Ihre gesamte Datenbank.",
|
||||
"warningBody": "Das Backup umfasst alle Benutzer, Integrationsgeheimnisse (verschlüsselt), API-Keys, Board-Konfigurationen und App-Daten. Speichern Sie die exportierte Datei sicher ab und teilen Sie diese nicht.",
|
||||
"typePrompt": "Geben Sie \"{phrase}\" ein, um fortzufahren",
|
||||
@@ -5140,8 +5186,8 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Datenbank wiederherstellen",
|
||||
"description": "",
|
||||
"warning": "",
|
||||
"description": "Lade ein zuvor exportiertes Backup-ZIP‑File hoch, um deine Datenbank wiederherzustellen. Damit ersetzt du deine gesamte jetzige Datenbank. Falls das Backup mit einem anderen Verschlüsselungs-Key erstellt wurde, werden vertrauliche Daten (Secrets) automatisch neu verschlüsselt.",
|
||||
"warning": "Ein Backup wiederherzustellen, wird deine jetzige Datenbank komplett ersetzen und das Programm neu starten. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"dropzone": {
|
||||
"title": "Ziehen Sie Ihre Backup-ZIP hierher oder klicken Sie zum Durchsuchen",
|
||||
"description": "Nur .zip Dateien aus einer Homarr SQLite-Sicherung werden akzeptiert"
|
||||
@@ -6050,7 +6096,7 @@
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
"available": "",
|
||||
"available": "Verfügbar",
|
||||
"partiallyAvailable": "",
|
||||
"processing": "",
|
||||
"requested": "",
|
||||
@@ -6332,10 +6378,10 @@
|
||||
"header": {
|
||||
"season": "Staffel",
|
||||
"episodes": "Episoden",
|
||||
"status": ""
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"requested": ""
|
||||
"requested": "Angefragt"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -6448,18 +6494,18 @@
|
||||
}
|
||||
},
|
||||
"onboardingTour": {
|
||||
"learnMore": "",
|
||||
"next": "",
|
||||
"prev": "",
|
||||
"done": "",
|
||||
"skip": "",
|
||||
"learnMore": "Lese die Dokumentation",
|
||||
"next": "Weiter",
|
||||
"prev": "Zurück",
|
||||
"done": "Fertig",
|
||||
"skip": "Überspringe die Einführung",
|
||||
"manage": {
|
||||
"welcome": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "Willkommen bei Homarr!",
|
||||
"description": "Das ist dein Kontrollzentrum. Von hier steuerst du Boards, Apps, Integrationen, Benutzer und die Servereinstellungen. Lass uns eine Einführung in jeden Bereich machen."
|
||||
},
|
||||
"boardsList": {
|
||||
"title": "",
|
||||
"title": "Deine Boards",
|
||||
"description": ""
|
||||
},
|
||||
"boardsCreate": {
|
||||
@@ -6493,15 +6539,15 @@
|
||||
},
|
||||
"board": {
|
||||
"welcome": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "Das ist dein Board",
|
||||
"description": "Ein Drag-and-drop-Dashboard, wo du Apps und Widgets anordnen kannst. Du kannst jederzeit Strg+K drücken, um nach Apps, Boards und Aktionen zu suchen."
|
||||
},
|
||||
"editMode": {
|
||||
"title": "Bearbeitungsmodus",
|
||||
"description": ""
|
||||
"description": "Drücke hier, um in den Bearbeitungsmodus zu kommen. Dort kannst du Widgets, Apps und Abschnitte hinzufügen, welche du durch anschließendes Ziehen neu anordnen und an den Ecken in der Größe anpassen kannst."
|
||||
},
|
||||
"settings": {
|
||||
"title": "",
|
||||
"title": "Board-Einstellungen",
|
||||
"description": ""
|
||||
},
|
||||
"switcher": {
|
||||
|
||||
+1823
-1777
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Configure server settings"
|
||||
"subtitle": "Configure server settings."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
@@ -351,7 +351,7 @@
|
||||
"delete": {
|
||||
"label": "Permanently delete user",
|
||||
"description": "This deletes this user, including their preferences. This will not delete any boards. The user will not be notified.",
|
||||
"confirm": "Are you sure that you want to delete the user {username}, as well as their preferences"
|
||||
"confirm": "Are you sure that you want to delete the user {username}, as well as their preferences?"
|
||||
},
|
||||
"select": {
|
||||
"label": "Select a user",
|
||||
@@ -517,7 +517,7 @@
|
||||
"transfer": {
|
||||
"label": "Transfer group ownership",
|
||||
"description": "Transfer ownership of this group to another user.",
|
||||
"confirm": "Are you want to transfer the ownership of the group, {name} to {username}",
|
||||
"confirm": "Are you want to transfer the ownership of the group, {name} to {username}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": "Successfully transferred group {group} to {user}"
|
||||
@@ -536,7 +536,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"label": "Delete group",
|
||||
"description": "Once you delete this group, you can not revert the change. Please be careful",
|
||||
"description": "Once you delete this group, you can not revert the change. Please be careful.",
|
||||
"confirm": "Are you sure you want to delete the group, {name}?",
|
||||
"notification": {
|
||||
"success": {
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-native reverse proxy and load balancer"
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Monitor which of your Docker containers have image updates available"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Document management system for archiving and organizing scanned documents"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "here",
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one.",
|
||||
"headerLoading": "Type to search icons…",
|
||||
"description": "Search the library, paste an image URL, or upload a file.",
|
||||
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one",
|
||||
"headerLoading": "Type to search icons",
|
||||
"clear": "Clear image",
|
||||
"uploadImage": "Upload image",
|
||||
"results": "Image search results",
|
||||
"localImages": "Your images",
|
||||
"svgIcons": "SVG icons",
|
||||
"otherImages": "Other images",
|
||||
"urlLoading": "Checking image…",
|
||||
"urlReady": "Image is ready to use",
|
||||
"urlError": "This image could not be loaded. Check the URL and try again.",
|
||||
"noResults": "No icons match \"{search}\".",
|
||||
"error": "Couldn't load icons."
|
||||
},
|
||||
@@ -1498,7 +1512,7 @@
|
||||
"login": "Log-in",
|
||||
"homeBoard": "Your home board",
|
||||
"loggedOut": "Logged out",
|
||||
"updateAvailable": "New update available: {tag}"
|
||||
"updateAvailable": "{countUpdates} updates available: {tag}"
|
||||
}
|
||||
},
|
||||
"dangerZone": "Danger zone",
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Show how many watched containers have updates available.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Monitored",
|
||||
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
|
||||
"updateAvailable": "Update available",
|
||||
"openReleaseNotes": "Open release notes",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Show title"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Show update ring"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Show container update list",
|
||||
"description": "Lists each monitored container that has an image update available"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Summary",
|
||||
"description": "Displays the summary of your DNS Hole",
|
||||
@@ -1885,7 +1931,7 @@
|
||||
"label": "Display seconds"
|
||||
},
|
||||
"useCustomTimezone": {
|
||||
"label": " "
|
||||
"label": "Use a fixed timezone"
|
||||
},
|
||||
"timezone": {
|
||||
"label": "Time zone",
|
||||
@@ -2430,19 +2476,19 @@
|
||||
"label": "Invert sorting"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Column order"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Column widths"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch containers stats",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Table layout not saved",
|
||||
"layoutSaveMessage": "The Docker table layout could not be saved. Try moving or resizing the column again."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "No containers found"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2738,17 +2784,17 @@
|
||||
"description": "Disabling this will not work for Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Show bitrate",
|
||||
"description": "Show the current playback bitrate next to the location"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Show location",
|
||||
"description": "Show whether the stream is on your LAN or WAN"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"streams": "{count, plural, one {# active stream} other {# active streams}}",
|
||||
"totalBitrate": "Total: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Currently playing",
|
||||
@@ -2757,9 +2803,9 @@
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
"directPlay": "Direct play",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"directStream": "Direct stream",
|
||||
"transcodeVideo": "Transcoding video",
|
||||
"transcodeAudio": "Transcoding audio",
|
||||
"transcoding": "Transcoding",
|
||||
"paused": "Paused",
|
||||
"remaining": "{minutes} min left",
|
||||
@@ -3047,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Album ID",
|
||||
"description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Interval in seconds",
|
||||
@@ -3107,18 +3153,18 @@
|
||||
"description": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Column order"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Column widths"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "",
|
||||
"noCommunications": "",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "No downloads found",
|
||||
"actionFailed": "Action failed",
|
||||
"actionFailedMessage": "The action could not be completed. Please try again."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3133,7 +3179,7 @@
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"downSpeed": {
|
||||
"columnTitle": "",
|
||||
"columnTitle": "Down",
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"index": {
|
||||
@@ -3142,16 +3188,16 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "",
|
||||
"detailsTitle": ""
|
||||
"detailsTitle": "Item ID"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": ""
|
||||
},
|
||||
"name": {
|
||||
"columnTitle": ""
|
||||
"columnTitle": "Job name"
|
||||
},
|
||||
"progress": {
|
||||
"columnTitle": "",
|
||||
"columnTitle": "Progress",
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"ratio": {
|
||||
@@ -3171,13 +3217,13 @@
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"state": {
|
||||
"columnTitle": "",
|
||||
"columnTitle": "State",
|
||||
"detailsTitle": ""
|
||||
},
|
||||
"time": {
|
||||
"columnTitle": "",
|
||||
"detailsTitle": "",
|
||||
"eta": ""
|
||||
"eta": "ETA"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "",
|
||||
@@ -3223,21 +3269,21 @@
|
||||
},
|
||||
"globalRatio": "",
|
||||
"contextMenu": {
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
"copyName": "Copy name",
|
||||
"copyId": "Copy ID",
|
||||
"clearFilters": "Clear filters"
|
||||
},
|
||||
"footer": {
|
||||
"filter": ""
|
||||
"filter": "Filter downloads"
|
||||
},
|
||||
"stats": {
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
"show": "Show global stats",
|
||||
"hide": "Hide global stats",
|
||||
"overallProgress": "Overall queue progress",
|
||||
"totalSize": "Total queue size",
|
||||
"clients": "{count, plural, one {# client} other {# clients}}"
|
||||
},
|
||||
"disconnected": ""
|
||||
"disconnected": "(disconnected)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "",
|
||||
@@ -3269,7 +3315,7 @@
|
||||
}
|
||||
},
|
||||
"mediaRequests-requestList": {
|
||||
"name": "",
|
||||
"name": "Media Requests List",
|
||||
"description": "",
|
||||
"option": {
|
||||
"linksTargetNewTab": {
|
||||
@@ -3291,15 +3337,15 @@
|
||||
"unknown": "",
|
||||
"pending": "",
|
||||
"processing": "",
|
||||
"requested": "",
|
||||
"requested": "Requested",
|
||||
"partiallyAvailable": "",
|
||||
"available": "",
|
||||
"blacklisted": "",
|
||||
"blacklisted": "Blacklisted",
|
||||
"deleted": ""
|
||||
},
|
||||
"status": {
|
||||
"pending": "",
|
||||
"approved": "",
|
||||
"approved": "Approved",
|
||||
"declined": "",
|
||||
"failed": "",
|
||||
"completed": ""
|
||||
@@ -3312,7 +3358,7 @@
|
||||
"option": {},
|
||||
"titles": {
|
||||
"stats": {
|
||||
"main": "",
|
||||
"main": "Media Stats",
|
||||
"approved": "",
|
||||
"pending": "",
|
||||
"processing": "",
|
||||
@@ -3356,15 +3402,15 @@
|
||||
"panel": {
|
||||
"statistics": {
|
||||
"empty": "",
|
||||
"transcodes": "",
|
||||
"transcodes": "Transcodes",
|
||||
"transcodesCount": "",
|
||||
"healthChecksCount": "",
|
||||
"filesCount": "",
|
||||
"savedSpace": "",
|
||||
"healthChecks": "",
|
||||
"videoCodecs": "",
|
||||
"videoContainers": "",
|
||||
"videoResolutions": ""
|
||||
"healthChecks": "Health checks",
|
||||
"videoCodecs": "Codecs",
|
||||
"videoContainers": "Containers",
|
||||
"videoResolutions": "Resolutions"
|
||||
},
|
||||
"workers": {
|
||||
"empty": "",
|
||||
@@ -3574,7 +3620,7 @@
|
||||
"option": {},
|
||||
"card": {
|
||||
"vpn": {
|
||||
"countConnected": ""
|
||||
"countConnected": "{count} connected"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3591,14 +3637,14 @@
|
||||
"name": ""
|
||||
},
|
||||
"wifi": {
|
||||
"name": ""
|
||||
"name": "Wi-Fi"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"label": ""
|
||||
},
|
||||
"guests": {
|
||||
"label": ""
|
||||
"label": "Guests"
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
@@ -3844,7 +3890,7 @@
|
||||
"option": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"gpu": "",
|
||||
"gpu": "GPU",
|
||||
"network": ""
|
||||
}
|
||||
},
|
||||
@@ -3862,7 +3908,7 @@
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"gpu": "",
|
||||
"network": "",
|
||||
"network": "NET",
|
||||
"up": "",
|
||||
"down": ""
|
||||
}
|
||||
@@ -3968,17 +4014,17 @@
|
||||
"label": "Show agent version"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Column order"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Column widths"
|
||||
}
|
||||
},
|
||||
"noRecords": "No systems found",
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Unable to save table layout",
|
||||
"layoutSaveMessage": "Your column order or widths could not be saved. Please try again."
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -4910,7 +4956,7 @@
|
||||
"title": "",
|
||||
"status": {
|
||||
"idle": "",
|
||||
"running": "",
|
||||
"running": "Running",
|
||||
"error": "",
|
||||
"disabled": ""
|
||||
},
|
||||
@@ -5376,7 +5422,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "",
|
||||
"confirm": "",
|
||||
"confirm": "Click again to remove permanently",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
@@ -5812,7 +5858,7 @@
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "",
|
||||
"placeholder": "Search for anything",
|
||||
"nothingFound": "",
|
||||
"error": {
|
||||
"fetch": ""
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-native reverse proxy and load balancer"
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Monitor which of your Docker containers have image updates available"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Document management system for archiving and organizing scanned documents"
|
||||
@@ -1803,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Show how many watched containers have updates available.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Monitored",
|
||||
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
|
||||
"updateAvailable": "Update available",
|
||||
"openReleaseNotes": "Open release notes",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Show title"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Show update ring"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Show container update list",
|
||||
"description": "Lists each monitored container that has an image update available"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Summary",
|
||||
"description": "Displays the summary of your DNS Hole",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+2276
-2230
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Asetukset",
|
||||
"subtitle": "Määritä palvelinasetukset."
|
||||
"subtitle": "Määritä palvelimen asetukset."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integraatiot",
|
||||
@@ -110,7 +110,7 @@
|
||||
"message": "Jotain meni pieleen kojelautaasi määritettäessä. Yritä uudelleen."
|
||||
},
|
||||
"helpers": {
|
||||
"tooltip": "URL-apurit",
|
||||
"tooltip": "URL-avustajat",
|
||||
"description": "Nämä apurit pyrkivät arvaamaan palveluidesi URL-osoitteet. Jos tämä ei toimi, kirjoita osoite manuaalisesti."
|
||||
}
|
||||
},
|
||||
@@ -131,7 +131,7 @@
|
||||
"summary": "Homarr havaitse {apps} sovellusta, joista {integrations} löytyy sisäänrakennettu integraatio",
|
||||
"appBadge": "Sovellus",
|
||||
"appSectionLabel": "Ohjauspaneeliin lisättävät sovellukset ({count} valittu)",
|
||||
"scanning": "Dockeria tutkitaan...",
|
||||
"scanning": "Tutkitaan Dockeria...",
|
||||
"scanningDescription": "Tunnistetaan järjestelmässäsi suoritettavia kontteja"
|
||||
}
|
||||
},
|
||||
@@ -159,7 +159,7 @@
|
||||
},
|
||||
"invite": {
|
||||
"title": "Liity Homarriin",
|
||||
"subtitle": "Tervetuloa Homarriin! Luo itsellesi käyttäjätunnus",
|
||||
"subtitle": "Tervetuloa Homarriin! Luo käyttäjätunnuksesi",
|
||||
"description": "Sinut kutsui {username}"
|
||||
},
|
||||
"init": {
|
||||
@@ -218,7 +218,7 @@
|
||||
"action": {
|
||||
"login": {
|
||||
"label": "Kirjaudu",
|
||||
"labelWith": "Kirjaudu {{provider}}-tunnuksella",
|
||||
"labelWith": "Kirjaudu palvelulla: {provider}",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Kirjautuminen onnistui",
|
||||
@@ -302,7 +302,7 @@
|
||||
"changeDdgBangs": {
|
||||
"notification": {
|
||||
"success": {
|
||||
"message": "DuckDuckGo Bangs -komentojen tila vaihdettiin"
|
||||
"message": "DuckDuckGon Bangs-komentojen tila vaihdettiin"
|
||||
},
|
||||
"error": {
|
||||
"message": "DuckDuckGo Bangs -komentojen tilaa ei voitu vaihtaa"
|
||||
@@ -397,7 +397,7 @@
|
||||
},
|
||||
"use-all": {
|
||||
"label": "Sovellusten käyttö",
|
||||
"description": "Sallii jäsenten lisätä kaikkia sovelluksia paneeleihinsa."
|
||||
"description": "Sallii jäsenten lisätä kaikkia sovelluksia paneeleihinsa"
|
||||
},
|
||||
"modify-all": {
|
||||
"label": "Sovellusten muokkaus",
|
||||
@@ -414,19 +414,19 @@
|
||||
"item": {
|
||||
"create": {
|
||||
"label": "Paneelien luonti",
|
||||
"description": "Sallii jäsenten luoda paneeleita."
|
||||
"description": "Sallii jäsenten luoda paneeleita"
|
||||
},
|
||||
"view-all": {
|
||||
"label": "Paneelien tarkastelu",
|
||||
"description": "Sallii jäsenten tarkastella kaikkia paneeleita."
|
||||
"description": "Sallii jäsenten tarkastella kaikkia paneeleita"
|
||||
},
|
||||
"modify-all": {
|
||||
"label": "Paneelien muokkaus",
|
||||
"description": "Sallii jäsenten muokata kaikkia paneeleita (pois lukien käyttöoikeudet ja vaaravyöhyke)."
|
||||
"description": "Sallii jäsenten muokata kaikkia paneeleita (pois lukien käyttöoikeudet ja vaaravyöhyke)"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "Paneelien hallinta",
|
||||
"description": "Sallii jäsenten tarkastella, muokata ja poistaa kaikkia paneeleita (mukaan lukien käyttöoikeudet ja vaaravyöhyke)."
|
||||
"description": "Sallii jäsenten tarkastella, muokata ja poistaa kaikkia paneeleita (mukaan lukien käyttöoikeudet ja vaaravyöhyke)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -996,7 +996,7 @@
|
||||
"grid": {
|
||||
"widgetCount": "{count} widgettiä",
|
||||
"noWidgets": "Widgettejä ei ole",
|
||||
"detected": "Havaittu Dockerin kautta"
|
||||
"detected": "Havaittu Dockerilla"
|
||||
},
|
||||
"category": {
|
||||
"dnsHole": "DNS",
|
||||
@@ -1228,7 +1228,7 @@
|
||||
},
|
||||
"tooManyRequests": {
|
||||
"title": "",
|
||||
"message": "Pyyntöjä oli liikaa. Pyyntöjäsi todennäköisesti rajoitetaan tai sinut estetään kohdejärjestelmässä."
|
||||
"message": "Pyyntöjä oli liikaa. Pyyntöjäsi todennäköisesti rajoitetaan tai sinut estetään kohdejärjestelmässä"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1254,8 +1254,8 @@
|
||||
"newLabel": "Uusi käyttäjätunnus"
|
||||
},
|
||||
"apiKey": {
|
||||
"label": "Rajapinnan avain",
|
||||
"newLabel": "Uusi rajapinnan avain"
|
||||
"label": "API-avain",
|
||||
"newLabel": "Uusi API-avain"
|
||||
},
|
||||
"password": {
|
||||
"label": "Salasana",
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Pilvinatiivi käänteinen välityspalvelin ja kuormantasaaja"
|
||||
},
|
||||
"wud": {
|
||||
"name": "Mikä Ylös Docker",
|
||||
"description": "Tarkkaile missä Docker-säiliöissä on saatavilla kuvapäivityksiä"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Asiakirjojen hallintajärjestelmä skannattujen dokumenttien arkistointiin ja järjestelyyn"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "Kuvakkeen URL-osoite",
|
||||
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta.",
|
||||
"headerLoading": "Kirjoita hakeaksesi kuvakkeita…",
|
||||
"description": "Etsi kirjastosta, liitä kuvan URL-osoite tai lataa tiedosto.",
|
||||
"header": "Etsi {countIcons} kuvaa nimen mukaan",
|
||||
"headerLoading": "Hae kuvia nimen mukaan",
|
||||
"clear": "Tyhjennä kuva",
|
||||
"uploadImage": "Lataa kuva",
|
||||
"results": "Haun tulokset",
|
||||
"localImages": "Omat kuvat",
|
||||
"svgIcons": "SVG kuvakkeet",
|
||||
"otherImages": "Muut kuvat",
|
||||
"urlLoading": "Tarkistetaan kuvaa…",
|
||||
"urlReady": "Kuva on valmis käytettäväksi",
|
||||
"urlError": "Kuvaa ei voitu ladata. Tarkista URL-osoite ja yritä uudelleen.",
|
||||
"noResults": "Ei kuvakkeita haulla \"{search}\".",
|
||||
"error": "Kuvakkeiden lataaminen epäonnistui."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Mikä Ylös Docker",
|
||||
"description": "Näytä kuinka monta katsottua astiaa on saatavilla päivityksiä.",
|
||||
"title": "Mikä Ylös Docker",
|
||||
"monitored": "Valvottu",
|
||||
"updatesAvailable": "{count, plural, one {# päivitys saatavilla} other {# päivitystä saatavilla}}",
|
||||
"updateAvailable": "Päivitys saatavilla",
|
||||
"openReleaseNotes": "Avaa julkaisutiedot",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Näytä otsikko"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Näytä päivitysrengas"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Asettelu",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Vaaka"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Pysty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Näytä kontin päivityslista",
|
||||
"description": "Luettele jokaisen seuratun säiliön joka on saatavilla päivityksessä"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
@@ -2239,7 +2285,7 @@
|
||||
},
|
||||
"showCurrentWindSpeed": {
|
||||
"label": "Näytä nykyinen tuulen nopeus",
|
||||
"description": "Vain nykyiselle säälle."
|
||||
"description": "Vain nykyiselle säälle"
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": "Käytä tuulen nopeudelle mph-yksikköä"
|
||||
@@ -2430,19 +2476,19 @@
|
||||
"label": "Käänteinen järjestys"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden järjestys"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden leveydet"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Taulukon asettelua ei tallennettu",
|
||||
"layoutSaveMessage": "Docker-taulukon asettelua ei voitu tallentaa. Yritä siirtää tai muuttaa sarakkeen kokoa uudelleen."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "Kontteja ei löytynyt"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2738,17 +2784,17 @@
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Näytä bittinopeus",
|
||||
"description": "Näytä nykyinen toiston bittinopeus sijainnin vieressä"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Näytä sijainti",
|
||||
"description": "Näytä, onko suoratoisto LAN- vai WAN-verkossa."
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"streams": "{count, plural, one {# aktiivinen suoratoisto} other {# aktiivista suoratoistoa}}",
|
||||
"totalBitrate": "Yhteensä: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
@@ -2757,9 +2803,9 @@
|
||||
"id": "",
|
||||
"status": "Tila",
|
||||
"directPlay": "Muuntamaton toisto",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"directStream": "Suora suoratoisto",
|
||||
"transcodeVideo": "Videon transkoodaus",
|
||||
"transcodeAudio": "Äänen transkoodaus",
|
||||
"transcoding": "Transkoodaus",
|
||||
"paused": "Keskeytetty",
|
||||
"remaining": "{minutes} min jäljellä",
|
||||
@@ -3104,21 +3150,21 @@
|
||||
},
|
||||
"limitPerIntegration": {
|
||||
"label": "Integraation elementtien määrärajoitus",
|
||||
"description": "Tämä rajoittaa näytettävien elementtien määrää integraatiokohtaisesti, ei kokonaisvaltaisesti."
|
||||
"description": "Tämä rajoittaa näytettävien elementtien määrää integraatiokohtaisesti, ei kokonaisvaltaisesti"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden järjestys"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden leveydet"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "Valitse kohteiden sarakkeet",
|
||||
"noCommunications": "Integraatiolta ei voida noutaa tietoja",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "Latauksia ei löytynyt",
|
||||
"actionFailed": "Toiminto epäonnistui",
|
||||
"actionFailedMessage": "Toimintoa ei voitu suorittaa. Yritä uudelleen."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3142,7 +3188,7 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "Tunniste",
|
||||
"detailsTitle": ""
|
||||
"detailsTitle": "Kohteen ID"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "Integraatio"
|
||||
@@ -3177,7 +3223,7 @@
|
||||
"time": {
|
||||
"columnTitle": "Valmistumisaika",
|
||||
"detailsTitle": "",
|
||||
"eta": ""
|
||||
"eta": "Arvioitu aika"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "Tyyppi",
|
||||
@@ -3223,21 +3269,21 @@
|
||||
},
|
||||
"globalRatio": "Kokonaissuhde",
|
||||
"contextMenu": {
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
"copyName": "Kopioi nimi",
|
||||
"copyId": "Kopioi ID",
|
||||
"clearFilters": "Tyhjennä suodattimet"
|
||||
},
|
||||
"footer": {
|
||||
"filter": ""
|
||||
"filter": "Suodata latauksia"
|
||||
},
|
||||
"stats": {
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
"show": "Näytä globaalit tilastot",
|
||||
"hide": "Piilota globaalit tilastot",
|
||||
"overallProgress": "Jonon kokonaisedistyminen",
|
||||
"totalSize": "Jonon kokonaiskoko",
|
||||
"clients": "{count, plural, one {# asiakas} other {# asiakasta}}"
|
||||
},
|
||||
"disconnected": ""
|
||||
"disconnected": "(ei yhteydessä)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "",
|
||||
@@ -3699,7 +3745,7 @@
|
||||
"unhealthy": "",
|
||||
"ok": "",
|
||||
"fail": "",
|
||||
"noSectionsEnabled": "Aktivoi ainakin yksi osio widgetin asetuksista.",
|
||||
"noSectionsEnabled": "Ota ainakin yksi osio käyttöön widgetin asetuksista.",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3968,17 +4014,17 @@
|
||||
"label": "Näytä agentin versio"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden järjestys"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Sarakkeiden leveydet"
|
||||
}
|
||||
},
|
||||
"noRecords": "Järjestelmiä ei löytynyt",
|
||||
"error": {
|
||||
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Taulukon asettelua ei voitu tallentaa",
|
||||
"layoutSaveMessage": "Sarakkeiden järjestystä tai leveyksiä ei voitu tallentaa. Yritä uudelleen."
|
||||
},
|
||||
"column": {
|
||||
"system": "Järjestelmä",
|
||||
@@ -4377,7 +4423,7 @@
|
||||
},
|
||||
"itemRadius": {
|
||||
"label": "Kohteen säde",
|
||||
"description": "Muuttaa paneelin korttien kulmien pyöristystä.",
|
||||
"description": "Muuttaa paneelin korttien pyöreyttä",
|
||||
"option": {
|
||||
"xs": "Erittäin pieni",
|
||||
"sm": "Pieni",
|
||||
@@ -4922,7 +4968,7 @@
|
||||
"label": "Analytiikka"
|
||||
},
|
||||
"ping": {
|
||||
"label": "Ping-koestukset"
|
||||
"label": "Pingit"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5287,7 +5333,7 @@
|
||||
"refresh": {
|
||||
"lastUpdated": "Viimeisin päivitys {when}"
|
||||
},
|
||||
"search": "Suodata {count} konttia...",
|
||||
"search": "Suodata {count} konttia",
|
||||
"selected": "{selectCount}/{totalCount} kontista valittu",
|
||||
"footer": "Yhteensä {count} konttia",
|
||||
"totalCpu": "CPU yhteensä: {cpu}%",
|
||||
@@ -5376,7 +5422,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Poista",
|
||||
"confirm": "",
|
||||
"confirm": "Napsauta uudelleen poistaaksesi pysyvästi",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
@@ -5918,14 +5964,14 @@
|
||||
"title": "Asetukset",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Valitse muutettava asetus.",
|
||||
"title": "Valitse muutettava asetus",
|
||||
"backHint": "Palaa takaisin askelpalauttimella tai painamalla Takaisin-painiketta",
|
||||
"backAction": "Palaa takaisin"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"error": {
|
||||
"message": "Asetusta ei voitu päivittää."
|
||||
"message": "Asetusta ei voitu päivittää"
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
@@ -6009,10 +6055,10 @@
|
||||
}
|
||||
},
|
||||
"pingIconsEnabled": {
|
||||
"label": "Käytä ping-kuvakkeita",
|
||||
"label": "Käytä ping-ikoneita",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Käytä ping-kuvakkeita"
|
||||
"title": "Käytä ping-ikoneita"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6191,7 +6237,7 @@
|
||||
"label": "Hallinnoi Dockeria"
|
||||
},
|
||||
"manageApi": {
|
||||
"label": "Swagger-rajapinta"
|
||||
"label": "Swagger API"
|
||||
},
|
||||
"manageLog": {
|
||||
"label": "Tarkastele lokeja"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"start": {
|
||||
"title": "Bienvenue sur Homarr",
|
||||
"subtitle": "Commençons par configurer votre instance Homarr.",
|
||||
"description": "Pour commencer, veuillez selectionner comment vous souhaitez configurer votre instance Homarr.",
|
||||
"description": "Pour commencer, veuillez sélectionner comment vous souhaitez configurer votre instance Homarr.",
|
||||
"action": {
|
||||
"scratch": "Partir de zéro",
|
||||
"importOldmarr": "Importer depuis Homarr avant la 1.0",
|
||||
@@ -129,7 +129,7 @@
|
||||
"detected": "Docker a détecté {count} services",
|
||||
"detectedBreakdown": "Intégrations {integrations} · Applications {apps}",
|
||||
"summary": "Les applications {apps} ont été détectées par Homarr, dont {integrations} ont des intégrations intégrées",
|
||||
"appBadge": "App",
|
||||
"appBadge": "Application",
|
||||
"appSectionLabel": "Applications à ajouter au tableau de bord ({count} sélectionné)",
|
||||
"scanning": "Analyse de Docker...",
|
||||
"scanningDescription": "Recherche de conteneurs en cours d'exécution sur votre système"
|
||||
@@ -604,7 +604,7 @@
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"search": "Trouver une application",
|
||||
"search": "Trouver une app",
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "Applications",
|
||||
@@ -673,7 +673,7 @@
|
||||
},
|
||||
"action": {
|
||||
"select": {
|
||||
"label": "Sélectionner l'app",
|
||||
"label": "Sélectionner une app",
|
||||
"notFound": "Aucune app trouvée",
|
||||
"search": "Rechercher une application",
|
||||
"noResults": "Pas de résultats",
|
||||
@@ -739,37 +739,37 @@
|
||||
"label": "Étiquette",
|
||||
"unit": "Unité",
|
||||
"mappings": "Correspondances",
|
||||
"tablePath": "",
|
||||
"tablePathHint": "",
|
||||
"tablePath": "Chemin du tableau JSON",
|
||||
"tablePathHint": "Chemin vers le tableau dans la réponse, par exemple $.data.items",
|
||||
"columns": "Colonnes",
|
||||
"header": "En-tête",
|
||||
"items": "Objets",
|
||||
"gridColumns": "Nombre de colonnes",
|
||||
"cardStyle": "Style des cartes",
|
||||
"bars": "Barres",
|
||||
"valuePath": "",
|
||||
"maxPath": "",
|
||||
"valuePath": "Chemin de la valeur JSON",
|
||||
"maxPath": "Chemin de la valeur max JSON",
|
||||
"showPercentage": "Afficher le pourcentage",
|
||||
"barSize": "Taille de la barre",
|
||||
"goodValues": "Bonnes valeurs (séparées par des virgules)",
|
||||
"dotSize": "Taille des points",
|
||||
"valueSize": "Taille",
|
||||
"labelPosition": "Position du libellé",
|
||||
"kvLayout": "",
|
||||
"striped": "",
|
||||
"kvLayout": "Mise en page",
|
||||
"striped": "Lignes en couleur alternées",
|
||||
"compact": "Mode Compact",
|
||||
"maxHeight": "",
|
||||
"maxHeight": "Hauteur maximale (px)",
|
||||
"color": "Couleur",
|
||||
"buttonLabel": "",
|
||||
"buttonColor": "",
|
||||
"buttonLabel": "Libellé du bouton",
|
||||
"buttonColor": "Couleur du bouton",
|
||||
"confirmText": "Texte de confirmation",
|
||||
"confirmTextHint": "",
|
||||
"confirmTextHint": "Si défini, une popup de confirmation apparaîtra avant d'exécuter la requête",
|
||||
"successMessage": "Message de réussite",
|
||||
"successMessageHint": "",
|
||||
"successMessageHint": "Message affiché après une exécution réussie",
|
||||
"enabled": "Activé",
|
||||
"template": {
|
||||
"label": "Template JSX",
|
||||
"description": ""
|
||||
"description": "Modèle JSX Mantine. Accédez aux champs de réponse de l'API via les liaisons de données (par ex. data.name). Utilisez uniquement les composants autorisés. Interdit : constructor, __proto__, eval, Function, window, document."
|
||||
}
|
||||
},
|
||||
"placeholder": {
|
||||
@@ -779,23 +779,23 @@
|
||||
"exampleUnit": "°C",
|
||||
"iconUrl": "https://...",
|
||||
"url": "https://api.example.com/api/v1/status",
|
||||
"headerName": "",
|
||||
"headerName": "X-API-Key",
|
||||
"jsonPath": "$.data.value",
|
||||
"tablePath": "",
|
||||
"columnJsonPath": "",
|
||||
"valuePath": "",
|
||||
"maxPath": "",
|
||||
"goodValues": "",
|
||||
"color": ""
|
||||
"tablePath": "$.data.items",
|
||||
"columnJsonPath": "$.name",
|
||||
"valuePath": "$.data.used",
|
||||
"maxPath": "$.data.total",
|
||||
"goodValues": "en ligne, vrai,",
|
||||
"color": "bleu"
|
||||
},
|
||||
"cardStyleOption": {
|
||||
"filled": "",
|
||||
"outline": "",
|
||||
"subtle": ""
|
||||
"filled": "Rempli",
|
||||
"outline": "Contour",
|
||||
"subtle": "Discret"
|
||||
},
|
||||
"layoutOption": {
|
||||
"list": "",
|
||||
"grid": ""
|
||||
"list": "Liste",
|
||||
"grid": "Grille"
|
||||
},
|
||||
"sizeOption": {
|
||||
"sm": "Petit",
|
||||
@@ -815,7 +815,7 @@
|
||||
"displayTypeDescription": {
|
||||
"singleValue": "Afficher une valeur extraite avec une étiquette et une unité",
|
||||
"keyValue": "Afficher plusieurs valeurs étiquetées côte à côte",
|
||||
"table": "",
|
||||
"table": "Afficher les lignes de données dans un format de tableau",
|
||||
"statGrid": "",
|
||||
"progressBars": "",
|
||||
"statusIndicator": "Points d'état colorés montrant en ligne/hors ligne ou l'état de santé",
|
||||
@@ -851,23 +851,23 @@
|
||||
"customJsx": "JSX personnalisé"
|
||||
},
|
||||
"secret": {
|
||||
"apiKey": "",
|
||||
"apiKey": "Clé API / jeton",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"savedPlaceholder": ""
|
||||
"savedPlaceholder": "••••••• (sauvegardé, laisser vide pour garder)"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Aperçu",
|
||||
"test": "Tester",
|
||||
"hint": "Cliquez sur Test pour prévisualiser la réponse API avec votre configuration actuelle.",
|
||||
"rawResponse": ""
|
||||
"rawResponse": "Réponse de JSON brute"
|
||||
},
|
||||
"notification": {
|
||||
"created": "Widget \"{name}\" créé avec succès.",
|
||||
"updated": "Widget \"{name}\" mis à jour avec succès.",
|
||||
"deleted": "Widget \"{name}\" supprimé.",
|
||||
"imported": "Widget importé avec succès.",
|
||||
"aiPromptCopied": "",
|
||||
"aiPromptCopied": "Prompt IA a été copiée dans le presse-papiers",
|
||||
"aiPromptDescription": "",
|
||||
"aiPromptCopy": "Copier dans le presse-papiers",
|
||||
"aiPromptNoResponse": "",
|
||||
@@ -1168,15 +1168,15 @@
|
||||
},
|
||||
"invalidUrl": {
|
||||
"title": "URL invalide",
|
||||
"message": "L'URL est invalide."
|
||||
"message": "L'URL est invalide"
|
||||
},
|
||||
"secretNotDefined": {
|
||||
"title": "Identifiants manquants",
|
||||
"message": "Les informations d'identification n'ont pas toutes été fournies"
|
||||
"message": "Tous les identifiants n'ont pas été fournis"
|
||||
},
|
||||
"invalidCredentials": {
|
||||
"title": "Informations d'identification incorrectes.",
|
||||
"message": "Les informations d'identification ne sont pas valides"
|
||||
"title": "Les identifiants sont invalides",
|
||||
"message": "Les identifiants sont invalides"
|
||||
},
|
||||
"commonError": {
|
||||
"title": "Échec de la connexion",
|
||||
@@ -1290,12 +1290,12 @@
|
||||
"newLabel": "Nouvelle clé API (secrète)"
|
||||
},
|
||||
"patchmonApiKey": {
|
||||
"label": "",
|
||||
"newLabel": ""
|
||||
"label": "Clé API",
|
||||
"newLabel": "Nouvelle clé API"
|
||||
},
|
||||
"patchmonApiSecret": {
|
||||
"label": "",
|
||||
"newLabel": ""
|
||||
"label": "Clé secrète d'API",
|
||||
"newLabel": "Nouveau secret API"
|
||||
},
|
||||
"githubAppId": {
|
||||
"label": "Identifiant de l'application",
|
||||
@@ -1321,15 +1321,15 @@
|
||||
"full": "Accès intégral à l'intégration"
|
||||
},
|
||||
"audiobookshelf": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "Étagère de livres audio",
|
||||
"description": "Serveur de livres audio et de podcasts auto-hébergés"
|
||||
},
|
||||
"navidrome": {
|
||||
"name": "Navidrome",
|
||||
"description": ""
|
||||
"description": "Serveur de streaming de musique auto-hébergé avec compatibilité avec l'API Subsonic"
|
||||
},
|
||||
"uptimeKuma": {
|
||||
"name": "",
|
||||
"name": "Uptime Kuma",
|
||||
"description": "",
|
||||
"secret": {
|
||||
"slug": {
|
||||
@@ -1341,19 +1341,23 @@
|
||||
}
|
||||
},
|
||||
"traefik": {
|
||||
"name": "",
|
||||
"name": "Traefik",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Qu'est ce que Docker",
|
||||
"description": "Surveiller lequel de vos conteneurs Docker a des mises à jour d'images disponibles"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"name": "Paperless-ngx",
|
||||
"description": ""
|
||||
},
|
||||
"patchmon": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "PatchMon",
|
||||
"description": "Gestion et surveillance des correctifs pour les hôtes Linux"
|
||||
},
|
||||
"bazarr": {
|
||||
"name": "",
|
||||
"name": "Bazarr",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
@@ -1436,9 +1440,19 @@
|
||||
},
|
||||
"here": "ici",
|
||||
"iconPicker": {
|
||||
"label": "URL de l'icône :",
|
||||
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous.",
|
||||
"headerLoading": "Tapez pour rechercher les icônes…",
|
||||
"label": "URL de l'icône ",
|
||||
"description": "Recherchez dans la bibliothèque, collez une URL d'image, ou téléchargez un fichier.",
|
||||
"header": "Rechercher les images {countIcons} par nom",
|
||||
"headerLoading": "Rechercher des images par nom",
|
||||
"clear": "Effacer l'image",
|
||||
"uploadImage": "Charger une image",
|
||||
"results": "Résultats de la recherche d'images",
|
||||
"localImages": "Vos images",
|
||||
"svgIcons": "Icônes SVG",
|
||||
"otherImages": "Autres images",
|
||||
"urlLoading": "Vérification de l'image…",
|
||||
"urlReady": "L'image est prête à être utilisée",
|
||||
"urlError": "Cette image n'a pas pu être chargée. Vérifiez l'URL et réessayez.",
|
||||
"noResults": "Aucune icône ne correspond à \"{search}\".",
|
||||
"error": "Impossible de charger les icônes."
|
||||
},
|
||||
@@ -1628,10 +1642,10 @@
|
||||
"actions": "Actions"
|
||||
},
|
||||
"status": {
|
||||
"success": "",
|
||||
"error": "",
|
||||
"loading": "",
|
||||
"idle": ""
|
||||
"success": "OK",
|
||||
"error": "Erreur",
|
||||
"loading": "Chargement",
|
||||
"idle": "Inactif"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -1785,7 +1799,7 @@
|
||||
}
|
||||
},
|
||||
"archiveTeamWarrior": {
|
||||
"name": "",
|
||||
"name": "ArchiveTeam Warrior",
|
||||
"description": "",
|
||||
"option": {
|
||||
"showBroadcastMessage": {
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Qu'est ce que Docker",
|
||||
"description": "Afficher combien de conteneurs surveillés ont des mises à jour disponibles.",
|
||||
"title": "Qu'est ce que Docker",
|
||||
"monitored": "Surveillé",
|
||||
"updatesAvailable": "{count, plural, one {# mise à jour disponible} other {# mises à jour disponibles}}",
|
||||
"updateAvailable": "Mise à jour disponible",
|
||||
"openReleaseNotes": "Ouvrir les notes de version",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Afficher le titre"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Afficher l'anneau de mise à jour"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Mise en page",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontale"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Afficher la liste de mise à jour du conteneur",
|
||||
"description": "Liste chaque conteneur surveillé qui a une mise à jour d'image disponible"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Résumé du puit DNS",
|
||||
"description": "Affiche le résumé de votre puit DNS",
|
||||
@@ -1907,14 +1953,14 @@
|
||||
"description": "Utiliser ISO 8601 pour formater la date (cela remplacera les autres options)"
|
||||
},
|
||||
"showWeather": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher la météo",
|
||||
"description": "Afficher une petite icône météo animée et la température dans le coin de la carte"
|
||||
},
|
||||
"weatherLocation": {
|
||||
"label": ""
|
||||
"label": "Lieu de la météo"
|
||||
},
|
||||
"isWeatherFormatFahrenheit": {
|
||||
"label": ""
|
||||
"label": "Utiliser les degrés Fahrenheit"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1944,7 +1990,7 @@
|
||||
"noStation": "Aucune station sélectionnée",
|
||||
"option": {
|
||||
"baseUrl": {
|
||||
"label": ""
|
||||
"label": "URL Search.ch"
|
||||
},
|
||||
"station": {
|
||||
"label": "Station"
|
||||
@@ -1976,8 +2022,8 @@
|
||||
"clear": "Effacer la mise en forme",
|
||||
"heading": "Titre {level}",
|
||||
"align": "Aligner le texte : {position}",
|
||||
"directionLtr": "",
|
||||
"directionRtl": "",
|
||||
"directionLtr": "Définir la direction du texte de gauche à droite",
|
||||
"directionRtl": "Définir la direction du texte de droite à gauche",
|
||||
"blockquote": "Citation",
|
||||
"horizontalLine": "Ligne horizontale",
|
||||
"bulletList": "Liste à puces",
|
||||
@@ -2047,7 +2093,7 @@
|
||||
"edit": "Modifier",
|
||||
"save": "Sauvegarder",
|
||||
"cancel": "Annuler",
|
||||
"readOnlyViewer": "",
|
||||
"readOnlyViewer": "Vous avez un accès en lecture seule à cette note. La modification est désactivée.",
|
||||
"saveForbidden": "Vous n'êtes pas autorisé à modifier cette note.",
|
||||
"saveFailed": "Impossible d'enregistrer cette note. Veuillez réessayer."
|
||||
},
|
||||
@@ -2262,13 +2308,13 @@
|
||||
"description": "À quoi devrait ressembler la date"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "{currentWindSpeed} km/h",
|
||||
"currentWindSpeed": "{currentWindSpeed} {unit}",
|
||||
"dailyForecast": {
|
||||
"sunrise": "Lever du Soleil",
|
||||
"sunset": "Coucher du soleil",
|
||||
"maxWindSpeed": "Vitesse maximale du vent : {maxWindSpeed} km/h",
|
||||
"maxWindGusts": "Rafales de vent maximales : {maxWindGusts} km/h",
|
||||
"humidity": ""
|
||||
"maxWindSpeed": "Vitesse maximale du vent : {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Rafales de vent maximales : {maxWindGusts} {unit}",
|
||||
"humidity": "Humidité : {humidity}%"
|
||||
},
|
||||
"kind": {
|
||||
"clear": "Clair",
|
||||
@@ -2328,9 +2374,9 @@
|
||||
"label": "Afficher les infos sur le système de fichiers"
|
||||
},
|
||||
"visibleStorageVolumes": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": ""
|
||||
"label": "Volumes de stockage visibles",
|
||||
"description": "Uniquement disponible lorsque toutes les intégrations sélectionnées sont Synology. Laissez vide pour afficher tous les volumes de stockage détectés.",
|
||||
"placeholder": "Sélectionnez les volumes de stockage"
|
||||
},
|
||||
"defaultTab": {
|
||||
"label": "Onglet par défaut"
|
||||
@@ -2352,7 +2398,7 @@
|
||||
"loadAverage": "Charge moyenne :",
|
||||
"minute": "1 minute",
|
||||
"minutes": "{count} minutes",
|
||||
"used": "Utilisé ",
|
||||
"used": "Utilisé",
|
||||
"available": "Disponible",
|
||||
"lastSeen": "Dernière mise à jour de statut : {lastSeen}"
|
||||
},
|
||||
@@ -2389,7 +2435,7 @@
|
||||
"cpu": "Coeurs",
|
||||
"memory": "Mémoire",
|
||||
"storage": "Stockage",
|
||||
"uptime": "Disponibilité",
|
||||
"uptime": "Disponible",
|
||||
"haState": "État HA",
|
||||
"storageType": {
|
||||
"local": "Stockage local",
|
||||
@@ -2412,7 +2458,7 @@
|
||||
"description": "Statistiques de vos conteneurs (Ce widget ne peut être ajouté qu'avec les privilèges d'administrateur)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": ""
|
||||
"label": "Colonnes à afficher"
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": "Activer le tri des éléments"
|
||||
@@ -2430,19 +2476,19 @@
|
||||
"label": "Inverser le tri"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Ordre des colonnes"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Largeurs des colonnes"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Impossible de récupérer les statistiques des conteneurs",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveTitle": "Disposition du tableau non sauvegardée",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "Aucun conteneur trouvé"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2479,9 +2525,9 @@
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"serviceUnavailable": "",
|
||||
"name": "VPN",
|
||||
"description": "Aperçu de vos connexions VPN",
|
||||
"serviceUnavailable": "VPN indisponible",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
@@ -2738,31 +2784,31 @@
|
||||
"description": "Désactiver cette option ne fonctionnera pas pour Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"label": "Afficher le débit",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"label": "Afficher la localisation",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"totalBitrate": "Total : {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "En cours de lecture",
|
||||
"user": "Utilisateur",
|
||||
"name": "Nom",
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
"status": "Statut",
|
||||
"directPlay": "Lecture directe",
|
||||
"directStream": "Flux direct",
|
||||
"transcodeVideo": "Transcodage vidéo",
|
||||
"transcodeAudio": "Transcodage audio",
|
||||
"transcoding": "Transcodage",
|
||||
"paused": "En pause",
|
||||
"remaining": "{minutes} min restantes",
|
||||
"metadata": {
|
||||
"title": "Statistiques détaillées",
|
||||
"video": {
|
||||
@@ -2788,111 +2834,111 @@
|
||||
"description": "Aperçu de votre archive de documents Paperless-ngx",
|
||||
"documentsTotal": "Nombre total de documents",
|
||||
"documentsInbox": "Boîte de réception",
|
||||
"correspondents": "",
|
||||
"tags": "",
|
||||
"documentTypes": "",
|
||||
"inboxRatio": "",
|
||||
"inbox": "",
|
||||
"total": "",
|
||||
"correspondents": "Correspondants",
|
||||
"tags": "Tags",
|
||||
"documentTypes": "Types de documents",
|
||||
"inboxRatio": "Ratio de la boîte de réception",
|
||||
"inbox": "boîte de réception",
|
||||
"total": "total",
|
||||
"option": {
|
||||
"showInboxRatio": {
|
||||
"label": "Afficher le ratio de la boîte de réception",
|
||||
"description": ""
|
||||
"description": "Afficher le rapport inbox/total des documents dans la section Héros"
|
||||
},
|
||||
"showInboxRing": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher l'anneau de la boîte de réception",
|
||||
"description": "Afficher un anneau de progression à côté du ratio de la boîte de réception"
|
||||
},
|
||||
"showDocumentsTotal": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher le total des documents",
|
||||
"description": "Afficher le nombre total de documents dans votre archive"
|
||||
},
|
||||
"showDocumentsInbox": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher le compteur de la boîte de réception",
|
||||
"description": "Afficher le nombre de documents non traités dans la boîte de réception"
|
||||
},
|
||||
"showCorrespondents": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les correspondants",
|
||||
"description": "Afficher le nombre total de correspondants"
|
||||
},
|
||||
"showTags": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les tags",
|
||||
"description": "Afficher le nombre total de tags"
|
||||
},
|
||||
"showDocumentTypes": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les types de documents",
|
||||
"description": "Afficher le nombre total de types de documents"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "Échec de la récupération des données Paperless-ngx"
|
||||
}
|
||||
},
|
||||
"patchmon": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"totalHosts": "",
|
||||
"hostsNeedingUpdates": "",
|
||||
"securityUpdates": "",
|
||||
"upToDateHosts": "",
|
||||
"hostsWithSecurityUpdates": "",
|
||||
"recentUpdates24h": "",
|
||||
"totalOutdatedPackages": "",
|
||||
"totalRepos": "",
|
||||
"osDistribution": "",
|
||||
"compliance": "",
|
||||
"complianceRatio": "",
|
||||
"lastUpdated": "",
|
||||
"name": "PatchMon",
|
||||
"description": "Aperçu de l'état des correctifs des hôtes depuis PatchMon",
|
||||
"title": "Statistiques PatchMon",
|
||||
"totalHosts": "Total des hôtes",
|
||||
"hostsNeedingUpdates": "Mises à jour requises",
|
||||
"securityUpdates": "Mises à jour de sécurité",
|
||||
"upToDateHosts": "Hôtes à jour",
|
||||
"hostsWithSecurityUpdates": "Hôtes avec mises à jour de sécurité",
|
||||
"recentUpdates24h": "Mises à jour (24h)",
|
||||
"totalOutdatedPackages": "Paquets obsolètes",
|
||||
"totalRepos": "Dépôts",
|
||||
"osDistribution": "Répartition des OS",
|
||||
"compliance": "Conformité des correctifs",
|
||||
"complianceRatio": "{upToDate} / {total}",
|
||||
"lastUpdated": "Mis à jour {time}",
|
||||
"option": {
|
||||
"showTotalHosts": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher le nombre total d'hôtes",
|
||||
"description": "Afficher le nombre total d'hôtes surveillés"
|
||||
},
|
||||
"showHostsNeedingUpdates": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les hôtes ayant besoin de mises à jour",
|
||||
"description": "Afficher les hôtes avec au moins un paquet obsolète"
|
||||
},
|
||||
"showSecurityUpdates": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les mises à jour de sécurité",
|
||||
"description": "Afficher le nombre total de mises à jour de sécurité disponibles"
|
||||
},
|
||||
"showUpToDateHosts": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les hôtes à jour",
|
||||
"description": "Afficher les hôtes avec aucun paquet obsolète"
|
||||
},
|
||||
"showHostsWithSecurityUpdates": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les hôtes avec les mises à jour de sécurité",
|
||||
"description": "Afficher les hôtes nécessitant des correctifs de sécurité"
|
||||
},
|
||||
"showRecentUpdates24h": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les mises à jour récentes (24h)",
|
||||
"description": "Afficher le nombre de mises à jour appliquées au cours des dernières 24 heures"
|
||||
},
|
||||
"showTotalOutdatedPackages": {
|
||||
"label": "",
|
||||
"label": "Afficher les paquets obsolètes",
|
||||
"description": ""
|
||||
},
|
||||
"showTotalRepos": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les dépôts",
|
||||
"description": "Afficher le nombre de dépôts actifs en cours de surveillance"
|
||||
},
|
||||
"enableThresholdColors": {
|
||||
"label": "",
|
||||
"label": "Activer les couleurs de seuil",
|
||||
"description": ""
|
||||
},
|
||||
"useCustomThresholds": {
|
||||
"label": "",
|
||||
"label": "Seuils avancés",
|
||||
"description": ""
|
||||
},
|
||||
"thresholdMode": {
|
||||
"option": {
|
||||
"absolute": "",
|
||||
"percent": ""
|
||||
"percent": "Pourcentage du total des hôtes"
|
||||
}
|
||||
},
|
||||
"hostsNeedingUpdatesThresholdMode": {
|
||||
"label": "",
|
||||
"label": "Nécessite le mode de mise à jour de seuil",
|
||||
"description": ""
|
||||
},
|
||||
"hostsNeedingUpdatesWarningAt": {
|
||||
@@ -2912,7 +2958,7 @@
|
||||
"description": ""
|
||||
},
|
||||
"securityUpdatesCriticalAt": {
|
||||
"label": "",
|
||||
"label": "Seuil critique de mise à jour de sécurité",
|
||||
"description": ""
|
||||
},
|
||||
"hostsWithSecurityUpdatesThresholdMode": {
|
||||
@@ -2952,33 +2998,33 @@
|
||||
"description": ""
|
||||
},
|
||||
"showOsDistribution": {
|
||||
"label": "",
|
||||
"label": "Afficher la répartition des OS",
|
||||
"description": ""
|
||||
},
|
||||
"osDistributionLimit": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"option": {
|
||||
"3": "",
|
||||
"5": "",
|
||||
"10": "",
|
||||
"all": ""
|
||||
"3": "Top 3",
|
||||
"5": "Top 5",
|
||||
"10": "Top 10",
|
||||
"all": "Tout"
|
||||
}
|
||||
},
|
||||
"showOsVersion": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher la version de l'OS",
|
||||
"description": "Ajouter la version de l'OS à chaque étiquette de distribution lorsque disponible"
|
||||
},
|
||||
"showComplianceHero": {
|
||||
"label": "",
|
||||
"label": "Afficher la jauge de conformité",
|
||||
"description": ""
|
||||
},
|
||||
"osDisplayMode": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "Mode d'affichage de l'OS",
|
||||
"description": "Choisir comment afficher la répartition des systèmes d'exploitation",
|
||||
"option": {
|
||||
"bars": "",
|
||||
"donut": ""
|
||||
"bars": "Barres horizontales",
|
||||
"donut": "Graphique en anneau"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2987,19 +3033,19 @@
|
||||
}
|
||||
},
|
||||
"bazarr": {
|
||||
"name": "",
|
||||
"name": "Bazarr",
|
||||
"description": "",
|
||||
"episodes": "",
|
||||
"movies": "",
|
||||
"providers": "",
|
||||
"episodes": "Épisodes manquants",
|
||||
"movies": "Films manquants",
|
||||
"providers": "Problèmes de fournisseurs",
|
||||
"status": "",
|
||||
"option": {
|
||||
"showMissingEpisodes": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Afficher les épisodes manquants",
|
||||
"description": "Afficher le nombre d'épisodes avec les sous-titres manquants"
|
||||
},
|
||||
"showMissingMovies": {
|
||||
"label": "",
|
||||
"label": "Afficher les films manquants",
|
||||
"description": ""
|
||||
},
|
||||
"showProviderIssues": {
|
||||
@@ -3047,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "Identifiant de l'album",
|
||||
"description": "C'est l'identifiant unique qui correspond à l'album Immich. Vous pouvez le trouver en ouvrant un album et copiant l'identifiant unique depuis l'URL, par exemple 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "C'est l'identifiant unique qui correspond à l'album Immich. Vous pouvez le trouver en ouvrant un album et copiant l'identifiant unique depuis l'URL, par exemple 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Intervalle en secondes",
|
||||
@@ -3107,18 +3153,18 @@
|
||||
"description": "Cela limitera le nombre d'éléments affichés par intégration, pas globalement"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Ordre des colonnes"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Largeur des colonnes"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "Sélectionner les colonnes dans les éléments",
|
||||
"noCommunications": "Impossible de charger les données à partir de l'intégration",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "Aucun téléchargement trouvé",
|
||||
"actionFailed": "Action échouée",
|
||||
"actionFailedMessage": "Cette action n'a pas pu être complétée. Veuillez ressayer."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3312,7 +3358,7 @@
|
||||
"option": {},
|
||||
"titles": {
|
||||
"stats": {
|
||||
"main": "Statistiques des médias",
|
||||
"main": "Stats des médias",
|
||||
"approved": "Déjà approuvé",
|
||||
"pending": "En attente de validation",
|
||||
"processing": "En cours de traitement",
|
||||
@@ -3382,7 +3428,7 @@
|
||||
"file": "Fichier",
|
||||
"size": "Taille",
|
||||
"transcode": "Transcodeur",
|
||||
"healthCheck": "Bilan de santé"
|
||||
"healthCheck": "Contrôle de santé"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3395,26 +3441,26 @@
|
||||
"label": ""
|
||||
},
|
||||
"showQueued": {
|
||||
"label": ""
|
||||
"label": "Afficher les éléments en attente"
|
||||
},
|
||||
"pageSize": {
|
||||
"label": ""
|
||||
"label": "Éléments par page"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"missing": "",
|
||||
"queued": ""
|
||||
"queued": "En file d’attente"
|
||||
},
|
||||
"type": {
|
||||
"movie": "",
|
||||
"episode": ""
|
||||
"movie": "Film",
|
||||
"episode": "Épisode"
|
||||
},
|
||||
"empty": {
|
||||
"missing": "",
|
||||
"queued": "",
|
||||
"noTabsEnabled": ""
|
||||
"missing": "Aucun élément manquant",
|
||||
"queued": "Aucun élément en file d'attente",
|
||||
"noTabsEnabled": "Activer au moins un onglet dans les paramètres du widget"
|
||||
},
|
||||
"timeLeft": ""
|
||||
"timeLeft": "Temps restant"
|
||||
},
|
||||
"rssFeed": {
|
||||
"name": "Flux RSS",
|
||||
@@ -3548,9 +3594,9 @@
|
||||
"not-found": "Non trouvé",
|
||||
"pre-release": "Pré-publication",
|
||||
"archived": "Archivé",
|
||||
"forked": "",
|
||||
"forked": "Forké",
|
||||
"starsCount": "Étoiles",
|
||||
"forksCount": "",
|
||||
"forksCount": "Forks",
|
||||
"issuesCount": "Problèmes ouverts",
|
||||
"markViewed": "Marquer comme vu(s)",
|
||||
"openProjectPage": "Ouvrir la page du projet",
|
||||
@@ -3661,7 +3707,7 @@
|
||||
"noItems": "Aucune notification à afficher.",
|
||||
"option": {
|
||||
"hideLogos": {
|
||||
"label": ""
|
||||
"label": "Masquer les logos"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3705,7 +3751,7 @@
|
||||
}
|
||||
},
|
||||
"uptimeKuma": {
|
||||
"name": "",
|
||||
"name": "Uptime Kuma",
|
||||
"description": "",
|
||||
"option": {
|
||||
"showAverageUptime": {
|
||||
@@ -3766,7 +3812,7 @@
|
||||
},
|
||||
"resource": {
|
||||
"routers": "",
|
||||
"services": "",
|
||||
"services": "Services",
|
||||
"middlewares": ""
|
||||
},
|
||||
"status": {
|
||||
@@ -3984,15 +4030,15 @@
|
||||
"system": "",
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"disk": "",
|
||||
"disk": "Disque",
|
||||
"gpu": "",
|
||||
"loadAvg": "",
|
||||
"net": "",
|
||||
"temp": "",
|
||||
"temp": "Temp",
|
||||
"battery": "",
|
||||
"services": "",
|
||||
"uptime": "",
|
||||
"agent": ""
|
||||
"agent": "Agent"
|
||||
}
|
||||
},
|
||||
"beszelSystemGrid": {
|
||||
@@ -4044,7 +4090,7 @@
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"memory": "Mémoire",
|
||||
"disk": "",
|
||||
"gpu": "",
|
||||
"loadAvg": "",
|
||||
@@ -4071,8 +4117,8 @@
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
},
|
||||
"history": "",
|
||||
"resolved": "",
|
||||
"history": "Historique",
|
||||
"resolved": "Résolu",
|
||||
"status": {
|
||||
"triggered": "",
|
||||
"ok": ""
|
||||
@@ -4127,12 +4173,12 @@
|
||||
"chart": {
|
||||
"cpu": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"series": ""
|
||||
"subtitle": "Utilisation moyenne du CPU à l'échelle du système",
|
||||
"series": "CPU"
|
||||
},
|
||||
"memory": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"title": "Utilisation Mémoire",
|
||||
"subtitle": "Utilisation précise au moment enregistré",
|
||||
"series": "",
|
||||
"cache": ""
|
||||
},
|
||||
@@ -4143,7 +4189,7 @@
|
||||
},
|
||||
"diskIO": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"subtitle": "Débit du système de fichiers root",
|
||||
"read": "",
|
||||
"write": ""
|
||||
},
|
||||
@@ -5434,7 +5480,7 @@
|
||||
"title": "Capacité",
|
||||
"resource": {
|
||||
"reserved": "Réservé",
|
||||
"used": "Utilisé "
|
||||
"used": "Utilisé"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
@@ -5720,7 +5766,7 @@
|
||||
"label": "Médias"
|
||||
},
|
||||
"apps": {
|
||||
"label": "Applications",
|
||||
"label": "Apps",
|
||||
"new": {
|
||||
"label": "Nouveau"
|
||||
},
|
||||
@@ -5815,7 +5861,7 @@
|
||||
"placeholder": "Rechercher quelque chose",
|
||||
"nothingFound": "Aucun résultat",
|
||||
"error": {
|
||||
"fetch": "Une erreur s'est produite lors de la récupération des données."
|
||||
"fetch": "Une erreur s'est produite lors de la récupération des données"
|
||||
},
|
||||
"mode": {
|
||||
"appIntegrationBoard": {
|
||||
@@ -6498,7 +6544,7 @@
|
||||
},
|
||||
"editMode": {
|
||||
"title": "Mode édition",
|
||||
"description": "Cliquez ici pour entrer en mode édition. Vous pouvez ensuite ajouter des widgets, des applications et des sections — faites glisser pour repositionner, redimensionnez avec la poignée d'angle. Cliquez à nouveau pour enregistrer vos modifications."
|
||||
"description": "Cliquez ici pour entrer en mode édition. Vous pourrez ensuite ajouter des widgets, des applications et des sections — faites glisser pour repositionner, redimensionnez avec la poignée d'angle. Cliquez à nouveau pour enregistrer vos modifications."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres du tableau de bord",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "וואטס אפ דוקר",
|
||||
"description": "עקוב אחר אילו מקונטיינרי הדוקר שלך כוללים עדכוני תמונה זמינים"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "כאן",
|
||||
"iconPicker": {
|
||||
"label": "קישור של הסמל",
|
||||
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} .",
|
||||
"headerLoading": "",
|
||||
"description": "חפש בספרייה, הדבק כתובת URL של תמונה, או העלה קובץ.",
|
||||
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} ",
|
||||
"headerLoading": "חפש תמונות לפי שם",
|
||||
"clear": "נקה תמונה",
|
||||
"uploadImage": "העלה תמונה",
|
||||
"results": "תוצאות חיפוש תמונות",
|
||||
"localImages": "התמונות שלך",
|
||||
"svgIcons": "סמלי SVG",
|
||||
"otherImages": "תמונות אחרות",
|
||||
"urlLoading": "בודק תמונה…",
|
||||
"urlReady": "התמונה מוכנה לשימוש",
|
||||
"urlError": "לא ניתן היה לטעון את התמונה. בדוק את כתובת ה-URL ונסה שוב.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "וואטס אפ דוקר",
|
||||
"description": "הצג כמה מהמכולות הנצפות עם עדכונים זמינים.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "מנוטר",
|
||||
"updatesAvailable": "{count, plural, one {# עדכון זמין} two {# עדכונים זמינים} many {# עדכונים זמינים} other {# עדכונים זמינים}}",
|
||||
"updateAvailable": "עדכון זמין",
|
||||
"openReleaseNotes": "פתח הערות שחרור",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "הצג כותרת"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "הצג טבעת עדכון"
|
||||
},
|
||||
"layout": {
|
||||
"label": "פריסה",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "אופקי"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "אנכי"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "הצג רשימת עדכוני מכולות",
|
||||
"description": "מפרט כל מכולה מנוטרת שיש עדכון תמונה זמין עבורה"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "סיכום חור אבטחה DNS",
|
||||
"description": "מציג את הסיכום חור אבטחת DNS שלך",
|
||||
@@ -2595,7 +2641,7 @@
|
||||
"today": "היום",
|
||||
"24h": "24 השעות האחרונות",
|
||||
"7d": "7 הימים האחרונים",
|
||||
"30d": "",
|
||||
"30d": "30 הימים האחרונים",
|
||||
"month": "",
|
||||
"lastMonth": ""
|
||||
}
|
||||
|
||||
+1617
-1571
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-native reverse proxy e bilanciatore del carico"
|
||||
},
|
||||
"wud": {
|
||||
"name": "Cos'è Il Docker",
|
||||
"description": "Monitora quali dei tuoi contenitori Docker sono disponibili aggiornamenti delle immagini"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Sistema di gestione documentale per archiviare e organizzare documenti scansionati"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "qui",
|
||||
"iconPicker": {
|
||||
"label": "Icona URL",
|
||||
"header": "Digita il nome o gli oggetti da filtrare per le icone... Homarr cercherà tra le icone {countIcons} per te.",
|
||||
"headerLoading": "Digita per cercare icone…",
|
||||
"description": "Cerca nella libreria, incolla un URL di immagine, o carica un file.",
|
||||
"header": "Digita il nome o gli oggetti da filtrare per le icone... Homarr cercherà tra le icone {countIcons} per te",
|
||||
"headerLoading": "Digita per cercare icone",
|
||||
"clear": "Cancella immagine",
|
||||
"uploadImage": "Carica immagine",
|
||||
"results": "Risultati della ricerca immagini",
|
||||
"localImages": "Le tue immagini",
|
||||
"svgIcons": "Icone SVG",
|
||||
"otherImages": "Altre immagini",
|
||||
"urlLoading": "Controllo immagine…",
|
||||
"urlReady": "L'immagine è pronta per l'uso",
|
||||
"urlError": "Questa immagine non può essere caricata. Controlla l'URL e riprova.",
|
||||
"noResults": "Nessuna icona corrisponde a \"{search}\".",
|
||||
"error": "Impossibile caricare le icone."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Cos'è Il Docker",
|
||||
"description": "Mostra quanti contenitori visti hanno aggiornamenti disponibili.",
|
||||
"title": "Cos'è Il Docker",
|
||||
"monitored": "Monitorato",
|
||||
"updatesAvailable": "{count, plural, one {# aggiornamento disponibile} other {# aggiornamenti disponibili}}",
|
||||
"updateAvailable": "Aggiornamento disponibile",
|
||||
"openReleaseNotes": "Apri note di rilascio",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Mostra titolo"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Mostra anello di aggiornamento"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Orizzontale"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Verticale"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Mostra elenco aggiornamenti container",
|
||||
"description": "Elenca ogni contenitore monitorato che ha un aggiornamento dell'immagine disponibile"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Riepilogo del filtraggio DNS",
|
||||
"description": "Mostra il riepilogo del tuo filtro DNS",
|
||||
@@ -2024,7 +2070,7 @@
|
||||
}
|
||||
},
|
||||
"anchorNote": {
|
||||
"name": "Nota Anchor",
|
||||
"name": "Ancora Nota",
|
||||
"description": "Visualizza una nota selezionata da Ancoraggio",
|
||||
"option": {
|
||||
"noteId": {
|
||||
@@ -2262,12 +2308,12 @@
|
||||
"description": "Come dovrebbe apparire la data"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "{currentWindSpeed} km/h",
|
||||
"currentWindSpeed": "{currentWindSpeed} {unit}",
|
||||
"dailyForecast": {
|
||||
"sunrise": "Alba",
|
||||
"sunset": "Tramonto",
|
||||
"maxWindSpeed": "Max velocità vento: {maxWindSpeed} km/h",
|
||||
"maxWindGusts": "Max raffiche di vento: {maxWindGusts} km/h",
|
||||
"maxWindSpeed": "Velocità massima del vento: {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Raffiche di vento massime: {maxWindGusts} {unit}",
|
||||
"humidity": "Umidità: {humidity}%"
|
||||
},
|
||||
"kind": {
|
||||
@@ -3047,7 +3093,7 @@
|
||||
"option": {
|
||||
"albumId": {
|
||||
"label": "ID Album",
|
||||
"description": "Si tratta di un ID univoco che si riferisce al tuo album Immich. Puoi trovare questo id aprendo un album e copiando la stringa univoca dall'URL, ad esempio 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
|
||||
"description": "Si tratta di un ID univoco che si riferisce al tuo album Immich. Puoi trovare questo id aprendo un album e copiando la stringa univoca dall'URL, ad esempio 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
|
||||
},
|
||||
"rotationIntervalSeconds": {
|
||||
"label": "Intervallo in secondi",
|
||||
@@ -3208,7 +3254,7 @@
|
||||
},
|
||||
"client": {
|
||||
"pause": "Metti in pausa il client",
|
||||
"resume": "Ripristina il client"
|
||||
"resume": "Riprendi il client"
|
||||
},
|
||||
"item": {
|
||||
"pause": "Metti in pausa l'elemento",
|
||||
@@ -5352,7 +5398,7 @@
|
||||
"label": "Arresta",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containers arrestati",
|
||||
"title": "Containers fermati",
|
||||
"message": "I containers sono stati arrestati con successo"
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "どのDockerコンテナがイメージアップデートを利用できるかを監視します"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "こちら",
|
||||
"iconPicker": {
|
||||
"label": "アイコン URL",
|
||||
"header": "アイコンをフィルタするために、名前またはオブジェクトを入力してください... Homarrは {countIcons} アイコンを検索します。",
|
||||
"headerLoading": "",
|
||||
"description": "ライブラリを検索し、画像の URL を貼り付けるか、ファイルをアップロードします。",
|
||||
"header": "{countIcons} 画像を名前で検索",
|
||||
"headerLoading": "名前で画像を検索",
|
||||
"clear": "画像をクリア",
|
||||
"uploadImage": "画像をアップロード",
|
||||
"results": "画像検索結果",
|
||||
"localImages": "あなたの画像",
|
||||
"svgIcons": "SVGアイコン",
|
||||
"otherImages": "その他の画像",
|
||||
"urlLoading": "画像を確認中…",
|
||||
"urlReady": "画像を使用する準備ができました",
|
||||
"urlError": "この画像を読み込めませんでした。URLを確認して再度お試し下さい。",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "利用可能な更新があるコンテナの数を表示します。",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "監視中",
|
||||
"updatesAvailable": "{count, plural, one {# アップデートが} other {# アップデートが}}",
|
||||
"updateAvailable": "アップデートがあります",
|
||||
"openReleaseNotes": "リリースノートを開く",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "タイトルを表示"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "更新の着信音を表示"
|
||||
},
|
||||
"layout": {
|
||||
"label": "レイアウト",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "水平方向"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "垂直方向"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "コンテナ更新リストを表示",
|
||||
"description": "利用可能な画像の更新がある各監視対象のコンテナを一覧表示します"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole 概要",
|
||||
"description": "DNS Hole の概要を表示します",
|
||||
@@ -2489,7 +2535,7 @@
|
||||
"description": "",
|
||||
"option": {
|
||||
"showBattery": {
|
||||
"label": ""
|
||||
"label": "バッテリーを表示"
|
||||
},
|
||||
"showLoad": {
|
||||
"label": ""
|
||||
@@ -2595,7 +2641,7 @@
|
||||
"today": "今日",
|
||||
"24h": "過去24時間",
|
||||
"7d": "過去7日間",
|
||||
"30d": "",
|
||||
"30d": "過去30日間",
|
||||
"month": "",
|
||||
"lastMonth": ""
|
||||
}
|
||||
@@ -6455,7 +6501,7 @@
|
||||
"skip": "",
|
||||
"manage": {
|
||||
"welcome": {
|
||||
"title": "",
|
||||
"title": "Homarrへようこそ!",
|
||||
"description": ""
|
||||
},
|
||||
"boardsList": {
|
||||
|
||||
@@ -1042,20 +1042,20 @@
|
||||
},
|
||||
"unknown": {
|
||||
"title": "알 수 없는 오류",
|
||||
"description": ""
|
||||
"description": "알 수 없는 오류가 발생했습니다. 자세한 내용을 보려면 아래 원인을 펼치세요"
|
||||
},
|
||||
"parse": {
|
||||
"title": "파싱 오류",
|
||||
"description": ""
|
||||
"description": "응답을 파싱할 수 없습니다. URL이 서비스의 기본 URL을 가리키는지 확인하세요."
|
||||
},
|
||||
"authorization": {
|
||||
"title": "인증 오류",
|
||||
"description": ""
|
||||
"description": "요청이 승인되지 않았습니다. 자격 증명이 올바르고 충분한 권한으로 구성되어 있는지 확인하세요."
|
||||
},
|
||||
"statusCode": {
|
||||
"title": "응답 오류",
|
||||
"description": "",
|
||||
"otherDescription": "",
|
||||
"description": "예상치 못한 {statusCode} ({reason}) 응답을 <url></url>에서 받았습니다. URL이 통합의 기본 URL을 가리키는지 확인하세요.",
|
||||
"otherDescription": "예상치 못한 {statusCode} 응답을 <url></url>에서 받았습니다. URL이 통합의 기본 URL을 가리키는지 확인하세요.",
|
||||
"reason": {
|
||||
"badRequest": "잘못된 요청",
|
||||
"notFound": "찾을 수 없음",
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "클라우드 네이티브 리버스 프록시 및 로드 밸런서"
|
||||
},
|
||||
"wud": {
|
||||
"name": "왓츠업 도커",
|
||||
"description": "Docker 컨테이너 중 이미지 업데이트가 가능한 것을 모니터링합니다."
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "스캔된 문서를 보관하고 정리하는 문서 관리 시스템"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "여기",
|
||||
"iconPicker": {
|
||||
"label": "아이콘 URL",
|
||||
"header": "이름 또는 주제를 입력해 아이콘을 필터링하세요... Homarr가 {countIcons}에서 아이콘을 검색합니다.",
|
||||
"headerLoading": "아이콘을 검색하려면 입력…",
|
||||
"description": "라이브러리에서 검색하거나, 이미지 URL을 붙여넣거나, 파일을 업로드하세요.",
|
||||
"header": "이름 또는 주제를 입력해 아이콘을 필터링하세요... Homarr가 {countIcons}에서 아이콘을 검색합니다",
|
||||
"headerLoading": "아이콘을 검색하려면 입력",
|
||||
"clear": "이미지 지우기",
|
||||
"uploadImage": "이미지 업로드",
|
||||
"results": "이미지 검색 결과",
|
||||
"localImages": "내 이미지",
|
||||
"svgIcons": "SVG 아이콘",
|
||||
"otherImages": "기타 이미지",
|
||||
"urlLoading": "이미지 확인 중…",
|
||||
"urlReady": "이미지 사용 준비 완료",
|
||||
"urlError": "이 이미지는 불러올 수 없습니다. URL을 확인하고 다시 시도하십시오.",
|
||||
"noResults": "\"{search}\"에 일치하는 아이콘이 없습니다.",
|
||||
"error": "아이콘을 불러올 수 없습니다."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "왓츠업 도커",
|
||||
"description": "모니터링 중인 컨테이너에서 사용 가능한 업데이트 수를 표시합니다.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "모니터링 중",
|
||||
"updatesAvailable": "{count, plural, other {업데이트 #개 사용 가능}}",
|
||||
"updateAvailable": "업데이트 사용 가능",
|
||||
"openReleaseNotes": "릴리스 노트 열기",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "제목 표시"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "업데이트 링 표시"
|
||||
},
|
||||
"layout": {
|
||||
"label": "레이아웃",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "가로"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "세로"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "컨테이너 업데이트 목록 표시",
|
||||
"description": "이미지 업데이트가 있는 각 모니터링 컨테이너를 나열합니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS 홀 요약",
|
||||
"description": "DNS 홀의 요약을 표시합니다",
|
||||
@@ -1938,16 +1984,16 @@
|
||||
}
|
||||
},
|
||||
"timetable": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"noStation": "",
|
||||
"name": "시간표",
|
||||
"description": "역의 출발 시간을 표시합니다",
|
||||
"title": "{station} 출발",
|
||||
"noStation": "선택한 역이 없습니다",
|
||||
"option": {
|
||||
"baseUrl": {
|
||||
"label": "Search.ch URL"
|
||||
},
|
||||
"station": {
|
||||
"label": ""
|
||||
"label": "역"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2015,7 +2061,7 @@
|
||||
},
|
||||
"placeholder": "노트 작성 시작",
|
||||
"dismiss": {
|
||||
"title": "변경사항 삭제",
|
||||
"title": "변경사항을 버리시겠습니까??",
|
||||
"message": "노트북에 저장되지 않은 변경 사항이 있습니다. 삭제하시겠습니까?",
|
||||
"action": {
|
||||
"discard": "저장하지 않음",
|
||||
@@ -2024,32 +2070,32 @@
|
||||
}
|
||||
},
|
||||
"anchorNote": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Anchor 노트",
|
||||
"description": "Anchor에서 선택한 노트를 표시합니다",
|
||||
"option": {
|
||||
"noteId": {
|
||||
"label": ""
|
||||
"label": "노트"
|
||||
},
|
||||
"showTitle": {
|
||||
"label": ""
|
||||
"label": "제목 표시"
|
||||
},
|
||||
"showUpdatedAt": {
|
||||
"label": ""
|
||||
"label": "마지막 업데이트 표시"
|
||||
}
|
||||
},
|
||||
"empty": "",
|
||||
"emptyContent": "",
|
||||
"notFound": "",
|
||||
"noNotes": "",
|
||||
"integrationRequired": "",
|
||||
"updatedAt": "",
|
||||
"untitled": "",
|
||||
"edit": "",
|
||||
"save": "",
|
||||
"cancel": "",
|
||||
"readOnlyViewer": "",
|
||||
"saveForbidden": "",
|
||||
"saveFailed": ""
|
||||
"empty": "표시할 노트를 선택하세요",
|
||||
"emptyContent": "이 노트에는 내용이 없습니다",
|
||||
"notFound": "노트를 찾을 수 없습니다",
|
||||
"noNotes": "노트를 찾을 수 없습니다",
|
||||
"integrationRequired": "먼저 Anchor 통합을 선택하세요",
|
||||
"updatedAt": "{date}에 업데이트됨",
|
||||
"untitled": "제목 없음",
|
||||
"edit": "편집",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"readOnlyViewer": "이 노트에 대한 뷰어 권한이 있습니다. 편집이 비활성화되었습니다.",
|
||||
"saveForbidden": "이 노트를 편집할 권한이 없습니다.",
|
||||
"saveFailed": "이 노트를 저장하지 못했습니다. 다시 시도하세요."
|
||||
},
|
||||
"iframe": {
|
||||
"name": "iFrame",
|
||||
@@ -2409,7 +2455,7 @@
|
||||
},
|
||||
"dockerContainers": {
|
||||
"name": "Docker 상태",
|
||||
"description": "",
|
||||
"description": "컨테이너 통계 (이 위젯은 관리자 권한으로만 추가할 수 있습니다)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": "표시할 열"
|
||||
@@ -2418,7 +2464,7 @@
|
||||
"label": "항목 정렬 활성화"
|
||||
},
|
||||
"defaultSort": {
|
||||
"label": "",
|
||||
"label": "기본 정렬에 사용되는 열",
|
||||
"option": {
|
||||
"name": "이름",
|
||||
"state": "상태",
|
||||
@@ -2430,52 +2476,52 @@
|
||||
"label": "역순 정렬"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "열 순서"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "열 너비"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "컨테이너 상태 불러오기 실패",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "테이블 레이아웃이 저장되지 않았습니다",
|
||||
"layoutSaveMessage": "Docker 테이블 레이아웃을 저장할 수 없습니다. 열을 다시 이동하거나 크기를 조정해 보세요."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "컨테이너 없음"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Coolify",
|
||||
"description": "서버와 애플리케이션을 포함한 Coolify 인스턴스 개요",
|
||||
"option": {
|
||||
"showServers": {
|
||||
"label": ""
|
||||
"label": "서버 표시"
|
||||
},
|
||||
"showApplications": {
|
||||
"label": ""
|
||||
"label": "애플리케이션 표시"
|
||||
},
|
||||
"showServices": {
|
||||
"label": ""
|
||||
"label": "서비스 표시"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"servers": "",
|
||||
"applications": "",
|
||||
"services": ""
|
||||
"servers": "서버를 찾을 수 없습니다",
|
||||
"applications": "애플리케이션을 찾을 수 없습니다",
|
||||
"services": "서비스를 찾을 수 없습니다"
|
||||
},
|
||||
"footer": {
|
||||
"updated": ""
|
||||
"updated": "{when}에 업데이트됨"
|
||||
},
|
||||
"error": {
|
||||
"noIntegration": "",
|
||||
"internalServerError": ""
|
||||
"noIntegration": "구성된 Coolify 통합이 없습니다",
|
||||
"internalServerError": "Coolify 데이터를 가져오지 못했습니다"
|
||||
},
|
||||
"server": {
|
||||
"buildServer": ""
|
||||
"buildServer": "빌드"
|
||||
},
|
||||
"instances": {
|
||||
"title": ""
|
||||
"title": "인스턴스"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
@@ -2516,85 +2562,85 @@
|
||||
}
|
||||
},
|
||||
"tracearr": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Tracearr",
|
||||
"description": "상세 스트림 및 트랜스코딩 데이터로 강화된 미디어 서버 모니터링",
|
||||
"option": {
|
||||
"showStreams": {
|
||||
"label": ""
|
||||
"label": "활성 스트림 표시"
|
||||
},
|
||||
"showStats": {
|
||||
"label": ""
|
||||
"label": "통계 요약 표시"
|
||||
},
|
||||
"showRecentActivity": {
|
||||
"label": ""
|
||||
"label": "최근 활동 표시"
|
||||
},
|
||||
"showViolations": {
|
||||
"label": ""
|
||||
"label": "위반 사항 표시"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"activeStreams": "",
|
||||
"totalUsers": "",
|
||||
"transcodes": "",
|
||||
"bandwidth": ""
|
||||
"activeStreams": "활성 스트림",
|
||||
"totalUsers": "총 사용자",
|
||||
"transcodes": "트랜스코드",
|
||||
"bandwidth": "대역폭"
|
||||
},
|
||||
"streams": {
|
||||
"empty": "",
|
||||
"user": "",
|
||||
"media": "",
|
||||
"state": "",
|
||||
"quality": "",
|
||||
"device": "",
|
||||
"progress": "",
|
||||
"empty": "활성 스트림이 없습니다",
|
||||
"user": "사용자",
|
||||
"media": "미디어",
|
||||
"state": "상태",
|
||||
"quality": "품질",
|
||||
"device": "장치",
|
||||
"progress": "진행률",
|
||||
"videoDecision": {
|
||||
"directPlay": "",
|
||||
"transcode": "",
|
||||
"directStream": ""
|
||||
"directPlay": "직접 재생",
|
||||
"transcode": "트랜스코딩",
|
||||
"directStream": "직접 스트림"
|
||||
}
|
||||
},
|
||||
"recentActivity": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"watched": "",
|
||||
"partial": ""
|
||||
"title": "최근 활동",
|
||||
"empty": "최근 활동이 없습니다",
|
||||
"watched": "시청함",
|
||||
"partial": "부분"
|
||||
},
|
||||
"violations": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"rule": "",
|
||||
"user": ""
|
||||
"title": "위반 사항",
|
||||
"empty": "최근 위반 사항이 없습니다",
|
||||
"rule": "규칙",
|
||||
"user": "사용자"
|
||||
},
|
||||
"noSectionsEnabled": "",
|
||||
"noSectionsEnabled": "활성화된 섹션이 없습니다",
|
||||
"error": {
|
||||
"noIntegration": "",
|
||||
"internalServerError": ""
|
||||
"noIntegration": "구성된 Tracearr 통합이 없습니다",
|
||||
"internalServerError": "Tracearr 데이터를 가져오지 못했습니다"
|
||||
}
|
||||
},
|
||||
"umami": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"visitors": "",
|
||||
"active": "",
|
||||
"pageviews": "",
|
||||
"visits": "",
|
||||
"bounceRate": "",
|
||||
"avgDuration": "",
|
||||
"name": "Umami Analytics",
|
||||
"description": "Umami 분석 인스턴스의 방문자 통계를 표시합니다",
|
||||
"visitors": "방문자",
|
||||
"active": "활성",
|
||||
"pageviews": "페이지뷰",
|
||||
"visits": "방문",
|
||||
"bounceRate": "이탈률",
|
||||
"avgDuration": "평균",
|
||||
"option": {
|
||||
"websiteId": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": "",
|
||||
"loading": "",
|
||||
"noWebsites": "",
|
||||
"configureIntegrationFirst": ""
|
||||
"label": "웹사이트",
|
||||
"description": "Umami 인스턴스에서 추적하는 웹사이트 선택",
|
||||
"placeholder": "웹사이트 선택",
|
||||
"loading": "웹사이트 불러오는 중...",
|
||||
"noWebsites": "웹사이트를 찾을 수 없습니다",
|
||||
"configureIntegrationFirst": "먼저 Umami 통합을 연결하세요"
|
||||
},
|
||||
"timeFrame": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "시간 범위",
|
||||
"description": "방문자 데이터를 표시할 기간",
|
||||
"option": {
|
||||
"today": "",
|
||||
"24h": "",
|
||||
"7d": "",
|
||||
"today": "오늘",
|
||||
"24h": "지난 24시간",
|
||||
"7d": "지난 7일",
|
||||
"30d": "지난 30일",
|
||||
"month": "이번 달",
|
||||
"lastMonth": "지난 달"
|
||||
@@ -2738,17 +2784,17 @@
|
||||
"description": "비활성화 옵션은 plex에서는 지원되지 않음"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "비트레이트 표시",
|
||||
"description": "위치 옆에 현재 재생 비트레이트를 표시합니다"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "위치 표시",
|
||||
"description": "스트림이 LAN 또는 WAN에 있는지 표시합니다"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"streams": "{count, plural, other {# 활성 스트림}}",
|
||||
"totalBitrate": "총: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "현재 재생 중",
|
||||
@@ -2757,9 +2803,9 @@
|
||||
"id": "아이디",
|
||||
"status": "상태",
|
||||
"directPlay": "직접 재생",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"directStream": "직접 스트림",
|
||||
"transcodeVideo": "비디오 트랜스코딩",
|
||||
"transcodeAudio": "오디오 트랜스코딩",
|
||||
"transcoding": "트랜스코딩",
|
||||
"paused": "일시 중지됨",
|
||||
"remaining": "남은 시간 {minutes}분",
|
||||
@@ -3107,18 +3153,18 @@
|
||||
"description": "통합당 표시되는 항목 수를 제한하여 전체 항목이 표시되지 않습니다"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "열 순서"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "열 너비"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "항목에서 열 선택",
|
||||
"noCommunications": "통합에서 데이터를 불러올 수 없습니다",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "다운로드 없음",
|
||||
"actionFailed": "작업 실패",
|
||||
"actionFailedMessage": "작업을 완료할 수 없습니다. 다시 시도해 주세요."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3142,7 +3188,7 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "아이디",
|
||||
"detailsTitle": ""
|
||||
"detailsTitle": "항목 ID"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "통합"
|
||||
@@ -3177,7 +3223,7 @@
|
||||
"time": {
|
||||
"columnTitle": "완료 시간",
|
||||
"detailsTitle": "완료/완료 후 경과 시간",
|
||||
"eta": ""
|
||||
"eta": "남은 시간"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "유형",
|
||||
@@ -3223,49 +3269,49 @@
|
||||
},
|
||||
"globalRatio": "전체 비율",
|
||||
"contextMenu": {
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
"copyName": "이름 복사",
|
||||
"copyId": "ID 복사",
|
||||
"clearFilters": "필터 지우기"
|
||||
},
|
||||
"footer": {
|
||||
"filter": ""
|
||||
"filter": "다운로드 필터"
|
||||
},
|
||||
"stats": {
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
"show": "전체 통계 표시",
|
||||
"hide": "전체 통계 숨기기",
|
||||
"overallProgress": "전체 대기열 진행률",
|
||||
"totalSize": "전체 대기열 크기",
|
||||
"clients": "{count, plural, other {# 클라이언트}}"
|
||||
},
|
||||
"disconnected": ""
|
||||
"disconnected": "(연결 끊김)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "미디어 릴리스",
|
||||
"description": "다른 통합에서 새로 추가된 미디어나 예정된 릴리스를 표시합니다",
|
||||
"option": {
|
||||
"layout": {
|
||||
"label": "",
|
||||
"label": "레이아웃",
|
||||
"option": {
|
||||
"backdrop": {
|
||||
"label": ""
|
||||
"label": "배경"
|
||||
},
|
||||
"poster": {
|
||||
"label": ""
|
||||
"label": "포스터"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showDescriptionTooltip": {
|
||||
"label": ""
|
||||
"label": "설명 도구 설명 표시"
|
||||
},
|
||||
"showType": {
|
||||
"label": ""
|
||||
"label": "미디어 유형 배지 표시"
|
||||
},
|
||||
"showSource": {
|
||||
"label": ""
|
||||
"label": "소스 통합 표시"
|
||||
}
|
||||
},
|
||||
"length": {
|
||||
"duration": ""
|
||||
"duration": "{length}분"
|
||||
}
|
||||
},
|
||||
"mediaRequests-requestList": {
|
||||
@@ -3291,11 +3337,11 @@
|
||||
"unknown": "알 수 없음",
|
||||
"pending": "대기 중",
|
||||
"processing": "처리 중",
|
||||
"requested": "",
|
||||
"requested": "요청됨",
|
||||
"partiallyAvailable": "부분",
|
||||
"available": "사용 가능",
|
||||
"blacklisted": "",
|
||||
"deleted": ""
|
||||
"blacklisted": "블랙리스트",
|
||||
"deleted": "삭제됨"
|
||||
},
|
||||
"status": {
|
||||
"pending": "대기 중",
|
||||
@@ -3433,10 +3479,10 @@
|
||||
"label": "게시물 수 제한"
|
||||
},
|
||||
"hideDescription": {
|
||||
"label": ""
|
||||
"label": "설명 숨기기"
|
||||
},
|
||||
"showPosterImage": {
|
||||
"label": ""
|
||||
"label": "포스터 이미지 표시"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3460,7 +3506,7 @@
|
||||
"label": "세부 정보 표시"
|
||||
},
|
||||
"showOnlyIcon": {
|
||||
"label": ""
|
||||
"label": "아이콘만 표시"
|
||||
},
|
||||
"topReleases": {
|
||||
"label": "최상위 릴리스",
|
||||
@@ -3469,18 +3515,18 @@
|
||||
"repositories": {
|
||||
"label": "저장소",
|
||||
"addRepository": {
|
||||
"label": ""
|
||||
"label": "저장소 추가"
|
||||
},
|
||||
"importRepositories": {
|
||||
"label": "",
|
||||
"loading": "",
|
||||
"noImagesFound": "",
|
||||
"listFoundImages": "",
|
||||
"listAlreadyImportedImages": "",
|
||||
"allImagesAlreadyImported": "",
|
||||
"onlyAdminCanImport": "",
|
||||
"selectAll": "",
|
||||
"deselectAll": ""
|
||||
"label": "Docker에서 가져오기",
|
||||
"loading": "Docker 이미지 불러오는 중",
|
||||
"noImagesFound": "Docker 이미지를 찾을 수 없습니다",
|
||||
"listFoundImages": "발견된 이미지 목록",
|
||||
"listAlreadyImportedImages": "이미 가져온 이미지 목록",
|
||||
"allImagesAlreadyImported": "모든 이미지를 이미 가져왔습니다",
|
||||
"onlyAdminCanImport": "관리자만 Docker에서 가져올 수 있습니다",
|
||||
"selectAll": "모두 선택",
|
||||
"deselectAll": "모두 선택 해제"
|
||||
},
|
||||
"provider": {
|
||||
"label": "제공자"
|
||||
@@ -3526,15 +3572,15 @@
|
||||
}
|
||||
},
|
||||
"importForm": {
|
||||
"title": ""
|
||||
"title": "Docker에서 가져오기"
|
||||
},
|
||||
"example": {
|
||||
"label": "예시"
|
||||
},
|
||||
"invalid": "잘못된 저장소 정의, 값을 확인하십시오",
|
||||
"noProvider": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
"label": "제공자 없음",
|
||||
"tooltip": "제공자를 파싱할 수 없습니다. 이미지를 가져온 후 수동으로 설정하세요"
|
||||
},
|
||||
"tokens": {
|
||||
"label": "공급자 토큰",
|
||||
@@ -3552,20 +3598,20 @@
|
||||
"starsCount": "별",
|
||||
"forksCount": "포크",
|
||||
"issuesCount": "열린 이슈",
|
||||
"markViewed": "",
|
||||
"markViewed": "본 것으로 표시",
|
||||
"openProjectPage": "프로젝트 페이지 열기",
|
||||
"openReleasePage": "릴리스 페이지 열기",
|
||||
"releaseDescription": "릴리스 설명",
|
||||
"projectDescription": "",
|
||||
"projectDescription": "프로젝트 설명",
|
||||
"created": "생성됨",
|
||||
"error": {
|
||||
"label": "오류",
|
||||
"messages": {
|
||||
"invalidIdentifier": "",
|
||||
"noMatchingVersion": "",
|
||||
"noReleasesFound": "",
|
||||
"invalidIdentifier": "잘못된 식별자",
|
||||
"noMatchingVersion": "일치하는 버전을 찾을 수 없습니다",
|
||||
"noReleasesFound": "릴리스를 찾을 수 없습니다",
|
||||
"noProviderSelected": "선택한 공급자가 없습니다",
|
||||
"noProviderResponse": "",
|
||||
"noProviderResponse": "제공자로부터 응답이 없습니다",
|
||||
"unexpected": "예기치 않은 공급자 오류"
|
||||
}
|
||||
}
|
||||
@@ -3627,31 +3673,31 @@
|
||||
}
|
||||
},
|
||||
"firewall": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "방화벽 모니터링",
|
||||
"description": "방화벽 요약을 표시합니다",
|
||||
"tab": {
|
||||
"system": "",
|
||||
"interfaces": ""
|
||||
"system": "시스템",
|
||||
"interfaces": "인터페이스"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "방화벽에서 데이터를 가져올 수 없습니다"
|
||||
},
|
||||
"option": {
|
||||
"interfaces": ""
|
||||
"interfaces": "표시할 네트워크 인터페이스"
|
||||
},
|
||||
"widget": {
|
||||
"fwname": "",
|
||||
"version": "",
|
||||
"versiontitle": "",
|
||||
"cputitle": "",
|
||||
"memorytitle": "",
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"fwname": "이름",
|
||||
"version": "버전",
|
||||
"versiontitle": "버전",
|
||||
"cputitle": "CPU 사용량",
|
||||
"memorytitle": "메모리 사용량",
|
||||
"cpu": "CPU",
|
||||
"memory": "메모리",
|
||||
"interfaces": {
|
||||
"name": "",
|
||||
"trans": "",
|
||||
"recv": "",
|
||||
"title": ""
|
||||
"name": "이름",
|
||||
"trans": "전송됨",
|
||||
"recv": "수신됨",
|
||||
"title": "네트워크 인터페이스"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3666,42 +3712,42 @@
|
||||
}
|
||||
},
|
||||
"speedtestTracker": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Speedtest Tracker",
|
||||
"description": "Speedtest Tracker 인스턴스의 속도 테스트 결과를 표시합니다.",
|
||||
"option": {
|
||||
"showLatestResult": {
|
||||
"label": ""
|
||||
"label": "최신 결과 표시"
|
||||
},
|
||||
"showStats": {
|
||||
"label": ""
|
||||
"label": "평균 표시"
|
||||
},
|
||||
"showRecentResults": {
|
||||
"label": ""
|
||||
"label": "최근 결과 표시"
|
||||
},
|
||||
"showPingGraph": {
|
||||
"label": ""
|
||||
"label": "핑 그래프 표시"
|
||||
}
|
||||
},
|
||||
"latestResult": "",
|
||||
"averages": "",
|
||||
"tests": "",
|
||||
"recentResults": "",
|
||||
"recentResultsSpeed": "",
|
||||
"recentResultsPing": "",
|
||||
"download": "",
|
||||
"upload": "",
|
||||
"ping": "",
|
||||
"status": "",
|
||||
"avgDownload": "",
|
||||
"avgUpload": "",
|
||||
"avgPing": "",
|
||||
"healthy": "",
|
||||
"unhealthy": "",
|
||||
"ok": "",
|
||||
"fail": "",
|
||||
"noSectionsEnabled": "",
|
||||
"latestResult": "최신 결과",
|
||||
"averages": "평균",
|
||||
"tests": "테스트 {count}개",
|
||||
"recentResults": "최근 결과",
|
||||
"recentResultsSpeed": "속도",
|
||||
"recentResultsPing": "핑",
|
||||
"download": "다운로드",
|
||||
"upload": "업로드",
|
||||
"ping": "핑",
|
||||
"status": "상태",
|
||||
"avgDownload": "평균 다운로드",
|
||||
"avgUpload": "평균 업로드",
|
||||
"avgPing": "평균 핑",
|
||||
"healthy": "정상",
|
||||
"unhealthy": "비정상",
|
||||
"ok": "정상",
|
||||
"fail": "실패",
|
||||
"noSectionsEnabled": "위젯 옵션에서 하나 이상의 섹션을 활성화하세요.",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "속도 테스트 데이터를 가져오지 못했습니다"
|
||||
}
|
||||
},
|
||||
"uptimeKuma": {
|
||||
@@ -3968,17 +4014,17 @@
|
||||
"label": "에이전트 버전 표시"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "열 순서"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "열 너비"
|
||||
}
|
||||
},
|
||||
"noRecords": "시스템을 찾을 수 없음",
|
||||
"error": {
|
||||
"internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "테이블 레이아웃을 저장할 수 없습니다",
|
||||
"layoutSaveMessage": "열 순서나 너비를 저장할 수 없습니다. 다시 시도해 주세요."
|
||||
},
|
||||
"column": {
|
||||
"system": "시스템",
|
||||
@@ -4610,7 +4656,7 @@
|
||||
"authorization": "권한"
|
||||
},
|
||||
"heroBanner": {
|
||||
"title": "",
|
||||
"title": "다시 오신 것을 환영합니다",
|
||||
"subtitle": "{app} 보드"
|
||||
}
|
||||
},
|
||||
@@ -4876,7 +4922,7 @@
|
||||
"title": "사용자",
|
||||
"enableGravatar": {
|
||||
"label": "Gravatar 활성화",
|
||||
"description": ""
|
||||
"description": "사용자 지정 아바타가 설정되지 않고 이메일이 구성된 경우 Libravatar/Gravatar의 사용자 아바타로 대체됩니다"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
@@ -4912,7 +4958,7 @@
|
||||
"idle": "대기",
|
||||
"running": "실행 중",
|
||||
"error": "오류",
|
||||
"disabled": ""
|
||||
"disabled": "비활성화됨"
|
||||
},
|
||||
"job": {
|
||||
"iconsUpdater": {
|
||||
@@ -5254,8 +5300,8 @@
|
||||
"subtitle": "{count}명이 여러 언어로 번역 기여"
|
||||
},
|
||||
"sponsors": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "스폰서",
|
||||
"subtitle": "재정적으로 지원 중인 {count}명"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "라이브러리",
|
||||
@@ -5269,12 +5315,12 @@
|
||||
"action": "행동"
|
||||
},
|
||||
"action": {
|
||||
"toggleBoardEdit": "",
|
||||
"toggleColorScheme": "",
|
||||
"saveNotebook": "",
|
||||
"openSpotlight": ""
|
||||
"toggleBoardEdit": "보드 편집 모드 전환",
|
||||
"toggleColorScheme": "라이트/다크 모드 전환",
|
||||
"saveNotebook": "노트북 저장 (노트북 위젯 내에서만)",
|
||||
"openSpotlight": "검색 열기"
|
||||
},
|
||||
"note": ""
|
||||
"note": "팁: Mod는 Ctrl 키와 macOS의 ⌘ 키를 모두 의미합니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5376,7 +5422,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "제거",
|
||||
"confirm": "",
|
||||
"confirm": "영구적으로 제거하려면 다시 클릭하세요",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "컨테이너 제거됨",
|
||||
|
||||
+1624
-1578
File diff suppressed because it is too large.
Load diff
+1753
-1707
File diff suppressed because it is too large.
Load diff
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Wat is er een Docker",
|
||||
"description": "Controleer welke van uw Docker containers afbeeldingen updates beschikbaar hebben"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Documentbeheersysteem voor het archiveren en organiseren van gescande documenten"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "hier",
|
||||
"iconPicker": {
|
||||
"label": "Icoon-URL",
|
||||
"header": "Typ naam of objecten om te filteren op iconen… Homarr doorzoekt {countIcons} iconen voor je.",
|
||||
"headerLoading": "",
|
||||
"description": "Zoek in de bibliotheek, plak een afbeelding-URL of upload een bestand.",
|
||||
"header": "Zoek {countIcons} afbeeldingen op naam",
|
||||
"headerLoading": "Afbeeldingen zoeken op naam",
|
||||
"clear": "Verwijder afbeelding",
|
||||
"uploadImage": "Afbeelding uploaden",
|
||||
"results": "Zoekresultaten afbeeldingen",
|
||||
"localImages": "Jouw afbeeldingen",
|
||||
"svgIcons": "SVG iconen",
|
||||
"otherImages": "Andere afbeeldingen",
|
||||
"urlLoading": "Controleren van afbeelding…",
|
||||
"urlReady": "Afbeelding is klaar voor gebruik",
|
||||
"urlError": "Deze afbeelding kan niet worden geladen. Controleer de URL en probeer het opnieuw.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Wat is er een Docker",
|
||||
"description": "Laat zien hoeveel containers er updates beschikbaar hebben.",
|
||||
"title": "Wat is er een Docker",
|
||||
"monitored": "Gecontroleerd",
|
||||
"updatesAvailable": "{count, plural, one {# update beschikbaar} other {# updates zijn beschikbaar}}",
|
||||
"updateAvailable": "Update beschikbaar",
|
||||
"openReleaseNotes": "Open release notities",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Toon titel"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Update ring tonen"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Lay-out",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontaal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Verticaal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Toon container update lijst",
|
||||
"description": "Lijst van alle gemonitorde container met een afbeelding update beschikbaar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS-hole overzicht",
|
||||
"description": "Toont het overzicht van je DNS-hole",
|
||||
@@ -3406,7 +3452,7 @@
|
||||
"queued": ""
|
||||
},
|
||||
"type": {
|
||||
"movie": "",
|
||||
"movie": "Film",
|
||||
"episode": ""
|
||||
},
|
||||
"empty": {
|
||||
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user