Compare commits

..
Author SHA1 Message Date
ajnart b218910476 fix(media-requests): cache statistics responses 2026-07-22 17:36:48 +02:00
162 changed files with 22610 additions and 42299 deletions

No files matched your search

@@ -1,622 +0,0 @@
# 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.
## Migration Candidates
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`
- `users/_components/user-list.tsx`
- `users/invites/_components/invite-list.tsx`
- `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`
---
## 1. Core Migration: mantine-react-table → mantine-datatable
### Import changes
```typescript
// BEFORE
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { MRT_ColumnDef } from "mantine-react-table";
// AFTER
import { DataTable, useDataTableColumns } from "mantine-datatable";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
```
### Column definition shape
```typescript
// BEFORE (MRT)
const columns: MRT_ColumnDef<MyRow>[] = [
{
accessorKey: "name",
header: "Name",
size: 200,
enableSorting: true,
Cell: ({ row }) => <Text>{row.original.name}</Text>,
},
];
// AFTER (mantine-datatable)
const columns: DataTableColumn<MyRow>[] = [
{
accessor: "name", // not accessorKey
title: "Name", // not header
width: 200, // not size
sortable: true, // not enableSorting
render: (record) => <Text>{record.name}</Text>, // not Cell
},
];
```
Key differences:
- `accessorKey` → `accessor`
- `header` → `title`
- `size` → `width`
- `Cell: ({ row }) =>` → `render: (record) =>`
- `enableSorting` → `sortable`
- `enableColumnActions` → does not exist (no column action menus)
- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps`
### Table component
```typescript
// BEFORE
const table = useTranslatedMantineReactTable({ columns, data, ... });
return <MantineReactTable table={table} />;
// AFTER
return (
<DataTable
records={data}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
...
/>
);
```
### Sorting is manual
mantine-datatable does NOT sort for you. You must sort the data yourself:
```typescript
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<MyRow>>({
columnAccessor: "name",
direction: "asc",
});
const sortedData = useMemo(() => {
const mult = sortStatus.direction === "desc" ? -1 : 1;
return [...data].toSorted((a, b) => {
const aVal = a[sortStatus.columnAccessor];
const bVal = b[sortStatus.columnAccessor];
if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult;
return ((aVal as number) - (bVal as number)) * mult;
});
}, [data, sortStatus]);
// Pass sortedData to records, not data
<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.
Create a CSS file alongside the component:
```css
/* styles.css */
.my-table,
.my-table .mantine-datatable-table,
.my-table .mantine-datatable-table thead,
.my-table .mantine-datatable-table tbody,
.my-table .mantine-datatable-table tfoot,
.my-table th,
.my-table td {
background-color: transparent !important;
}
.my-table .mantine-datatable-table tr {
background-color: transparent;
}
/* 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;
}
/* Headers: frosted glass effect */
.my-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
/* Cell overflow */
.my-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hide sort icon until hover */
.my-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.my-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
/* Row expansion area */
.my-table .mantine-datatable-row-expansion-cell {
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.
```typescript
interface SizeConfig {
fontSize: "xs" | "sm";
iconSize: number;
cellPadding: number;
showSpeedColumns: boolean;
showTimeColumn: boolean;
showStateColumn: boolean;
}
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 } },
];
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) {
if (width < maxWidth) return config;
}
return DEFAULT_SIZE_CONFIG;
}
// Usage: memoize it
const size = useMemo(() => getSizeConfig(width), [width]);
```
Then use a visibility check lookup table for columns:
```typescript
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,
state: (ctx) => ctx.size.showStateColumn,
};
function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
if (!ctx.optionsColumnSet.has(accessor)) return false;
const check = columnVisibilityChecks[accessor];
if (check && !check(ctx)) return false;
return true;
}
```
---
## 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.
### Hidden widget options
In `index.ts`, add hidden text options:
```typescript
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
```
With visibility override:
```typescript
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
}
```
### Translation keys required
Even hidden options need translation keys or the translation test fails:
```json
"widget.mywidget.option.columnOrder.label": "Column order",
"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:
```typescript
const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
```
### Hydration from server → localStorage
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 lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
);
}
requestAnimationFrame(() => { hydrated.current = true; });
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
```
### Persist localStorage → server
Watch for changes and write back:
```typescript
const prevOrder = useRef(columnsOrder);
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);
prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
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);
}
persistOption({ columnWidths: JSON.stringify(widthMap) });
}
}, [columnsOrder, columnsWidth, persistOption]);
```
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:
```typescript
interface ContextMenuState {
x: number;
y: number;
item: MyRow;
}
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const handleContextMenu = useCallback(
({ record, event }: { record: MyRow; event: React.MouseEvent }) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
},
[],
);
// On DataTable:
<DataTable onRowContextMenu={handleContextMenu} />
// Render:
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} />}
```
The menu component uses `Portal` for correct z-index:
```tsx
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
return (
<Portal>
<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 }} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
</Menu.Dropdown>
</Menu>
</Portal>
);
}
```
**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
<DataTable
rowExpansion={{
trigger: "click",
allowMultiple: true,
collapseProps: {
transitionDuration: 200,
animateOpacity: true,
transitionTimingFunction: "ease-out",
},
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
}}
/>
```
**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
Don't use `<Badge variant="dot" />` without children — it renders a pill with empty space. Use a plain `Box`:
```tsx
<Box
w={8}
h={8}
style={{
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${color}-filled)`,
flexShrink: 0,
}}
/>
```
### Tooltip overflow on long text columns
When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix:
```tsx
<Tooltip position="bottom-start" ...>
<Group style={{ overflow: "hidden" }}>
...
</Group>
</Tooltip>
```
Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally.
### Dark mode tooltips
Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets.
### Division by zero
Guard all ratio calculations:
```typescript
if (item.size > 0) ratio = item.sent / item.size;
if (totalDown > 0) ratio = totalUp / totalDown;
```
### Composite idAccessor
When rows can come from multiple integrations, IDs may collide. Use a composite key:
```typescript
idAccessor={(record) => `${record.integration.id}:${record.id}`}
```
### JSON option parsing
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; }
}
```
### Clipboard API
`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch:
```typescript
void navigator.clipboard.writeText(text).catch(() => {});
```
### Sync sortStatus with widget options
If the widget has configurable default sort, sync it:
```typescript
useEffect(() => {
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`
- The `saveItemOptions` mutation (column persistence) also needs `onError`
- Check `isError` on the main query and show a fallback UI
- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`:
```typescript
const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(),
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
};
```
---
## 10. i18n
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
- Column titles: `t("items.{accessor}.columnTitle")`
- Detail labels: `t("items.{accessor}.detailsTitle")`
- States: `t("states.{state}")`
- Actions: `t("actions.{action}")`
- 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:
```typescript
let tablePointerEvents: React.CSSProperties["pointerEvents"];
if (isEditMode) tablePointerEvents = "none";
let rowContextMenuHandler = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} />
```
---
## 12. DataTable Props Reference (Commonly Used)
```typescript
<DataTable
records={sortedData}
columns={effectiveColumns}
storeColumnsKey={storeKey}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
fetching={isFetching && data.length === 0}
loaderBackgroundBlur={2}
fz={size.fontSize}
textSelectionDisabled
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noItems")}
idAccessor={(record) => record.uniqueKey}
height="100%"
className="my-table"
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
rowBackgroundColor={(record) => conditionalBgMap[record.state]}
onRowContextMenu={handleContextMenu}
rowExpansion={{ ... }}
onScroll={() => { if (contextMenu) closeContextMenu(); }}
/>
```
---
## 13. Layout Stability
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
- Give non-name columns explicit `width` values so only the name column flexes
- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally
- 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`)
2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.)
3. Implement manual sorting with `useState` + `useMemo`
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
-3
View File
@@ -33,9 +33,6 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.73.0
- 1.72.0
- 1.71.0
- 1.70.0 - 1.70.0
- 1.69.2 - 1.69.2
- 1.69.0 - 1.69.0
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
$schema: "https://docs.renovatebot.com/renovate-schema.json", $schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["config:recommended", ":dependencyDashboardApproval"], extends: ["config:recommended"],
packageRules: [ packageRules: [
{ {
matchPackageNames: ["^@homarr/"], matchPackageNames: ["^@homarr/"],
@@ -47,7 +47,7 @@
rangeStrategy: "bump", rangeStrategy: "bump",
automerge: false, automerge: false,
baseBranchPatterns: ["dev"], baseBranchPatterns: ["dev"],
dependencyDashboard: true, dependencyDashboard: false,
// prevent special characters in branch names (otherwise git errors occur on windows) // prevent special characters in branch names (otherwise git errors occur on windows)
branchNameStrict: true, branchNameStrict: true,
} }
-9
View File
@@ -46,8 +46,6 @@ homarr/
├── tooling/ ├── tooling/
│ ├── typescript/ # Base tsconfig │ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action │ └── github/ # CI setup action
├── tools/
│ └── homarr-dev/ # Go CLI for local and PR Docker images
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL) ├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs ├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build └── Dockerfile # Multi-stage production build
@@ -70,9 +68,6 @@ When modifying user-facing code, you MUST also update the corresponding document
## Monorepo Commands ## Monorepo Commands
- `pnpm dev` — Next.js app only - `pnpm dev` — Next.js app only
- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary
- `pnpm db:seed` — seed default database data explicitly
- `pnpm docker:dev:up` — start the Redis development service in the background
- `pnpm dev:docs` — Docusaurus docs site only - `pnpm dev:docs` — Docusaurus docs site only
- `pnpm turbo build` — build all packages - `pnpm turbo build` — build all packages
- `pnpm turbo build --filter=@homarr/docs` — build docs only - `pnpm turbo build --filter=@homarr/docs` — build docs only
@@ -86,7 +81,3 @@ When modifying user-facing code, you MUST also update the corresponding document
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app - UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Icons: @tabler/icons-react - Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types - Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
-4
View File
@@ -1,7 +1,3 @@
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17) ## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10) ## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
@@ -1,41 +0,0 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Browse development images
```bash
pnpm dev:cli --
```
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
## Build images
```bash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
## Rebuild images
```bash
pnpm dev:cli -- rebuild <name>
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
## Run images
```bash
homarr run <name>
homarr run --pr <number>
```
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -17,9 +17,8 @@ This guide will help you set up your development environment to start contributi
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/). - **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`. - **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
- **Git**: You can download it from the [official website](https://git-scm.com/). - **Git**: You can download it from the [official website](https://git-scm.com/).
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI. - **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/). - **A GitHub account**: You can create one on the [official website](https://github.com/).
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
### Setting up the project ### Setting up the project
@@ -35,11 +34,11 @@ This guide will help you set up your development environment to start contributi
4. Install the dependencies with `pnpm install`. 4. Install the dependencies with `pnpm install`.
5. Create `.env` from `.env.example`. In a POSIX shell, run `cp .env.example .env`; in PowerShell, run `Copy-Item .env.example .env`. Set `DB_URL` to an absolute path for your SQLite file. 5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
6. Start Redis in the background with `pnpm docker:dev:up` if your development workflow needs it. 6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
7. Create or update the database with `pnpm db:migration:sqlite:run`. This also seeds the default data. To run the seed explicitly later, use `pnpm db:seed`. 7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s` 8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
@@ -50,15 +49,8 @@ This guide will help you set up your development environment to start contributi
#### During development #### During development
- `pnpm dev`: Start the development server. - `pnpm dev`: Start the development server.
- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images.
- `pnpm dev:cli -- build <name>`: Build the current checkout as `homarr:<name>`.
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
- `pnpm dev:cli -- rebuild <name>`: Rebuild a local image from its recorded checkout or pull request.
- `pnpm dev:cli:install`: Optionally install the developer CLI as a `homarr` binary.
- `pnpm cli`: Run cli commands. - `pnpm cli`: Run cli commands.
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file. - `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
- `pnpm db:seed`: Explicitly seed the default database data.
- `pnpm docker:dev:up`: Start the Redis development service in the background.
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`. - `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
- `pnpm db:studio`: Open a visual studio for the database. - `pnpm db:studio`: Open a visual studio for the database.
- `pnpm package:new`: Create a new package in the monorepo. - `pnpm package:new`: Create a new package in the monorepo.
@@ -71,7 +63,6 @@ This guide will help you set up your development environment to start contributi
- `pnpm typecheck`: Run the typescript type checker. - `pnpm typecheck`: Run the typescript type checker.
#### Building the project #### Building the project
- `pnpm build`: Build the project for production. - `pnpm build`: Build the project for production.
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds. - `docker build -t homarr .`: Build a docker image for the project.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image. - `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
@@ -55,9 +55,9 @@ A random secret can be generated by using the following command: `openssl rand -
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| ------------------------ | ----------- | --------------- | ------- | | ------------------------ | ----------- | --------------- | ------- |
| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - | | ``DOCKER_HOSTNAMES`` | Comma seperated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - | | ``DOCKER_PORTS`` | Comma seperated list of ports to connect to | For example `2375,2376` | - |
| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - | | ``DOCKER_SOCKET_PATHS`` | Comma seperated list of paths to sockets | For example `/var/run/docker.sock,/run/user/1000/podman/podman.sock` | - |
## Database ## Database
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
+1 -1
View File
@@ -31,4 +31,4 @@ Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scal
### Using external icons ### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon. If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it. It is important that the URL directly points to the file without any advertisements or elements around it.
@@ -127,8 +127,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled. If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
<details> <details>
<summary> <summary>
Example Setup Example Setup
@@ -206,7 +204,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- | | ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false | | ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false | | ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic | | ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
### Permission System ### Permission System
@@ -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"> <img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.25.0](https://img.shields.io/badge/Version-8.25.0-informational?style=flat) ![Version: 8.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.73.0](https://img.shields.io/badge/AppVersion-v1.73.0-informational?style=flat) ![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes A Helm chart to deploy homarr for Kubernetes
@@ -259,7 +259,7 @@ httproute:
value: / value: /
backendRefs: backendRefs:
- name: homarr - name: homarr
port: 7575 port: 8080
```` ````
</details> </details>
@@ -419,17 +419,17 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` | | envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` |
| fullnameOverride | string | `""` | Overrides chart's fullname | | fullnameOverride | string | `""` | Overrides chart's fullname |
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" | | hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration | | httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
| httproute.enabled | bool | `false` | Enable HTTPRoute | | httproute.enabled | bool | `false` | Enable HTTPRoute |
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) | | httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. | | httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to | | httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) | | httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression | | httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match | | httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion | | image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry | | imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations | | ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress | | ingress.enabled | bool | `false` | Enable ingress |
@@ -55,74 +55,6 @@ services:
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`. With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock` In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
### Using Podman
On Linux, Homarr can connect to Podman's [Docker-compatible API](https://docs.podman.io/en/latest/markdown/podman-system-service.1.html).
The Podman API socket must be running and mounted into the Homarr container.
The socket grants full access to Podman, including arbitrary code execution as the user running the API, so only mount it into trusted containers.
For rootless Podman, enable the socket for the user that runs Homarr:
```bash
systemctl --user enable --now podman.socket
```
The rootless socket is located at `$XDG_RUNTIME_DIR/podman/podman.sock` (usually `/run/user/<uid>/podman/podman.sock`).
You can confirm the path with:
```bash
podman info --format '{{.Host.RemoteSocket.Path}}'
```
Mount that socket into the Homarr container and point `DOCKER_SOCKET_PATHS` to its path **inside** the container:
```yaml
services:
homarr:
container_name: homarr
image: ghcr.io/homarr-labs/homarr:latest
restart: unless-stopped
volumes:
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
- ./homarr/appdata:/appdata
environment:
SECRET_ENCRYPTION_KEY: your_64_character_hex_string
DOCKER_SOCKET_PATHS: /var/run/podman.sock
ports:
- "7575:7575"
```
Start Homarr with `podman compose up -d`.
Run the compose project as the same user that owns the rootless Podman socket so `$XDG_RUNTIME_DIR` resolves to the correct path.
To keep the user socket available after logout and across reboots, enable lingering with `loginctl enable-linger <user>`.
For rootful Podman, enable the system socket with `sudo systemctl enable --now podman.socket`.
Its default path is `/run/podman/podman.sock`; mount that path instead and start Homarr with `sudo podman compose up -d`.
:::note SELinux
On systems with SELinux enabled, Podman's documentation recommends disabling label separation for a container that accesses the API socket.
Add the following to the Homarr service:
```yaml
security_opt:
- label=disable
```
:::
You can connect Homarr to multiple Docker-compatible sockets by mounting each socket and listing their in-container paths, separated by commas:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
environment:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
### Managing your Docker containers within Homarr ### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system. Homarr allows you to interact with Docker containers running on your system.
Each container row has an action menu with the following options: Each container row has an action menu with the following options:
@@ -10,21 +10,19 @@ import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { navidromeIntegration } from '.'; import { navidromeIntegration } from '.';
import { audioStatsWidget } from '@site/docs/widgets/audio-stats'; import { audioStatsWidget } from '@site/docs/widgets/audio-stats';
import { mediaServerWidget } from '@site/docs/widgets/media-server';
<IntegrationHeader <IntegrationHeader
integration={navidromeIntegration} integration={navidromeIntegration}
categories={['Media', 'Media server']} categories={['Media']}
/> />
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing audio streams via the Subsonic API. Navidrome provides music library statistics including artist, album, and song counts, as well as now playing information via the Subsonic API.
### Widgets & Capabilities ### Widgets & Capabilities
<IntegrationCapabilites <IntegrationCapabilites
items={[ items={[
{ widget: audioStatsWidget }, { widget: audioStatsWidget },
{ widget: mediaServerWidget },
]} ]}
/> />
+1 -1
View File
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL ### Icon URL
This is the URL to the icon that will be displayed on the app widget. This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories. It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button.
![Icon URL](./img/app-icon-url.png) ![Icon URL](./img/app-icon-url.png)
+20 -22
View File
@@ -1,45 +1,43 @@
--- ---
title: "Audio Stats" title: 'Audio Stats'
description: "Displays media and listening statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration." description: 'Displays library statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration.'
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { audioStatsWidget } from "."; import { audioStatsWidget } from '.';
import { navidromeIntegration } from "@site/docs/integrations/navidrome"; import { navidromeIntegration } from '@site/docs/integrations/navidrome';
import { audiobookshelfIntegration } from "@site/docs/integrations/audiobookshelf"; import { audiobookshelfIntegration } from '@site/docs/integrations/audiobookshelf';
<WidgetHeader widget={audioStatsWidget} categories={["Media"]} />
<WidgetHeader
widget={audioStatsWidget}
categories={['Media']}
/>
This widget adapts its display based on the linked integration: This widget adapts its display based on the linked integration:
- **Navidrome**: Shows artist, album, and song counts - **Navidrome**: Shows artist, album, and song counts, plus a now playing section with active streams
- **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions - **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions
Only options relevant to your linked integration are shown in the configuration panel. Only options relevant to your linked integration are shown in the configuration panel.
### Supported Integrations ### Supported Integrations
<WidgetIntegrations <WidgetIntegrations items={[{
items={[
{
integration: navidromeIntegration, integration: navidromeIntegration,
note: "Displays music library statistics", note: "Displays music library stats and now playing"
}, }, {
{
integration: audiobookshelfIntegration, integration: audiobookshelfIntegration,
note: "Displays audiobook/podcast library stats and listening time", note: "Displays audiobook/podcast library stats and listening time"
}, }]} />
]}
/>
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={audioStatsWidget.configuration} /> <WidgetConfig configuration={audioStatsWidget.configuration} />
@@ -27,6 +27,24 @@ export const audioStatsWidget: WidgetDefinition = {
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
{
name: "Show now playing",
description: "Displays currently playing tracks (Navidrome only)",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show now playing list",
description: "Displays a detailed list of now playing tracks (Navidrome only)",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Max now playing items",
description: "Maximum number of now playing tracks to display (Navidrome only)",
values: "Number (1-5)",
defaultValue: "3",
},
{ {
name: "Show library count", name: "Show library count",
description: "Displays the number of libraries (Audiobookshelf only)", description: "Displays the number of libraries (Audiobookshelf only)",
@@ -15,7 +15,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} /> <WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data. This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots ### Screenshots
@@ -17,17 +17,10 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']} categories={['System Monitoring']}
/> />
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Interactions
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
- **Open system statistics** by selecting a row
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations items={[{
@@ -40,19 +40,12 @@ import dockerTable from './img/table.png';
### Interactions ### Interactions
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode. Each container row has an action menu (accessible via the three-dot icon) with the following options:
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
- **View logs** - opens a dedicated terminal page streaming the container's logs - **View logs** - opens a dedicated terminal page streaming the container's logs
- **Start** / **Stop** / **Restart** - manage the container's state - **Start** / **Stop** / **Restart** - manage the container's state
- **Remove** - permanently remove the container after a second confirmation click - **Remove** - remove the container
- **Add to Homarr** - create a bookmark app from the container - **Add to Homarr** - create a bookmark app from the container
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration
<WidgetConfig configuration={dockerContainersWidget.configuration} />
@@ -8,12 +8,6 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers", path: "../../widgets/docker-containers",
configuration: { configuration: {
items: [ items: [
{
name: "Columns to show",
description: "Select which columns are visible in the table",
values: "Name, State, Host, CPU usage, Memory usage, and Actions",
defaultValue: "All columns",
},
{ {
name: "Enable items sorting", name: "Enable items sorting",
description: "Allows to sort containers by clicking on the column headers", description: "Allows to sort containers by clicking on the column headers",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+9 -35
View File
@@ -4,7 +4,6 @@ description: 'Allows you to view and manage your Downloads from both Torrent and
hide_title: true hide_title: true
--- ---
import { aria2Integration } from '@site/docs/integrations/aria2';
import { delugeIntegration } from '@site/docs/integrations/deluge'; import { delugeIntegration } from '@site/docs/integrations/deluge';
import { nzbgetIntegration } from '@site/docs/integrations/nzbget'; import { nzbgetIntegration } from '@site/docs/integrations/nzbget';
import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent'; import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent';
@@ -15,6 +14,9 @@ import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from '@site/src/components/widgets/header';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react";
import TabItem from '@theme/TabItem';
import Tabs from '@theme/Tabs';
import { downloadsWidget } from '.'; import { downloadsWidget } from '.';
@@ -23,37 +25,16 @@ import { downloadsWidget } from '.';
categories={['Torrent client', 'Usenet client', 'Downloads']} categories={['Torrent client', 'Usenet client', 'Downloads']}
/> />
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface. This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information.
### Screenshots ### Screenshots
import downloadsCard from './img/screenshot.png'; import downloadsCard from './img/screenshot.png';
import contextMenuImg from './img/context-menu.png';
<div> <div>
<img src={downloadsCard} alt="Downloads widget showing torrent and usenet items" width={128 * 4} /> <img src={downloadsCard} alt="downloads" width={128 * 4} />
</div> </div>
#### Right-click context menu
<div>
<img src={contextMenuImg} alt="Downloads widget context menu with pause, delete, and copy actions" width={128 * 4} />
</div>
### Features
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **Status indicators** — color-coded badges show download state at a glance
- **Speed overview** — total download and upload speeds displayed in the footer
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations items={[{
@@ -71,23 +52,16 @@ import contextMenuImg from './img/context-menu.png';
}, { }, {
integration: qBittorentIntegration, integration: qBittorentIntegration,
note: "Torrent client" note: "Torrent client"
}, {
integration: aria2Integration,
note: "Multi-protocol download client"
}, { }, {
integration: slskdIntegration, integration: slskdIntegration,
note: "Soulseek client (read-only)" note: "Soulseek client"
}]} /> }]} />
### Interactions ### Interactions
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions - Pause / Resume one download by clicking on the according icon.
- **Click** any row to expand it and see full download details inline - Delete one download by clicking on the trash icon. (Can also delete the files)
- **Hover** a download name for a quick stats tooltip - Pause / Resume all downloads by using the bottom button.
- **Stats toggle** — show or hide the global stats bar from the footer
- **Pause / Resume** all downloads using the footer controls
- **Filter** by integration or status using the filter button in the footer
- **Sort** by clicking column headers (with persistent sort direction)
### Adding the widget ### Adding the widget
+32 -24
View File
@@ -2,23 +2,26 @@ import { WidgetDefinition } from "@site/src/types";
import { IconDownload } from "@tabler/icons-react"; import { IconDownload } from "@tabler/icons-react";
const columnsList = [ const columnsList = [
"id",
"actions",
"added",
"category",
"downSpeed",
"index",
"integration",
"name", "name",
"progress", "progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio", "ratio",
"received", "received",
"sent", "sent",
"category", "size",
"integration", "state",
"index", "time",
"type", "type",
"upSpeed",
]; ];
const sortColumns = columnsList.filter((c) => !["state", "category", "integration"].includes(c)); const sortingExclusion = ["actions", "id", "state"];
const columnsSort = columnsList.filter((column) => !sortingExclusion.some((exclusion) => exclusion === column));
export const downloadsWidget: WidgetDefinition = { export const downloadsWidget: WidgetDefinition = {
icon: IconDownload, icon: IconDownload,
@@ -29,20 +32,25 @@ export const downloadsWidget: WidgetDefinition = {
items: [ items: [
{ {
name: "Columns to show", name: "Columns to show",
description: description: "Select the columns you want to display in the widget.",
"Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.",
values: `List of columns: ${columnsList.join(", ")}`, values: `List of columns: ${columnsList.join(", ")}`,
defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "), defaultValue: ["integration", "name", "progress", "time", "actions"].join(", "),
}, },
{ {
name: "Default sort column", name: "Enable items sorting",
description: "The column used for initial sorting when the widget loads.", description: "Enables the sorting in the table.",
defaultValue: "progress", values: { type: "boolean" },
values: `List of columns: ${sortColumns.join(", ")}`, defaultValue: "no",
},
{
name: "Column used for sorting by default",
description: "By default it will sort by this column.",
defaultValue: "type",
values: `List of columns: ${columnsSort.join(", ")}`,
}, },
{ {
name: "Invert sorting", name: "Invert sorting",
description: "This will invert the default sorting order.", description: "This will invert the sorting order of the table.",
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "no", defaultValue: "no",
}, },
@@ -68,13 +76,13 @@ export const downloadsWidget: WidgetDefinition = {
name: "Hide completed torrent under this threshold (in kiB/s)", name: "Hide completed torrent under this threshold (in kiB/s)",
description: description:
"This will hide completed torrent entries that have a download speed below the specified threshold.", "This will hide completed torrent entries that have a download speed below the specified threshold.",
values: "Any number above 0, 0 to disable", values: "Any number above 1, 0 to disable",
defaultValue: "0", defaultValue: "0",
}, },
{ {
name: "Categories/labels to filter", name: "Categories/names to filter",
description: "You can filter the items by categories or labels. Use a comma to separate multiple values.", description: "You can filter the items by categories or names. Use a comma to separate multiple values.",
values: "Comma-separated list of categories or labels", values: "Comma-separated list of categories or names",
defaultValue: "-", defaultValue: "-",
}, },
{ {
@@ -86,13 +94,13 @@ export const downloadsWidget: WidgetDefinition = {
}, },
{ {
name: "Use filter to calculate Ratio", name: "Use filter to calculate Ratio",
description: "This will use the category filter when calculating the global torrent ratio.", description: "This will use the filter to calculate the ratio of items shown in the widget.",
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
{ {
name: "Limit items per integration", name: "Limit items per integration",
description: "This will limit the number of items shown per integration, not globally.", description: "This will limit the number of items shown per integration, not globally",
values: "Any number above 1", values: "Any number above 1",
defaultValue: "50", defaultValue: "50",
}, },
@@ -14,7 +14,6 @@ import TabItem from '@theme/TabItem';
import { mediaServerWidget } from '.'; import { mediaServerWidget } from '.';
import { embyIntegration } from '@site/docs/integrations/emby'; import { embyIntegration } from '@site/docs/integrations/emby';
import { jellyfinIntegration } from '@site/docs/integrations/jellyfin'; import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
import { plexIntegration } from '@site/docs/integrations/plex'; import { plexIntegration } from '@site/docs/integrations/plex';
@@ -40,9 +39,6 @@ import mediaTable from './img/table.png';
integration: embyIntegration, integration: embyIntegration,
}, { }, {
integration: jellyfinIntegration, integration: jellyfinIntegration,
}, {
integration: navidromeIntegration,
note: "Will always show only currently playing audio streams, not all active sessions."
}, { }, {
integration: plexIntegration, integration: plexIntegration,
note: "Will always show only currently playing streams, not all active sessions." note: "Will always show only currently playing streams, not all active sessions."
@@ -54,3 +50,4 @@ import mediaTable from './img/table.png';
### Configuration ### Configuration
<WidgetConfig configuration={mediaServerWidget.configuration} /> <WidgetConfig configuration={mediaServerWidget.configuration} />
+1 -1
View File
@@ -10,7 +10,7 @@ export const mediaServerWidget: WidgetDefinition = {
items: [ items: [
{ {
name: "Show only currently playing", name: "Show only currently playing",
description: "Plex and Navidrome always show only currently playing streams.", description: "Disabling this will not work for plex.",
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
@@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox, icon: IconBox,
href: "/manage/apps", href: "/manage/apps",
label: t("items.apps"), label: t("items.apps"),
hidden: !session?.user.permissions.includes("app-create"), hidden: !session?.user.permissions.includes("board-modify-all"),
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
@@ -71,20 +71,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconAffiliateFilled, icon: IconAffiliateFilled,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session?.user.permissions.includes("integration-create"), hidden: !session,
"data-onboarding-tour-id": "manage-integrations", "data-onboarding-tour-id": "manage-integrations",
}, },
{ {
icon: IconApi, icon: IconApi,
href: "/manage/custom-widgets", href: "/manage/custom-widgets",
label: t("items.customWidgets"), label: t("items.customWidgets"),
hidden: !session?.user.permissions.includes("admin"), hidden: !session,
}, },
{ {
icon: IconSearch, icon: IconSearch,
href: "/manage/search-engines", href: "/manage/search-engines",
label: t("items.searchEngies"), label: t("items.searchEngies"),
hidden: !session?.user.permissions.includes("search-engine-create"), hidden: !session,
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
@@ -94,7 +94,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconPhotoFilled, icon: IconPhotoFilled,
href: "/manage/medias", href: "/manage/medias",
label: t("items.medias"), label: t("items.medias"),
hidden: !session?.user.permissions.includes("media-upload"), hidden: !session,
"data-onboarding-tour-id": "manage-medias", "data-onboarding-tour-id": "manage-medias",
}, },
{ {
@@ -19,7 +19,6 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css"; import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu"; import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu"; import { WidgetContextMenu } from "./widget-context-menu";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
interface BoardItemContentProps { interface BoardItemContentProps {
item: SectionItem; item: SectionItem;
@@ -113,7 +112,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
onReset={() => { onReset={() => {
removePersistedWidgetQueries(queryClient); void queryClient.invalidateQueries({ queryKey: [["widget"]] });
reset(); reset();
}} }}
fallbackRender={({ resetErrorBoundary, error }) => ( fallbackRender={({ resetErrorBoundary, error }) => (
@@ -1,54 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
describe("removePersistedWidgetQueries", () => {
test("removes stale widget queries and allows fresh data to be fetched", async () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
await expect(
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
).resolves.toBe("fresh");
});
test("removes persisted queries outside the widget router", () => {
const queryClient = new QueryClient();
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
const appQueryKey = [["app", "byId"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(dockerQueryKey, "stale");
queryClient.setQueryData(appQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
test("removes persisted widget queries when the widget kind and query path differ", () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
});
@@ -1,7 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
};
+2 -2
View File
@@ -6,7 +6,7 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.15.1" "pnpm": ">=11.13.1"
}, },
"packageManager": "pnpm@11.15.1" "packageManager": "pnpm@11.13.1"
} }
+3 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.73.0", "version": "1.71.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
@@ -17,15 +17,11 @@
"db:migration:postgresql:run": "pnpm -F db migration:postgresql:run", "db:migration:postgresql:run": "pnpm -F db migration:postgresql:run",
"db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate", "db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate",
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run", "db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
"db:seed": "pnpm -F db seed",
"db:push": "pnpm -F db push:sqlite", "db:push": "pnpm -F db push:sqlite",
"db:studio": "pnpm -F db studio", "db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev", "dev": "pnpm -F nextjs dev",
"dev:cli": "go -C tools/homarr-dev run .",
"dev:cli:install": "go -C tools/homarr-dev install .",
"dev:docs": "pnpm -F @homarr/docs dev", "dev:docs": "pnpm -F @homarr/docs dev",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up", "docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"docker:dev:up": "docker compose -f ./development/development.docker-compose.yml up -d redis",
"format": "turbo format --continue -- --check", "format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue", "format:fix": "turbo format --continue",
"lint": "turbo lint --continue", "lint": "turbo lint --continue",
@@ -73,7 +69,7 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.15.1" "pnpm": ">=11.13.1"
}, },
"packageManager": "pnpm@11.15.1" "packageManager": "pnpm@11.13.1"
} }
@@ -1,45 +0,0 @@
import { describe, expect, test } from "vitest";
import { BoundedAsyncQueue } from "../bounded-async-queue";
describe("BoundedAsyncQueue", () => {
test("keeps only the newest values when the queue is full", async () => {
const queue = new BoundedAsyncQueue<number>(2);
queue.push(1);
queue.push(2);
queue.push(3);
queue.close();
await expect(queue.next()).resolves.toEqual({ value: 2, done: false });
await expect(queue.next()).resolves.toEqual({ value: 3, done: false });
await expect(queue.next()).resolves.toEqual({ value: undefined, done: true });
});
test("resolves a pending read as soon as a value arrives", async () => {
const queue = new BoundedAsyncQueue<string>(1);
const next = queue.next();
queue.push("latest");
await expect(next).resolves.toEqual({ value: "latest", done: false });
});
test("resolves a pending read when the producer closes", async () => {
const queue = new BoundedAsyncQueue<number>(1);
const next = queue.next();
queue.close();
await expect(next).resolves.toEqual({ value: undefined, done: true });
});
test("rejects a pending read when the producer fails", async () => {
const queue = new BoundedAsyncQueue<number>(1);
const next = queue.next();
const error = new Error("upstream failed");
queue.fail(error);
await expect(next).rejects.toBe(error);
});
});
+43 -40
View File
@@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -13,10 +14,8 @@ import {
import { settleIntegrationQueries } from "../../settle-integrations"; import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" }); const logger = createLogger({ module: "beszelRouter" });
const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
@@ -204,44 +203,34 @@ export const beszelRouter = createTRPCRouter({
systemId: z.string(), systemId: z.string(),
}), }),
) )
.subscription(async function* ({ ctx, input, signal }) { .subscription(({ ctx, input }) => {
const integration = ctx.integrations[0]; return observable<LiveStatsEvent>((emit) => {
if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
}
const queue = new BoundedAsyncQueue<LiveStatsEvent>(MAX_PENDING_LIVE_EVENTS);
const controller = new AbortController(); const controller = new AbortController();
let isActive = true;
let emittedEventCount = 0; let emittedEventCount = 0;
const stop = () => {
controller.abort();
void queue.return();
};
if (signal?.aborted) return;
signal?.addEventListener("abort", stop, { once: true });
logger.debug("Beszel realtime subscription started", { logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id, userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((candidate) => candidate.id), integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId, systemId: input.systemId,
}); });
try {
const instance = await createIntegrationAsync(integration);
if (controller.signal.aborted) return;
void (async () => { void (async () => {
try { const integration = ctx.integrations[0];
if (typeof instance.subscribeRealtimeMetrics !== "function") { if (!integration) {
throw new TRPCError({ emit.error(
code: "METHOD_NOT_SUPPORTED", new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }),
message: `Integration ${integration.kind} does not support realtime metrics`, );
}); return;
} }
try {
const instance = await createIntegrationAsync(integration);
if (typeof instance.subscribeRealtimeMetrics === "function") {
await instance.subscribeRealtimeMetrics( await instance.subscribeRealtimeMetrics(
input.systemId, input.systemId,
(event) => { (event) => {
if (!isActive) return;
emittedEventCount += 1; emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) { if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", { logger.debug("Forwarding Beszel realtime events", {
@@ -253,17 +242,20 @@ export const beszelRouter = createTRPCRouter({
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined, statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
}); });
} }
queue.push(event); emit.next(event);
}, },
controller.signal, controller.signal,
); );
queue.close(); } else {
} catch (error) { emit.error(
if (controller.signal.aborted) { new TRPCError({
queue.close(); code: "METHOD_NOT_SUPPORTED",
return; message: `Integration ${integration.kind} does not support realtime metrics`,
}),
);
} }
} catch (error) {
if (isActive) {
logger.warn("Beszel realtime subscription failed", { logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id, userId: ctx.session?.user?.id,
integrationId: integration.id, integrationId: integration.id,
@@ -271,7 +263,7 @@ export const beszelRouter = createTRPCRouter({
emittedEventCount, emittedEventCount,
error: errorMessage(error), error: errorMessage(error),
}); });
queue.fail( emit.error(
error instanceof TRPCError error instanceof TRPCError
? error ? error
: new TRPCError({ : new TRPCError({
@@ -280,18 +272,29 @@ export const beszelRouter = createTRPCRouter({
}), }),
); );
} }
})(); }
for await (const event of queue) yield event; if (isActive) {
} finally { logger.debug("Beszel realtime subscription completed", {
stop();
signal?.removeEventListener("abort", stop);
logger.debug("Beszel realtime subscription stopped", {
userId: ctx.session?.user?.id, userId: ctx.session?.user?.id,
integrationId: integration.id, integrationId: integration.id,
systemId: input.systemId, systemId: input.systemId,
emittedEventCount, emittedEventCount,
}); });
emit.complete();
} }
})();
return () => {
isActive = false;
logger.debug("Beszel realtime subscription cancelled", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
emittedEventCount,
});
controller.abort();
};
});
}), }),
}); });
@@ -1,88 +0,0 @@
type QueueResult<T> = IteratorResult<T, undefined>;
type PendingRead<T> = {
resolve: (result: QueueResult<T>) => void;
reject: (reason?: unknown) => void;
};
/**
* A small async queue for live streams.
*
* Live metrics are snapshots, so retaining every snapshot while a client is
* slow is both unnecessary and dangerous. When the queue is full, discard
* the oldest pending snapshot and keep the newest one.
*/
export class BoundedAsyncQueue<T> implements AsyncIterableIterator<T> {
private readonly values: T[] = [];
private pendingRead: PendingRead<T> | undefined;
private closed = false;
private failed = false;
private failure: unknown;
public constructor(private readonly maxSize: number) {
if (!Number.isInteger(maxSize) || maxSize < 1) {
throw new Error("A bounded async queue requires a positive integer max size");
}
}
public push(value: T): void {
if (this.closed) return;
if (this.pendingRead) {
const pendingRead = this.pendingRead;
this.pendingRead = undefined;
pendingRead.resolve({ value, done: false });
return;
}
if (this.values.length >= this.maxSize) this.values.shift();
this.values.push(value);
}
public close(): void {
if (this.closed) return;
this.closed = true;
if (this.pendingRead) {
const pendingRead = this.pendingRead;
this.pendingRead = undefined;
pendingRead.resolve({ value: undefined, done: true });
}
}
public fail(error: unknown): void {
if (this.closed) return;
this.closed = true;
this.failed = true;
this.failure = error;
this.values.length = 0;
if (this.pendingRead) {
const pendingRead = this.pendingRead;
this.pendingRead = undefined;
pendingRead.reject(error);
}
}
public next(): Promise<QueueResult<T>> {
if (this.values.length > 0) {
return Promise.resolve({ value: this.values.shift() as T, done: false });
}
if (this.failed) return Promise.reject(this.failure);
if (this.closed) return Promise.resolve({ value: undefined, done: true });
return new Promise<QueueResult<T>>((resolve, reject) => {
this.pendingRead = { resolve, reject };
});
}
public return(): Promise<QueueResult<T>> {
this.values.length = 0;
this.close();
return Promise.resolve({ value: undefined, done: true });
}
public [Symbol.asyncIterator](): AsyncIterableIterator<T> {
return this;
}
}
@@ -17,7 +17,7 @@ export const mediaServerRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)", "Get currently active streams from Plex/Jellyfin/Emby media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
}, },
}) })
.concat(createMediaServerIntegrationMiddleware("query")) .concat(createMediaServerIntegrationMiddleware("query"))
+4 -22
View File
@@ -19,15 +19,8 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
options?: Options<TIntegration, TResult>, options?: Options<TIntegration, TResult>,
): Promise<TResult[]> { ): Promise<TResult[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
const results: TResult[] = []; return settled.flatMap((result, index) => {
const errors: unknown[] = []; if (result.status === "fulfilled") return [result.value];
settled.forEach((result, index) => {
if (result.status === "fulfilled") {
results.push(result.value);
return;
}
const integration = integrations[index]; const integration = integrations[index];
logger.warn( logger.warn(
new ErrorWithMetadata( new ErrorWithMetadata(
@@ -36,18 +29,7 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
{ cause: result.reason }, { cause: result.reason },
), ),
); );
if (options?.fallback && integration) return [options.fallback(integration, result.reason)];
if (options?.fallback && integration) { return [];
results.push(options.fallback(integration, result.reason));
return;
}
errors.push(result.reason);
}); });
if (results.length === 0 && errors.length > 0) {
throw errors[0];
}
return results;
} }
@@ -1,50 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { settleIntegrationQueries } from "../settle-integrations";
const mocks = vi.hoisted(() => ({
logger: { warn: vi.fn() },
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => mocks.logger,
}));
vi.mock("@homarr/core/infrastructure/logs/error", () => ({
ErrorWithMetadata: Error,
}));
const integrations = [
{ id: "first", name: "First", kind: "plex" },
{ id: "second", name: "Second", kind: "jellyfin" },
];
describe("settleIntegrationQueries", () => {
test("returns an empty result when no integrations are configured", async () => {
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
});
test("keeps successful integrations when another integration fails", async () => {
const error = new Error("Plex unavailable");
await expect(
settleIntegrationQueries(integrations, (integration) =>
integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name),
),
).resolves.toEqual(["Second"]);
});
test("rejects when every integration fails", async () => {
const error = new Error("Integration unavailable");
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error);
});
test("uses configured fallbacks when every integration fails", async () => {
await expect(
settleIntegrationQueries(integrations, () => Promise.reject(new Error("Integration unavailable")), {
fallback: (integration) => integration.name,
}),
).resolves.toEqual(["First", "Second"]);
});
});
+4 -24
View File
@@ -6,24 +6,20 @@ import { and, eq } from "@homarr/db";
import { accounts, sessions, users } from "@homarr/db/schema"; import { accounts, sessions, users } from "@homarr/db/schema";
import type { SupportedAuthProvider } from "@homarr/definitions"; import type { SupportedAuthProvider } from "@homarr/definitions";
export const createAdapter = ( export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
db: Database,
provider: SupportedAuthProvider | "unknown",
enableDangerousCredentialsLinking = false,
): Adapter => {
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts }); const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
return { return {
...drizzleAdapter, ...drizzleAdapter,
// Keep users isolated by their primary provider. The explicit credentials-linking // We override the default implementation as we want to have a provider
// opt-in is the only exception and keeps credentials as the primary provider. // flag in the user instead of the account to not intermingle users from different providers
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
getUserByEmail: async (email) => { getUserByEmail: async (email) => {
if (provider === "unknown") { if (provider === "unknown") {
throw new Error("Unable to get user by email for unknown provider"); throw new Error("Unable to get user by email for unknown provider");
} }
let user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: and(eq(users.email, email), eq(users.provider, provider)), where: and(eq(users.email, email), eq(users.provider, provider)),
columns: { columns: {
id: true, id: true,
@@ -34,22 +30,6 @@ export const createAdapter = (
}, },
}); });
if (!user && provider === "oidc" && enableDangerousCredentialsLinking) {
// Credentials accounts do not have an email-verification flow, so their
// Auth.js emailVerified field is always null. The OIDC profile's strict
// email_verified check is the security boundary for this opt-in.
user = await db.query.users.findFirst({
where: and(eq(users.email, email), eq(users.provider, "credentials")),
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
},
});
}
if (!user) { if (!user) {
return null; return null;
} }
+1 -1
View File
@@ -44,7 +44,7 @@ export const createConfiguration = (
headers: ReadonlyHeaders | null, headers: ReadonlyHeaders | null,
useSecureCookies: boolean, useSecureCookies: boolean,
) => { ) => {
const adapter = createAdapter(db, provider, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING); const adapter = createAdapter(db, provider);
return NextAuth({ return NextAuth({
logger: { logger: {
error: (error) => { error: (error) => {
-1
View File
@@ -46,7 +46,6 @@ export const env = createEnv({
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(), AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false), AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false), AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: createBooleanSchema(false),
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z
.enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"]) .enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"])
.default("client_secret_basic"), .default("client_secret_basic"),
+1 -3
View File
@@ -24,7 +24,6 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
name: true, name: true,
image: true, image: true,
colorScheme: true, colorScheme: true,
provider: true,
}, },
}); });
@@ -34,7 +33,6 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
// Groups from oidc provider are provided from the profile, it's not typed. // Groups from oidc provider are provided from the profile, it's not typed.
if ( if (
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT && !env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
dbUser.provider === "oidc" &&
profile && profile &&
groupsKey in profile && groupsKey in profile &&
Array.isArray(profile[groupsKey]) Array.isArray(profile[groupsKey])
@@ -59,7 +57,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
}); });
} }
if (profile && dbUser.provider === "oidc") { if (profile) {
const profileUsername = extractProfileName(profile); const profileUsername = extractProfileName(profile);
if (!profileUsername) { if (!profileUsername) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`); throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
@@ -7,7 +7,6 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { env } from "../../env"; import { env } from "../../env";
import { createRedirectUri } from "../../redirect"; import { createRedirectUri } from "../../redirect";
import { assertVerifiedEmailForCredentialsLinking } from "./verified-email";
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
id: "oidc", id: "oidc",
@@ -19,8 +18,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD, token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD,
}, },
issuer: env.AUTH_OIDC_ISSUER, issuer: env.AUTH_OIDC_ISSUER,
allowDangerousEmailAccountLinking: allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING || env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING,
authorization: { authorization: {
params: { params: {
scope: env.AUTH_OIDC_SCOPE_OVERWRITE, scope: env.AUTH_OIDC_SCOPE_OVERWRITE,
@@ -57,7 +55,6 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
if (!profile.sub) { if (!profile.sub) {
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`); throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
} }
assertVerifiedEmailForCredentialsLinking(profile, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
const name = extractProfileName(profile); const name = extractProfileName(profile);
if (!name) { if (!name) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`); throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
@@ -1,11 +0,0 @@
export const assertVerifiedEmailForCredentialsLinking = (
profile: { email?: unknown; email_verified?: unknown },
isCredentialsLinkingEnabled: boolean,
) => {
if (
isCredentialsLinkingEnabled &&
(profile.email_verified !== true || typeof profile.email !== "string" || profile.email.trim().length === 0)
) {
throw new Error("OIDC provider did not return a verified email while credentials linking is enabled");
}
};
@@ -1,87 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { OidcProvider } from "../oidc/oidc-provider";
import { assertVerifiedEmailForCredentialsLinking } from "../oidc/verified-email";
const mockEnv = vi.hoisted(() => ({
AUTH_OIDC_CLIENT_ID: "client-id",
AUTH_OIDC_CLIENT_NAME: "OIDC",
AUTH_OIDC_CLIENT_SECRET: "client-secret",
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: false,
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: false,
AUTH_OIDC_FORCE_USERINFO: false,
AUTH_OIDC_ISSUER: "https://example.com",
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: undefined,
AUTH_OIDC_SCOPE_OVERWRITE: "openid email profile",
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_basic" as const,
}));
vi.mock("../../env", () => ({ env: mockEnv }));
vi.mock("next-auth", () => ({ customFetch: Symbol("customFetch") }));
afterEach(() => {
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = false;
});
describe("assertVerifiedEmailForCredentialsLinking", () => {
test("allows a strictly verified OIDC email when credentials linking is enabled", () => {
expect(() =>
assertVerifiedEmailForCredentialsLinking({ email: "test@example.com", email_verified: true }, true),
).not.toThrow();
});
test.each([
{ email: "test@example.com", email_verified: false },
{ email: "test@example.com", email_verified: "true" },
{ email_verified: true },
{ email: null, email_verified: true },
{ email: " ", email_verified: true },
{},
])("rejects an OIDC profile without a strictly verified email when credentials linking is enabled", (profile) => {
expect(() => assertVerifiedEmailForCredentialsLinking(profile, true)).toThrow(
"OIDC provider did not return a verified email while credentials linking is enabled",
);
});
test("does not require the claim when credentials linking is disabled", () => {
expect(() => assertVerifiedEmailForCredentialsLinking({}, false)).not.toThrow();
});
test("OIDC provider rejects an unverified profile when credentials linking is enabled", () => {
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = true;
const provider = OidcProvider(null);
expect(provider.allowDangerousEmailAccountLinking).toBe(true);
const mapProfile = provider.profile;
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
expect(() =>
mapProfile(
{
sub: "subject",
preferred_username: "test",
email: "test@example.com",
email_verified: false,
},
{},
),
).toThrow("OIDC provider did not return a verified email while credentials linking is enabled");
});
test("OIDC provider accepts an unverified profile when credentials linking is disabled", () => {
const provider = OidcProvider(null);
const mapProfile = provider.profile;
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
expect(() =>
mapProfile(
{
sub: "subject",
preferred_username: "test",
email: "test@example.com",
email_verified: false,
},
{},
),
).not.toThrow();
});
});
+1 -89
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { eq } from "@homarr/db"; import { users } from "@homarr/db/schema";
import { accounts, apiKeys, groupMembers, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import { createAdapter } from "../adapter"; import { createAdapter } from "../adapter";
@@ -65,91 +64,4 @@ describe("createAdapter should create drizzle adapter", () => {
// Assert // Assert
await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider"); await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider");
}); });
test("getUserByEmail should return a credentials user for oidc when credentials linking is enabled", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
const user = await adapter.getUserByEmail?.(email);
expect(user?.id).toBe("credentials-user");
});
test("getUserByEmail should not return credentials users for non-oidc providers", async () => {
const db = createDb();
const adapter = createAdapter(db, "ldap", true);
const email = "test@example.com";
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
const user = await adapter.getUserByEmail?.(email);
expect(user).toBeNull();
});
test("getUserByEmail should prefer an existing oidc user over a credentials user", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values([
{ id: "credentials-user", name: "credentials", email, provider: "credentials" },
{ id: "oidc-user", name: "oidc", email, provider: "oidc" },
]);
const user = await adapter.getUserByEmail?.(email);
expect(user?.id).toBe("oidc-user");
});
test("linkAccount should preserve the credentials user and their data", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values({
id: "credentials-user",
name: "test",
email,
password: "hashed-password",
provider: "credentials",
colorScheme: "light",
completedBoardTour: true,
});
await db.insert(groups).values({ id: "group", name: "Test group", position: 1 });
await db.insert(groupMembers).values({ groupId: "group", userId: "credentials-user" });
await db.insert(apiKeys).values({ id: "api-key", apiKey: "secret", userId: "credentials-user" });
const user = await adapter.getUserByEmail?.(email);
if (!user) throw new Error("Expected credentials user to be found for OIDC linking");
await adapter.linkAccount?.({
userId: user.id,
type: "oidc",
provider: "oidc",
providerAccountId: "oidc-subject",
});
const linkedUser = await db.query.users.findFirst({ where: eq(users.id, "credentials-user") });
const account = await db.query.accounts.findFirst({ where: eq(accounts.providerAccountId, "oidc-subject") });
const membership = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "credentials-user"),
});
const apiKey = await db.query.apiKeys.findFirst({ where: eq(apiKeys.userId, "credentials-user") });
const userByAccount = await adapter.getUserByAccount?.({
provider: "oidc",
providerAccountId: "oidc-subject",
});
const allUsers = await db.query.users.findMany();
expect(linkedUser).toMatchObject({
id: "credentials-user",
password: "hashed-password",
provider: "credentials",
colorScheme: "light",
completedBoardTour: true,
});
expect(account?.userId).toBe("credentials-user");
expect(membership?.groupId).toBe("group");
expect(apiKey?.id).toBe("api-key");
expect(userByAccount?.id).toBe("credentials-user");
expect(allUsers).toHaveLength(1);
});
}); });
+7 -39
View File
@@ -135,7 +135,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should add missing group membership", async () => { test("should add missing group membership", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
await createUserAsync(db, "oidc"); await createUserAsync(db);
await createGroupAsync(db); await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db); const eventHandler = createSignInEventHandler(db);
@@ -155,7 +155,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should remove group membership", async () => { test("should remove group membership", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
await createUserAsync(db, "oidc"); await createUserAsync(db);
await createGroupAsync(db); await createGroupAsync(db);
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
userId: "1", userId: "1",
@@ -179,7 +179,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should not remove group membership for everyone group", async () => { test("should not remove group membership for everyone group", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
await createUserAsync(db, "oidc"); await createUserAsync(db);
await createGroupAsync(db, everyoneGroup); await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({ await db.insert(groupMembers).values({
userId: "1", userId: "1",
@@ -204,7 +204,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
// Arrange // Arrange
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true; mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true;
const db = createDb(); const db = createDb();
await createUserAsync(db, "oidc"); await createUserAsync(db);
await createGroupAsync(db); await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db); const eventHandler = createSignInEventHandler(db);
@@ -229,10 +229,10 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
["ldap" as const, { name: "test-new" }, undefined], ["ldap" as const, { name: "test-new" }, undefined],
["oidc" as const, { name: "test" }, { preferred_username: "test-new" }], ["oidc" as const, { name: "test" }, { preferred_username: "test-new" }],
["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }], ["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }],
])("signInEventHandler should update username for %s provider", async (provider, user, profile) => { ])("signInEventHandler should update username for %s provider", async (_provider, user, profile) => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
await createUserAsync(db, provider); await createUserAsync(db);
const eventHandler = createSignInEventHandler(db); const eventHandler = createSignInEventHandler(db);
// Act // Act
@@ -251,32 +251,6 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
}); });
expect(dbUser?.name).toBe("test-new"); expect(dbUser?.name).toBe("test-new");
}); });
test("signInEventHandler should preserve credentials-owned data when the user signs in with linked oidc", async () => {
const db = createDb();
await createUserAsync(db, "credentials", "local-avatar");
await createGroupAsync(db);
await db.insert(groupMembers).values({ userId: "1", groupId: "1" });
const eventHandler = createSignInEventHandler(db);
await eventHandler?.({
user: { id: "1", name: "test" },
profile: {
preferred_username: "oidc-name",
picture: "oidc-avatar",
someRandomGroupsKey: [],
},
account: null,
});
const dbUser = await db.query.users.findFirst({ where: eq(users.id, "1") });
const membership = await db.query.groupMembers.findFirst({ where: eq(groupMembers.userId, "1") });
expect(dbUser).toMatchObject({
name: "test",
image: "local-avatar",
provider: "credentials",
});
expect(membership?.groupId).toBe("1");
});
test("signInEventHandler should set color-scheme cookie", async () => { test("signInEventHandler should set color-scheme cookie", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
@@ -301,17 +275,11 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
}); });
}); });
const createUserAsync = async ( const createUserAsync = async (db: Database) =>
db: Database,
provider: "credentials" | "ldap" | "oidc" = "credentials",
image?: string,
) =>
await db.insert(users).values({ await db.insert(users).values({
id: "1", id: "1",
name: "test", name: "test",
colorScheme: "dark", colorScheme: "dark",
image,
provider,
}); });
const createGroupAsync = async (db: Database, name = "test") => const createGroupAsync = async (db: Database, name = "test") =>
@@ -67,14 +67,12 @@ export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo, url: RequestInfo,
options?: RequestInit & { timeout?: number; bodyTimeout?: number }, options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise<Response> => { ): Promise<Response> => {
const agent = const agent = await createCertificateAgentAsync(
options?.dispatcher ??
(await createCertificateAgentAsync(
undefined, undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined, options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
)); );
if (options?.timeout) { if (options?.timeout) {
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options; const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
return await withTimeoutAsync( return await withTimeoutAsync(
async (signal) => async (signal) =>
fetch(url, { fetch(url, {
@@ -86,7 +84,7 @@ export const fetchWithTrustedCertificatesAsync = async (
); );
} }
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {}; const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
return fetch(url, { return fetch(url, {
...fetchOptions, ...fetchOptions,
dispatcher: agent, dispatcher: agent,
+1 -1
View File
@@ -418,7 +418,7 @@ export const integrationDefs = {
name: "Navidrome", name: "Navidrome",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
category: ["mediaLibrary", "mediaService"], category: ["mediaLibrary"],
documentationUrl: null, documentationUrl: null,
defaultPort: 4533, defaultPort: 4533,
}, },
@@ -1,109 +0,0 @@
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { MantineProvider } from "@mantine/core";
import type { Root } from "react-dom/client";
const { findIconsQuery } = vi.hoisted(() => ({
findIconsQuery: vi.fn((_query: { searchText: string }) => ({
data: { icons: [], countIcons: 0 },
isLoading: false,
isError: false,
refetch: vi.fn(),
})),
}));
vi.mock("@homarr/api/client", () => ({
clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
}));
vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { permissions: [] } } }),
}));
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
}));
vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
import { IconPicker } from "./icon-picker";
const directIconUrl = "https://cdn.example.com/shopify.svg";
const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!setter) throw new Error("Input value setter was not found");
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
};
describe("IconPicker", () => {
let container: HTMLDivElement;
let root: Root;
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
beforeEach(() => {
findIconsQuery.mockClear();
});
afterEach(() => {
root.unmount();
container.remove();
});
const renderPicker = async (onChange: (value: string) => void) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
});
const input = container.querySelector("input");
if (!input) throw new Error("Icon picker input was not rendered");
return input;
};
test("keeps a direct URL visible without searching the icon repositories", async () => {
const onChange = vi.fn();
const input = await renderPicker(onChange);
await act(async () => {
setInputValue(input, directIconUrl);
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(onChange).toHaveBeenCalledWith(directIconUrl);
expect(input.value).toBe(directIconUrl);
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
});
test("keeps icon names in repository search mode", async () => {
const onChange = vi.fn();
const input = await renderPicker(onChange);
await act(async () => {
setInputValue(input, "shopify");
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(onChange).not.toHaveBeenCalled();
expect(input.value).toBe("shopify");
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
});
});
@@ -63,7 +63,7 @@ export const IconPicker = ({
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100); const [debouncedQuery] = useDebouncedValue(query, 100);
const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : ""; const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery( const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
{ searchText }, { searchText },
@@ -165,15 +165,8 @@ export const IconPicker = ({
leftSection={leftSection} leftSection={leftSection}
value={inputValue} value={inputValue}
onChange={(event) => { onChange={(event) => {
const nextValue = event.currentTarget.value;
const trimmedValue = nextValue.trim();
setQuery(nextValue);
if (isDirectIconUrl(trimmedValue)) {
setValue(trimmedValue);
combobox.closeDropdown();
} else {
combobox.openDropdown(); combobox.openDropdown();
} setQuery(event.currentTarget.value);
}} }}
onClick={() => combobox.openDropdown()} onClick={() => combobox.openDropdown()}
onFocus={(event) => { onFocus={(event) => {
@@ -227,12 +220,3 @@ const shouldShowPreview = (value: string | null | undefined): value is string =>
if (!value) return false; if (!value) return false;
return !localizationPathRegex.test(value); return !localizationPathRegex.test(value);
}; };
const isDirectIconUrl = (value: string) => {
try {
const url = new URL(value.trim());
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
} catch {
return false;
}
};
@@ -23,15 +23,10 @@ export const createSessionStore = <TValue>(integration: { id: string }) => {
return null; return null;
} }
}, },
/** async setAsync(value: TValue) {
* @param value session to store logger.debug("Updating session in store", { store: channelName });
* @param options pass `ttlSeconds` when the credential has a known lifetime, so a stored
* session can never outlive the token it holds
*/
async setAsync(value: TValue, options?: { ttlSeconds?: number }) {
logger.debug("Updating session in store", { store: channelName, ttlSeconds: options?.ttlSeconds });
try { try {
await channel.setAsync(encryptSecret(superjson.stringify(value)), options); await channel.setAsync(encryptSecret(superjson.stringify(value)));
} catch (error) { } catch (error) {
logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error })); logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error }));
} }
@@ -1,8 +1,6 @@
import { ResponseError } from "@homarr/common/server"; import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { Response as UndiciResponse } from "undici";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
@@ -82,47 +80,11 @@ export const normalizeRealtimeSnapshot = (
return events; return events;
}; };
export interface BeszelSession { interface BeszelSession {
token: string; token: string;
userId: string; userId: string;
/** Epoch milliseconds, taken from the token's `exp` claim. Undefined when it could not be read. */
expiresAt?: number;
} }
/**
* Re-authenticate this long before the token actually lapses, so a request can never be sent
* with a token that expires while it is in flight.
*/
const sessionExpiryLeewayMs = 60_000;
/**
* Reads the expiration out of a PocketBase auth token.
*
* @returns expiry as epoch milliseconds, or null when the token is malformed or carries no
* numeric `exp` claim — in which case we fall back to the existing 401 retry path.
*/
export const parseTokenExpiration = (token: string): number | null => {
const payload = token.split(".")[1];
if (!payload) return null;
try {
const decoded = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
const { exp } = JSON.parse(decoded) as { exp?: unknown };
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null;
} catch {
return null;
}
};
/**
* Beszel answers requests carrying an expired token with `200` and an empty list rather than `401`,
* because its collection rules filter on `@request.auth.id` instead of rejecting. The 401 retry in
* `fetchWithAuthAsync` therefore never fires, so a stored session would otherwise be trusted
* forever. Sessions have to expire themselves.
*/
export const isSessionExpired = (session: BeszelSession, now = Date.now()) =>
session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now;
export class BeszelIntegration extends Integration { export class BeszelIntegration extends Integration {
private readonly sessionStore: SessionStore<BeszelSession>; private readonly sessionStore: SessionStore<BeszelSession>;
@@ -133,19 +95,11 @@ export class BeszelIntegration extends Integration {
private async authenticateAsync(): Promise<BeszelSession> { private async authenticateAsync(): Promise<BeszelSession> {
const existingSession = await this.sessionStore.getAsync(); const existingSession = await this.sessionStore.getAsync();
if (existingSession && !isSessionExpired(existingSession)) { if (existingSession) {
logger.debug("Using stored Beszel session", { integrationId: this.integration.id }); logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
return existingSession; return existingSession;
} }
if (existingSession) {
logger.info("Stored Beszel session expired, re-authenticating", {
integrationId: this.integration.id,
expiresAt: existingSession.expiresAt,
});
await this.sessionStore.clearAsync();
}
const authUrl = this.url("/api/collections/users/auth-with-password"); const authUrl = this.url("/api/collections/users/auth-with-password");
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname }); logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
const response = await fetchWithTrustedCertificatesAsync(authUrl, { const response = await fetchWithTrustedCertificatesAsync(authUrl, {
@@ -164,30 +118,9 @@ export class BeszelIntegration extends Integration {
} }
const data = (await response.json()) as BeszelAuthResponse; const data = (await response.json()) as BeszelAuthResponse;
const expiresAt = parseTokenExpiration(data.token) ?? undefined; const session: BeszelSession = { token: data.token, userId: data.record.id };
await this.sessionStore.setAsync(session);
// A token that has already lapsed by the time it reaches us means our clock and Beszel's logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
// disagree by more than the token lifetime. Re-authenticating would only mint another dead
// token, and using this one would silently produce the empty responses this check exists to
// prevent — so fail loudly instead.
if (expiresAt !== undefined && expiresAt <= Date.now()) {
logger.warn("Beszel issued an already-expired token", { integrationId: this.integration.id, expiresAt });
throw new ErrorWithMetadata("Beszel issued an already-expired authentication token, check for clock skew", {
integrationId: this.integration.id,
expiresAt,
});
}
const session: BeszelSession = { token: data.token, userId: data.record.id, expiresAt };
// Give the stored entry the token's own lifetime, so the cache cannot outlive the credential
// even if the session is never read again before it lapses.
const ttlSeconds = expiresAt === undefined ? undefined : Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
await this.sessionStore.setAsync(session, ttlSeconds === undefined ? undefined : { ttlSeconds });
logger.debug("Saved Beszel session", {
integrationId: this.integration.id,
userId: session.userId,
expiresAt,
});
return session; return session;
} }
@@ -424,24 +357,10 @@ export class BeszelIntegration extends Integration {
let emittedSnapshotCount = 0; let emittedSnapshotCount = 0;
while (!signal.aborted) { while (!signal.aborted) {
let streamResponse: UndiciResponse | undefined;
let streamAgent: Awaited<ReturnType<typeof createCertificateAgentAsync>> | undefined;
const closeStreamAsync = async () => {
const response = streamResponse;
streamResponse = undefined;
const agent = streamAgent;
streamAgent = undefined;
if (response?.body) await response.body.cancel().catch(() => {});
if (agent) await agent.close().catch(() => {});
};
try { try {
let session = await this.authenticateAsync(); let session = await this.authenticateAsync();
const openStream = async () => { const openStream = async () =>
streamAgent = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 }); await fetchWithTrustedCertificatesAsync(realtimeUrl, {
try {
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: { headers: {
Accept: "text/event-stream", Accept: "text/event-stream",
Authorization: session.token, Authorization: session.token,
@@ -450,15 +369,7 @@ export class BeszelIntegration extends Integration {
}, },
signal, signal,
bodyTimeout: 0, bodyTimeout: 0,
dispatcher: streamAgent,
}); });
streamResponse = response;
return response;
} catch (error) {
await closeStreamAsync();
throw error;
}
};
logger.debug("Opening Beszel rt_metrics SSE connection", { logger.debug("Opening Beszel rt_metrics SSE connection", {
integrationId: this.integration.id, integrationId: this.integration.id,
@@ -467,7 +378,6 @@ export class BeszelIntegration extends Integration {
}); });
let response = await openStream(); let response = await openStream();
if (response.status === 401) { if (response.status === 401) {
await closeStreamAsync();
await this.sessionStore.clearAsync(); await this.sessionStore.clearAsync();
session = await this.authenticateAsync(); session = await this.authenticateAsync();
response = await openStream(); response = await openStream();
@@ -492,21 +402,14 @@ export class BeszelIntegration extends Integration {
clientId = frame.id || fallbackClientId; clientId = frame.id || fallbackClientId;
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID"); if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
if (!streamAgent) throw new Error("Beszel realtime stream agent is not available");
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, { const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST", method: "POST",
headers: { Authorization: session.token, "Content-Type": "application/json" }, headers: { Authorization: session.token, "Content-Type": "application/json" },
body: JSON.stringify({ clientId, subscriptions: [topic] }), body: JSON.stringify({ clientId, subscriptions: [topic] }),
signal, signal,
dispatcher: streamAgent,
}); });
try {
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync(); if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse); if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
await subscribeResponse.arrayBuffer();
} finally {
if (subscribeResponse.body) await subscribeResponse.body.cancel().catch(() => {});
}
logger.debug("Subscribed to Beszel rt_metrics topic", { logger.debug("Subscribed to Beszel rt_metrics topic", {
integrationId: this.integration.id, integrationId: this.integration.id,
systemId, systemId,
@@ -558,12 +461,10 @@ export class BeszelIntegration extends Integration {
} finally { } finally {
await reader.cancel().catch(() => {}); await reader.cancel().catch(() => {});
reader.releaseLock(); reader.releaseLock();
await closeStreamAsync();
} }
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly"); if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
} catch (error) { } catch (error) {
await closeStreamAsync();
if (signal.aborted) break; if (signal.aborted) break;
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) { if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
retryAttempt += 1; retryAttempt += 1;
@@ -10,7 +10,6 @@ vi.hoisted(() => {
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090"; const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? ""; const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? ""; const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
const closeAgent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@homarr/redis", () => ({ vi.mock("@homarr/redis", () => ({
createGetSetChannel: () => ({ createGetSetChannel: () => ({
@@ -33,7 +32,7 @@ vi.mock("@homarr/core/infrastructure/logs", () => ({
vi.mock("@homarr/core/infrastructure/http", () => ({ vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init), fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}), createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: closeAgent }), createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
})); }));
vi.mock("@homarr/core/infrastructure/certificates", () => ({ vi.mock("@homarr/core/infrastructure/certificates", () => ({
@@ -154,7 +153,6 @@ describe("subscribeRealtimeMetrics", () => {
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]); expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]); expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
expect(closeAgent).toHaveBeenCalled();
} finally { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
@@ -1,213 +0,0 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
process.env.ENABLE_DNS_CACHING = "false";
});
// A stateful stand-in for redis, so a session actually survives between calls and the
// expiry logic can be exercised end to end.
const store = vi.hoisted(() => new Map<string, { value: string; ttlSeconds?: number }>());
vi.mock("@homarr/redis", () => ({
createGetSetChannel: (name: string) => ({
getAsync: () => Promise.resolve(store.get(name)?.value ?? null),
setAsync: (value: string, options?: { ttlSeconds?: number }) => {
store.set(name, { value, ttlSeconds: options?.ttlSeconds });
return Promise.resolve();
},
removeAsync: () => {
store.delete(name);
return Promise.resolve();
},
}),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
ErrorWithMetadata: class extends Error {},
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: vi.fn() }),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
import { BeszelIntegration, isSessionExpired, parseTokenExpiration } from "../beszel-integration";
const base64Url = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url");
const createToken = (expiresAtSeconds: number) => `header.${base64Url({ exp: expiresAtSeconds })}.signature`;
describe("parseTokenExpiration", () => {
test("reads the exp claim as epoch milliseconds", () => {
expect(parseTokenExpiration(createToken(1_800_000_000))).toBe(1_800_000_000_000);
});
test("decodes base64url payloads containing - and _", () => {
// A payload that base64-encodes with both + and /, which must be url-decoded first.
const token = `header.${Buffer.from(JSON.stringify({ sub: "a>b?c>>", exp: 1_700_000_000 })).toString("base64url")}.sig`;
expect(parseTokenExpiration(token)).toBe(1_700_000_000_000);
});
test.each([
["not-a-jwt", "no segments"],
["header.%%%not-base64%%%.signature", "undecodable payload"],
[`header.${base64Url({ id: "user-1" })}.signature`, "no exp claim"],
[`header.${base64Url({ exp: "soon" })}.signature`, "non-numeric exp"],
])("returns null for %s (%s)", (token) => {
expect(parseTokenExpiration(token)).toBeNull();
});
});
const session = (expiresAt?: number) => ({ token: "t", userId: "u", expiresAt });
describe("isSessionExpired", () => {
const now = Date.UTC(2026, 6, 26, 12, 0, 0);
test("treats a session with an unknown expiry as valid, falling back to the 401 retry", () => {
expect(isSessionExpired(session(undefined), now)).toBe(false);
});
test("keeps a session that is comfortably in the future", () => {
expect(isSessionExpired(session(now + 60 * 60 * 1000), now)).toBe(false);
});
test("expires a session that lapses within the leeway window", () => {
expect(isSessionExpired(session(now + 30_000), now)).toBe(true);
});
test("expires a session that has already lapsed", () => {
expect(isSessionExpired(session(now - 1), now)).toBe(true);
});
});
const createIntegration = () =>
new BeszelIntegration({
id: "test-beszel-session",
name: "Test Beszel",
url: "http://localhost:8090",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "user@example.com" },
{ kind: "password", value: "password" },
],
});
describe("BeszelIntegration session lifecycle", () => {
const originalFetch = globalThis.fetch;
let authCount = 0;
let issuedToken = "";
beforeEach(() => {
store.clear();
authCount = 0;
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/collections/users/auth-with-password")) {
authCount++;
return Promise.resolve(Response.json({ token: issuedToken, record: { id: "user-1" } }));
}
if (url.includes("/api/collections/systems/records")) {
return Promise.resolve(Response.json({ page: 1, perPage: 500, totalItems: 0, totalPages: 0, items: [] }));
}
throw new Error(`Unexpected request: ${url}`);
}) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("reuses a stored session while its token is still valid", async () => {
issuedToken = createToken(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
const integration = createIntegration();
await integration.getSystemsAsync();
await integration.getSystemsAsync();
expect(authCount).toBe(1);
});
test("persists the session with a ttl matching the token lifetime", async () => {
const lifetimeSeconds = 7 * 24 * 60 * 60;
issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds);
await createIntegration().getSystemsAsync();
const stored = store.get("session-store:test-beszel-session");
expect(stored?.ttlSeconds).toBeGreaterThan(lifetimeSeconds - 60);
expect(stored?.ttlSeconds).toBeLessThanOrEqual(lifetimeSeconds);
});
test("re-authenticates once the stored token has expired", async () => {
// Beszel replies 200 + empty list to an expired token instead of 401, so without an expiry
// check the stale session would be reused forever. See homarr-labs/homarr#6470.
vi.useFakeTimers();
try {
const lifetimeSeconds = 7 * 24 * 60 * 60;
const issuedAt = new Date("2026-07-26T00:00:00.000Z");
vi.setSystemTime(issuedAt);
issuedToken = createToken(Math.floor(issuedAt.getTime() / 1000) + lifetimeSeconds);
const integration = createIntegration();
await integration.getSystemsAsync();
expect(authCount).toBe(1);
// Step past the token's lifetime, exactly as an idle Homarr instance would.
vi.setSystemTime(new Date(issuedAt.getTime() + (lifetimeSeconds + 1) * 1000));
issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds);
await integration.getSystemsAsync();
expect(authCount).toBe(2);
} finally {
vi.useRealTimers();
}
});
test("uses a short-lived token that is valid now but lapses inside the leeway window", async () => {
// The leeway exists to stop a token expiring mid-request; it must not reject a token that is
// still valid, or an instance with a short authToken.duration could never authenticate.
issuedToken = createToken(Math.floor(Date.now() / 1000) + 30);
await expect(createIntegration().getSystemsAsync()).resolves.toEqual([]);
expect(authCount).toBe(1);
expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeGreaterThan(0);
});
test("fails loudly when Beszel issues an already-expired token instead of returning empty data", async () => {
issuedToken = createToken(Math.floor(Date.now() / 1000) - 600);
const caught = await createIntegration()
.getSystemsAsync()
.then(
() => null,
(error: unknown) => error,
);
// The integration base class wraps thrown errors, so assert against the cause chain.
const messages: string[] = [];
for (let error: unknown = caught; error instanceof Error; error = error.cause) messages.push(error.message);
expect(messages.join(" | ")).toMatch(/clock skew/i);
// The dead token must not be persisted, or it would be served to the next caller.
expect(store.has("session-store:test-beszel-session")).toBe(false);
expect(authCount).toBe(1);
});
test("does not store a ttl when the token carries no readable expiry", async () => {
issuedToken = "opaque-token-without-claims";
await createIntegration().getSystemsAsync();
expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeUndefined();
});
});
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent"; const type = "torrent";
const client = await this.getClientAsync(); const client = await this.getClientAsync();
const torrents = await client.listTorrents({ limit: input.limit }); const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
const rates = torrents.reduce( const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }), ({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 }, { down: 0, up: 0 },
@@ -41,11 +41,7 @@ export interface StreamSession {
audioCodec: string | null; audioCodec: string | null;
videoCodec: string | null; videoCodec: string | null;
}; };
isVideoDirect: boolean;
isAudioDirect: boolean;
containerChanged: boolean;
}; };
bitrateKbps: number | null;
} | null; } | null;
} | null; } | null;
} }
@@ -56,9 +56,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
if (sessionInfo.NowPlayingItem) { if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks); const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks); const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
const bitrateBps =
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
currentlyPlaying = { currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
@@ -101,11 +98,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
videoCodec: sessionInfo.TranscodingInfo?.VideoCodec ?? null, videoCodec: sessionInfo.TranscodingInfo?.VideoCodec ?? null,
}, },
container: sessionInfo.TranscodingInfo?.Container ?? null, container: sessionInfo.TranscodingInfo?.Container ?? null,
isVideoDirect: sessionInfo.TranscodingInfo?.IsVideoDirect ?? true,
isAudioDirect: sessionInfo.TranscodingInfo?.IsAudioDirect ?? true,
containerChanged: sessionInfo.PlayState?.PlayMethod === "DirectStream",
}, },
bitrateKbps,
}, },
}; };
} }
@@ -6,8 +6,6 @@ import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error"; import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types"; import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types";
import { subsonicResponseSchema } from "./navidrome-types"; import { subsonicResponseSchema } from "./navidrome-types";
@@ -24,7 +22,7 @@ const subsonicAuthErrorCodes = new Set([40, 41]);
const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] => const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
[value].flat().filter((item): item is TValue => item !== undefined); [value].flat().filter((item): item is TValue => item !== undefined);
export class NavidromeIntegration extends Integration implements IMediaServerIntegration { export class NavidromeIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> { protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/rest/ping.view", this.getAuthParams()); const url = this.url("/rest/ping.view", this.getAuthParams());
const response = await input.fetchAsync(url); const response = await input.fetchAsync(url);
@@ -55,49 +53,20 @@ export class NavidromeIntegration extends Integration implements IMediaServerInt
} }
public async getDashboardDataAsync(): Promise<NavidromeDashboardData> { public async getDashboardDataAsync(): Promise<NavidromeDashboardData> {
const [artistCount, libraryCounts] = await Promise.all([ const [artistCount, libraryCounts, nowPlaying] = await Promise.all([
this.getArtistCountAsync(), this.getArtistCountAsync(),
this.getAlbumAndSongCountsAsync(), this.getAlbumAndSongCountsAsync(),
this.getNowPlayingAsync(),
]); ]);
return { return {
artistCount, artistCount,
albumCount: libraryCounts.albumCount, albumCount: libraryCounts.albumCount,
songCount: libraryCounts.songCount, songCount: libraryCounts.songCount,
nowPlaying,
}; };
} }
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const nowPlaying = await this.getNowPlayingAsync();
return nowPlaying.map(
(entry): StreamSession => ({
sessionId: `${entry.username}-${entry.playerName}`,
sessionName: entry.playerName,
user: {
userId: entry.username,
username: entry.username,
profilePictureUrl: null,
},
currentlyPlaying: {
type: "audio",
name: entry.title,
seasonName: entry.artist || undefined,
episodeName: null,
albumName: entry.album,
episodeCount: null,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
},
}),
);
}
private async getArtistCountAsync(): Promise<number> { private async getArtistCountAsync(): Promise<number> {
const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true }); const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0); return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
@@ -67,4 +67,5 @@ export interface NavidromeDashboardData {
artistCount: number; artistCount: number;
albumCount: number; albumCount: number;
songCount: number; songCount: number;
nowPlaying: NavidromeNowPlayingEntry[];
} }
@@ -1,12 +1,6 @@
// @vitest-environment node
import { Response } from "undici"; import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types"; import type { IntegrationSecret } from "../../base/types";
@@ -40,24 +34,24 @@ const subsonicOk = <T extends Record<string, unknown>>(data: T) =>
"subsonic-response": { status: "ok", version: "1.16.1", ...data }, "subsonic-response": { status: "ok", version: "1.16.1", ...data },
}); });
const subsonicFailed = (message: string, code = 70) => const subsonicFailed = (message: string) =>
JSON.stringify({ JSON.stringify({
"subsonic-response": { status: "failed", error: { code, message } }, "subsonic-response": { status: "failed", error: { code: 70, message } },
}); });
const makeAlbums = (count: number, songCount: number) =>
Array.from({ length: count }, (_, index) => ({
id: `album-${index}`,
name: `Album ${index}`,
songCount,
}));
beforeEach(() => { beforeEach(() => {
mockFetch.mockReset(); mockFetch.mockReset();
}); });
describe("NavidromeIntegration.getDashboardDataAsync", () => { describe("NavidromeIntegration.getDashboardDataAsync", () => {
test("paginates album list and counts songs", async () => { test("paginates album list and counts songs", async () => {
const makeAlbums = (count: number, songCount: number) =>
Array.from({ length: count }, (_, i) => ({
id: `album-${i}`,
name: `Album ${i}`,
songCount,
}));
mockFetch.mockImplementation((url) => { mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url); const urlStr = toUrlString(url);
@@ -90,7 +84,13 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
>; >;
} }
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType< if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync typeof fetchWithTrustedCertificatesAsync
>; >;
}); });
@@ -101,6 +101,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
expect(result.albumCount).toBe(700); expect(result.albumCount).toBe(700);
expect(result.songCount).toBe(500 * 10 + 200 * 5); expect(result.songCount).toBe(500 * 10 + 200 * 5);
expect(result.artistCount).toBe(1); expect(result.artistCount).toBe(1);
expect(result.nowPlaying).toEqual([]);
}); });
test("handles empty library gracefully", async () => { test("handles empty library gracefully", async () => {
@@ -119,7 +120,13 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>; ) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
} }
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType< if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync typeof fetchWithTrustedCertificatesAsync
>; >;
}); });
@@ -130,6 +137,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
expect(result.artistCount).toBe(0); expect(result.artistCount).toBe(0);
expect(result.albumCount).toBe(0); expect(result.albumCount).toBe(0);
expect(result.songCount).toBe(0); expect(result.songCount).toBe(0);
expect(result.nowPlaying).toEqual([]);
}); });
test("throws on non-empty-library subsonic error", async () => { test("throws on non-empty-library subsonic error", async () => {
@@ -143,10 +151,8 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
const integration = createIntegration(); const integration = createIntegration();
await expect(integration.getDashboardDataAsync()).rejects.toThrow(); await expect(integration.getDashboardDataAsync()).rejects.toThrow();
}); });
});
describe("NavidromeIntegration.getCurrentSessionsAsync", () => { test("now playing maps entries correctly", async () => {
test("maps now playing entries to stream sessions", async () => {
mockFetch.mockImplementation((url) => { mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url); const urlStr = toUrlString(url);
@@ -155,22 +161,7 @@ describe("NavidromeIntegration.getCurrentSessionsAsync", () => {
new Response( new Response(
subsonicOk({ subsonicOk({
nowPlaying: { nowPlaying: {
entry: [ entry: { title: "Song", artist: "Band", album: "LP", username: "user1", playerName: "Chrome" },
{
title: "Song",
artist: "Band",
album: "LP",
username: "user1",
playerName: "Chrome",
},
{
title: "Second Song",
artist: "Second Artist",
album: "Second Album",
username: "user2",
playerName: "Mobile",
},
],
}, },
}), }),
{ status: 200 }, { status: 200 },
@@ -178,107 +169,33 @@ describe("NavidromeIntegration.getCurrentSessionsAsync", () => {
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>; ) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
} }
if (urlStr.includes("getArtists")) {
return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (urlStr.includes("getAlbumList2")) {
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync typeof fetchWithTrustedCertificatesAsync
>; >;
}); });
const integration = createIntegration(); const integration = createIntegration();
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: true }); const result = await integration.getDashboardDataAsync();
expect(result).toEqual([ expect(result.nowPlaying).toHaveLength(1);
{ expect(result.nowPlaying[0]).toEqual({
sessionId: "user1-Chrome", title: "Song",
sessionName: "Chrome", artist: "Band",
user: { album: "LP",
userId: "user1",
username: "user1", username: "user1",
profilePictureUrl: null, playerName: "Chrome",
},
currentlyPlaying: {
type: "audio",
name: "Song",
seasonName: "Band",
episodeName: null,
albumName: "LP",
episodeCount: null,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
},
},
{
sessionId: "user2-Mobile",
sessionName: "Mobile",
user: {
userId: "user2",
username: "user2",
profilePictureUrl: null,
},
currentlyPlaying: {
type: "audio",
name: "Second Song",
seasonName: "Second Artist",
episodeName: null,
albumName: "Second Album",
episodeCount: null,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
},
},
]);
});
test("returns an empty array when there are no now playing entries", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: false });
expect(result).toEqual([]);
});
test("normalizes subsonic auth failures", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(
new Response(subsonicFailed("Wrong username or password", 40), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
await expect(integration.getCurrentSessionsAsync({ showOnlyPlaying: true })).rejects.toMatchObject({
message: "An unknown error occured while executing Integration method",
cause: { message: "Wrong username or password" },
}); });
}); });
}); });
@@ -43,12 +43,6 @@ function parseResolution(
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null; return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
} }
function isStreamDirect(decision: string | undefined): boolean {
// Plex reports "transcode" when a stream is re-encoded. "copy" (remuxed
// without re-encoding) and an absent decision both count as direct.
return decision !== "transcode";
}
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> { public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey"); const token = super.getSecretValue("apiKey");
@@ -93,16 +87,6 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const videoStream = streams.find((stream) => stream.$.streamType === "1"); const videoStream = streams.find((stream) => stream.$.streamType === "1");
const audioStream = streams.find((stream) => stream.$.streamType === "2"); const audioStream = streams.find((stream) => stream.$.streamType === "2");
const isVideoDirect = isStreamDirect(transcodeElement?.$.videoDecision);
const isAudioDirect = isStreamDirect(transcodeElement?.$.audioDecision);
const containerChanged = Boolean(
transcodeElement?.$.container &&
mediaInfoElement?.$.container &&
transcodeElement.$.container !== mediaInfoElement.$.container,
);
const bitrateKbps =
parseOptionalNumber(sessionElement?.$.bandwidth) ?? parseOptionalNumber(mediaInfoElement?.$.bitrate);
return { return {
sessionId: sessionElement?.$.id ?? "unknown", sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`, sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -140,11 +124,7 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
audioCodec: transcodeElement?.$.audioCodec ?? null, audioCodec: transcodeElement?.$.audioCodec ?? null,
videoCodec: transcodeElement?.$.videoCodec ?? null, videoCodec: transcodeElement?.$.videoCodec ?? null,
}, },
isVideoDirect,
isAudioDirect,
containerChanged,
}, },
bitrateKbps,
}, },
}, },
}; };
+3 -7
View File
@@ -65,9 +65,6 @@ const optionMapping: OptionMapping = {
useCustomTimezone: () => true, useCustomTimezone: () => true,
customTimeFormat: () => undefined, customTimeFormat: () => undefined,
customDateFormat: () => undefined, customDateFormat: () => undefined,
showWeather: () => undefined,
weatherLocation: () => undefined,
isWeatherFormatFahrenheit: () => undefined,
}, },
downloads: { downloads: {
activeTorrentThreshold: (oldOptions) => activeTorrentThreshold: (oldOptions) =>
@@ -77,16 +74,15 @@ const optionMapping: OptionMapping = {
categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined), categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined),
filterIsWhitelist: (oldOptions) => filterIsWhitelist: (oldOptions) =>
"labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined, "labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined,
enableRowSorting: (oldOptions) => ("rowSorting" in oldOptions ? oldOptions.rowSorting : undefined),
showCompletedTorrent: (oldOptions) => showCompletedTorrent: (oldOptions) =>
"displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined, "displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined,
columns: () => ["integration", "name", "progress", "time", "state"], columns: () => ["integration", "name", "progress", "time", "actions"],
defaultSort: () => "progress", defaultSort: () => "type",
descendingDefaultSort: () => false, descendingDefaultSort: () => false,
showCompletedUsenet: () => true, showCompletedUsenet: () => true,
showCompletedHttp: () => true, showCompletedHttp: () => true,
limitPerIntegration: () => undefined, limitPerIntegration: () => undefined,
columnOrder: () => undefined,
columnWidths: () => undefined,
}, },
weather: { weather: {
forecastDayCount: (oldOptions) => oldOptions.forecastDays, forecastDayCount: (oldOptions) => oldOptions.forecastDays,
@@ -13,6 +13,4 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMediaReleasesAsync(); return await integrationInstance.getMediaReleasesAsync();
}, },
cacheTtlMs: 5 * 60 * 1000,
fallbackToStaleOnError: true,
}); });
@@ -787,34 +787,11 @@ const getLinuxServerIOReleaseAsync = async (
error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` }, error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
}; };
const regex = compileVersionRegex(versionRegex); // LSIO's /api/v1/images only exposes the single latest version, so a version
if (versionRegex && !regex) { // filter can never surface an older release (#4351). When the image has a GitHub
return { // repository, delegate to the GitHub release provider to fetch the real release
success: false, // history and apply the filter, while keeping LSIO's own image metadata. Any
error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` }, // GitHub failure (no repo, no releases, rate limit) falls back to the LSIO latest.
};
}
if (!regex || regex.test(release.version)) {
return {
success: true,
data: {
latestRelease: release.version,
latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
createdAt: release.initial_date,
starsCount: release.stars,
},
};
}
// LSIO's /api/v1/images only exposes the single latest version, so a filter
// that does not match it cannot surface an older release (#4351). Delegate to
// the image's GitHub repository to fetch the real release history while
// keeping LSIO's own image metadata.
const githubRepo = parseGithubRepo(release.github_url); const githubRepo = parseGithubRepo(release.github_url);
if (githubRepo) { if (githubRepo) {
const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex); const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex);
@@ -831,9 +808,17 @@ const getLinuxServerIOReleaseAsync = async (
}, },
}; };
} }
return githubResult;
} }
const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) {
return {
success: false,
error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` },
};
}
if (regex && !regex.test(release.version)) {
return { return {
success: false, success: false,
error: { error: {
@@ -841,6 +826,21 @@ const getLinuxServerIOReleaseAsync = async (
message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`, message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`,
}, },
}; };
}
return {
success: true,
data: {
latestRelease: release.version,
latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
createdAt: release.initial_date,
starsCount: release.stars,
},
};
}; };
const quayReleasesSchema = z.object({ const quayReleasesSchema = z.object({
@@ -1,45 +0,0 @@
import type { MediaRelease } from "@homarr/integrations/types";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { mediaReleaseRequestHandler } from "../media-release";
const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
const integration = {
id: "plex-integration",
kind: "plex",
} as Parameters<typeof mediaReleaseRequestHandler.handler>[0];
describe("mediaReleaseRequestHandler", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-28T00:00:00Z"));
mediaReleaseRequestHandler.invalidateCache();
mocks.createIntegrationAsync.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
test("keeps the last successful releases when a refresh fails", async () => {
const releases = [{ id: "release-1" }] as MediaRelease[];
const getMediaReleasesAsync = vi.fn().mockResolvedValueOnce(releases).mockRejectedValueOnce(new Error("Plex down"));
mocks.createIntegrationAsync.mockResolvedValue({ getMediaReleasesAsync });
const handler = mediaReleaseRequestHandler.handler(integration, {});
const initial = await handler.getDataAsync();
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
const fallback = await handler.getDataAsync();
expect(fallback).toEqual(initial);
expect(getMediaReleasesAsync).toHaveBeenCalledTimes(2);
});
});
@@ -126,128 +126,6 @@ describe("getLatestMatchingReleaseAsync for GitHub Container Registry", () => {
}); });
}); });
describe("getLatestMatchingReleaseAsync for LinuxServer.io", () => {
beforeEach(() => {
mockedFetch.mockReset();
});
test("uses the LinuxServer.io latest release without fetching GitHub history", async () => {
mockedFetch.mockResolvedValueOnce(createLinuxServerResponse());
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "lscr.io/linuxserver/tautulli:latest",
});
expect(result).toEqual({
success: true,
data: {
latestRelease: "v2.17.2-ls237",
latestReleaseAt: new Date("2026-07-17T00:25:42.000Z"),
releaseDescription: "Rebase to Alpine 3.24.",
projectUrl: "https://github.com/linuxserver/docker-tautulli",
projectDescription: "Tautulli image",
isArchived: false,
createdAt: new Date("2015-08-10T00:00:00.000Z"),
starsCount: 934,
},
});
expect(mockedFetch).toHaveBeenCalledOnce();
expectUrl(0, "https://api.linuxserver.io/api/v1/images");
});
test("fetches GitHub history when a version filter requires it", async () => {
mockedFetch.mockImplementation(async (input) => {
const url = new URL(input.toString());
if (url.origin === "https://api.linuxserver.io") return createLinuxServerResponse();
if (url.pathname === "/repos/linuxserver/docker-tautulli/releases") {
return createJsonResponse([
{
tag_name: "v2.16.0-ls200",
published_at: "2026-01-10T12:00:00.000Z",
html_url: "https://github.com/linuxserver/docker-tautulli/releases/tag/v2.16.0-ls200",
body: "Older supported image",
prerelease: false,
},
]);
}
if (url.pathname === "/repos/linuxserver/docker-tautulli") {
return createJsonResponse({
html_url: "https://github.com/linuxserver/docker-tautulli",
description: "GitHub description",
fork: false,
archived: false,
created_at: "2015-08-10T00:00:00.000Z",
stargazers_count: 934,
open_issues_count: 2,
forks_count: 80,
});
}
return createJsonResponse({ message: "Not Found" }, { status: 404, statusText: "Not Found" });
});
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "linuxserver/tautulli",
versionRegex: "^v2\\.16\\..+-ls[0-9]+$",
});
expect(result).toEqual({
success: true,
data: expect.objectContaining({
latestRelease: "v2.16.0-ls200",
projectUrl: "https://github.com/linuxserver/docker-tautulli",
projectDescription: "Tautulli image",
}),
});
expect(mockedFetch).toHaveBeenCalledTimes(3);
});
test("uses the LinuxServer.io latest release when it matches the version filter", async () => {
mockedFetch.mockResolvedValueOnce(createLinuxServerResponse());
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "linuxserver/tautulli",
versionRegex: "^v2\\.17\\..+-ls[0-9]+$",
});
expect(result).toEqual({
success: true,
data: expect.objectContaining({ latestRelease: "v2.17.2-ls237" }),
});
expect(mockedFetch).toHaveBeenCalledOnce();
expectUrl(0, "https://api.linuxserver.io/api/v1/images");
});
test("returns the GitHub error when filtered release history cannot be fetched", async () => {
mockedFetch.mockImplementation(async (input) => {
const url = new URL(input.toString());
if (url.origin === "https://api.linuxserver.io") return createLinuxServerResponse();
return createJsonResponse({ message: "GitHub unavailable" }, { status: 404, statusText: "Not Found" });
});
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "linuxserver/tautulli",
versionRegex: "^v2\\.16\\..+-ls[0-9]+$",
});
expect(result).toEqual({
success: false,
error: expect.objectContaining({ code: "unexpected", message: expect.stringContaining("GitHub unavailable") }),
});
expect(mockedFetch.mock.calls.length).toBeGreaterThan(1);
});
});
interface MockGhcrResponsesInput { interface MockGhcrResponsesInput {
registryOrigin?: string; registryOrigin?: string;
repositoryName: string; repositoryName: string;
@@ -307,27 +185,6 @@ const createJsonResponse = (body: unknown, init: JsonResponseInit = {}) =>
}, },
}); });
const createLinuxServerResponse = () =>
createJsonResponse({
data: {
repositories: {
linuxserver: [
{
name: "tautulli",
initial_date: "2015-08-10",
github_url: "https://github.com/linuxserver/docker-tautulli",
description: "Tautulli image",
version: "v2.17.2-ls237",
version_timestamp: "2026-07-17 00:25:42+00:00",
stars: 934,
deprecated: false,
changelog: [{ date: "2026-07-05", desc: "Rebase to Alpine 3.24." }],
},
],
},
},
});
const expectUrl = (callIndex: number, expectedUrl: string) => { const expectUrl = (callIndex: number, expectedUrl: string) => {
expect(mockedFetch.mock.calls[callIndex]?.[0].toString().split("?")[0]).toBe(expectedUrl); expect(mockedFetch.mock.calls[callIndex]?.[0].toString().split("?")[0]).toBe(expectedUrl);
}; };
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+13 -364
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "crwdns44:0crwdne44:0" "label": "crwdns44:0crwdne44:0"
}, },
"enableRightClickOnWidgets": {
"label": "crwdns6095:0crwdne6095:0",
"description": "crwdns6097:0crwdne6097:0"
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "crwdns2694:0crwdne2694:0" "label": "crwdns2694:0crwdne2694:0"
}, },
@@ -866,7 +862,7 @@
"created": "crwdns5553:0{name}crwdne5553:0", "created": "crwdns5553:0{name}crwdne5553:0",
"updated": "crwdns5555:0{name}crwdne5555:0", "updated": "crwdns5555:0{name}crwdne5555:0",
"deleted": "crwdns5557:0{name}crwdne5557:0", "deleted": "crwdns5557:0{name}crwdne5557:0",
"imported": "crwdns6079:0crwdne6079:0", "imported": "crwdns5559:0crwdne5559:0",
"aiPromptCopied": "crwdns5561:0crwdne5561:0", "aiPromptCopied": "crwdns5561:0crwdne5561:0",
"aiPromptDescription": "crwdns5563:0crwdne5563:0", "aiPromptDescription": "crwdns5563:0crwdne5563:0",
"aiPromptCopy": "crwdns5565:0crwdne5565:0", "aiPromptCopy": "crwdns5565:0crwdne5565:0",
@@ -1289,14 +1285,6 @@
"label": "crwdns3618:0crwdne3618:0", "label": "crwdns3618:0crwdne3618:0",
"newLabel": "crwdns3620:0crwdne3620:0" "newLabel": "crwdns3620:0crwdne3620:0"
}, },
"patchmonApiKey": {
"label": "crwdns5859:0crwdne5859:0",
"newLabel": "crwdns5861:0crwdne5861:0"
},
"patchmonApiSecret": {
"label": "crwdns5863:0crwdne5863:0",
"newLabel": "crwdns5865:0crwdne5865:0"
},
"githubAppId": { "githubAppId": {
"label": "crwdns3676:0crwdne3676:0", "label": "crwdns3676:0crwdne3676:0",
"newLabel": "crwdns3678:0crwdne3678:0" "newLabel": "crwdns3678:0crwdne3678:0"
@@ -1340,21 +1328,9 @@
} }
} }
}, },
"traefik": {
"name": "crwdns5753:0crwdne5753:0",
"description": "crwdns5755:0crwdne5755:0"
},
"paperlessNgx": { "paperlessNgx": {
"name": "crwdns5003:0crwdne5003:0", "name": "crwdns5003:0crwdne5003:0",
"description": "crwdns5005:0crwdne5005:0" "description": "crwdns5005:0crwdne5005:0"
},
"patchmon": {
"name": "crwdns5867:0crwdne5867:0",
"description": "crwdns5869:0crwdne5869:0"
},
"bazarr": {
"name": "crwdns5799:0crwdne5799:0",
"description": "crwdns5801:0crwdne5801:0"
} }
}, },
"media": { "media": {
@@ -1626,12 +1602,6 @@
"refresh": "crwdns5739:0crwdne5739:0", "refresh": "crwdns5739:0crwdne5739:0",
"options": "crwdns5709:0crwdne5709:0", "options": "crwdns5709:0crwdne5709:0",
"actions": "crwdns5711:0crwdne5711:0" "actions": "crwdns5711:0crwdne5711:0"
},
"status": {
"success": "crwdns6049:0crwdne6049:0",
"error": "crwdns6051:0crwdne6051:0",
"loading": "crwdns6053:0crwdne6053:0",
"idle": "crwdns6055:0crwdne6055:0"
} }
}, },
"create": { "create": {
@@ -1905,16 +1875,6 @@
"customDateFormat": { "customDateFormat": {
"label": "crwdns2726:0crwdne2726:0", "label": "crwdns2726:0crwdne2726:0",
"description": "crwdns2728:0crwdne2728:0" "description": "crwdns2728:0crwdne2728:0"
},
"showWeather": {
"label": "crwdns6113:0crwdne6113:0",
"description": "crwdns6115:0crwdne6115:0"
},
"weatherLocation": {
"label": "crwdns6117:0crwdne6117:0"
},
"isWeatherFormatFahrenheit": {
"label": "crwdns6119:0crwdne6119:0"
} }
} }
}, },
@@ -1976,8 +1936,6 @@
"clear": "crwdns884:0crwdne884:0", "clear": "crwdns884:0crwdne884:0",
"heading": "crwdns886:0{level}crwdne886:0", "heading": "crwdns886:0{level}crwdne886:0",
"align": "crwdns888:0{position}crwdne888:0", "align": "crwdns888:0{position}crwdne888:0",
"directionLtr": "crwdns6043:0crwdne6043:0",
"directionRtl": "crwdns6045:0crwdne6045:0",
"blockquote": "crwdns890:0crwdne890:0", "blockquote": "crwdns890:0crwdne890:0",
"horizontalLine": "crwdns892:0crwdne892:0", "horizontalLine": "crwdns892:0crwdne892:0",
"bulletList": "crwdns894:0crwdne894:0", "bulletList": "crwdns894:0crwdne894:0",
@@ -2267,8 +2225,7 @@
"sunrise": "crwdns2712:0crwdne2712:0", "sunrise": "crwdns2712:0crwdne2712:0",
"sunset": "crwdns2714:0crwdne2714:0", "sunset": "crwdns2714:0crwdne2714:0",
"maxWindSpeed": "crwdns3772:0{maxWindSpeed}crwdnd3772:0{unit}crwdne3772:0", "maxWindSpeed": "crwdns3772:0{maxWindSpeed}crwdnd3772:0{unit}crwdne3772:0",
"maxWindGusts": "crwdns3774:0{maxWindGusts}crwdnd3774:0{unit}crwdne3774:0", "maxWindGusts": "crwdns3774:0{maxWindGusts}crwdnd3774:0{unit}crwdne3774:0"
"humidity": "crwdns6111:0{humidity}crwdne6111:0"
}, },
"kind": { "kind": {
"clear": "crwdns1032:0crwdne1032:0", "clear": "crwdns1032:0crwdne1032:0",
@@ -2732,11 +2689,6 @@
"user": "crwdns2476:0crwdne2476:0", "user": "crwdns2476:0crwdne2476:0",
"name": "crwdns2478:0crwdne2478:0", "name": "crwdns2478:0crwdne2478:0",
"id": "crwdns2480:0crwdne2480:0", "id": "crwdns2480:0crwdne2480:0",
"status": "crwdns6085:0crwdne6085:0",
"directPlay": "crwdns6087:0crwdne6087:0",
"transcoding": "crwdns6089:0crwdne6089:0",
"paused": "crwdns6091:0crwdne6091:0",
"remaining": "crwdns6093:0{minutes}crwdne6093:0",
"metadata": { "metadata": {
"title": "crwdns3744:0crwdne3744:0", "title": "crwdns3744:0crwdne3744:0",
"video": { "video": {
@@ -2802,193 +2754,6 @@
"internalServerError": "crwdns5055:0crwdne5055:0" "internalServerError": "crwdns5055:0crwdne5055:0"
} }
}, },
"patchmon": {
"name": "crwdns5871:0crwdne5871:0",
"description": "crwdns5873:0crwdne5873:0",
"title": "crwdns5875:0crwdne5875:0",
"totalHosts": "crwdns5877:0crwdne5877:0",
"hostsNeedingUpdates": "crwdns5879:0crwdne5879:0",
"securityUpdates": "crwdns5881:0crwdne5881:0",
"upToDateHosts": "crwdns5883:0crwdne5883:0",
"hostsWithSecurityUpdates": "crwdns5885:0crwdne5885:0",
"recentUpdates24h": "crwdns5887:0crwdne5887:0",
"totalOutdatedPackages": "crwdns5889:0crwdne5889:0",
"totalRepos": "crwdns5891:0crwdne5891:0",
"osDistribution": "crwdns5893:0crwdne5893:0",
"compliance": "crwdns5895:0crwdne5895:0",
"complianceRatio": "crwdns5897:0{upToDate}crwdnd5897:0{total}crwdne5897:0",
"lastUpdated": "crwdns5899:0{time}crwdne5899:0",
"option": {
"showTotalHosts": {
"label": "crwdns5901:0crwdne5901:0",
"description": "crwdns5903:0crwdne5903:0"
},
"showHostsNeedingUpdates": {
"label": "crwdns5905:0crwdne5905:0",
"description": "crwdns5907:0crwdne5907:0"
},
"showSecurityUpdates": {
"label": "crwdns5909:0crwdne5909:0",
"description": "crwdns5911:0crwdne5911:0"
},
"showUpToDateHosts": {
"label": "crwdns5913:0crwdne5913:0",
"description": "crwdns5915:0crwdne5915:0"
},
"showHostsWithSecurityUpdates": {
"label": "crwdns5917:0crwdne5917:0",
"description": "crwdns5919:0crwdne5919:0"
},
"showRecentUpdates24h": {
"label": "crwdns5921:0crwdne5921:0",
"description": "crwdns5923:0crwdne5923:0"
},
"showTotalOutdatedPackages": {
"label": "crwdns5925:0crwdne5925:0",
"description": "crwdns5927:0crwdne5927:0"
},
"showTotalRepos": {
"label": "crwdns5929:0crwdne5929:0",
"description": "crwdns5931:0crwdne5931:0"
},
"enableThresholdColors": {
"label": "crwdns5933:0crwdne5933:0",
"description": "crwdns5935:0crwdne5935:0"
},
"useCustomThresholds": {
"label": "crwdns5937:0crwdne5937:0",
"description": "crwdns5939:0crwdne5939:0"
},
"thresholdMode": {
"option": {
"absolute": "crwdns5941:0crwdne5941:0",
"percent": "crwdns5943:0crwdne5943:0"
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "crwdns5945:0crwdne5945:0",
"description": "crwdns5947:0crwdne5947:0"
},
"hostsNeedingUpdatesWarningAt": {
"label": "crwdns5949:0crwdne5949:0",
"description": "crwdns5951:0crwdne5951:0"
},
"hostsNeedingUpdatesCriticalAt": {
"label": "crwdns5953:0crwdne5953:0",
"description": "crwdns5955:0crwdne5955:0"
},
"securityUpdatesThresholdMode": {
"label": "crwdns5957:0crwdne5957:0",
"description": "crwdns5959:0crwdne5959:0"
},
"securityUpdatesWarningAt": {
"label": "crwdns5961:0crwdne5961:0",
"description": "crwdns5963:0crwdne5963:0"
},
"securityUpdatesCriticalAt": {
"label": "crwdns5965:0crwdne5965:0",
"description": "crwdns5967:0crwdne5967:0"
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "crwdns5969:0crwdne5969:0",
"description": "crwdns5971:0crwdne5971:0"
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "crwdns5973:0crwdne5973:0",
"description": "crwdns5975:0crwdne5975:0"
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "crwdns5977:0crwdne5977:0",
"description": "crwdns5979:0crwdne5979:0"
},
"upToDateHostsThresholdMode": {
"label": "crwdns5981:0crwdne5981:0",
"description": "crwdns5983:0crwdne5983:0"
},
"upToDateHostsWarningAt": {
"label": "crwdns5985:0crwdne5985:0",
"description": "crwdns5987:0crwdne5987:0"
},
"upToDateHostsCriticalAt": {
"label": "crwdns5989:0crwdne5989:0",
"description": "crwdns5991:0crwdne5991:0"
},
"totalOutdatedPackagesThresholdMode": {
"label": "crwdns5993:0crwdne5993:0",
"description": "crwdns5995:0crwdne5995:0"
},
"totalOutdatedPackagesWarningAt": {
"label": "crwdns5997:0crwdne5997:0",
"description": "crwdns5999:0crwdne5999:0"
},
"totalOutdatedPackagesCriticalAt": {
"label": "crwdns6001:0crwdne6001:0",
"description": "crwdns6003:0crwdne6003:0"
},
"showOsDistribution": {
"label": "crwdns6005:0crwdne6005:0",
"description": "crwdns6007:0crwdne6007:0"
},
"osDistributionLimit": {
"label": "crwdns6009:0crwdne6009:0",
"description": "crwdns6011:0crwdne6011:0",
"option": {
"3": "crwdns6013:0crwdne6013:0",
"5": "crwdns6015:0crwdne6015:0",
"10": "crwdns6017:0crwdne6017:0",
"all": "crwdns6019:0crwdne6019:0"
}
},
"showOsVersion": {
"label": "crwdns6021:0crwdne6021:0",
"description": "crwdns6023:0crwdne6023:0"
},
"showComplianceHero": {
"label": "crwdns6025:0crwdne6025:0",
"description": "crwdns6027:0crwdne6027:0"
},
"osDisplayMode": {
"label": "crwdns6029:0crwdne6029:0",
"description": "crwdns6031:0crwdne6031:0",
"option": {
"bars": "crwdns6033:0crwdne6033:0",
"donut": "crwdns6035:0crwdne6035:0"
}
}
},
"error": {
"internalServerError": "crwdns6037:0crwdne6037:0"
}
},
"bazarr": {
"name": "crwdns5803:0crwdne5803:0",
"description": "crwdns5805:0crwdne5805:0",
"episodes": "crwdns5807:0crwdne5807:0",
"movies": "crwdns5809:0crwdne5809:0",
"providers": "crwdns5811:0crwdne5811:0",
"status": "crwdns5813:0crwdne5813:0",
"option": {
"showMissingEpisodes": {
"label": "crwdns5815:0crwdne5815:0",
"description": "crwdns5817:0crwdne5817:0"
},
"showMissingMovies": {
"label": "crwdns5819:0crwdne5819:0",
"description": "crwdns5821:0crwdne5821:0"
},
"showProviderIssues": {
"label": "crwdns5823:0crwdne5823:0",
"description": "crwdns5825:0crwdne5825:0"
},
"showHealthIssues": {
"label": "crwdns5827:0crwdne5827:0",
"description": "crwdns5829:0crwdne5829:0"
}
},
"error": {
"internalServerError": "crwdns5831:0crwdne5831:0"
}
},
"immich-serverStats": { "immich-serverStats": {
"name": "crwdns3880:0crwdne3880:0", "name": "crwdns3880:0crwdne3880:0",
"description": "crwdns3882:0crwdne3882:0", "description": "crwdns3882:0crwdne3882:0",
@@ -3017,11 +2782,10 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "crwdns3908:0crwdne3908:0", "name": "crwdns3908:0crwdne3908:0",
"allPhotos": "crwdns6103:0crwdne6103:0",
"option": { "option": {
"albumId": { "albumId": {
"label": "crwdns5715:0crwdne5715:0", "label": "crwdns5715:0crwdne5715:0",
"description": "crwdns6105:0crwdne6105:0" "description": "crwdns5717:0crwdne5717:0"
}, },
"rotationIntervalSeconds": { "rotationIntervalSeconds": {
"label": "crwdns3914:0crwdne3914:0", "label": "crwdns3914:0crwdne3914:0",
@@ -3036,8 +2800,9 @@
"description": "crwdns5721:0crwdne5721:0" "description": "crwdns5721:0crwdne5721:0"
} }
}, },
"description": "crwdns6107:0crwdne6107:0", "description": "crwdns3922:0crwdne3922:0",
"noPhotos": "crwdns6109:0crwdne6109:0" "noAlbumSelected": "crwdns3924:0crwdne3924:0",
"noPhotos": "crwdns3926:0crwdne3926:0"
}, },
"downloads": { "downloads": {
"name": "crwdns1170:0crwdne1170:0", "name": "crwdns1170:0crwdne1170:0",
@@ -3079,20 +2844,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "crwdns3442:0crwdne3442:0", "label": "crwdns3442:0crwdne3442:0",
"description": "crwdns3444:0crwdne3444:0" "description": "crwdns3444:0crwdne3444:0"
},
"columnOrder": {
"label": "crwdns6121:0crwdne6121:0"
},
"columnWidths": {
"label": "crwdns6123:0crwdne6123:0"
} }
}, },
"errors": { "errors": {
"noColumns": "crwdns1194:0crwdne1194:0", "noColumns": "crwdns1194:0crwdne1194:0",
"noCommunications": "crwdns1196:0crwdne1196:0", "noCommunications": "crwdns1196:0crwdne1196:0"
"noItems": "crwdns6125:0crwdne6125:0",
"actionFailed": "crwdns6127:0crwdne6127:0",
"actionFailedMessage": "crwdns6129:0crwdne6129:0"
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3115,8 +2871,7 @@
"detailsTitle": "crwdns1214:0crwdne1214:0" "detailsTitle": "crwdns1214:0crwdne1214:0"
}, },
"id": { "id": {
"columnTitle": "crwdns1216:0crwdne1216:0", "columnTitle": "crwdns1216:0crwdne1216:0"
"detailsTitle": "crwdns6131:0crwdne6131:0"
}, },
"integration": { "integration": {
"columnTitle": "crwdns1218:0crwdne1218:0" "columnTitle": "crwdns1218:0crwdne1218:0"
@@ -3150,8 +2905,7 @@
}, },
"time": { "time": {
"columnTitle": "crwdns1246:0crwdne1246:0", "columnTitle": "crwdns1246:0crwdne1246:0",
"detailsTitle": "crwdns1248:0crwdne1248:0", "detailsTitle": "crwdns1248:0crwdne1248:0"
"eta": "crwdns6133:0crwdne6133:0"
}, },
"type": { "type": {
"columnTitle": "crwdns1250:0crwdne1250:0", "columnTitle": "crwdns1250:0crwdne1250:0",
@@ -3195,23 +2949,7 @@
} }
} }
}, },
"globalRatio": "crwdns1300:0crwdne1300:0", "globalRatio": "crwdns1300:0crwdne1300:0"
"contextMenu": {
"copyName": "crwdns6135:0crwdne6135:0",
"copyId": "crwdns6137:0crwdne6137:0",
"clearFilters": "crwdns6139:0crwdne6139:0"
},
"footer": {
"filter": "crwdns6141:0crwdne6141:0"
},
"stats": {
"show": "crwdns6143:0crwdne6143:0",
"hide": "crwdns6145:0crwdne6145:0",
"overallProgress": "crwdns6147:0crwdne6147:0",
"totalSize": "crwdns6149:0crwdne6149:0",
"clients": "crwdns6151:0count={count}crwdne6151:0"
},
"disconnected": "crwdns6153:0crwdne6153:0"
}, },
"mediaReleases": { "mediaReleases": {
"name": "crwdns3532:0crwdne3532:0", "name": "crwdns3532:0crwdne3532:0",
@@ -3248,12 +2986,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "crwdns1306:0crwdne1306:0" "label": "crwdns1306:0crwdne1306:0"
},
"statusFilter": {
"label": "crwdns6081:0crwdne6081:0"
},
"recentDays": {
"label": "crwdns6083:0crwdne6083:0"
} }
}, },
"pending": { "pending": {
@@ -3361,35 +3093,6 @@
} }
} }
}, },
"mediaMissing": {
"name": "crwdns5833:0crwdne5833:0",
"description": "crwdns5835:0crwdne5835:0",
"option": {
"showMissing": {
"label": "crwdns5837:0crwdne5837:0"
},
"showQueued": {
"label": "crwdns5839:0crwdne5839:0"
},
"pageSize": {
"label": "crwdns5841:0crwdne5841:0"
}
},
"tab": {
"missing": "crwdns5843:0crwdne5843:0",
"queued": "crwdns5845:0crwdne5845:0"
},
"type": {
"movie": "crwdns5847:0crwdne5847:0",
"episode": "crwdns5849:0crwdne5849:0"
},
"empty": {
"missing": "crwdns5851:0crwdne5851:0",
"queued": "crwdns5853:0crwdne5853:0",
"noTabsEnabled": "crwdns5855:0crwdne5855:0"
},
"timeLeft": "crwdns5857:0crwdne5857:0"
},
"rssFeed": { "rssFeed": {
"name": "crwdns1352:0crwdne1352:0", "name": "crwdns1352:0crwdne1352:0",
"description": "crwdns1354:0crwdne1354:0", "description": "crwdns1354:0crwdne1354:0",
@@ -3633,11 +3336,7 @@
"name": "crwdns3476:0crwdne3476:0", "name": "crwdns3476:0crwdne3476:0",
"description": "crwdns3478:0crwdne3478:0", "description": "crwdns3478:0crwdne3478:0",
"noItems": "crwdns3480:0crwdne3480:0", "noItems": "crwdns3480:0crwdne3480:0",
"option": { "option": {}
"hideLogos": {
"label": "crwdns5757:0crwdne5757:0"
}
}
}, },
"speedtestTracker": { "speedtestTracker": {
"name": "crwdns4014:0crwdne4014:0", "name": "crwdns4014:0crwdne4014:0",
@@ -3718,40 +3417,6 @@
"internalServerError": "crwdns5095:0crwdne5095:0" "internalServerError": "crwdns5095:0crwdne5095:0"
} }
}, },
"traefik": {
"name": "crwdns5759:0crwdne5759:0",
"description": "crwdns5761:0crwdne5761:0",
"option": {
"showTcp": {
"label": "crwdns5763:0crwdne5763:0"
},
"showUdp": {
"label": "crwdns5765:0crwdne5765:0"
},
"showEntryPoints": {
"label": "crwdns5767:0crwdne5767:0"
}
},
"instances": "crwdns5769:0crwdne5769:0",
"versionUnknown": "crwdns5771:0crwdne5771:0",
"summary": {
"routers": "crwdns5773:0crwdne5773:0",
"entryPoints": "crwdns5775:0crwdne5775:0"
},
"resource": {
"routers": "crwdns5777:0crwdne5777:0",
"services": "crwdns5779:0crwdne5779:0",
"middlewares": "crwdns5781:0crwdne5781:0"
},
"status": {
"healthy": "crwdns5783:0crwdne5783:0",
"errors": "crwdns5785:0{count}crwdne5785:0",
"warnings": "crwdns5787:0{count}crwdne5787:0"
},
"error": {
"internalServerError": "crwdns5789:0crwdne5789:0"
}
},
"audioStats": { "audioStats": {
"name": "crwdns5097:0crwdne5097:0", "name": "crwdns5097:0crwdne5097:0",
"description": "crwdns5099:0crwdne5099:0", "description": "crwdns5099:0crwdne5099:0",
@@ -4118,8 +3783,8 @@
}, },
"disk": { "disk": {
"title": "crwdns4943:0crwdne4943:0", "title": "crwdns4943:0crwdne4943:0",
"subtitle": "crwdns6039:0crwdne6039:0", "subtitle": "crwdns4945:0crwdne4945:0",
"series": "crwdns6041:0crwdne6041:0" "series": "crwdns4947:0crwdne4947:0"
}, },
"diskIO": { "diskIO": {
"title": "crwdns4949:0crwdne4949:0", "title": "crwdns4949:0crwdne4949:0",
@@ -4127,11 +3792,6 @@
"read": "crwdns4953:0crwdne4953:0", "read": "crwdns4953:0crwdne4953:0",
"write": "crwdns4955:0crwdne4955:0" "write": "crwdns4955:0crwdne4955:0"
}, },
"storage": {
"title": "crwdns5793:0crwdne5793:0",
"subtitle": "crwdns5795:0crwdne5795:0",
"series": "crwdns5797:0crwdne5797:0"
},
"network": { "network": {
"title": "crwdns4957:0crwdne4957:0", "title": "crwdns4957:0crwdne4957:0",
"subtitle": "crwdns4959:0crwdne4959:0", "subtitle": "crwdns4959:0crwdne4959:0",
@@ -5264,9 +4924,6 @@
"title": "crwdns1892:0crwdne1892:0", "title": "crwdns1892:0crwdne1892:0",
"table": { "table": {
"updated": "crwdns1894:0{when}crwdne1894:0", "updated": "crwdns1894:0{when}crwdne1894:0",
"refresh": {
"lastUpdated": "crwdns6047:0{when}crwdne6047:0"
},
"search": "crwdns3432:0{count}crwdne3432:0", "search": "crwdns3432:0{count}crwdne3432:0",
"selected": "crwdns1898:0{selectCount}crwdnd1898:0{totalCount}crwdne1898:0", "selected": "crwdns1898:0{selectCount}crwdnd1898:0{totalCount}crwdne1898:0",
"footer": "crwdns3434:0{count}crwdne3434:0", "footer": "crwdns3434:0{count}crwdne3434:0",
@@ -5995,14 +5652,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "crwdns6099:0crwdne6099:0",
"children": {
"detail": {
"title": "crwdns6101:0crwdne6101:0"
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "crwdns4367:0crwdne4367:0" "label": "crwdns4367:0crwdne4367:0"
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -358
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Symbole für Pings verwenden" "label": "Symbole für Pings verwenden"
}, },
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "Standard Suchmaschine" "label": "Standard Suchmaschine"
}, },
@@ -1289,14 +1285,6 @@
"label": "", "label": "",
"newLabel": "" "newLabel": ""
}, },
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": { "githubAppId": {
"label": "", "label": "",
"newLabel": "" "newLabel": ""
@@ -1340,21 +1328,9 @@
} }
} }
}, },
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": { "paperlessNgx": {
"name": "", "name": "",
"description": "" "description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
} }
}, },
"media": { "media": {
@@ -1626,12 +1602,6 @@
"refresh": "", "refresh": "",
"options": "", "options": "",
"actions": "" "actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
} }
}, },
"create": { "create": {
@@ -1905,16 +1875,6 @@
"customDateFormat": { "customDateFormat": {
"label": "Benutzerdefiniertes Datumsformat", "label": "Benutzerdefiniertes Datumsformat",
"description": "ISO 8601 Standard benutzen um das Datum zu formatieren (dies wird andere Optionen überschreiben)" "description": "ISO 8601 Standard benutzen um das Datum zu formatieren (dies wird andere Optionen überschreiben)"
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
} }
} }
}, },
@@ -1976,8 +1936,6 @@
"clear": "Formatierung entfernen", "clear": "Formatierung entfernen",
"heading": "Überschrift {level}", "heading": "Überschrift {level}",
"align": "Text ausrichten: {position}", "align": "Text ausrichten: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "Blockzitat", "blockquote": "Blockzitat",
"horizontalLine": "Horizontale Linie", "horizontalLine": "Horizontale Linie",
"bulletList": "Aufzählung", "bulletList": "Aufzählung",
@@ -2267,8 +2225,7 @@
"sunrise": "Sonnenaufgang", "sunrise": "Sonnenaufgang",
"sunset": "Sonnenuntergang", "sunset": "Sonnenuntergang",
"maxWindSpeed": "Maximale Windgeschwindigkeit: {maxWindSpeed} km/h", "maxWindSpeed": "Maximale Windgeschwindigkeit: {maxWindSpeed} km/h",
"maxWindGusts": "Maximale Windböen: {maxWindGusts} km/h", "maxWindGusts": "Maximale Windböen: {maxWindGusts} km/h"
"humidity": ""
}, },
"kind": { "kind": {
"clear": "Klar", "clear": "Klar",
@@ -2732,11 +2689,6 @@
"user": "Benutzer", "user": "Benutzer",
"name": "", "name": "",
"id": "", "id": "",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": { "metadata": {
"title": "", "title": "",
"video": { "video": {
@@ -2802,193 +2754,6 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": { "immich-serverStats": {
"name": "", "name": "",
"description": "", "description": "",
@@ -3017,7 +2782,6 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "", "name": "",
"allPhotos": "",
"option": { "option": {
"albumId": { "albumId": {
"label": "", "label": "",
@@ -3037,6 +2801,7 @@
} }
}, },
"description": "", "description": "",
"noAlbumSelected": "",
"noPhotos": "" "noPhotos": ""
}, },
"downloads": { "downloads": {
@@ -3079,20 +2844,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "", "label": "",
"description": "" "description": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
} }
}, },
"errors": { "errors": {
"noColumns": "Spalten in Elementen auswählen", "noColumns": "Spalten in Elementen auswählen",
"noCommunications": "Kann keine Daten von der Integration laden", "noCommunications": "Kann keine Daten von der Integration laden"
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3115,8 +2871,7 @@
"detailsTitle": "Aktueller Index im Client" "detailsTitle": "Aktueller Index im Client"
}, },
"id": { "id": {
"columnTitle": "ID", "columnTitle": "ID"
"detailsTitle": ""
}, },
"integration": { "integration": {
"columnTitle": "" "columnTitle": ""
@@ -3150,8 +2905,7 @@
}, },
"time": { "time": {
"columnTitle": "Endzeit", "columnTitle": "Endzeit",
"detailsTitle": "Zeit danach/zur Fertigstellung", "detailsTitle": "Zeit danach/zur Fertigstellung"
"eta": ""
}, },
"type": { "type": {
"columnTitle": "Typ", "columnTitle": "Typ",
@@ -3195,23 +2949,7 @@
} }
} }
}, },
"globalRatio": "Globales Verhältnis", "globalRatio": "Globales Verhältnis"
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
}, },
"mediaReleases": { "mediaReleases": {
"name": "", "name": "",
@@ -3248,12 +2986,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Links in neuem Tab öffnen" "label": "Links in neuem Tab öffnen"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
} }
}, },
"pending": { "pending": {
@@ -3361,35 +3093,6 @@
} }
} }
}, },
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": { "rssFeed": {
"name": "RSS Feeds", "name": "RSS Feeds",
"description": "Einen oder mehrere generische RSS, ATOM oder JSON-Feeds überwachen und anzeigen", "description": "Einen oder mehrere generische RSS, ATOM oder JSON-Feeds überwachen und anzeigen",
@@ -3633,11 +3336,7 @@
"name": "", "name": "",
"description": "", "description": "",
"noItems": "", "noItems": "",
"option": { "option": {}
"hideLogos": {
"label": ""
}
}
}, },
"speedtestTracker": { "speedtestTracker": {
"name": "", "name": "",
@@ -3718,40 +3417,6 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": { "audioStats": {
"name": "", "name": "",
"description": "", "description": "",
@@ -4127,11 +3792,6 @@
"read": "", "read": "",
"write": "" "write": ""
}, },
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": { "network": {
"title": "", "title": "",
"subtitle": "", "subtitle": "",
@@ -5264,9 +4924,6 @@
"title": "Container", "title": "Container",
"table": { "table": {
"updated": "Aktualisiert {when}", "updated": "Aktualisiert {when}",
"refresh": {
"lastUpdated": ""
},
"search": "{count} Container durchsuchen", "search": "{count} Container durchsuchen",
"selected": "{selectCount} von {totalCount} ausgewählten Containern", "selected": "{selectCount} von {totalCount} ausgewählten Containern",
"footer": "", "footer": "",
@@ -5995,14 +5652,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "" "label": ""
} }
+20 -371
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Symbole für Pings verwenden" "label": "Symbole für Pings verwenden"
}, },
"enableRightClickOnWidgets": {
"label": "Aktiviere Rechtsklick auf Widgets",
"description": "Zeigt Homarrs Kontextmenü, wenn mit der rechten Maustaste auf ein Widget geklickt wird. Deaktivieren, um das Standardmenü des Browsers zu verwenden (z.B. \"Link in neuem Tab öffnen\")."
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "Standard Suchmaschine" "label": "Standard Suchmaschine"
}, },
@@ -1025,7 +1021,7 @@
"mediaMonitoring": "Überwachung", "mediaMonitoring": "Überwachung",
"speedtest": "Geschwindigkeitstest", "speedtest": "Geschwindigkeitstest",
"analytics": "Nutzungsdaten", "analytics": "Nutzungsdaten",
"vpn": "VPN", "vpn": "",
"mediaLibrary": "Medienbibliothek", "mediaLibrary": "Medienbibliothek",
"uptimeMonitoring": "Überwachung der Betriebszeit" "uptimeMonitoring": "Überwachung der Betriebszeit"
}, },
@@ -1289,14 +1285,6 @@
"label": "API-Schlüssel (Secret)", "label": "API-Schlüssel (Secret)",
"newLabel": "Neuer API-Schlüssel (Secret)" "newLabel": "Neuer API-Schlüssel (Secret)"
}, },
"patchmonApiKey": {
"label": "API Key",
"newLabel": "Neuer API Key"
},
"patchmonApiSecret": {
"label": "API Secret",
"newLabel": "Neues API Secret"
},
"githubAppId": { "githubAppId": {
"label": "App ID", "label": "App ID",
"newLabel": "Neue App ID" "newLabel": "Neue App ID"
@@ -1340,21 +1328,9 @@
} }
} }
}, },
"traefik": {
"name": "Traefik",
"description": ""
},
"paperlessNgx": { "paperlessNgx": {
"name": "Paperless-ngx", "name": "Paperless-ngx",
"description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente" "description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente"
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "Bazarr",
"description": ""
} }
}, },
"media": { "media": {
@@ -1492,7 +1468,7 @@
"switchToDarkMode": "Zum dunklen Design wechseln", "switchToDarkMode": "Zum dunklen Design wechseln",
"switchToLightMode": "Zum hellen Design wechseln", "switchToLightMode": "Zum hellen Design wechseln",
"management": "Verwaltung", "management": "Verwaltung",
"docker": "Docker", "docker": "",
"preferences": "Ihre Einstellungen", "preferences": "Ihre Einstellungen",
"logout": "Abmelden", "logout": "Abmelden",
"login": "Anmelden", "login": "Anmelden",
@@ -1623,15 +1599,9 @@
"menu": { "menu": {
"label": { "label": {
"settings": "Einstellungen", "settings": "Einstellungen",
"refresh": "Aktualisieren", "refresh": "",
"options": "Optionen", "options": "",
"actions": "Aktionen" "actions": ""
},
"status": {
"success": "Ok",
"error": "Fehler",
"loading": "Wird geladen",
"idle": "Leerlauf"
} }
}, },
"create": { "create": {
@@ -1905,16 +1875,6 @@
"customDateFormat": { "customDateFormat": {
"label": "Benutzerdefiniertes Datumsformat", "label": "Benutzerdefiniertes Datumsformat",
"description": "ISO 8601 Standard benutzen um das Datum zu formatieren (dies wird andere Optionen überschreiben)" "description": "ISO 8601 Standard benutzen um das Datum zu formatieren (dies wird andere Optionen überschreiben)"
},
"showWeather": {
"label": "Wetter anzeigen",
"description": "Zeigt ein kleines animiertes Wettersymbol und die Temperatur in der Ecke der Karte an"
},
"weatherLocation": {
"label": "Ort für Wetterbericht"
},
"isWeatherFormatFahrenheit": {
"label": "Fahrenheit verwenden"
} }
} }
}, },
@@ -1944,7 +1904,7 @@
"noStation": "Keine Station ausgewählt", "noStation": "Keine Station ausgewählt",
"option": { "option": {
"baseUrl": { "baseUrl": {
"label": "Search.ch URL" "label": ""
}, },
"station": { "station": {
"label": "Station" "label": "Station"
@@ -1976,8 +1936,6 @@
"clear": "Formatierung entfernen", "clear": "Formatierung entfernen",
"heading": "Überschrift {level}", "heading": "Überschrift {level}",
"align": "Text ausrichten: {position}", "align": "Text ausrichten: {position}",
"directionLtr": "Setze Textausrichtung von links nach rechts",
"directionRtl": "Setze Textausrichtung von rechts nach links",
"blockquote": "Blockzitat", "blockquote": "Blockzitat",
"horizontalLine": "Horizontale Linie", "horizontalLine": "Horizontale Linie",
"bulletList": "Aufzählung", "bulletList": "Aufzählung",
@@ -2267,8 +2225,7 @@
"sunrise": "Sonnenaufgang", "sunrise": "Sonnenaufgang",
"sunset": "Sonnenuntergang", "sunset": "Sonnenuntergang",
"maxWindSpeed": "Maximale Windgeschwindigkeit: {maxWindSpeed} {unit}", "maxWindSpeed": "Maximale Windgeschwindigkeit: {maxWindSpeed} {unit}",
"maxWindGusts": "Maximale Windböen: {maxWindGusts} {unit}", "maxWindGusts": "Maximale Windböen: {maxWindGusts} {unit}"
"humidity": "Luftfeuchtigkeit: {humidity}%"
}, },
"kind": { "kind": {
"clear": "Klar", "clear": "Klar",
@@ -2330,7 +2287,7 @@
"visibleStorageVolumes": { "visibleStorageVolumes": {
"label": "", "label": "",
"description": "", "description": "",
"placeholder": "Speichervolumen auswählen" "placeholder": ""
}, },
"defaultTab": { "defaultTab": {
"label": "Standard Tab" "label": "Standard Tab"
@@ -2412,7 +2369,7 @@
"description": "Statistiken Ihrer Container (Dieses Widget kann nur mit Administratorrechten hinzugefügt werden)", "description": "Statistiken Ihrer Container (Dieses Widget kann nur mit Administratorrechten hinzugefügt werden)",
"option": { "option": {
"columns": { "columns": {
"label": "Anzuzeigende Spalten" "label": ""
}, },
"enableRowSorting": { "enableRowSorting": {
"label": "Sortierung von Elementen aktivieren" "label": "Sortierung von Elementen aktivieren"
@@ -2468,9 +2425,9 @@
} }
}, },
"vpn": { "vpn": {
"name": "VPN", "name": "",
"description": "Übersicht deiner VPN-Verbindungen", "description": "",
"serviceUnavailable": "VPN nicht verfügbar", "serviceUnavailable": "",
"option": {} "option": {}
}, },
"ups": { "ups": {
@@ -2732,11 +2689,6 @@
"user": "Benutzer", "user": "Benutzer",
"name": "Name", "name": "Name",
"id": "Id", "id": "Id",
"status": "Status",
"directPlay": "Direkte Wiedergabe",
"transcoding": "Transkodierung",
"paused": "Pausiert",
"remaining": "{minutes} Min. verbleibend",
"metadata": { "metadata": {
"title": "Statistiken für Nerds", "title": "Statistiken für Nerds",
"video": { "video": {
@@ -2802,193 +2754,6 @@
"internalServerError": "Fehler beim Abrufen der Paperless-ngx-Daten" "internalServerError": "Fehler beim Abrufen der Paperless-ngx-Daten"
} }
}, },
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "Hosts gesamt",
"hostsNeedingUpdates": "Update erforderlich",
"securityUpdates": "Sicherheits-Updates",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "Hosts mit Sicherheits-Updates",
"recentUpdates24h": "Updates (24h)",
"totalOutdatedPackages": "Veraltete Pakete",
"totalRepos": "Repositories",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "Alle Hosts anzeigen",
"description": "Gesamtanzahl der überwachten Hosts anzeigen"
},
"showHostsNeedingUpdates": {
"label": "Hosts die Updates benötigen anzeigen",
"description": "Hosts mit mindestens einem veralteten Paket anzeigen"
},
"showSecurityUpdates": {
"label": "Sicherheits-Updates anzeigen",
"description": "Gesamtzahl der verfügbaren Sicherheits-Updates anzeigen"
},
"showUpToDateHosts": {
"label": "Zeige aktuelle Hosts",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": { "immich-serverStats": {
"name": "Immich Serverstatistik einblenden", "name": "Immich Serverstatistik einblenden",
"description": "Informationen über Ihre Immich Instanz", "description": "Informationen über Ihre Immich Instanz",
@@ -3017,7 +2782,6 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "Immich Album", "name": "Immich Album",
"allPhotos": "",
"option": { "option": {
"albumId": { "albumId": {
"label": "Album ID", "label": "Album ID",
@@ -3037,6 +2801,7 @@
} }
}, },
"description": "Diashow der Bilder in Ihrem Immich Album", "description": "Diashow der Bilder in Ihrem Immich Album",
"noAlbumSelected": "Kein Album ausgewählt, Widget bearbeiten und Album auswählen",
"noPhotos": "Keine Fotos in diesem Album" "noPhotos": "Keine Fotos in diesem Album"
}, },
"downloads": { "downloads": {
@@ -3079,20 +2844,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "Elemente pro Integration begrenzen", "label": "Elemente pro Integration begrenzen",
"description": "Dies begrenzt die Anzahl der Elemente pro Integration, jedoch nicht global" "description": "Dies begrenzt die Anzahl der Elemente pro Integration, jedoch nicht global"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
} }
}, },
"errors": { "errors": {
"noColumns": "Spalten in Elementen auswählen", "noColumns": "Spalten in Elementen auswählen",
"noCommunications": "Kann keine Daten von der Integration laden", "noCommunications": "Kann keine Daten von der Integration laden"
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3115,8 +2871,7 @@
"detailsTitle": "Aktueller Index im Client" "detailsTitle": "Aktueller Index im Client"
}, },
"id": { "id": {
"columnTitle": "ID", "columnTitle": "ID"
"detailsTitle": ""
}, },
"integration": { "integration": {
"columnTitle": "Integration" "columnTitle": "Integration"
@@ -3150,8 +2905,7 @@
}, },
"time": { "time": {
"columnTitle": "Endzeit", "columnTitle": "Endzeit",
"detailsTitle": "Zeit danach/zur Fertigstellung", "detailsTitle": "Zeit danach/zur Fertigstellung"
"eta": ""
}, },
"type": { "type": {
"columnTitle": "Typ", "columnTitle": "Typ",
@@ -3195,23 +2949,7 @@
} }
} }
}, },
"globalRatio": "Globales Verhältnis", "globalRatio": "Globales Verhältnis"
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
}, },
"mediaReleases": { "mediaReleases": {
"name": "Medien Veröffentlichungen", "name": "Medien Veröffentlichungen",
@@ -3248,12 +2986,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Links in neuem Tab öffnen" "label": "Links in neuem Tab öffnen"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
} }
}, },
"pending": { "pending": {
@@ -3361,35 +3093,6 @@
} }
} }
}, },
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": { "rssFeed": {
"name": "RSS Feeds", "name": "RSS Feeds",
"description": "Einen oder mehrere generische RSS, ATOM oder JSON-Feeds überwachen und anzeigen", "description": "Einen oder mehrere generische RSS, ATOM oder JSON-Feeds überwachen und anzeigen",
@@ -3633,11 +3336,7 @@
"name": "Benachrichtigungen", "name": "Benachrichtigungen",
"description": "Benachrichtigungshistorie von einer Integration anzeigen", "description": "Benachrichtigungshistorie von einer Integration anzeigen",
"noItems": "Keine Benachrichtigungen zum Anzeigen.", "noItems": "Keine Benachrichtigungen zum Anzeigen.",
"option": { "option": {}
"hideLogos": {
"label": ""
}
}
}, },
"speedtestTracker": { "speedtestTracker": {
"name": "Speedtest Tracker", "name": "Speedtest Tracker",
@@ -3718,40 +3417,6 @@
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten" "internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
} }
}, },
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": { "audioStats": {
"name": "Audiostatistiken", "name": "Audiostatistiken",
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf", "description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
@@ -4127,11 +3792,6 @@
"read": "", "read": "",
"write": "" "write": ""
}, },
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": { "network": {
"title": "", "title": "",
"subtitle": "", "subtitle": "",
@@ -5234,8 +4894,8 @@
"subtitle": "{count} tragen Übersetzungen in viele Sprachen bei" "subtitle": "{count} tragen Übersetzungen in viele Sprachen bei"
}, },
"sponsors": { "sponsors": {
"title": "Sponsoren", "title": "",
"subtitle": "{count} unterstützen das Projekt finanziell" "subtitle": ""
}, },
"libraries": { "libraries": {
"title": "Bibliotheken", "title": "Bibliotheken",
@@ -5264,9 +4924,6 @@
"title": "Container", "title": "Container",
"table": { "table": {
"updated": "Aktualisiert {when}", "updated": "Aktualisiert {when}",
"refresh": {
"lastUpdated": ""
},
"search": "{count} Container durchsuchen", "search": "{count} Container durchsuchen",
"selected": "{selectCount} von {totalCount} ausgewählten Containern", "selected": "{selectCount} von {totalCount} ausgewählten Containern",
"footer": "Insgesamt {count} Container", "footer": "Insgesamt {count} Container",
@@ -5995,14 +5652,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "Alle Einstellungen öffnen" "label": "Alle Einstellungen öffnen"
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+20 -78
View File
@@ -1905,16 +1905,6 @@
"customDateFormat": { "customDateFormat": {
"label": "Custom date format", "label": "Custom date format",
"description": "Use ISO 8601 to format date (this will override other options)" "description": "Use ISO 8601 to format date (this will override other options)"
},
"showWeather": {
"label": "Show weather",
"description": "Display a small animated weather icon and temperature in the corner of the card"
},
"weatherLocation": {
"label": "Weather location"
},
"isWeatherFormatFahrenheit": {
"label": "Use Fahrenheit"
} }
} }
}, },
@@ -2428,21 +2418,10 @@
}, },
"descendingDefaultSort": { "descendingDefaultSort": {
"label": "Invert sorting" "label": "Invert sorting"
},
"columnOrder": {
"label": "Column order"
},
"columnWidths": {
"label": "Column widths"
} }
}, },
"error": { "error": {
"internalServerError": "Failed to fetch containers stats", "internalServerError": "Failed to fetch containers stats"
"layoutSaveTitle": "Table layout not saved",
"layoutSaveMessage": "The Docker table layout could not be saved. Try moving or resizing the column again."
},
"empty": {
"noContainers": "No containers found"
} }
}, },
"coolify": { "coolify": {
@@ -2736,20 +2715,8 @@
"showOnlyPlaying": { "showOnlyPlaying": {
"label": "Show only currently playing", "label": "Show only currently playing",
"description": "Disabling this will not work for plex" "description": "Disabling this will not work for plex"
},
"showBitrate": {
"label": "Show bitrate",
"description": "Show the current playback bitrate next to the location"
},
"showLocation": {
"label": "Show location",
"description": "Show whether the stream is on your LAN or WAN"
} }
}, },
"footer": {
"streams": "{count, plural, one {# active stream} other {# active streams}}",
"totalBitrate": "Total: {bitrate}"
},
"items": { "items": {
"currentlyPlaying": "Currently playing", "currentlyPlaying": "Currently playing",
"user": "User", "user": "User",
@@ -2757,9 +2724,6 @@
"id": "Id", "id": "Id",
"status": "Status", "status": "Status",
"directPlay": "Direct play", "directPlay": "Direct play",
"directStream": "Direct stream",
"transcodeVideo": "Transcoding video",
"transcodeAudio": "Transcoding audio",
"transcoding": "Transcoding", "transcoding": "Transcoding",
"paused": "Paused", "paused": "Paused",
"remaining": "{minutes} min left", "remaining": "{minutes} min left",
@@ -3105,20 +3069,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "Limit items per integration", "label": "Limit items per integration",
"description": "This will limit the number of items shown per integration, not globally" "description": "This will limit the number of items shown per integration, not globally"
},
"columnOrder": {
"label": "Column order"
},
"columnWidths": {
"label": "Column widths"
} }
}, },
"errors": { "errors": {
"noColumns": "Select Columns in Items", "noColumns": "Select Columns in Items",
"noCommunications": "Can't load data from integration", "noCommunications": "Can't load data from integration"
"noItems": "No downloads found",
"actionFailed": "Action failed",
"actionFailedMessage": "The action could not be completed. Please try again."
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3141,8 +3096,7 @@
"detailsTitle": "Current index within client" "detailsTitle": "Current index within client"
}, },
"id": { "id": {
"columnTitle": "Id", "columnTitle": "Id"
"detailsTitle": "Item ID"
}, },
"integration": { "integration": {
"columnTitle": "Integration" "columnTitle": "Integration"
@@ -3176,8 +3130,7 @@
}, },
"time": { "time": {
"columnTitle": "Finish time", "columnTitle": "Finish time",
"detailsTitle": "Time since/to completion", "detailsTitle": "Time since/to completion"
"eta": "ETA"
}, },
"type": { "type": {
"columnTitle": "Type", "columnTitle": "Type",
@@ -3221,23 +3174,7 @@
} }
} }
}, },
"globalRatio": "Global Ratio", "globalRatio": "Global Ratio"
"contextMenu": {
"copyName": "Copy name",
"copyId": "Copy ID",
"clearFilters": "Clear filters"
},
"footer": {
"filter": "Filter downloads"
},
"stats": {
"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)"
}, },
"mediaReleases": { "mediaReleases": {
"name": "Media releases", "name": "Media releases",
@@ -3794,6 +3731,18 @@
"label": "Show songs", "label": "Show songs",
"description": "Show the total number of songs in your music library (Navidrome)" "description": "Show the total number of songs in your music library (Navidrome)"
}, },
"showNowPlaying": {
"label": "Show now playing",
"description": "Show tracks currently being played (Navidrome)"
},
"showNowPlayingList": {
"label": "Show now playing list",
"description": "Show individual tracks instead of only the active stream count (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Max now playing items",
"description": "Maximum number of tracks to show in the now playing list (Navidrome)"
},
"showLibraryCount": { "showLibraryCount": {
"label": "Show library count", "label": "Show library count",
"description": "Show the number of libraries on your instance (Audiobookshelf)" "description": "Show the number of libraries on your instance (Audiobookshelf)"
@@ -3827,6 +3776,8 @@
"podcasts": "Podcasts", "podcasts": "Podcasts",
"listeningTime": "Listening Time", "listeningTime": "Listening Time",
"activeSessions": "Active Sessions", "activeSessions": "Active Sessions",
"nowPlaying": "Now Playing",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"error": { "error": {
"internalServerError": "Failed to fetch audio stats data" "internalServerError": "Failed to fetch audio stats data"
} }
@@ -3966,19 +3917,11 @@
}, },
"showAgent": { "showAgent": {
"label": "Show agent version" "label": "Show agent version"
},
"columnOrder": {
"label": "Column order"
},
"columnWidths": {
"label": "Column widths"
} }
}, },
"noRecords": "No systems found", "noRecords": "No systems found",
"error": { "error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings.", "internalServerError": "Unable to connect to Beszel. Check your integration settings."
"layoutSaveTitle": "Unable to save table layout",
"layoutSaveMessage": "Your column order or widths could not be saved. Please try again."
}, },
"column": { "column": {
"system": "System", "system": "System",
@@ -5376,7 +5319,6 @@
}, },
"remove": { "remove": {
"label": "Remove", "label": "Remove",
"confirm": "Click again to remove permanently",
"notification": { "notification": {
"success": { "success": {
"title": "Containers removed", "title": "Containers removed",
+28 -379
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Usar iconos para pings" "label": "Usar iconos para pings"
}, },
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "Motor de búsqueda predeterminado" "label": "Motor de búsqueda predeterminado"
}, },
@@ -1289,14 +1285,6 @@
"label": "Clave API (Credencial)", "label": "Clave API (Credencial)",
"newLabel": "Nueva clave API (Credencial)" "newLabel": "Nueva clave API (Credencial)"
}, },
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": { "githubAppId": {
"label": "ID de aplicación", "label": "ID de aplicación",
"newLabel": "Nuevo ID de aplicación" "newLabel": "Nuevo ID de aplicación"
@@ -1340,21 +1328,9 @@
} }
} }
}, },
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": { "paperlessNgx": {
"name": "", "name": "",
"description": "" "description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
} }
}, },
"media": { "media": {
@@ -1626,12 +1602,6 @@
"refresh": "", "refresh": "",
"options": "", "options": "",
"actions": "" "actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
} }
}, },
"create": { "create": {
@@ -1905,16 +1875,6 @@
"customDateFormat": { "customDateFormat": {
"label": "Formato de fecha personalizado", "label": "Formato de fecha personalizado",
"description": "Usar ISO 8601 para dar formato a la hora (esto anulará otras opciones)" "description": "Usar ISO 8601 para dar formato a la hora (esto anulará otras opciones)"
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
} }
} }
}, },
@@ -1976,8 +1936,6 @@
"clear": "Borrar formato", "clear": "Borrar formato",
"heading": "Encabezado {level}", "heading": "Encabezado {level}",
"align": "Alinear texto: {position}", "align": "Alinear texto: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "Cita", "blockquote": "Cita",
"horizontalLine": "Línea horizontal", "horizontalLine": "Línea horizontal",
"bulletList": "Lista sin ordenar", "bulletList": "Lista sin ordenar",
@@ -2267,8 +2225,7 @@
"sunrise": "Amanecer", "sunrise": "Amanecer",
"sunset": "Atardecer", "sunset": "Atardecer",
"maxWindSpeed": "Velocidad máxima del viento: {maxWindSpeed} {unit}", "maxWindSpeed": "Velocidad máxima del viento: {maxWindSpeed} {unit}",
"maxWindGusts": "Máximas ráfagas de viento: {maxWindGusts} {unit}", "maxWindGusts": "Máximas ráfagas de viento: {maxWindGusts} {unit}"
"humidity": ""
}, },
"kind": { "kind": {
"clear": "Despejado", "clear": "Despejado",
@@ -2560,30 +2517,30 @@
} }
}, },
"umami": { "umami": {
"name": "Estadísticas Umami", "name": "",
"description": "Mostrar estadísticas de visitantes de su instancia de Umami Analytics", "description": "",
"visitors": "visitantes", "visitors": "",
"active": "activo", "active": "",
"pageviews": "vistas de página", "pageviews": "",
"visits": "visitas", "visits": "",
"bounceRate": "rebote", "bounceRate": "",
"avgDuration": "promedio", "avgDuration": "",
"option": { "option": {
"websiteId": { "websiteId": {
"label": "Sitio web", "label": "",
"description": "Seleccione un sitio web rastreado por su instancia de Umami", "description": "",
"placeholder": "Seleccione un sitio web", "placeholder": "",
"loading": "Cargando sitios web...", "loading": "",
"noWebsites": "No se encontraron sitios web", "noWebsites": "",
"configureIntegrationFirst": "Conecte primero una integración de Umami" "configureIntegrationFirst": ""
}, },
"timeFrame": { "timeFrame": {
"label": "Período de tiempo", "label": "",
"description": "El período de tiempo para mostrar los datos de los visitantes", "description": "",
"option": { "option": {
"today": "Hoy", "today": "",
"24h": "Últimas 24 horas", "24h": "",
"7d": "Últimos 7 días", "7d": "",
"30d": "", "30d": "",
"month": "", "month": "",
"lastMonth": "" "lastMonth": ""
@@ -2732,11 +2689,6 @@
"user": "Usuario", "user": "Usuario",
"name": "Nombre", "name": "Nombre",
"id": "ID", "id": "ID",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": { "metadata": {
"title": "Estadísticas", "title": "Estadísticas",
"video": { "video": {
@@ -2802,193 +2754,6 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": { "immich-serverStats": {
"name": "Estadísticas del servidor Immich", "name": "Estadísticas del servidor Immich",
"description": "Información del servidor Immich", "description": "Información del servidor Immich",
@@ -3017,7 +2782,6 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "Álbum de Immich", "name": "Álbum de Immich",
"allPhotos": "",
"option": { "option": {
"albumId": { "albumId": {
"label": "ID de álbum", "label": "ID de álbum",
@@ -3037,6 +2801,7 @@
} }
}, },
"description": "Presentación de fotos de un álbum de Immich", "description": "Presentación de fotos de un álbum de Immich",
"noAlbumSelected": "No se ha seleccionado ningún álbum. Edita el widget y selecciona uno.",
"noPhotos": "No hay fotos en este álbum" "noPhotos": "No hay fotos en este álbum"
}, },
"downloads": { "downloads": {
@@ -3079,20 +2844,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "Limitar elementos por integración", "label": "Limitar elementos por integración",
"description": "Esto limitará el número de elementos mostrados por integración, no globalmente" "description": "Esto limitará el número de elementos mostrados por integración, no globalmente"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
} }
}, },
"errors": { "errors": {
"noColumns": "Seleccionar Columnas en Artículos", "noColumns": "Seleccionar Columnas en Artículos",
"noCommunications": "No se pueden cargar los datos desde la integración", "noCommunications": "No se pueden cargar los datos desde la integración"
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3115,8 +2871,7 @@
"detailsTitle": "Índice actual dentro del cliente" "detailsTitle": "Índice actual dentro del cliente"
}, },
"id": { "id": {
"columnTitle": "ID", "columnTitle": "ID"
"detailsTitle": ""
}, },
"integration": { "integration": {
"columnTitle": "Integración" "columnTitle": "Integración"
@@ -3150,8 +2905,7 @@
}, },
"time": { "time": {
"columnTitle": "Hora de finalización", "columnTitle": "Hora de finalización",
"detailsTitle": "Tiempo desde/hasta la finalización", "detailsTitle": "Tiempo desde/hasta la finalización"
"eta": ""
}, },
"type": { "type": {
"columnTitle": "Tipo", "columnTitle": "Tipo",
@@ -3195,23 +2949,7 @@
} }
} }
}, },
"globalRatio": "Relación global", "globalRatio": "Relación global"
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
}, },
"mediaReleases": { "mediaReleases": {
"name": "Lanzamientos de medios", "name": "Lanzamientos de medios",
@@ -3248,12 +2986,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Abrir enlaces en una pestaña nueva" "label": "Abrir enlaces en una pestaña nueva"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
} }
}, },
"pending": { "pending": {
@@ -3361,35 +3093,6 @@
} }
} }
}, },
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": { "rssFeed": {
"name": "Fuentes RSS", "name": "Fuentes RSS",
"description": "Monitorear y mostrar una o más RSS genéricas, ATOM o JSON", "description": "Monitorear y mostrar una o más RSS genéricas, ATOM o JSON",
@@ -3633,11 +3336,7 @@
"name": "Notificaciones", "name": "Notificaciones",
"description": "Mostrar el historial de notificaciones desde una integración", "description": "Mostrar el historial de notificaciones desde una integración",
"noItems": "Ninguna notificación para mostrar.", "noItems": "Ninguna notificación para mostrar.",
"option": { "option": {}
"hideLogos": {
"label": ""
}
}
}, },
"speedtestTracker": { "speedtestTracker": {
"name": "Speedtest Tracker", "name": "Speedtest Tracker",
@@ -3718,40 +3417,6 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": { "audioStats": {
"name": "", "name": "",
"description": "", "description": "",
@@ -4127,11 +3792,6 @@
"read": "", "read": "",
"write": "" "write": ""
}, },
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": { "network": {
"title": "", "title": "",
"subtitle": "", "subtitle": "",
@@ -5234,8 +4894,8 @@
"subtitle": "{count} contribuyendo con las traducciones en muchos idiomas" "subtitle": "{count} contribuyendo con las traducciones en muchos idiomas"
}, },
"sponsors": { "sponsors": {
"title": "Patrocinadores", "title": "",
"subtitle": "{count} apoyando económicamente el proyecto" "subtitle": ""
}, },
"libraries": { "libraries": {
"title": "Librerías", "title": "Librerías",
@@ -5264,9 +4924,6 @@
"title": "Contenedores", "title": "Contenedores",
"table": { "table": {
"updated": "Actualizado {when}", "updated": "Actualizado {when}",
"refresh": {
"lastUpdated": ""
},
"search": "Buscar {count} contenedores", "search": "Buscar {count} contenedores",
"selected": "{selectCount} de {totalCount} contenedores seleccionados", "selected": "{selectCount} de {totalCount} contenedores seleccionados",
"footer": "{count} contenedores en total", "footer": "{count} contenedores en total",
@@ -5995,14 +5652,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "" "label": ""
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+157 -508
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "השתמש בסמלים עבור פינגים" "label": "השתמש בסמלים עבור פינגים"
}, },
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "מנוע חיפוש ברירת מחדל" "label": "מנוע חיפוש ברירת מחדל"
}, },
@@ -1289,14 +1285,6 @@
"label": "מפתח API (סודי)", "label": "מפתח API (סודי)",
"newLabel": "מפתח API חדש (סודי)" "newLabel": "מפתח API חדש (סודי)"
}, },
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": { "githubAppId": {
"label": "מזהה אפליקציה", "label": "מזהה אפליקציה",
"newLabel": "מזהה אפליקציה חדש" "newLabel": "מזהה אפליקציה חדש"
@@ -1340,21 +1328,9 @@
} }
} }
}, },
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": { "paperlessNgx": {
"name": "", "name": "",
"description": "" "description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
} }
}, },
"media": { "media": {
@@ -1505,8 +1481,8 @@
"noResults": "לא נמצאו תוצאות", "noResults": "לא נמצאו תוצאות",
"unsavedChanges": "ישנם שינויים שלא נשמרו!", "unsavedChanges": "ישנם שינויים שלא נשמרו!",
"servers": "שרתים", "servers": "שרתים",
"applications": "אפליקציות", "applications": "",
"services": "שירותים", "services": "",
"preview": { "preview": {
"show": "הצג תצוגה מקדימה", "show": "הצג תצוגה מקדימה",
"hide": "הסתר תצוגה המקדימה" "hide": "הסתר תצוגה המקדימה"
@@ -1626,12 +1602,6 @@
"refresh": "", "refresh": "",
"options": "", "options": "",
"actions": "" "actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
} }
}, },
"create": { "create": {
@@ -1905,16 +1875,6 @@
"customDateFormat": { "customDateFormat": {
"label": "פורמט זמן מותאם אישית", "label": "פורמט זמן מותאם אישית",
"description": "השתמש ב-ISO 8601 כדי לעצב תצוגת זמן (זה ידרוס אפשרויות אחרות)" "description": "השתמש ב-ISO 8601 כדי לעצב תצוגת זמן (זה ידרוס אפשרויות אחרות)"
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
} }
} }
}, },
@@ -1938,16 +1898,16 @@
} }
}, },
"timetable": { "timetable": {
"name": "לוח זמנים", "name": "",
"description": "מציג זמני יציאה לתחנה", "description": "",
"title": "יציאות {station}", "title": "",
"noStation": "לא נבחרה תחנה", "noStation": "",
"option": { "option": {
"baseUrl": { "baseUrl": {
"label": "" "label": ""
}, },
"station": { "station": {
"label": "תחנה" "label": ""
} }
} }
}, },
@@ -1976,8 +1936,6 @@
"clear": "נקה עיצוב", "clear": "נקה עיצוב",
"heading": "כותרת {level}", "heading": "כותרת {level}",
"align": "יישור טקסט: {position}", "align": "יישור טקסט: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "ציטוט", "blockquote": "ציטוט",
"horizontalLine": "קו אופקי", "horizontalLine": "קו אופקי",
"bulletList": "רשימת תבליט", "bulletList": "רשימת תבליט",
@@ -2024,32 +1982,32 @@
} }
}, },
"anchorNote": { "anchorNote": {
"name": "פתק Anchor", "name": "",
"description": "מציג פתק נבחר מ-Anchor", "description": "",
"option": { "option": {
"noteId": { "noteId": {
"label": "פתק" "label": ""
}, },
"showTitle": { "showTitle": {
"label": "הצג כותרת" "label": ""
}, },
"showUpdatedAt": { "showUpdatedAt": {
"label": "הצג עדכון אחרון" "label": ""
} }
}, },
"empty": "בחר פתק להצגה", "empty": "",
"emptyContent": "לפתק זה אין תוכן", "emptyContent": "",
"notFound": "הפתק לא נמצא", "notFound": "",
"noNotes": "לא נמצאו פתקים", "noNotes": "",
"integrationRequired": "בחר תחילה אינטגרציית Anchor", "integrationRequired": "",
"updatedAt": "עודכן ב- {date}", "updatedAt": "",
"untitled": "ללא כותרת", "untitled": "",
"edit": "עריכה", "edit": "",
"save": "שמור", "save": "",
"cancel": "בטל", "cancel": "",
"readOnlyViewer": "יש לך גישת צפייה לפתק זה. עריכה מושבתת.", "readOnlyViewer": "",
"saveForbidden": "אין לך הרשאה לערוך פתק זה.", "saveForbidden": "",
"saveFailed": "שמירת הפתק נכשלה. נסה שוב." "saveFailed": ""
}, },
"iframe": { "iframe": {
"name": "iFrame", "name": "iFrame",
@@ -2083,7 +2041,7 @@
"label": "אפשר מיקום גיאוגרפי" "label": "אפשר מיקום גיאוגרפי"
}, },
"allowModals": { "allowModals": {
"label": "אפשר חלונות קופצים" "label": ""
} }
}, },
"error": { "error": {
@@ -2267,8 +2225,7 @@
"sunrise": "זריחה", "sunrise": "זריחה",
"sunset": "שקיעה", "sunset": "שקיעה",
"maxWindSpeed": "מהירות רוח מקסימלית: {maxWindSpeed} קמ״ש", "maxWindSpeed": "מהירות רוח מקסימלית: {maxWindSpeed} קמ״ש",
"maxWindGusts": "משבי רוח מקסימלים: {maxWindGusts} קמ״ש", "maxWindGusts": "משבי רוח מקסימלים: {maxWindGusts} קמ״ש"
"humidity": ""
}, },
"kind": { "kind": {
"clear": "בהיר", "clear": "בהיר",
@@ -2319,7 +2276,7 @@
"label": "הצג מידע זיכרון" "label": "הצג מידע זיכרון"
}, },
"gpu": { "gpu": {
"label": "הצג מידע GPU" "label": ""
}, },
"showUptime": { "showUptime": {
"label": "הצג זמן פעולה" "label": "הצג זמן פעולה"
@@ -2435,36 +2392,36 @@
} }
}, },
"coolify": { "coolify": {
"name": "קוליפיי", "name": "",
"description": "סקירה של מופע Coolify שלך עם שרתים ואפליקציות", "description": "",
"option": { "option": {
"showServers": { "showServers": {
"label": "הצג שרתים" "label": ""
}, },
"showApplications": { "showApplications": {
"label": "הצג אפליקציות" "label": ""
}, },
"showServices": { "showServices": {
"label": "הצג שירותים" "label": ""
} }
}, },
"empty": { "empty": {
"servers": "לא נמצאו שרתים", "servers": "",
"applications": "לא נמצאו אפליקציות", "applications": "",
"services": "לא נמצאו שירותים" "services": ""
}, },
"footer": { "footer": {
"updated": "עודכן ב- {when}" "updated": ""
}, },
"error": { "error": {
"noIntegration": "לא הוגדרה אינטגרציית Coolify", "noIntegration": "",
"internalServerError": "אחזור נתוני Coolify נכשל" "internalServerError": ""
}, },
"server": { "server": {
"buildServer": "בנייה" "buildServer": ""
}, },
"instances": { "instances": {
"title": "מופעים" "title": ""
} }
}, },
"vpn": { "vpn": {
@@ -2505,85 +2462,85 @@
} }
}, },
"tracearr": { "tracearr": {
"name": "טרייסאר", "name": "",
"description": "ניטור שרת מדיה משופר עם נתוני זרם וקידוד מפורטים", "description": "",
"option": { "option": {
"showStreams": { "showStreams": {
"label": "הצג זרמים פעילים" "label": ""
}, },
"showStats": { "showStats": {
"label": "הצג סיכום סטטיסטיקות" "label": ""
}, },
"showRecentActivity": { "showRecentActivity": {
"label": "הצג פעילות אחרונה" "label": ""
}, },
"showViolations": { "showViolations": {
"label": "הצג הפרות" "label": ""
} }
}, },
"stats": { "stats": {
"activeStreams": "זרמים פעילים", "activeStreams": "",
"totalUsers": "סך המשתמשים", "totalUsers": "",
"transcodes": "קידודים", "transcodes": "",
"bandwidth": "רוחב פס" "bandwidth": ""
}, },
"streams": { "streams": {
"empty": "אין זרמים פעילים", "empty": "",
"user": "משתמש", "user": "",
"media": "מדיה", "media": "",
"state": "מצב", "state": "",
"quality": "איכות", "quality": "",
"device": "מכשיר", "device": "",
"progress": "התקדמות", "progress": "",
"videoDecision": { "videoDecision": {
"directPlay": "הפעלה ישירה", "directPlay": "",
"transcode": "קידוד", "transcode": "",
"directStream": "זרם ישיר" "directStream": ""
} }
}, },
"recentActivity": { "recentActivity": {
"title": "פעילות אחרונה", "title": "",
"empty": "אין פעילות אחרונה", "empty": "",
"watched": "נצפה", "watched": "",
"partial": "חלקי" "partial": ""
}, },
"violations": { "violations": {
"title": "הפרות", "title": "",
"empty": "אין הפרות אחרונות", "empty": "",
"rule": "כלל", "rule": "",
"user": "משתמש" "user": ""
}, },
"noSectionsEnabled": "לא הופעלו מקטעים", "noSectionsEnabled": "",
"error": { "error": {
"noIntegration": "לא הוגדרה אינטגרציית Tracearr", "noIntegration": "",
"internalServerError": "אחזור נתוני Tracearr נכשל" "internalServerError": ""
} }
}, },
"umami": { "umami": {
"name": "Umami ניתוחים", "name": "",
"description": "הצג נתוני מבקרים ממופע Umami Analytics שלך", "description": "",
"visitors": "מבקרים", "visitors": "",
"active": "פעילים", "active": "",
"pageviews": "צפיות דף", "pageviews": "",
"visits": "ביקורים", "visits": "",
"bounceRate": "נטישה", "bounceRate": "",
"avgDuration": "ממוצע", "avgDuration": "",
"option": { "option": {
"websiteId": { "websiteId": {
"label": "אתר", "label": "",
"description": "בחר אתר המנוטר על ידי מופע ה-Umami שלך", "description": "",
"placeholder": "בחר אתר", "placeholder": "",
"loading": "טוען אתרים...", "loading": "",
"noWebsites": "לא נמצאו אתרים", "noWebsites": "",
"configureIntegrationFirst": "חבר תחילה אינטגרציית Umami" "configureIntegrationFirst": ""
}, },
"timeFrame": { "timeFrame": {
"label": "מסגרת זמן", "label": "",
"description": "פרק הזמן להצגת נתוני מבקרים", "description": "",
"option": { "option": {
"today": "היום", "today": "",
"24h": "24 השעות האחרונות", "24h": "",
"7d": "7 הימים האחרונים", "7d": "",
"30d": "", "30d": "",
"month": "", "month": "",
"lastMonth": "" "lastMonth": ""
@@ -2732,11 +2689,6 @@
"user": "משתמש", "user": "משתמש",
"name": "שם", "name": "שם",
"id": "מזהה", "id": "מזהה",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": { "metadata": {
"title": "סטטיסטיקות לחנונים", "title": "סטטיסטיקות לחנונים",
"video": { "video": {
@@ -2802,234 +2754,46 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": { "immich-serverStats": {
"name": "סטטיסטיקות שרת Immich", "name": "",
"description": "מידע על מופע ה-Immich שלך", "description": "",
"option": { "option": {
"showUsers": { "showUsers": {
"label": "הצג משתמשים", "label": "",
"description": "הצג מספר משתמשים במופע שלך" "description": ""
}, },
"showPhotos": { "showPhotos": {
"label": "הצג תמונות", "label": "",
"description": "הצג מספר תמונות במופע שלך" "description": ""
}, },
"showVideos": { "showVideos": {
"label": "הצג סרטונים", "label": "",
"description": "הצג מספר סרטונים במופע שלך" "description": ""
}, },
"showStorage": { "showStorage": {
"label": "הצג אחסון", "label": "",
"description": "הצג את כמות האחסון הכוללת בשימוש" "description": ""
} }
}, },
"users": "משתמשים", "users": "",
"photos": "תמונות", "photos": "",
"videos": "סרטונים", "videos": "",
"storage": "אחסון בשימוש" "storage": ""
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "אלבום Immich", "name": "",
"allPhotos": "",
"option": { "option": {
"albumId": { "albumId": {
"label": "", "label": "",
"description": "" "description": ""
}, },
"rotationIntervalSeconds": { "rotationIntervalSeconds": {
"label": "מרווח בשניות", "label": "",
"description": "מחליף תמונות באלבום כל X שניות. חייב להיות לפחות 1. חייב להיות פחות מ-3600." "description": ""
}, },
"showPhotoInfo": { "showPhotoInfo": {
"label": "הצג מידע תמונה", "label": "",
"description": "הצג תאריך התמונה והאינדקס הנוכחי בתחתית המצגת" "description": ""
}, },
"randomizePhotos": { "randomizePhotos": {
"label": "", "label": "",
@@ -3037,6 +2801,7 @@
} }
}, },
"description": "", "description": "",
"noAlbumSelected": "",
"noPhotos": "" "noPhotos": ""
}, },
"downloads": { "downloads": {
@@ -3079,20 +2844,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "הגבלת פריטים לכל אינטגרציה", "label": "הגבלת פריטים לכל אינטגרציה",
"description": "פעולה זו תגביל את מספר הפריטים המוצגים בכל אינטגרציה, לא באופן גלובלי" "description": "פעולה זו תגביל את מספר הפריטים המוצגים בכל אינטגרציה, לא באופן גלובלי"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
} }
}, },
"errors": { "errors": {
"noColumns": "בחר עמודות בפריטים", "noColumns": "בחר עמודות בפריטים",
"noCommunications": "לא ניתן לטעון נתונים מהאינטגרציה", "noCommunications": "לא ניתן לטעון נתונים מהאינטגרציה"
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3115,8 +2871,7 @@
"detailsTitle": "מדד נוכחי בתוך הלקוח" "detailsTitle": "מדד נוכחי בתוך הלקוח"
}, },
"id": { "id": {
"columnTitle": "מזהה", "columnTitle": "מזהה"
"detailsTitle": ""
}, },
"integration": { "integration": {
"columnTitle": "אינטגרציה" "columnTitle": "אינטגרציה"
@@ -3150,8 +2905,7 @@
}, },
"time": { "time": {
"columnTitle": "זמן סיום", "columnTitle": "זמן סיום",
"detailsTitle": "זמן מאז/עד סיום", "detailsTitle": "זמן מאז/עד סיום"
"eta": ""
}, },
"type": { "type": {
"columnTitle": "סוּג", "columnTitle": "סוּג",
@@ -3195,23 +2949,7 @@
} }
} }
}, },
"globalRatio": "יחס גלובלי", "globalRatio": "יחס גלובלי"
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
}, },
"mediaReleases": { "mediaReleases": {
"name": "מדיה משוחררת", "name": "מדיה משוחררת",
@@ -3248,12 +2986,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "פתח לינק בלשונית חדשה" "label": "פתח לינק בלשונית חדשה"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
} }
}, },
"pending": { "pending": {
@@ -3361,35 +3093,6 @@
} }
} }
}, },
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": { "rssFeed": {
"name": "הזנות RSS", "name": "הזנות RSS",
"description": "מעקב והצג עדכוני RSS, ATOM או JSON גנרי אחד או יותר", "description": "מעקב והצג עדכוני RSS, ATOM או JSON גנרי אחד או יותר",
@@ -3410,7 +3113,7 @@
"label": "הסתר תיאור" "label": "הסתר תיאור"
}, },
"showPosterImage": { "showPosterImage": {
"label": "הצג תמונת פוסטר" "label": ""
} }
} }
}, },
@@ -3633,49 +3336,45 @@
"name": "התראות", "name": "התראות",
"description": "הצגת היסטוריית התראות מאינטגרציה", "description": "הצגת היסטוריית התראות מאינטגרציה",
"noItems": "אין התראות להצגה.", "noItems": "אין התראות להצגה.",
"option": { "option": {}
"hideLogos": {
"label": ""
}
}
}, },
"speedtestTracker": { "speedtestTracker": {
"name": "מעקב Speedtest", "name": "",
"description": "מציג תוצאות בדיקת מהירות ממופע מעקב Speedtest שלך.", "description": "",
"option": { "option": {
"showLatestResult": { "showLatestResult": {
"label": "הצג תוצאה אחרונה" "label": ""
}, },
"showStats": { "showStats": {
"label": "הצג ממוצעים" "label": ""
}, },
"showRecentResults": { "showRecentResults": {
"label": "הצג תוצאות אחרונות" "label": ""
}, },
"showPingGraph": { "showPingGraph": {
"label": "הצג גרף פינג" "label": ""
} }
}, },
"latestResult": "תוצאה אחרונה", "latestResult": "",
"averages": "ממוצעים", "averages": "",
"tests": "{count} בדיקות", "tests": "",
"recentResults": "תוצאות אחרונות", "recentResults": "",
"recentResultsSpeed": "מהירות", "recentResultsSpeed": "",
"recentResultsPing": "פינג", "recentResultsPing": "",
"download": "הורדה", "download": "",
"upload": "העלאה", "upload": "",
"ping": "פינג", "ping": "",
"status": "מצב", "status": "",
"avgDownload": "הורדה ממוצעת", "avgDownload": "",
"avgUpload": "העלאה ממוצעת", "avgUpload": "",
"avgPing": "פינג ממוצע", "avgPing": "",
"healthy": "בריא", "healthy": "",
"unhealthy": "לא בריא", "unhealthy": "",
"ok": "בסדר", "ok": "",
"fail": "כשל", "fail": "",
"noSectionsEnabled": "הפעל לפחות מקטע אחד באפשרויות הווידג'ט.", "noSectionsEnabled": "",
"error": { "error": {
"internalServerError": "אחזור נתוני speedtest נכשל" "internalServerError": ""
} }
}, },
"uptimeKuma": { "uptimeKuma": {
@@ -3718,40 +3417,6 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": { "audioStats": {
"name": "", "name": "",
"description": "", "description": "",
@@ -3832,7 +3497,7 @@
"option": { "option": {
"cpu": "מעבד", "cpu": "מעבד",
"memory": "זיכרון", "memory": "זיכרון",
"gpu": "מעבד גרפי", "gpu": "",
"network": "רשת" "network": "רשת"
} }
}, },
@@ -3849,35 +3514,35 @@
"card": { "card": {
"cpu": "מעבד", "cpu": "מעבד",
"memory": "זיכרון", "memory": "זיכרון",
"gpu": "מעבד גרפי", "gpu": "",
"network": "רשת", "network": "רשת",
"up": "העלאה", "up": "העלאה",
"down": "הורדה" "down": "הורדה"
} }
}, },
"systemDisks": { "systemDisks": {
"name": "דיסקי מערכת", "name": "",
"description": "שימוש בדיסקים במערכת", "description": "",
"option": { "option": {
"showTemperatureIfAvailable": { "showTemperatureIfAvailable": {
"label": "הצג טמפרטורה אם זמינה" "label": ""
}, },
"displayMode": { "displayMode": {
"label": "מצב תצוגה", "label": "",
"option": { "option": {
"percentage": { "percentage": {
"label": "אחוז (לדוגמה, 76%)" "label": ""
}, },
"absolute": { "absolute": {
"label": "ערכים מוחלטים (לדוגמה, 800GB / 1TB)" "label": ""
}, },
"free": { "free": {
"label": "אחוז שטח פנוי (לדוגמה, 24% פנוי)" "label": ""
} }
} }
}, },
"showBackgroundBar": { "showBackgroundBar": {
"label": "הצג סרגל שימוש ברקע" "label": ""
}, },
"visibleStorageVolumes": { "visibleStorageVolumes": {
"label": "", "label": "",
@@ -3886,7 +3551,7 @@
} }
}, },
"status": { "status": {
"unhealthy": "לא בריא" "unhealthy": ""
} }
}, },
"customApi": { "customApi": {
@@ -4127,11 +3792,6 @@
"read": "", "read": "",
"write": "" "write": ""
}, },
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": { "network": {
"title": "", "title": "",
"subtitle": "", "subtitle": "",
@@ -4855,8 +4515,8 @@
"user": { "user": {
"title": "משתמשים", "title": "משתמשים",
"enableGravatar": { "enableGravatar": {
"label": "הפעל Gravatar", "label": "",
"description": "מציג אווטר מ-Libravatar/Gravatar כברירת מחדל כשאין אווטר מותאם אישית וקיים דוא\"ל" "description": ""
} }
}, },
"search": { "search": {
@@ -5234,8 +4894,8 @@
"subtitle": "{count} תורמים בתרגום לשפות שונות" "subtitle": "{count} תורמים בתרגום לשפות שונות"
}, },
"sponsors": { "sponsors": {
"title": "ספונסרים", "title": "",
"subtitle": "{count} תומכים בפרויקט כלכלית" "subtitle": ""
}, },
"libraries": { "libraries": {
"title": "ספריות", "title": "ספריות",
@@ -5264,9 +4924,6 @@
"title": "מיכלים", "title": "מיכלים",
"table": { "table": {
"updated": "עודכן ב- {when}", "updated": "עודכן ב- {when}",
"refresh": {
"lastUpdated": ""
},
"search": "חפש {count} קונטיינרים", "search": "חפש {count} קונטיינרים",
"selected": "{selectCount} מ- {totalCount} קונטיינרים נבחרו", "selected": "{selectCount} מ- {totalCount} קונטיינרים נבחרו",
"footer": "סה״כ {count} קונטיינרים", "footer": "סה״כ {count} קונטיינרים",
@@ -5995,14 +5652,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "" "label": ""
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -358
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Usa le icone per i ping" "label": "Usa le icone per i ping"
}, },
"enableRightClickOnWidgets": {
"label": "Abilita clic destro sui widget",
"description": "Mostra il menu contestuale di Homarr quando si fa clic destro su un widget. Disabilita per usare il menu predefinito del browser (es. \"Apri collegamento in una nuova scheda\")."
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "Motore di ricerca predefinito" "label": "Motore di ricerca predefinito"
}, },
@@ -1289,14 +1285,6 @@
"label": "Chiave API (Secret)", "label": "Chiave API (Secret)",
"newLabel": "Nuova Chiave API (Secret)" "newLabel": "Nuova Chiave API (Secret)"
}, },
"patchmonApiKey": {
"label": "Chiave API",
"newLabel": "Nuova chiave API"
},
"patchmonApiSecret": {
"label": "Secret API",
"newLabel": "Nuova API Secret"
},
"githubAppId": { "githubAppId": {
"label": "Id App", "label": "Id App",
"newLabel": "Nuovo Id App" "newLabel": "Nuovo Id App"
@@ -1340,21 +1328,9 @@
} }
} }
}, },
"traefik": {
"name": "Traefik",
"description": "Cloud-native reverse proxy e bilanciatore del carico"
},
"paperlessNgx": { "paperlessNgx": {
"name": "Paperless-ngx", "name": "Paperless-ngx",
"description": "Sistema di gestione documentale per archiviare e organizzare documenti scansionati" "description": "Sistema di gestione documentale per archiviare e organizzare documenti scansionati"
},
"patchmon": {
"name": "PatchMon",
"description": "Gestione delle patch e monitoraggio per gli host Linux"
},
"bazarr": {
"name": "Bazarr",
"description": "Applicazione complementare a Sonarr e Radarr che gestisce e scarica i sottotitoli"
} }
}, },
"media": { "media": {
@@ -1626,12 +1602,6 @@
"refresh": "Aggiorna", "refresh": "Aggiorna",
"options": "Opzioni", "options": "Opzioni",
"actions": "Azioni" "actions": "Azioni"
},
"status": {
"success": "OK",
"error": "Errore",
"loading": "Caricamento",
"idle": "Inattivo"
} }
}, },
"create": { "create": {
@@ -1905,16 +1875,6 @@
"customDateFormat": { "customDateFormat": {
"label": "Formato data personalizzato", "label": "Formato data personalizzato",
"description": "Usa ISO 8601 per formattare la data (questo sovrascriverà altre opzioni)" "description": "Usa ISO 8601 per formattare la data (questo sovrascriverà altre opzioni)"
},
"showWeather": {
"label": "Mostra meteo",
"description": "Mostra una piccola icona animata del meteo e la temperatura nell'angolo della scheda"
},
"weatherLocation": {
"label": "Località meteo"
},
"isWeatherFormatFahrenheit": {
"label": "Usa Fahrenheit"
} }
} }
}, },
@@ -1976,8 +1936,6 @@
"clear": "Rimuovi formattazione", "clear": "Rimuovi formattazione",
"heading": "Intestazione {level}", "heading": "Intestazione {level}",
"align": "Allinea testo: {position}", "align": "Allinea testo: {position}",
"directionLtr": "Imposta la direzione del testo da sinistra a sinistra",
"directionRtl": "Imposta la direzione del testo da destra a destra",
"blockquote": "Citazione", "blockquote": "Citazione",
"horizontalLine": "Linea orizzontale", "horizontalLine": "Linea orizzontale",
"bulletList": "Elenco puntato", "bulletList": "Elenco puntato",
@@ -2267,8 +2225,7 @@
"sunrise": "Alba", "sunrise": "Alba",
"sunset": "Tramonto", "sunset": "Tramonto",
"maxWindSpeed": "Max velocità vento: {maxWindSpeed} km/h", "maxWindSpeed": "Max velocità vento: {maxWindSpeed} km/h",
"maxWindGusts": "Max raffiche di vento: {maxWindGusts} km/h", "maxWindGusts": "Max raffiche di vento: {maxWindGusts} km/h"
"humidity": "Umidità: {humidity}%"
}, },
"kind": { "kind": {
"clear": "Sereno", "clear": "Sereno",
@@ -2732,11 +2689,6 @@
"user": "Utente", "user": "Utente",
"name": "Nome", "name": "Nome",
"id": "Id", "id": "Id",
"status": "Stato",
"directPlay": "Riproduzione diretta",
"transcoding": "Transcodifica",
"paused": "In pausa",
"remaining": "{minutes} min rimanenti",
"metadata": { "metadata": {
"title": "Statistiche per nerd", "title": "Statistiche per nerd",
"video": { "video": {
@@ -2802,193 +2754,6 @@
"internalServerError": "Impossibile recuperare i dati di Paperless-ngx" "internalServerError": "Impossibile recuperare i dati di Paperless-ngx"
} }
}, },
"patchmon": {
"name": "PatchMon",
"description": "Panoramica dello stato delle patch host da PatchMon",
"title": "Statistiche PatchMon",
"totalHosts": "Host Totali",
"hostsNeedingUpdates": "Necessita di aggiornamenti",
"securityUpdates": "Aggiornamenti di sicurezza",
"upToDateHosts": "Host aggiornati",
"hostsWithSecurityUpdates": "Host con aggiornamenti di sicurezza",
"recentUpdates24h": "Aggiornamenti (24h)",
"totalOutdatedPackages": "Pacchetti obsoleti",
"totalRepos": "Repository",
"osDistribution": "Distribuzione OS",
"compliance": "Conformità Patch",
"complianceRatio": "{upToDate} / {total}",
"lastUpdated": "Aggiornato {time}",
"option": {
"showTotalHosts": {
"label": "Mostra host totali",
"description": "Visualizzare il numero totale di host monitorati"
},
"showHostsNeedingUpdates": {
"label": "Mostra host che necessitano di aggiornamenti",
"description": "Visualizza gli host con almeno un pacchetto obsoleto"
},
"showSecurityUpdates": {
"label": "Mostra aggiornamenti di sicurezza",
"description": "Mostra il numero totale di aggiornamenti di sicurezza disponibili"
},
"showUpToDateHosts": {
"label": "Mostra host aggiornati",
"description": "Visualizza host con zero pacchetti obsoleti"
},
"showHostsWithSecurityUpdates": {
"label": "Mostra host con aggiornamenti di sicurezza",
"description": "Visualizza host che richiedono patch di sicurezza"
},
"showRecentUpdates24h": {
"label": "Mostra aggiornamenti recenti (24h)",
"description": "Mostra il numero di aggiornamenti applicati nelle ultime 24 ore"
},
"showTotalOutdatedPackages": {
"label": "Mostra pacchetti obsoleti",
"description": "Mostra il numero totale di pacchetti obsoleti su tutti gli host"
},
"showTotalRepos": {
"label": "Mostra repository",
"description": "Mostra il numero di repository attivi che vengono controllati"
},
"enableThresholdColors": {
"label": "Abilita colori di soglia",
"description": "Valori della statistica di colore verde, giallo, o rosso in base alle preimpostazioni di gravità"
},
"useCustomThresholds": {
"label": "Soglie avanzate",
"description": "Personalizza gli avvisi e le soglie critiche per stat utilizzando conteggi assoluti o percentuali del totale degli host"
},
"thresholdMode": {
"option": {
"absolute": "Conteggio assoluto",
"percent": "Percentuale degli host totali"
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "Modalità soglia aggiornamenti richiesti",
"description": "Confronta il numero di aggiornamenti richiesti come valore assoluto o percentuale su host totali"
},
"hostsNeedingUpdatesWarningAt": {
"label": "Soglia di avviso aggiornamenti richiesti",
"description": "Inserisci il giallo quando il valore raggiunge questa soglia"
},
"hostsNeedingUpdatesCriticalAt": {
"label": "Soglia critica aggiornamenti richiesti",
"description": "Inserisci il rosso quando il valore raggiunge questa soglia"
},
"securityUpdatesThresholdMode": {
"label": "Modalità soglia aggiornamenti di sicurezza",
"description": "Confronta il valore degli aggiornamenti di sicurezza come valore assoluto o percentuale del totale degli host"
},
"securityUpdatesWarningAt": {
"label": "Soglia di avviso aggiornamenti di sicurezza",
"description": "Inserisci il giallo quando il valore raggiunge questa soglia"
},
"securityUpdatesCriticalAt": {
"label": "Soglia critica aggiornamenti di sicurezza",
"description": "Inserisci il rosso quando il valore raggiunge questa soglia"
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "Modalità soglia host con aggiornamenti di sicurezza",
"description": "Confronta il numero di host come valore assoluto o percentuale su host totali"
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "Soglia di avviso host con aggiornamenti di sicurezza",
"description": "Inserisci il giallo quando il valore raggiunge questa soglia"
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "Soglia critica host con aggiornamenti di sicurezza",
"description": "Inserisci il rosso quando il valore raggiunge questa soglia"
},
"upToDateHostsThresholdMode": {
"label": "Modalità soglia host aggiornati",
"description": "Confronta il numero di host aggiornati come valore assoluto o percentuale su host totali"
},
"upToDateHostsWarningAt": {
"label": "Soglia di avviso host aggiornati",
"description": "Inserisci il giallo quando il valore è pari o superiore a questa soglia ma al di sotto della copertura completa"
},
"upToDateHostsCriticalAt": {
"label": "Soglia critica host aggiornati",
"description": "Inserisci rosso quando il valore scende al di sotto di questa soglia"
},
"totalOutdatedPackagesThresholdMode": {
"label": "Modalità soglia pacchetti obsoleti",
"description": "Confronta il numero di pacchetti obsoleti come valore assoluto o percentuale sui host totali"
},
"totalOutdatedPackagesWarningAt": {
"label": "Soglia di avviso pacchetti obsoleti",
"description": "Inserisci il giallo quando il valore raggiunge questa soglia"
},
"totalOutdatedPackagesCriticalAt": {
"label": "Soglia critica pacchetti obsoleti",
"description": "Inserisci il rosso quando il valore raggiunge questa soglia"
},
"showOsDistribution": {
"label": "Mostra distribuzione OS",
"description": "Mostra un dettaglio degli host monitorati per sistema operativo"
},
"osDistributionLimit": {
"label": "Limite distribuzione OS",
"description": "Numero massimo di sistemi operativi da visualizzare",
"option": {
"3": "Top 3",
"5": "Top 5",
"10": "Top 10",
"all": "Tutto"
}
},
"showOsVersion": {
"label": "Mostra versione OS",
"description": "Aggiungi la versione del sistema operativo a ogni etichetta di distribuzione quando disponibile"
},
"showComplianceHero": {
"label": "Mostra sezione principale conformità",
"description": "Mostra un anello di conformità con la percentuale di host aggiornati"
},
"osDisplayMode": {
"label": "Modalità di visualizzazione OS",
"description": "Scegli come visualizzare la distribuzione del sistema operativo",
"option": {
"bars": "Barre orizzontali",
"donut": "Grafico a ciambella"
}
}
},
"error": {
"internalServerError": "Impossibile recuperare i dati di PatchMon"
}
},
"bazarr": {
"name": "Bazarr",
"description": "Panoramica dei sottotitoli mancanti e dello stato di salute della tua istanza Bazarr",
"episodes": "Episodi Mancanti",
"movies": "Film Mancanti",
"providers": "Problemi del Provider",
"status": "Problemi di stato",
"option": {
"showMissingEpisodes": {
"label": "Mostra episodi mancanti",
"description": "Mostra il numero di episodi con sottotitoli mancanti"
},
"showMissingMovies": {
"label": "Mostra film mancanti",
"description": "Mostra il numero di film con sottotitoli mancanti"
},
"showProviderIssues": {
"label": "Mostra problemi del provider",
"description": "Mostra i provider di sottotitoli con problemi"
},
"showHealthIssues": {
"label": "Mostra problemi di stato",
"description": "Mostra il numero di problemi di stato di Bazarr"
}
},
"error": {
"internalServerError": "Impossibile recuperare i dati Bazarr"
}
},
"immich-serverStats": { "immich-serverStats": {
"name": "Statistiche Server Immich", "name": "Statistiche Server Immich",
"description": "Informazioni sulla tua istanza Immich", "description": "Informazioni sulla tua istanza Immich",
@@ -3017,7 +2782,6 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "Album Immich", "name": "Album Immich",
"allPhotos": "Tutte le foto",
"option": { "option": {
"albumId": { "albumId": {
"label": "ID Album", "label": "ID Album",
@@ -3037,6 +2801,7 @@
} }
}, },
"description": "Presentazione delle immagini nel tuo album Immich", "description": "Presentazione delle immagini nel tuo album Immich",
"noAlbumSelected": "Nessun album selezionato, modifica il widget e scegli un album",
"noPhotos": "Nessuna foto in questo album" "noPhotos": "Nessuna foto in questo album"
}, },
"downloads": { "downloads": {
@@ -3079,20 +2844,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "Limite elementi per integrazione", "label": "Limite elementi per integrazione",
"description": "Ciò limiterà il numero di elementi mostrati per integrazione, non globalmente" "description": "Ciò limiterà il numero di elementi mostrati per integrazione, non globalmente"
},
"columnOrder": {
"label": "Ordine delle colonne"
},
"columnWidths": {
"label": "Larghezza delle colonne"
} }
}, },
"errors": { "errors": {
"noColumns": "Seleziona colonne in elementi", "noColumns": "Seleziona colonne in elementi",
"noCommunications": "Impossibile caricare i dati dall'integrazione", "noCommunications": "Impossibile caricare i dati dall'integrazione"
"noItems": "Nessun download trovato",
"actionFailed": "Azione non riuscita",
"actionFailedMessage": "L'azione non può essere completata. Per favore riprova."
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3115,8 +2871,7 @@
"detailsTitle": "Indice corrente all'interno del client" "detailsTitle": "Indice corrente all'interno del client"
}, },
"id": { "id": {
"columnTitle": "Id", "columnTitle": "Id"
"detailsTitle": "ID elemento"
}, },
"integration": { "integration": {
"columnTitle": "Integrazione" "columnTitle": "Integrazione"
@@ -3150,8 +2905,7 @@
}, },
"time": { "time": {
"columnTitle": "Ora di fine", "columnTitle": "Ora di fine",
"detailsTitle": "Tempo dal/al completamento", "detailsTitle": "Tempo dal/al completamento"
"eta": "ETA"
}, },
"type": { "type": {
"columnTitle": "Tipo", "columnTitle": "Tipo",
@@ -3195,23 +2949,7 @@
} }
} }
}, },
"globalRatio": "Rapporto globale", "globalRatio": "Rapporto globale"
"contextMenu": {
"copyName": "Copia nome",
"copyId": "Copia ID",
"clearFilters": "Resetta filtri"
},
"footer": {
"filter": "Filtro download"
},
"stats": {
"show": "Mostra statistiche globali",
"hide": "Nascondi statistiche globali",
"overallProgress": "Avanzamento complessivo della coda",
"totalSize": "Dimensione totale della coda",
"clients": "{count, plural, one {# cliente} other {# clienti}}"
},
"disconnected": "(disconnesso)"
}, },
"mediaReleases": { "mediaReleases": {
"name": "Comunicati stampa", "name": "Comunicati stampa",
@@ -3248,12 +2986,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Apri i link in nuova scheda" "label": "Apri i link in nuova scheda"
},
"statusFilter": {
"label": "Stati da mostrare"
},
"recentDays": {
"label": "Mostra solo le richieste recenti"
} }
}, },
"pending": { "pending": {
@@ -3361,35 +3093,6 @@
} }
} }
}, },
"mediaMissing": {
"name": "Contenuti multimediali mancanti & in coda",
"description": "Mostra i film e gli episodi mancanti e in coda da Radarr e Sonarr",
"option": {
"showMissing": {
"label": "Mostra elementi mancanti"
},
"showQueued": {
"label": "Mostra elementi in coda"
},
"pageSize": {
"label": "Elementi per pagina"
}
},
"tab": {
"missing": "Mancanti",
"queued": "In coda"
},
"type": {
"movie": "Film",
"episode": "Episodio"
},
"empty": {
"missing": "Nessun elemento mancante",
"queued": "Nessun elemento in coda",
"noTabsEnabled": "Abilita almeno una scheda nelle impostazioni del widget"
},
"timeLeft": "Tempo residuo"
},
"rssFeed": { "rssFeed": {
"name": "Feed RSS", "name": "Feed RSS",
"description": "Monitora e visualizza uno o più feed generici RSS, ATOM o JSON", "description": "Monitora e visualizza uno o più feed generici RSS, ATOM o JSON",
@@ -3633,11 +3336,7 @@
"name": "Notifiche", "name": "Notifiche",
"description": "Visualizza la cronologia delle notifiche da un'integrazione", "description": "Visualizza la cronologia delle notifiche da un'integrazione",
"noItems": "Nessuna notifica da visualizzare.", "noItems": "Nessuna notifica da visualizzare.",
"option": { "option": {}
"hideLogos": {
"label": "Nascondi loghi"
}
}
}, },
"speedtestTracker": { "speedtestTracker": {
"name": "SpeedTest Tracker", "name": "SpeedTest Tracker",
@@ -3718,40 +3417,6 @@
"internalServerError": "Impossibile recuperare i dati di uptime" "internalServerError": "Impossibile recuperare i dati di uptime"
} }
}, },
"traefik": {
"name": "Traefik",
"description": "Panoramica dei router, dei servizi, dei middleware e dei punti di ingresso di Traefik",
"option": {
"showTcp": {
"label": "Mostrare TCP"
},
"showUdp": {
"label": "Mostra UDP"
},
"showEntryPoints": {
"label": "Mostra punti di accesso"
}
},
"instances": "Istanze di Traefik",
"versionUnknown": "Versione sconosciuta",
"summary": {
"routers": "Routers",
"entryPoints": "Punti di accesso"
},
"resource": {
"routers": "Routers",
"services": "Servizi",
"middlewares": "Middleware"
},
"status": {
"healthy": "Normale funzionamento",
"errors": "{count} errori",
"warnings": "{count} avvisi"
},
"error": {
"internalServerError": "Recupero dei dati Traefik non riuscito"
}
},
"audioStats": { "audioStats": {
"name": "Statistiche audio", "name": "Statistiche audio",
"description": "Statistiche della libreria e di riproduzione da Navidrome o Audiobookshelf", "description": "Statistiche della libreria e di riproduzione da Navidrome o Audiobookshelf",
@@ -4127,11 +3792,6 @@
"read": "Lettura", "read": "Lettura",
"write": "Scrittura" "write": "Scrittura"
}, },
"storage": {
"title": "Spazio di archiviazione",
"subtitle": "Utilizzo del disco per filesystem",
"series": "Usato"
},
"network": { "network": {
"title": "Larghezza di Banda", "title": "Larghezza di Banda",
"subtitle": "Traffico di rete delle interfacce pubbliche", "subtitle": "Traffico di rete delle interfacce pubbliche",
@@ -5264,9 +4924,6 @@
"title": "Containers", "title": "Containers",
"table": { "table": {
"updated": "Aggiornato {when}", "updated": "Aggiornato {when}",
"refresh": {
"lastUpdated": "Ultimo aggiornamento {when}"
},
"search": "Cerca {count} container", "search": "Cerca {count} container",
"selected": "{selectCount} di {totalCount} containers selezionati", "selected": "{selectCount} di {totalCount} containers selezionati",
"footer": "Totale container {count}", "footer": "Totale container {count}",
@@ -5995,14 +5652,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "Abilita clic destro sui widget",
"children": {
"detail": {
"title": "Abilita clic destro sui widget"
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "Apri la pagina completa delle preferenze" "label": "Apri la pagina completa delle preferenze"
} }
+28 -379
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Pingにアイコンを使う" "label": "Pingにアイコンを使う"
}, },
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "既定の検索エンジン" "label": "既定の検索エンジン"
}, },
@@ -1289,14 +1285,6 @@
"label": "API キー (シークレット)", "label": "API キー (シークレット)",
"newLabel": "新しい API キー (シークレット)" "newLabel": "新しい API キー (シークレット)"
}, },
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": { "githubAppId": {
"label": "App Id", "label": "App Id",
"newLabel": "新規App Id" "newLabel": "新規App Id"
@@ -1340,21 +1328,9 @@
} }
} }
}, },
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": { "paperlessNgx": {
"name": "", "name": "",
"description": "" "description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
} }
}, },
"media": { "media": {
@@ -1626,12 +1602,6 @@
"refresh": "", "refresh": "",
"options": "", "options": "",
"actions": "" "actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
} }
}, },
"create": { "create": {
@@ -1905,16 +1875,6 @@
"customDateFormat": { "customDateFormat": {
"label": "日付の書式をカスタマイズ", "label": "日付の書式をカスタマイズ",
"description": "日付の書式に ISO-8601を使用します(これは他のオプションを上書きします)" "description": "日付の書式に ISO-8601を使用します(これは他のオプションを上書きします)"
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
} }
} }
}, },
@@ -1976,8 +1936,6 @@
"clear": "書式をクリア", "clear": "書式をクリア",
"heading": "見出し {level}", "heading": "見出し {level}",
"align": "テキストの整列: {position}", "align": "テキストの整列: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "引用", "blockquote": "引用",
"horizontalLine": "水平線", "horizontalLine": "水平線",
"bulletList": "箇条書きリスト", "bulletList": "箇条書きリスト",
@@ -2267,8 +2225,7 @@
"sunrise": "日出", "sunrise": "日出",
"sunset": "日没", "sunset": "日没",
"maxWindSpeed": "最大風速: {maxWindSpeed} {unit}", "maxWindSpeed": "最大風速: {maxWindSpeed} {unit}",
"maxWindGusts": "最大瞬間風速: {maxWindGusts} {unit}", "maxWindGusts": "最大瞬間風速: {maxWindGusts} {unit}"
"humidity": ""
}, },
"kind": { "kind": {
"clear": "晴れ", "clear": "晴れ",
@@ -2560,30 +2517,30 @@
} }
}, },
"umami": { "umami": {
"name": "Umami 統計情報", "name": "",
"description": "あなたの Umami 統計情報インスタンスから訪問者統計を表示します", "description": "",
"visitors": "訪問者数", "visitors": "",
"active": "活動中", "active": "",
"pageviews": "ページビュー", "pageviews": "",
"visits": "訪問数", "visits": "",
"bounceRate": "直帰率", "bounceRate": "",
"avgDuration": "平均", "avgDuration": "",
"option": { "option": {
"websiteId": { "websiteId": {
"label": "ウェブサイト", "label": "",
"description": "あなたの Umami インスタンスで追跡されているウェブサイトを選択してください", "description": "",
"placeholder": "ウェブサイトを選択", "placeholder": "",
"loading": "ウェブサイトを読み込み中…", "loading": "",
"noWebsites": "ウェブサイトが見つかりません", "noWebsites": "",
"configureIntegrationFirst": "最初に Umami 統合を接続してください" "configureIntegrationFirst": ""
}, },
"timeFrame": { "timeFrame": {
"label": "期間", "label": "",
"description": "訪問者データを表示する期間", "description": "",
"option": { "option": {
"today": "今日", "today": "",
"24h": "過去24時間", "24h": "",
"7d": "過去7日間", "7d": "",
"30d": "", "30d": "",
"month": "", "month": "",
"lastMonth": "" "lastMonth": ""
@@ -2732,11 +2689,6 @@
"user": "ユーザー", "user": "ユーザー",
"name": "名称", "name": "名称",
"id": "Id", "id": "Id",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": { "metadata": {
"title": "詳細統計情報", "title": "詳細統計情報",
"video": { "video": {
@@ -2802,193 +2754,6 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": { "immich-serverStats": {
"name": "Immich サーバーの統計", "name": "Immich サーバーの統計",
"description": "Immich インスタンスに関する情報", "description": "Immich インスタンスに関する情報",
@@ -3017,7 +2782,6 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "Immich アルバム", "name": "Immich アルバム",
"allPhotos": "",
"option": { "option": {
"albumId": { "albumId": {
"label": "アルバム ID", "label": "アルバム ID",
@@ -3037,6 +2801,7 @@
} }
}, },
"description": "Immichアルバムの写真のスライド", "description": "Immichアルバムの写真のスライド",
"noAlbumSelected": "アルバムが選択されていない、ウィジェットを編集してアルバムを選択してください",
"noPhotos": "このアルバムには写真がありません" "noPhotos": "このアルバムには写真がありません"
}, },
"downloads": { "downloads": {
@@ -3079,20 +2844,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "連携機能ごとに項目を制限", "label": "連携機能ごとに項目を制限",
"description": "これは、連携機能ごとに表示されるアイテム数を制限します。グローバルではありません" "description": "これは、連携機能ごとに表示されるアイテム数を制限します。グローバルではありません"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
} }
}, },
"errors": { "errors": {
"noColumns": "項目内の列を選択", "noColumns": "項目内の列を選択",
"noCommunications": "連携機能からデータを読み込めません", "noCommunications": "連携機能からデータを読み込めません"
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3115,8 +2871,7 @@
"detailsTitle": "クライアント内の現在のインデックス" "detailsTitle": "クライアント内の現在のインデックス"
}, },
"id": { "id": {
"columnTitle": "Id", "columnTitle": "Id"
"detailsTitle": ""
}, },
"integration": { "integration": {
"columnTitle": "連携機能" "columnTitle": "連携機能"
@@ -3150,8 +2905,7 @@
}, },
"time": { "time": {
"columnTitle": "終了時刻", "columnTitle": "終了時刻",
"detailsTitle": "完了までの時間", "detailsTitle": "完了までの時間"
"eta": ""
}, },
"type": { "type": {
"columnTitle": "種類", "columnTitle": "種類",
@@ -3195,23 +2949,7 @@
} }
} }
}, },
"globalRatio": "グローバル比", "globalRatio": "グローバル比"
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
}, },
"mediaReleases": { "mediaReleases": {
"name": "メディアリリース", "name": "メディアリリース",
@@ -3248,12 +2986,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "リンクを新しいタブで開く" "label": "リンクを新しいタブで開く"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
} }
}, },
"pending": { "pending": {
@@ -3361,35 +3093,6 @@
} }
} }
}, },
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": { "rssFeed": {
"name": "RSS フィード", "name": "RSS フィード",
"description": "1つまたは複数の一般的な RSS、ATOM、または JSON フィードを監視して表示します", "description": "1つまたは複数の一般的な RSS、ATOM、または JSON フィードを監視して表示します",
@@ -3633,11 +3336,7 @@
"name": "通知", "name": "通知",
"description": "連携機能からの通知履歴を表示する", "description": "連携機能からの通知履歴を表示する",
"noItems": "表示する通知はありません。", "noItems": "表示する通知はありません。",
"option": { "option": {}
"hideLogos": {
"label": ""
}
}
}, },
"speedtestTracker": { "speedtestTracker": {
"name": "スピードテストトラッカー", "name": "スピードテストトラッカー",
@@ -3718,40 +3417,6 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": { "audioStats": {
"name": "", "name": "",
"description": "", "description": "",
@@ -4127,11 +3792,6 @@
"read": "", "read": "",
"write": "" "write": ""
}, },
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": { "network": {
"title": "", "title": "",
"subtitle": "", "subtitle": "",
@@ -5234,8 +4894,8 @@
"subtitle": "{count} の多くの言語で翻訳を行っている" "subtitle": "{count} の多くの言語で翻訳を行っている"
}, },
"sponsors": { "sponsors": {
"title": "スポンサー", "title": "",
"subtitle": "{count}人がプロジェクトを財政的に支援しています" "subtitle": ""
}, },
"libraries": { "libraries": {
"title": "ライブラリ", "title": "ライブラリ",
@@ -5264,9 +4924,6 @@
"title": "コンテナ", "title": "コンテナ",
"table": { "table": {
"updated": "{when} に更新しました", "updated": "{when} に更新しました",
"refresh": {
"lastUpdated": ""
},
"search": "{count} 個のコンテナを検索", "search": "{count} 個のコンテナを検索",
"selected": "{totalCount} 個のうち、{selectCount} 個のコンテナが選択されています", "selected": "{totalCount} 個のうち、{selectCount} 個のコンテナが選択されています",
"footer": "合計 {count} 個のコンテナ", "footer": "合計 {count} 個のコンテナ",
@@ -5995,14 +5652,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "" "label": ""
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -358
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Bruk ikoner for pinger" "label": "Bruk ikoner for pinger"
}, },
"enableRightClickOnWidgets": {
"label": "",
"description": ""
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "Standard søkemotor" "label": "Standard søkemotor"
}, },
@@ -1289,14 +1285,6 @@
"label": "API Nøkkel (Hemliglet)", "label": "API Nøkkel (Hemliglet)",
"newLabel": "Ny API Key (Secret)" "newLabel": "Ny API Key (Secret)"
}, },
"patchmonApiKey": {
"label": "",
"newLabel": ""
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
},
"githubAppId": { "githubAppId": {
"label": "App Id", "label": "App Id",
"newLabel": "Ny App Id" "newLabel": "Ny App Id"
@@ -1340,21 +1328,9 @@
} }
} }
}, },
"traefik": {
"name": "",
"description": ""
},
"paperlessNgx": { "paperlessNgx": {
"name": "", "name": "",
"description": "" "description": ""
},
"patchmon": {
"name": "",
"description": ""
},
"bazarr": {
"name": "",
"description": ""
} }
}, },
"media": { "media": {
@@ -1626,12 +1602,6 @@
"refresh": "", "refresh": "",
"options": "", "options": "",
"actions": "" "actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
} }
}, },
"create": { "create": {
@@ -1905,16 +1875,6 @@
"customDateFormat": { "customDateFormat": {
"label": "Egendefinert dato format", "label": "Egendefinert dato format",
"description": "Bruk ISO 8601 til å formatere datoen (dette vil overstyre andre alternativer)" "description": "Bruk ISO 8601 til å formatere datoen (dette vil overstyre andre alternativer)"
},
"showWeather": {
"label": "",
"description": ""
},
"weatherLocation": {
"label": ""
},
"isWeatherFormatFahrenheit": {
"label": ""
} }
} }
}, },
@@ -1976,8 +1936,6 @@
"clear": "Fjern formatering", "clear": "Fjern formatering",
"heading": "Overskrift {level}", "heading": "Overskrift {level}",
"align": "Juster tekst: {position}", "align": "Juster tekst: {position}",
"directionLtr": "",
"directionRtl": "",
"blockquote": "Blokksitat", "blockquote": "Blokksitat",
"horizontalLine": "Horisontal linje", "horizontalLine": "Horisontal linje",
"bulletList": "Punktliste", "bulletList": "Punktliste",
@@ -2267,8 +2225,7 @@
"sunrise": "Soloppgang", "sunrise": "Soloppgang",
"sunset": "Solnedgang", "sunset": "Solnedgang",
"maxWindSpeed": "Maks vindhastighet: {maxWindSpeed} {unit}", "maxWindSpeed": "Maks vindhastighet: {maxWindSpeed} {unit}",
"maxWindGusts": "Maks vindkast: {maxWindGusts} {unit}", "maxWindGusts": "Maks vindkast: {maxWindGusts} {unit}"
"humidity": ""
}, },
"kind": { "kind": {
"clear": "Tøm", "clear": "Tøm",
@@ -2732,11 +2689,6 @@
"user": "Bruker", "user": "Bruker",
"name": "Navn", "name": "Navn",
"id": "Id", "id": "Id",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"metadata": { "metadata": {
"title": "Statistikk for nerder", "title": "Statistikk for nerder",
"video": { "video": {
@@ -2802,193 +2754,6 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
},
"showSecurityUpdates": {
"label": "",
"description": ""
},
"showUpToDateHosts": {
"label": "",
"description": ""
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
},
"showRecentUpdates24h": {
"label": "",
"description": ""
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
},
"enableThresholdColors": {
"label": "",
"description": ""
},
"useCustomThresholds": {
"label": "",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
},
"showOsDistribution": {
"label": "",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
}
},
"showOsVersion": {
"label": "",
"description": ""
},
"showComplianceHero": {
"label": "",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"option": {
"bars": "",
"donut": ""
}
}
},
"error": {
"internalServerError": ""
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
},
"showMissingMovies": {
"label": "",
"description": ""
},
"showProviderIssues": {
"label": "",
"description": ""
},
"showHealthIssues": {
"label": "",
"description": ""
}
},
"error": {
"internalServerError": ""
}
},
"immich-serverStats": { "immich-serverStats": {
"name": "Immich-serverstatistikk", "name": "Immich-serverstatistikk",
"description": "Informasjon om din Immich-instans", "description": "Informasjon om din Immich-instans",
@@ -3017,7 +2782,6 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "Immich Album", "name": "Immich Album",
"allPhotos": "",
"option": { "option": {
"albumId": { "albumId": {
"label": "Album ID", "label": "Album ID",
@@ -3037,6 +2801,7 @@
} }
}, },
"description": "Lysbildefremvisning av bilder i ditt Immich-album", "description": "Lysbildefremvisning av bilder i ditt Immich-album",
"noAlbumSelected": "Ingen album valgt, rediger widgeten og velg et album",
"noPhotos": "Ingen bilder i dette albumet" "noPhotos": "Ingen bilder i dette albumet"
}, },
"downloads": { "downloads": {
@@ -3079,20 +2844,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "Begrens elementer per integrasjon", "label": "Begrens elementer per integrasjon",
"description": "Dette begrenser antall elementer vist per integrasjon, ikke globalt" "description": "Dette begrenser antall elementer vist per integrasjon, ikke globalt"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
} }
}, },
"errors": { "errors": {
"noColumns": "Velg kolonner i elementer", "noColumns": "Velg kolonner i elementer",
"noCommunications": "Kan ikke laste inn data fra integrasjon", "noCommunications": "Kan ikke laste inn data fra integrasjon"
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3115,8 +2871,7 @@
"detailsTitle": "Gjeldende indeks i klienten" "detailsTitle": "Gjeldende indeks i klienten"
}, },
"id": { "id": {
"columnTitle": "Id", "columnTitle": "Id"
"detailsTitle": ""
}, },
"integration": { "integration": {
"columnTitle": "Integrasjon" "columnTitle": "Integrasjon"
@@ -3150,8 +2905,7 @@
}, },
"time": { "time": {
"columnTitle": "Fullfør tid", "columnTitle": "Fullfør tid",
"detailsTitle": "Tid siden/til fullførelse", "detailsTitle": "Tid siden/til fullførelse"
"eta": ""
}, },
"type": { "type": {
"columnTitle": "Type", "columnTitle": "Type",
@@ -3195,23 +2949,7 @@
} }
} }
}, },
"globalRatio": "Global ratio", "globalRatio": "Global ratio"
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
}, },
"mediaReleases": { "mediaReleases": {
"name": "Medieutgivelser", "name": "Medieutgivelser",
@@ -3248,12 +2986,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Åpne lenker i ny fane" "label": "Åpne lenker i ny fane"
},
"statusFilter": {
"label": ""
},
"recentDays": {
"label": ""
} }
}, },
"pending": { "pending": {
@@ -3361,35 +3093,6 @@
} }
} }
}, },
"mediaMissing": {
"name": "",
"description": "",
"option": {
"showMissing": {
"label": ""
},
"showQueued": {
"label": ""
},
"pageSize": {
"label": ""
}
},
"tab": {
"missing": "",
"queued": ""
},
"type": {
"movie": "",
"episode": ""
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
},
"timeLeft": ""
},
"rssFeed": { "rssFeed": {
"name": "RSS feed", "name": "RSS feed",
"description": "Overvåk og vis en eller flere generiske RSS, ATOM eller JSON-feeder", "description": "Overvåk og vis en eller flere generiske RSS, ATOM eller JSON-feeder",
@@ -3633,11 +3336,7 @@
"name": "Varsler", "name": "Varsler",
"description": "Vis varselhistorikk fra en integrasjon", "description": "Vis varselhistorikk fra en integrasjon",
"noItems": "Ingen varsler å vise.", "noItems": "Ingen varsler å vise.",
"option": { "option": {}
"hideLogos": {
"label": ""
}
}
}, },
"speedtestTracker": { "speedtestTracker": {
"name": "Speedtest tracker", "name": "Speedtest tracker",
@@ -3718,40 +3417,6 @@
"internalServerError": "" "internalServerError": ""
} }
}, },
"traefik": {
"name": "",
"description": "",
"option": {
"showTcp": {
"label": ""
},
"showUdp": {
"label": ""
},
"showEntryPoints": {
"label": ""
}
},
"instances": "",
"versionUnknown": "",
"summary": {
"routers": "",
"entryPoints": ""
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
},
"error": {
"internalServerError": ""
}
},
"audioStats": { "audioStats": {
"name": "", "name": "",
"description": "", "description": "",
@@ -4127,11 +3792,6 @@
"read": "", "read": "",
"write": "" "write": ""
}, },
"storage": {
"title": "",
"subtitle": "",
"series": ""
},
"network": { "network": {
"title": "", "title": "",
"subtitle": "", "subtitle": "",
@@ -5264,9 +4924,6 @@
"title": "Containere", "title": "Containere",
"table": { "table": {
"updated": "Oppdatert {when}", "updated": "Oppdatert {when}",
"refresh": {
"lastUpdated": ""
},
"search": "Søk {count} containere", "search": "Søk {count} containere",
"selected": "{selectCount} av {totalCount} containere valgt", "selected": "{selectCount} av {totalCount} containere valgt",
"footer": "Totalt {count} containere", "footer": "Totalt {count} containere",
@@ -5995,14 +5652,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "",
"children": {
"detail": {
"title": ""
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "" "label": ""
} }
+13 -364
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Użyj ikon dla pingów" "label": "Użyj ikon dla pingów"
}, },
"enableRightClickOnWidgets": {
"label": "Włącz prawe kliknięcie na widżetach",
"description": "Pokaż menu kontekstowe Homarr po kliknięciu prawym przyciskiem na widżet. Wyłącz, aby używać domyślnego menu przeglądarki (np. \"Otwórz link w nowej karcie\")."
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "Domyślna wyszukiwarka" "label": "Domyślna wyszukiwarka"
}, },
@@ -1289,14 +1285,6 @@
"label": "Klucz API (Secret)", "label": "Klucz API (Secret)",
"newLabel": "Nowy klucz API (Secret)" "newLabel": "Nowy klucz API (Secret)"
}, },
"patchmonApiKey": {
"label": "Klucz API",
"newLabel": "Nowy klucz API"
},
"patchmonApiSecret": {
"label": "Sekret API",
"newLabel": "Nowy sekret API"
},
"githubAppId": { "githubAppId": {
"label": "Identyfikator aplikacji", "label": "Identyfikator aplikacji",
"newLabel": "Nowy identyfikator aplikacji" "newLabel": "Nowy identyfikator aplikacji"
@@ -1340,21 +1328,9 @@
} }
} }
}, },
"traefik": {
"name": "Traefik",
"description": "Chmurowy reverse proxy i load balancer"
},
"paperlessNgx": { "paperlessNgx": {
"name": "Paperless-ngx", "name": "Paperless-ngx",
"description": "System zarządzania dokumentami do archiwizacji i organizacji zeskanowaych dokumnteów" "description": "System zarządzania dokumentami do archiwizacji i organizacji zeskanowaych dokumnteów"
},
"patchmon": {
"name": "PatchMon",
"description": "Zarządzanie łatkami i monitorowanie hostów Linux"
},
"bazarr": {
"name": "Bazarr",
"description": "Aplikacja towarzysząca Sonarr i Radarr do zarządzania i pobierania napisów"
} }
}, },
"media": { "media": {
@@ -1626,12 +1602,6 @@
"refresh": "Odśwież", "refresh": "Odśwież",
"options": "Opcje", "options": "Opcje",
"actions": "Akcje" "actions": "Akcje"
},
"status": {
"success": "OK",
"error": "Błąd",
"loading": "Ładowanie",
"idle": "Bezczynny"
} }
}, },
"create": { "create": {
@@ -1905,16 +1875,6 @@
"customDateFormat": { "customDateFormat": {
"label": "Niestandardowy format daty", "label": "Niestandardowy format daty",
"description": "Użyj ISO 8601 do formatu daty (spowoduje to nadpisanie innych opcji)" "description": "Użyj ISO 8601 do formatu daty (spowoduje to nadpisanie innych opcji)"
},
"showWeather": {
"label": "Pokaż pogodę",
"description": "Wyświetl małą animowaną ikonę pogody i temperaturę w rogu karty"
},
"weatherLocation": {
"label": "Lokalizacja pogody"
},
"isWeatherFormatFahrenheit": {
"label": "Użyj Fahrenheita"
} }
} }
}, },
@@ -1976,8 +1936,6 @@
"clear": "Wyczyść formatowanie", "clear": "Wyczyść formatowanie",
"heading": "Nagłówek {level}", "heading": "Nagłówek {level}",
"align": "Wyrównanie tekstu: {position}", "align": "Wyrównanie tekstu: {position}",
"directionLtr": "Ustaw kierunek tekstu od lewej do prawej",
"directionRtl": "Ustaw kierunek tekstu od prawej do lewej",
"blockquote": "Cytat", "blockquote": "Cytat",
"horizontalLine": "Linia pozioma", "horizontalLine": "Linia pozioma",
"bulletList": "Lista punktowana", "bulletList": "Lista punktowana",
@@ -2267,8 +2225,7 @@
"sunrise": "Wschód słońca", "sunrise": "Wschód słońca",
"sunset": "Zachód słońca", "sunset": "Zachód słońca",
"maxWindSpeed": "Maksymalna prędkość wiatru: {maxWindSpeed} km/h", "maxWindSpeed": "Maksymalna prędkość wiatru: {maxWindSpeed} km/h",
"maxWindGusts": "Maksymalne porywy wiatru: {maxWindGusts} km/h", "maxWindGusts": "Maksymalne porywy wiatru: {maxWindGusts} km/h"
"humidity": "Wilgotność: {humidity}%"
}, },
"kind": { "kind": {
"clear": "Bezchmurnie", "clear": "Bezchmurnie",
@@ -2328,9 +2285,9 @@
"label": "Pokaż informacje o systemie plików" "label": "Pokaż informacje o systemie plików"
}, },
"visibleStorageVolumes": { "visibleStorageVolumes": {
"label": "Widoczne woluminy pamięci", "label": "",
"description": "Dostępne tylko gdy wszystkie wybrane integracje to Synology. Pozostaw puste, aby wyświetlić wszystkie wykryte woluminy pamięci.", "description": "",
"placeholder": "Wybierz woluminy pamięci" "placeholder": ""
}, },
"defaultTab": { "defaultTab": {
"label": "Domyślna karta" "label": "Domyślna karta"
@@ -2732,11 +2689,6 @@
"user": "Użytkownik", "user": "Użytkownik",
"name": "Nazwa", "name": "Nazwa",
"id": "ID", "id": "ID",
"status": "Status",
"directPlay": "Odtwarzanie bezpośrednie",
"transcoding": "Transkodowanie",
"paused": "Zatrzymane",
"remaining": "{minutes} min pozostało",
"metadata": { "metadata": {
"title": "Statystyki dla nerdów", "title": "Statystyki dla nerdów",
"video": { "video": {
@@ -2802,193 +2754,6 @@
"internalServerError": "Nie udało się pobrać danych Paperless-ngx" "internalServerError": "Nie udało się pobrać danych Paperless-ngx"
} }
}, },
"patchmon": {
"name": "PatchMon",
"description": "Przegląd stanu poprawek hostów z PatchMon",
"title": "Statystyki PatchMon",
"totalHosts": "Łączna liczba hostów",
"hostsNeedingUpdates": "Wymaga aktualizacji",
"securityUpdates": "Aktualizacje zabezpieczeń",
"upToDateHosts": "Aktualne hosty",
"hostsWithSecurityUpdates": "Hosty z aktualizacjami zabezpieczeń",
"recentUpdates24h": "Aktualizacje (24h)",
"totalOutdatedPackages": "Nieaktualne pakiety",
"totalRepos": "Repozytoria",
"osDistribution": "Dystrybucja systemu",
"compliance": "Zgodność poprawek",
"complianceRatio": "{upToDate} / {total}",
"lastUpdated": "Zaktualizowano {time}",
"option": {
"showTotalHosts": {
"label": "Pokaż łączną liczbę hostów",
"description": "Wyświetla łączną liczbę monitorowanych hostów"
},
"showHostsNeedingUpdates": {
"label": "Pokaż hosty wymagające aktualizacji",
"description": "Wyświetla hosty z co najmniej jednym nieaktualnym pakietem"
},
"showSecurityUpdates": {
"label": "Pokaż aktualizacje zabezpieczeń",
"description": "Wyświetla całkowitą liczbę dostępnych aktualizacji zabezpieczeń"
},
"showUpToDateHosts": {
"label": "Pokaż aktualne hosty",
"description": "Wyświetla hosty bez nieaktualnych pakietów"
},
"showHostsWithSecurityUpdates": {
"label": "Pokaż hosty z aktualizacjami zabezpieczeń",
"description": "Wyświetla hosty wymagające poprawek bezpieczeństwa"
},
"showRecentUpdates24h": {
"label": "Pokaż ostatnie aktualizacje (24h)",
"description": "Wyświetla liczbę aktualizacji zastosowanych w ciągu ostatnich 24 godzin"
},
"showTotalOutdatedPackages": {
"label": "Pokaż nieaktualne pakiety",
"description": "Wyświetla całkowitą liczbę nieaktualnych pakietów na wszystkich hostach"
},
"showTotalRepos": {
"label": "Pokaż repozytoria",
"description": "Wyświetla liczbę monitorowanych aktywnych repozytoriów"
},
"enableThresholdColors": {
"label": "Włącz kolory progów",
"description": "Koloruj wartości statystyk na zielono, żółto lub czerwono w zależności od ustawionych progów ważności"
},
"useCustomThresholds": {
"label": "Zaawansowane progi",
"description": "Dostosuj progi ostrzeżeń i krytyczne dla każdej statystyki, używając liczby bezwzględnej lub procentu wszystkich hostów"
},
"thresholdMode": {
"option": {
"absolute": "Liczba bezwzględna",
"percent": "Procent wszystkich hostów"
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "Tryb progu dla wymagających aktualizacji",
"description": "Porównaj liczbę hostów wymagających aktualizacji jako wartość bezwzględną lub procent wszystkich hostów"
},
"hostsNeedingUpdatesWarningAt": {
"label": "Próg ostrzeżenia dla wymagających aktualizacji",
"description": "Zmień na żółty, gdy wartość osiągnie ten próg"
},
"hostsNeedingUpdatesCriticalAt": {
"label": "Próg krytyczny dla wymagających aktualizacji",
"description": "Zmień na czerwony, gdy wartość osiągnie ten próg"
},
"securityUpdatesThresholdMode": {
"label": "Tryb progu dla aktualizacji zabezpieczeń",
"description": "Porównaj liczbę aktualizacji zabezpieczeń jako wartość bezwzględną lub procent wszystkich hostów"
},
"securityUpdatesWarningAt": {
"label": "Próg ostrzeżenia dla aktualizacji zabezpieczeń",
"description": "Ustaw żółty kolor, gdy wartość osiągnie ten próg"
},
"securityUpdatesCriticalAt": {
"label": "Próg krytyczny aktualizacji zabezpieczeń",
"description": "Ustaw czerwony kolor, gdy wartość osiągnie ten próg"
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "Tryb progu liczby hostów z aktualizacjami zabezpieczeń",
"description": "Porównaj liczbę hostów jako wartość bezwzględną lub procent całkowitej liczby hostów"
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "Próg ostrzegawczy dla hostów z aktualizacjami zabezpieczeń",
"description": "Ustaw żółty kolor, gdy wartość osiągnie ten próg"
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "Próg krytyczny dla hostów z aktualizacjami zabezpieczeń",
"description": "Ustaw czerwony kolor, gdy wartość osiągnie ten próg"
},
"upToDateHostsThresholdMode": {
"label": "Tryb progu dla aktualnych hostów",
"description": "Porównaj liczbę aktualnych hostów jako wartość bezwzględną lub procent całkowitej liczby hostów"
},
"upToDateHostsWarningAt": {
"label": "Próg ostrzegawczy dla aktualnych hostów",
"description": "Ustaw żółty kolor, gdy wartość jest równa lub wyższa od tego progu, ale poniżej pełnego pokrycia"
},
"upToDateHostsCriticalAt": {
"label": "Próg krytyczny dla aktualnych hostów",
"description": "Ustaw czerwony kolor, gdy wartość spadnie poniżej tego progu"
},
"totalOutdatedPackagesThresholdMode": {
"label": "Tryb progu dla nieaktualnych pakietów",
"description": "Porównaj liczbę nieaktualnych pakietów jako wartość bezwzględną lub procent całkowitej liczby hostów"
},
"totalOutdatedPackagesWarningAt": {
"label": "Próg ostrzegawczy dla nieaktualnych pakietów",
"description": "Ustaw żółty kolor, gdy wartość osiągnie ten próg"
},
"totalOutdatedPackagesCriticalAt": {
"label": "Próg krytyczny dla nieaktualnych pakietów",
"description": "Ustaw czerwony kolor, gdy wartość osiągnie ten próg"
},
"showOsDistribution": {
"label": "Pokaż dystrybucję systemu operacyjnego",
"description": "Wyświetl podział monitorowanych hostów według systemu operacyjnego"
},
"osDistributionLimit": {
"label": "Limit dystrybucji systemu operacyjnego",
"description": "Maksymalna liczba systemów operacyjnych do wyświetlenia",
"option": {
"3": "Top 3",
"5": "Top 5",
"10": "Top 10",
"all": "Wszystkie"
}
},
"showOsVersion": {
"label": "Pokaż wersję systemu operacyjnego",
"description": "Dodaj wersję systemu operacyjnego do każdej etykiety dystrybucji, jeśli jest dostępna"
},
"showComplianceHero": {
"label": "Pokaż wskaźnik zgodności",
"description": "Wyświetl pierścień zgodności pokazujący procent aktualnych hostów"
},
"osDisplayMode": {
"label": "Tryb wyświetlania systemu operacyjnego",
"description": "Wybierz sposób wyświetlania dystrybucji systemu operacyjnego",
"option": {
"bars": "Paski poziome",
"donut": "Wykres pierścieniowy"
}
}
},
"error": {
"internalServerError": "Nie udało się pobrać danych PatchMon"
}
},
"bazarr": {
"name": "Bazarr",
"description": "Przegląd brakujących napisów i stanu zdrowia z instancji Bazarr",
"episodes": "Brakujące odcinki",
"movies": "Brakujące filmy",
"providers": "Problemy dostawców",
"status": "Problemy ze stanem",
"option": {
"showMissingEpisodes": {
"label": "Pokaż brakujące odcinki",
"description": "Wyświetla liczbę odcinków z brakującymi napisami"
},
"showMissingMovies": {
"label": "Pokaż brakujące filmy",
"description": "Wyświetla liczbę filmów z brakującymi napisami"
},
"showProviderIssues": {
"label": "Pokaż problemy dostawców",
"description": "Wyświetla liczbę dostawców napisów z problemami"
},
"showHealthIssues": {
"label": "Pokaż problemy ze stanem",
"description": "Wyświetla liczbę problemów ze stanem Bazarr"
}
},
"error": {
"internalServerError": "Nie udało się pobrać danych z Bazarr"
}
},
"immich-serverStats": { "immich-serverStats": {
"name": "Statystyki serwera Immich", "name": "Statystyki serwera Immich",
"description": "Informacje o Twojej instancji Immich", "description": "Informacje o Twojej instancji Immich",
@@ -3017,7 +2782,6 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "Album Immich", "name": "Album Immich",
"allPhotos": "Wszystkie zdjęcia",
"option": { "option": {
"albumId": { "albumId": {
"label": "ID albumu", "label": "ID albumu",
@@ -3037,6 +2801,7 @@
} }
}, },
"description": "Pokaz slajdów zdjęć z Twojego albumu w Immich", "description": "Pokaz slajdów zdjęć z Twojego albumu w Immich",
"noAlbumSelected": "Nie wybrano albumu. Edytuj widget i wybierz album",
"noPhotos": "Brak zdjęć w tym albumie" "noPhotos": "Brak zdjęć w tym albumie"
}, },
"downloads": { "downloads": {
@@ -3079,20 +2844,11 @@
"limitPerIntegration": { "limitPerIntegration": {
"label": "Limit elementów na integrację", "label": "Limit elementów na integrację",
"description": "To ograniczy liczbę wyświetlanych elementów dla każdej integracji, a nie globalnie." "description": "To ograniczy liczbę wyświetlanych elementów dla każdej integracji, a nie globalnie."
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
} }
}, },
"errors": { "errors": {
"noColumns": "Wybierz kolumny w elementach", "noColumns": "Wybierz kolumny w elementach",
"noCommunications": "Nie można załadować danych z integracji", "noCommunications": "Nie można załadować danych z integracji"
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3115,8 +2871,7 @@
"detailsTitle": "Bieżący indeks w ramach klienta" "detailsTitle": "Bieżący indeks w ramach klienta"
}, },
"id": { "id": {
"columnTitle": "ID", "columnTitle": "ID"
"detailsTitle": ""
}, },
"integration": { "integration": {
"columnTitle": "Integracja" "columnTitle": "Integracja"
@@ -3150,8 +2905,7 @@
}, },
"time": { "time": {
"columnTitle": "Czas zakończenia", "columnTitle": "Czas zakończenia",
"detailsTitle": "Czas od/do zakończenia", "detailsTitle": "Czas od/do zakończenia"
"eta": ""
}, },
"type": { "type": {
"columnTitle": "Typ", "columnTitle": "Typ",
@@ -3195,23 +2949,7 @@
} }
} }
}, },
"globalRatio": "Wskaźnik globalny", "globalRatio": "Wskaźnik globalny"
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
}, },
"mediaReleases": { "mediaReleases": {
"name": "Wydania mediów", "name": "Wydania mediów",
@@ -3248,12 +2986,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Otwieraj linki w nowej karcie" "label": "Otwieraj linki w nowej karcie"
},
"statusFilter": {
"label": "Statusy do wyświetlenia"
},
"recentDays": {
"label": "Pokaż tylko ostatnie żądania"
} }
}, },
"pending": { "pending": {
@@ -3361,35 +3093,6 @@
} }
} }
}, },
"mediaMissing": {
"name": "Brakujące i oczekujące media",
"description": "Pokazuje brakujące i oczekujące filmy i odcinki z Radarr i Sonarr",
"option": {
"showMissing": {
"label": "Pokaż brakujące elementy"
},
"showQueued": {
"label": "Pokaż oczekujące elementy"
},
"pageSize": {
"label": "Elementów na stronę"
}
},
"tab": {
"missing": "Brakujące",
"queued": "Oczekujące"
},
"type": {
"movie": "Film",
"episode": "Odcinek"
},
"empty": {
"missing": "Brak brakujących elementów",
"queued": "Brak oczekujących elementów",
"noTabsEnabled": "Włącz co najmniej jedną kartę w ustawieniach widżetu"
},
"timeLeft": "Pozostały czas"
},
"rssFeed": { "rssFeed": {
"name": "Kanały RSS", "name": "Kanały RSS",
"description": "Monitoruj i wyświetlaj jeden lub więcej ogólnych kanałów RSS, ATOM lub JSON", "description": "Monitoruj i wyświetlaj jeden lub więcej ogólnych kanałów RSS, ATOM lub JSON",
@@ -3633,11 +3336,7 @@
"name": "Powiadomienia", "name": "Powiadomienia",
"description": "Wyświetl historię powiadomień z integracji", "description": "Wyświetl historię powiadomień z integracji",
"noItems": "Brak powiadomień do wyświetlenia.", "noItems": "Brak powiadomień do wyświetlenia.",
"option": { "option": {}
"hideLogos": {
"label": "Ukryj loga"
}
}
}, },
"speedtestTracker": { "speedtestTracker": {
"name": "Speedtest Tracker", "name": "Speedtest Tracker",
@@ -3718,40 +3417,6 @@
"internalServerError": "Nie udało się pobrać danych czasu pracy" "internalServerError": "Nie udało się pobrać danych czasu pracy"
} }
}, },
"traefik": {
"name": "Traefik",
"description": "Przegląd routerów, usług, middleware i punktów wejścia Traefik",
"option": {
"showTcp": {
"label": "Pokaż TCP"
},
"showUdp": {
"label": "Pokaż UDP"
},
"showEntryPoints": {
"label": "Pokaż punkty wejścia"
}
},
"instances": "Instancje Traefik",
"versionUnknown": "Wersja nieznana",
"summary": {
"routers": "Routery",
"entryPoints": "Punkty wejścia"
},
"resource": {
"routers": "Routery",
"services": "Usługi",
"middlewares": "Middleware"
},
"status": {
"healthy": "Dobry",
"errors": "{count, plural, one {<b>#</b> błąd} few {<b>#</b> błędy} many {<b>#</b> błędów} other {<b>#</b> błędów}}",
"warnings": "{count, plural, one {<b>#</b> ostrzeżenie} few {<b>#</b> ostrzeżenia} many {<b>#</b> ostrzeżeń} other {<b>#</b> ostrzeżeń}}"
},
"error": {
"internalServerError": "Nie udało się pobrać danych Traefik"
}
},
"audioStats": { "audioStats": {
"name": "Audio stats", "name": "Audio stats",
"description": "Statystyki biblioteki i odtwarzania z Navidrome lub Audiobookshelf", "description": "Statystyki biblioteki i odtwarzania z Navidrome lub Audiobookshelf",
@@ -3880,9 +3545,9 @@
"label": "Pokaż pasek wykorzystania w tle" "label": "Pokaż pasek wykorzystania w tle"
}, },
"visibleStorageVolumes": { "visibleStorageVolumes": {
"label": "Widoczne woluminy pamięci", "label": "",
"description": "Dostępne tylko gdy wszystkie wybrane integracje to Synology. Pozostaw puste, aby wyświetlić wszystkie wykryte woluminy pamięci.", "description": "",
"placeholder": "Wybierz woluminy pamięci" "placeholder": ""
} }
}, },
"status": { "status": {
@@ -4127,11 +3792,6 @@
"read": "Odczyt", "read": "Odczyt",
"write": "Zapis" "write": "Zapis"
}, },
"storage": {
"title": "Pamięć",
"subtitle": "Użycie dysku według systemu plików",
"series": "Używane"
},
"network": { "network": {
"title": "Przepustowość", "title": "Przepustowość",
"subtitle": "Ruch sieciowy interfejsów publicznych", "subtitle": "Ruch sieciowy interfejsów publicznych",
@@ -5264,9 +4924,6 @@
"title": "Kontenery", "title": "Kontenery",
"table": { "table": {
"updated": "Zaktualizowano {when}", "updated": "Zaktualizowano {when}",
"refresh": {
"lastUpdated": "Ostatnie odświeżenie {when}"
},
"search": "Szukaj wśród {count} kontenerów", "search": "Szukaj wśród {count} kontenerów",
"selected": "Wybrano {selectCount} z {totalCount} kontenerów", "selected": "Wybrano {selectCount} z {totalCount} kontenerów",
"footer": "Łącznie {count} kontenerów", "footer": "Łącznie {count} kontenerów",
@@ -5995,14 +5652,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "Włącz prawe kliknięcie na widżetach",
"children": {
"detail": {
"title": "Włącz prawe kliknięcie na widżetach"
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "Otwórz pełną stronę preferencji" "label": "Otwórz pełną stronę preferencji"
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more