Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b74d73de0 | ||
|
|
44d7426e9f | ||
|
|
c08ad3a69a | ||
|
|
25b7657a79 | ||
|
|
df438b4cd2 | ||
|
|
a1f91eb385 | ||
|
|
8d5ffb2dd9 | ||
|
|
56e6b23af6 | ||
|
|
5488bf4d18 | ||
|
|
621ea07549 | ||
|
|
f64c01456b | ||
|
|
56198c3f02 | ||
|
|
ca1bb6f341 | ||
|
|
bf6b6663cd | ||
|
|
5c2d86767f | ||
|
|
7c75642d4d | ||
|
|
7652eb8c1c | ||
|
|
e761c10f32 | ||
|
|
2513e8454f | ||
|
|
f6137b2dcc | ||
|
|
425dd90871 | ||
|
|
527e911eef | ||
|
|
ff2cdecc86 | ||
|
|
742bde1d27 | ||
|
|
64febbce12 | ||
|
|
98bb1aecf8 | ||
|
|
06c489bd48 |
No files matched your search
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.75.0
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
- 1.72.0
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
|
||||
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||
|
||||
@@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw
|
||||
apps/docs/docs/integrations/uptime-kuma @ajnart
|
||||
packages/integrations/src/uptime-kuma @ajnart
|
||||
|
||||
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
|
||||
apps/docs/docs/integrations/whats-up-docker @tiimonn
|
||||
apps/docs/docs/widgets/whats-up-docker @tiimonn
|
||||
packages/api/src/router/widgets/wud.ts @tiimonn
|
||||
packages/integrations/src/wud @tiimonn
|
||||
packages/request-handler/src/wud.ts @tiimonn
|
||||
packages/widgets/src/wud @tiimonn
|
||||
|
||||
# Traefik - by @Nerdy-Technician
|
||||
apps/docs/docs/integrations/traefik @Nerdy-Technician
|
||||
apps/docs/docs/widgets/traefik @Nerdy-Technician
|
||||
|
||||
+2
-2
@@ -55,8 +55,8 @@ pnpm typecheck
|
||||
docs/
|
||||
├── getting-started/ # Installation guides, glossary, prerequisites
|
||||
├── management/ # Boards, apps, users, integrations, settings, tasks, API
|
||||
├── integrations/ # 55 integration guides (one folder each)
|
||||
├── widgets/ # 42 widget guides (one folder each)
|
||||
├── integrations/ # 56 integration guides (one folder each)
|
||||
├── widgets/ # 43 widget guides (one folder each)
|
||||
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
|
||||
└── community/ # FAQ, donate, license, translations, get-in-touch
|
||||
```
|
||||
|
||||
@@ -9,17 +9,19 @@ tags:
|
||||
---
|
||||
|
||||
## Icon picker
|
||||
|
||||
Icons in Homarr are automatically requested from multiple sources:
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
|
||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
||||
|
||||
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
|
||||
- [selfh.st icons](https://github.com/selfhst/icons)
|
||||
- [Simple icons](https://github.com/simple-icons/simple-icons)
|
||||
- [Tabler icons](https://tabler-icons.io/)
|
||||
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
|
||||
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
|
||||
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
|
||||
|
||||
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
|
||||
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
|
||||
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
|
||||
|
||||
### Finding Missing Icons
|
||||
|
||||
@@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
|
||||
|
||||
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
|
||||
|
||||
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
|
||||
|
||||
### Using external icons
|
||||
|
||||
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
|
||||
|
||||
### Uploading a local image
|
||||
|
||||
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.75.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -27,6 +27,8 @@ import Alert from '@theme/Admonition';
|
||||
]}
|
||||
/>
|
||||
|
||||
The storage values shown by these widgets use the root ZFS dataset's usable `used` and `available` space, including RAIDZ parity overhead and dataset limits.
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { wudIntegration } from ".";
|
||||
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
|
||||
|
||||
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
|
||||
|
||||
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: [],
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
|
||||
"Enter that username and password in Homarr — Basic authentication is handled automatically",
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const wudIntegration: IntegrationDefinition = {
|
||||
name: "What's Up Docker",
|
||||
description:
|
||||
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
path: "../../integrations/whats-up-docker",
|
||||
};
|
||||
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
|
||||
### Icon URL
|
||||
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import temperature from './img/temperature.png';
|
||||
### Interactions
|
||||
|
||||
- Toggle entity state by clicking on the entity (only when clickable enabled).
|
||||
- After a successful toggle, the displayed state refreshes immediately.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
@@ -64,4 +65,4 @@ import retrieveEntityId from './img/retrieve-entity-id.png';
|
||||
|
||||
<div>
|
||||
<img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,4 +47,7 @@ import withDisplayname from './img/with-displayname.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
|
||||
|
||||
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "What's Up Docker"
|
||||
description: "Displays how many watched containers have image updates available."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { wudWidget } from ".";
|
||||
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
|
||||
|
||||
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
|
||||
|
||||
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: wudIntegration,
|
||||
note: "Displays monitored container and available-update counts",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={wudWidget.configuration} />
|
||||
@@ -0,0 +1,37 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconBrandDocker } from "@tabler/icons-react";
|
||||
|
||||
export const wudWidget: WidgetDefinition = {
|
||||
icon: IconBrandDocker,
|
||||
name: "What's Up Docker",
|
||||
description: "Displays how many watched containers have image updates available.",
|
||||
path: "../../widgets/whats-up-docker",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show title",
|
||||
description: "Displays the icon and \"What's Up Docker\" label above the stats",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show update ring",
|
||||
description: "Displays a ring showing the percentage of monitored containers with a pending update",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
|
||||
values: { type: "select", options: ["Horizontal", "Vertical"] },
|
||||
defaultValue: "Horizontal",
|
||||
},
|
||||
{
|
||||
name: "Show container update list",
|
||||
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
suggestedSearch={mode === "create" ? form.values.name : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
{...form.getInputProps("iconUrl")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Suspense, use, useEffect, useState } from "react";
|
||||
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
import { UpdateIndicator } from "./update";
|
||||
|
||||
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
|
||||
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
|
||||
|
||||
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
|
||||
const loadUserAvatarMenu = () => {
|
||||
if (userAvatarMenuPromise) return userAvatarMenuPromise;
|
||||
|
||||
const promise = import("~/components/user-avatar-menu");
|
||||
userAvatarMenuPromise = promise;
|
||||
void promise.catch(() => {
|
||||
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
|
||||
|
||||
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
|
||||
const { UserAvatarMenu } = use(loadUserAvatarMenu());
|
||||
return <UserAvatarMenu {...props} />;
|
||||
};
|
||||
|
||||
interface UserButtonClientProps {
|
||||
avatar: ReactNode;
|
||||
isAdmin: boolean;
|
||||
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
|
||||
|
||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
||||
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||
const [isMenuMounted, setIsMenuMounted] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const session = useSession();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
|
||||
staleTime: 60 * 60 * 1_000,
|
||||
});
|
||||
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||
const openMenu = () => {
|
||||
setIsMenuMounted(true);
|
||||
setIsMenuOpen(true);
|
||||
};
|
||||
const renderButton = (onClick: () => void, isLoading = false) => (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
onFocus={preloadUserAvatarMenu}
|
||||
onMouseEnter={preloadUserAvatarMenu}
|
||||
onPointerDown={preloadUserAvatarMenu}
|
||||
aria-busy={isLoading || undefined}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
<Box pos="relative" style={{ display: "inline-flex" }}>
|
||||
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
|
||||
{isLoading && (
|
||||
<Loader
|
||||
aria-label="Loading user menu"
|
||||
size={18}
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
style={{ transform: "translate(-50%, -50%)" }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</UpdateIndicator>
|
||||
</UnstyledButton>
|
||||
);
|
||||
const button = renderButton(openMenu);
|
||||
const loadingButton = renderButton(() => undefined, true);
|
||||
|
||||
if (!isMenuMounted) return button;
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary }) =>
|
||||
renderButton(() => {
|
||||
resetErrorBoundary();
|
||||
openMenu();
|
||||
})
|
||||
}
|
||||
>
|
||||
<Suspense fallback={loadingButton}>
|
||||
<LazyUserAvatarMenu
|
||||
availableUpdates={visibleUpdates}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
opened={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
{button}
|
||||
</LazyUserAvatarMenu>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
{avatar}
|
||||
</UpdateIndicator>
|
||||
</UnstyledButton>
|
||||
</UserAvatarMenu>
|
||||
);
|
||||
};
|
||||
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
isDockerEnabled?: boolean;
|
||||
opened: boolean;
|
||||
onOpenChange: (opened: boolean) => void;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({
|
||||
children,
|
||||
availableUpdates,
|
||||
isDockerEnabled,
|
||||
opened,
|
||||
onOpenChange,
|
||||
}: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const session = useSession();
|
||||
|
||||
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
|
||||
@@ -429,6 +429,13 @@
|
||||
<br/>
|
||||
<p align="center">Uptime<br/>Kuma</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/whats-up-docker" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg" alt="What's Up Docker" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">What's<br/>Up<br/>Docker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -45,7 +45,10 @@ describe("Custom JSX custom widgets", () => {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
|
||||
const loginNavigation = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
|
||||
timeout: 60_000,
|
||||
waitUntil: "commit",
|
||||
});
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await loginNavigation;
|
||||
|
||||
|
||||
@@ -7,42 +7,6 @@ import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.74.0",
|
||||
"version": "1.75.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, like } from "@homarr/db";
|
||||
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import { iconsFindSchema } from "@homarr/validation/icons";
|
||||
|
||||
@@ -15,38 +15,41 @@ export const iconsRouter = createTRPCRouter({
|
||||
})
|
||||
.input(iconsFindSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const term = input.searchText?.toLowerCase().trim() ?? "";
|
||||
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
|
||||
|
||||
let whereCondition = undefined;
|
||||
if (term.length > 0) {
|
||||
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
|
||||
}
|
||||
const term = normalizeImageName(input.searchText ?? "");
|
||||
const repositories = await ctx.db.query.iconRepositories.findMany({
|
||||
orderBy: (table, { asc, sql }) => [sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
|
||||
with: {
|
||||
icons: {
|
||||
columns: { id: true, name: true, url: true },
|
||||
orderBy: (table, { asc, sql }) => [
|
||||
sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`,
|
||||
asc(table.name),
|
||||
],
|
||||
limit: term.length === 0 ? input.limitPerGroup : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
icons: await ctx.db.query.iconRepositories.findMany({
|
||||
with: {
|
||||
icons: {
|
||||
columns: { id: true, name: true, url: true },
|
||||
where: whereCondition,
|
||||
orderBy: (table, { asc, sql }) => {
|
||||
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
||||
const nameAsc = asc(table.name);
|
||||
if (term.length === 0) {
|
||||
return [svgFirst, nameAsc];
|
||||
}
|
||||
const tier = sql`CASE
|
||||
WHEN LOWER(${table.name}) = ${term} THEN 0
|
||||
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
|
||||
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
|
||||
ELSE 3
|
||||
END`;
|
||||
return [tier, svgFirst, nameAsc];
|
||||
},
|
||||
limit: input.limitPerGroup,
|
||||
},
|
||||
},
|
||||
}),
|
||||
icons:
|
||||
term.length === 0
|
||||
? repositories
|
||||
: repositories.map((repository) => ({
|
||||
...repository,
|
||||
icons: repository.icons
|
||||
.flatMap((icon) => {
|
||||
const rank = getImageMatchRank(term, icon.name || icon.url);
|
||||
return rank === null ? [] : [{ icon, rank }];
|
||||
})
|
||||
.toSorted((a, b) =>
|
||||
a.rank !== b.rank
|
||||
? a.rank - b.rank
|
||||
: Number(b.icon.name.toLowerCase().endsWith(".svg")) -
|
||||
Number(a.icon.name.toLowerCase().endsWith(".svg")) || a.icon.name.localeCompare(b.icon.name),
|
||||
)
|
||||
.slice(0, input.limitPerGroup)
|
||||
.map(({ icon }) => icon),
|
||||
})),
|
||||
countIcons: await ctx.db.$count(icons),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { iconRepositories, icons } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { iconsRouter } from "../icons";
|
||||
|
||||
describe("findIcons", () => {
|
||||
test.each(["homeassistant", "home_assistant", "home assistant.png"])(
|
||||
"matches normalized search %s before applying the group limit",
|
||||
async (searchText) => {
|
||||
const db = createDb();
|
||||
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await db.insert(iconRepositories).values({ id: "repository", slug: "dashboard-icons" });
|
||||
await db.insert(icons).values([
|
||||
{
|
||||
id: "irrelevant",
|
||||
name: "000-unrelated.svg",
|
||||
url: "https://example.com/000-unrelated.svg",
|
||||
checksum: "irrelevant",
|
||||
iconRepositoryId: "repository",
|
||||
},
|
||||
{
|
||||
id: "home-assistant",
|
||||
name: "home-assistant.svg",
|
||||
url: "https://example.com/home-assistant.svg",
|
||||
checksum: "home-assistant",
|
||||
iconRepositoryId: "repository",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.findIcons({ searchText, limitPerGroup: 1 });
|
||||
|
||||
expect(result.icons[0]?.icons).toEqual([
|
||||
{
|
||||
id: "home-assistant",
|
||||
name: "home-assistant.svg",
|
||||
url: "https://example.com/home-assistant.svg",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -44,4 +44,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
@@ -41,6 +42,13 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
const client = await createIntegrationAsync(integration);
|
||||
const success = await client.triggerToggleAsync(input.entityId);
|
||||
|
||||
if (!success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Home Assistant failed to toggle the entity",
|
||||
});
|
||||
}
|
||||
|
||||
return success;
|
||||
}),
|
||||
executeAutomation: protectedProcedure
|
||||
@@ -55,6 +63,13 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
.input(z.object({ automationId: z.string() }))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.triggerAutomationAsync(input.automationId);
|
||||
const success = await client.triggerAutomationAsync(input.automationId);
|
||||
|
||||
if (!success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Home Assistant failed to trigger the automation",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const wudRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "wud"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
return {
|
||||
stats: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -28,3 +28,34 @@ export const bestMatch = <TItem extends Record<string, unknown>>(
|
||||
return best;
|
||||
}, null);
|
||||
};
|
||||
|
||||
export const normalizeImageName = (value: string) => {
|
||||
const fileName = value.split(/[?#]/, 1)[0]?.split("/").pop() ?? value;
|
||||
const withoutExtension = fileName.replace(/\.(?:avif|gif|jpe?g|png|svg|webp)$/i, "");
|
||||
|
||||
return withoutExtension
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/&/g, " and ")
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
export const getImageMatchRank = (normalizedSearch: string, candidateValue: string): number | null => {
|
||||
const candidate = normalizeImageName(candidateValue);
|
||||
if (!candidate) return null;
|
||||
|
||||
const compactSearch = normalizedSearch.replaceAll(" ", "");
|
||||
const compactCandidate = candidate.replaceAll(" ", "");
|
||||
if (candidate === normalizedSearch) return 0;
|
||||
if (compactCandidate === compactSearch) return 1;
|
||||
if (candidate.startsWith(`${normalizedSearch} `)) return 2;
|
||||
|
||||
const searchTokens = normalizedSearch.split(" ");
|
||||
const candidateTokens = candidate.split(" ");
|
||||
if (searchTokens.every((term) => candidateTokens.some((token) => token === term))) return 3;
|
||||
if (searchTokens.every((term) => candidateTokens.some((token) => token.startsWith(term)))) return 4;
|
||||
if (candidate.includes(normalizedSearch)) return 5;
|
||||
return null;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { bestMatch, capitalize } from "../string";
|
||||
import { bestMatch, capitalize, getImageMatchRank, normalizeImageName } from "../string";
|
||||
|
||||
const capitalizeTestCases = [
|
||||
["hello", "Hello"],
|
||||
@@ -54,3 +54,27 @@ describe("bestMatch should find the best match in an array of options", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("image name matching", () => {
|
||||
test.each([
|
||||
["home-assistant.svg", "home assistant"],
|
||||
["https://example.com/Home_Assistant.PNG?v=1", "home assistant"],
|
||||
["jelly.fin.webp", "jelly fin"],
|
||||
["Caf\u00e9.svg", "cafe"],
|
||||
["\u6771\u4eac.svg", "\u6771\u4eac"],
|
||||
["\u0939\u093f\u0928\u094d\u0926\u0940.svg", "\u0939\u093f\u0928\u094d\u0926\u0940"],
|
||||
])("normalizes %s", (value, expected) => {
|
||||
expect(normalizeImageName(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["homeassistant", "home-assistant.svg", 1],
|
||||
["home assistant", "home_assistant.png", 0],
|
||||
["home assistant", "Home Assistant.webp", 0],
|
||||
["\u6771\u4eac", "\u6771\u4eac.svg", 0],
|
||||
["\u0939\u093f\u0928\u094d\u0926\u0940", "\u0939\u093f\u0928\u094d\u0926\u0940.png", 0],
|
||||
["home assistant", "unrelated.svg", null],
|
||||
])("ranks %s against %s", (search, candidate, expected) => {
|
||||
expect(getImageMatchRank(normalizeImageName(search), candidate)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
||||
import { withTimeoutAsync } from "./timeout";
|
||||
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
export const getDefaultUserAgent = () => `Homarr/${packageJson.version} (+https://homarr.dev)`;
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: TrustedCertificateHostname[],
|
||||
): typeof checkServerIdentity => {
|
||||
@@ -79,6 +83,10 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
async (signal) =>
|
||||
fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
"User-Agent": getDefaultUserAgent(),
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
}),
|
||||
@@ -89,6 +97,10 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
|
||||
return fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
"User-Agent": getDefaultUserAgent(),
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
@@ -210,6 +210,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/unifi-controller"
|
||||
| "/docs/integrations/unraid"
|
||||
| "/docs/integrations/uptime-kuma"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/management/api"
|
||||
| "/docs/management/apps"
|
||||
| "/docs/management/backup"
|
||||
@@ -276,6 +277,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/whats-up-docker"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
| "/docs/integrations/sabnzbd"
|
||||
@@ -330,6 +332,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/integrations/traefik"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/app"
|
||||
@@ -382,4 +385,5 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/beszel-alerts"
|
||||
| "/docs/widgets/beszel-system-stats"
|
||||
| "/docs/widgets/traefik"
|
||||
| "/docs/widgets/custom-api";
|
||||
| "/docs/widgets/custom-api"
|
||||
| "/docs/widgets/whats-up-docker";
|
||||
@@ -54,5 +54,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
beszel: "beszel",
|
||||
traefik: "traefik",
|
||||
archiveTeamWarrior: null,
|
||||
wud: "whats-up-docker",
|
||||
mock: null,
|
||||
};
|
||||
@@ -55,4 +55,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
wud: "whats-up-docker",
|
||||
};
|
||||
@@ -468,6 +468,14 @@ export const integrationDefs = {
|
||||
category: ["archiving"],
|
||||
documentationUrl: null,
|
||||
},
|
||||
wud: {
|
||||
name: "What's Up Docker",
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"),
|
||||
defaultPort: 3000,
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
name: "Mock",
|
||||
|
||||
@@ -24,7 +24,9 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
firewall: getIntegrationKindsByCategory("firewall"),
|
||||
notifications: getIntegrationKindsByCategory("notifications"),
|
||||
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
|
||||
@@ -50,6 +52,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
traefik: ["traefik"],
|
||||
wud: ["wud"],
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
|
||||
@@ -53,6 +53,7 @@ export const widgetKinds = [
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"wud",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
|
||||
@@ -1,3 +1,89 @@
|
||||
[data-combobox-selected="true"] .iconCard {
|
||||
border-color: var(--mantine-primary-color-6);
|
||||
.dropdown {
|
||||
min-width: min(32rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.options {
|
||||
max-height: min(28rem, 60vh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.iconGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
|
||||
}
|
||||
|
||||
.iconOption {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
height: 3.25rem;
|
||||
padding: var(--mantine-spacing-xs);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.iconOption:hover,
|
||||
.iconOption[data-combobox-selected="true"] {
|
||||
border-color: var(--mantine-primary-color-6);
|
||||
background: var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.iconOption:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.formatBadge,
|
||||
.sectionBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
background: var(--mantine-color-red-9);
|
||||
color: var(--mantine-color-white);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.formatBadge {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 0.2rem;
|
||||
padding: 0.15rem 0.2rem;
|
||||
}
|
||||
|
||||
.sectionBadge {
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.urlPreview {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.previewFrame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
flex: 0 0 3rem;
|
||||
}
|
||||
|
||||
.previewLoader {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.iconOption {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -4,31 +4,84 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vi
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
import type { Root } from "react-dom/client";
|
||||
import type { IconGroup } from "./icon-picker.utils";
|
||||
|
||||
const { findIconsQuery } = vi.hoisted(() => ({
|
||||
findIconsQuery: vi.fn((_query: { searchText: string }) => ({
|
||||
data: { icons: [], countIcons: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
const { findIconsQuery, refetch, uploadSelection, sessionPermissions } = vi.hoisted(() => ({
|
||||
findIconsQuery: vi.fn(),
|
||||
refetch: vi.fn(),
|
||||
uploadSelection: vi.fn(),
|
||||
sessionPermissions: { current: [] as string[] },
|
||||
}));
|
||||
|
||||
interface QueryData {
|
||||
icons: IconGroup[];
|
||||
countIcons: number;
|
||||
}
|
||||
|
||||
const emptyResult: QueryData = { icons: [], countIcons: 11_000 };
|
||||
let queryResult: {
|
||||
data?: QueryData;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
|
||||
clientApi: {
|
||||
icon: {
|
||||
findIcons: {
|
||||
useQuery: (input: { searchText: string; limitPerGroup: number }, options: { enabled: boolean }) => {
|
||||
findIconsQuery(input, options);
|
||||
return { ...queryResult, refetch };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: { user: { permissions: [] } } }),
|
||||
useSession: () => ({ data: { user: { permissions: sessionPermissions.current } } }),
|
||||
}));
|
||||
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}:${Object.values(params).join(",")}` : key,
|
||||
}));
|
||||
vi.mock("../upload-media/upload-media", () => ({
|
||||
UploadMedia: ({
|
||||
children,
|
||||
onSuccess,
|
||||
}: {
|
||||
children: (props: object) => unknown;
|
||||
onSuccess: (medias: unknown) => void;
|
||||
}) =>
|
||||
children({
|
||||
loading: false,
|
||||
onClick: () => {
|
||||
uploadSelection();
|
||||
onSuccess([{ id: "uploaded", url: "/api/user-medias/uploaded" }]);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
|
||||
|
||||
import { IconPicker } from "./icon-picker";
|
||||
|
||||
const directIconUrl = "https://cdn.example.com/shopify.svg";
|
||||
const directImageUrl = "https://cdn.example.com/shopify.svg";
|
||||
|
||||
const iconGroups = [
|
||||
{
|
||||
id: "remote",
|
||||
slug: "dashboard-icons",
|
||||
icons: [
|
||||
{ id: "png", name: "shopify.png", url: "https://cdn.example.com/shopify.png" },
|
||||
{ id: "svg", name: "shopify.svg", url: "https://cdn.example.com/shopify.svg" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
slug: "local",
|
||||
icons: [{ id: "local-icon", name: "my-shop.png", url: "/api/user-medias/local-icon" }],
|
||||
},
|
||||
];
|
||||
|
||||
const setInputValue = (input: HTMLInputElement, value: string) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
@@ -37,6 +90,18 @@ const setInputValue = (input: HTMLInputElement, value: string) => {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
const pressKey = (input: HTMLInputElement, key: string, init?: KeyboardEventInit) => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }));
|
||||
};
|
||||
|
||||
const focusInput = async (input: HTMLInputElement) => {
|
||||
await act(async () => input.focus());
|
||||
};
|
||||
|
||||
const advanceDebounce = async () => {
|
||||
await act(async () => vi.advanceTimersByTimeAsync(300));
|
||||
};
|
||||
|
||||
describe("IconPicker", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -45,9 +110,9 @@ describe("IconPicker", () => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
value: (media: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
media,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
@@ -56,54 +121,186 @@ describe("IconPicker", () => {
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: () => undefined, writable: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
findIconsQuery.mockClear();
|
||||
refetch.mockClear();
|
||||
uploadSelection.mockClear();
|
||||
sessionPermissions.current = [];
|
||||
queryResult = { data: emptyResult, isLoading: false, isFetching: false, isError: false };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.unmount();
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.querySelectorAll("[data-combobox-dropdown]").forEach((element) => element.remove());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const renderPicker = async (onChange: (value: string) => void) => {
|
||||
const renderPicker = async (
|
||||
onChange: (value: string) => void,
|
||||
props: Partial<React.ComponentProps<typeof IconPicker>> = {},
|
||||
) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
|
||||
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange, ...props })));
|
||||
});
|
||||
const input = container.querySelector("input");
|
||||
if (!input) throw new Error("Icon picker input was not rendered");
|
||||
return input;
|
||||
};
|
||||
|
||||
test("keeps a direct URL visible without searching the icon repositories", async () => {
|
||||
test("clears an existing value without restoring it", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
const input = await renderPicker(onChange, { value: directImageUrl });
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(input, directIconUrl);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
await act(async () => setInputValue(input, ""));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(directIconUrl);
|
||||
expect(input.value).toBe(directIconUrl);
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("keeps icon names in repository search mode", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
test.each([{ metaKey: true }, { ctrlKey: true }])(
|
||||
"clears the entire value with a line-delete shortcut",
|
||||
async (modifier) => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange, { value: directImageUrl });
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(input, "shopify");
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
await act(async () => pressKey(input, "Backspace", modifier));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(input.value).toBe("shopify");
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(input.value).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
test("debounces repository searches", async () => {
|
||||
const input = await renderPicker(vi.fn());
|
||||
findIconsQuery.mockClear();
|
||||
|
||||
await act(async () => setInputValue(input, "shopify"));
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(false);
|
||||
|
||||
await advanceDebounce();
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
|
||||
});
|
||||
|
||||
test("pre-searches the suggested app name when opened", async () => {
|
||||
const input = await renderPicker(vi.fn(), { suggestedSearch: "Jellyfin" });
|
||||
|
||||
await focusInput(input);
|
||||
expect(input.value).toBe("Jellyfin");
|
||||
await advanceDebounce();
|
||||
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "Jellyfin")).toBe(true);
|
||||
});
|
||||
|
||||
test("shows local images before SVG and raster library images", async () => {
|
||||
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
|
||||
const input = await renderPicker(vi.fn());
|
||||
|
||||
await focusInput(input);
|
||||
const content = document.body.textContent ?? "";
|
||||
|
||||
expect(document.body.querySelector('[role="listbox"]')?.getAttribute("aria-label")).toBe("iconPicker.results");
|
||||
expect(content.indexOf("iconPicker.localImages")).toBeLessThan(content.indexOf("iconPicker.svgIcons"));
|
||||
expect(content.indexOf("iconPicker.svgIcons")).toBeLessThan(content.indexOf("iconPicker.otherImages"));
|
||||
});
|
||||
|
||||
test("selects a library image with a pointer", async () => {
|
||||
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
await focusInput(input);
|
||||
|
||||
const option = document.body.querySelector<HTMLElement>(
|
||||
'[data-combobox-option][value="/api/user-medias/local-icon"]',
|
||||
);
|
||||
if (!option) throw new Error("Local image option was not rendered");
|
||||
await act(async () => option.click());
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
|
||||
expect(input.value).toBe("/api/user-medias/local-icon");
|
||||
});
|
||||
|
||||
test("selects a library image with the keyboard", async () => {
|
||||
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
await focusInput(input);
|
||||
|
||||
await act(async () => pressKey(input, "ArrowDown"));
|
||||
await act(async () => pressKey(input, "Enter"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
|
||||
});
|
||||
|
||||
test("keeps a direct URL visible and reports a successful preview", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
|
||||
await act(async () => setInputValue(input, directImageUrl));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(directImageUrl);
|
||||
expect(input.value).toBe(directImageUrl);
|
||||
expect(findIconsQuery.mock.calls.at(-1)?.[1]).toMatchObject({ enabled: false });
|
||||
expect(document.body.textContent).toContain("iconPicker.urlLoading");
|
||||
|
||||
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
|
||||
if (!preview) throw new Error("Direct URL preview was not rendered");
|
||||
await act(async () => preview.dispatchEvent(new Event("load")));
|
||||
expect(document.body.textContent).toContain("iconPicker.urlReady");
|
||||
});
|
||||
|
||||
test("keeps a failed direct URL editable and shows an error", async () => {
|
||||
const input = await renderPicker(vi.fn());
|
||||
await act(async () => setInputValue(input, "https://invalid.example/image.png"));
|
||||
|
||||
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
|
||||
if (!preview) throw new Error("Direct URL preview was not rendered");
|
||||
await act(async () => preview.dispatchEvent(new Event("error")));
|
||||
|
||||
expect(input.value).toBe("https://invalid.example/image.png");
|
||||
expect(document.body.textContent).toContain("iconPicker.urlError");
|
||||
});
|
||||
|
||||
test("restores the committed value when an unfinished search is cancelled", async () => {
|
||||
const input = await renderPicker(vi.fn(), { value: directImageUrl });
|
||||
|
||||
await act(async () => setInputValue(input, "unfinished search"));
|
||||
await act(async () => pressKey(input, "Escape"));
|
||||
|
||||
expect(input.value).toBe(directImageUrl);
|
||||
});
|
||||
|
||||
test("offers retry when the repository search fails", async () => {
|
||||
queryResult = { data: undefined, isLoading: false, isFetching: false, isError: true };
|
||||
const input = await renderPicker(vi.fn());
|
||||
await focusInput(input);
|
||||
|
||||
const retry = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "action.tryAgain",
|
||||
);
|
||||
if (!retry) throw new Error("Retry button was not rendered");
|
||||
await act(async () => retry.click());
|
||||
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("selects an uploaded local image when the user has permission", async () => {
|
||||
sessionPermissions.current = ["media-upload"];
|
||||
const onChange = vi.fn();
|
||||
await renderPicker(onChange);
|
||||
|
||||
const upload = container.querySelector<HTMLButtonElement>('[aria-label="iconPicker.uploadImage"]');
|
||||
if (!upload) throw new Error("Upload button was not rendered");
|
||||
await act(async () => upload.click());
|
||||
|
||||
expect(uploadSelection).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith("/api/user-medias/uploaded");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import type { FocusEventHandler, KeyboardEventHandler } from "react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CloseButton,
|
||||
Combobox,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Indicator,
|
||||
InputBase,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCloud,
|
||||
IconMoodEmpty,
|
||||
IconPhoto,
|
||||
IconPhotoOff,
|
||||
IconSearchOff,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -29,6 +38,7 @@ import { supportedLanguages } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UploadMedia } from "../upload-media/upload-media";
|
||||
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
|
||||
import classes from "./icon-picker.module.css";
|
||||
|
||||
interface IconPickerProps {
|
||||
@@ -39,9 +49,12 @@ interface IconPickerProps {
|
||||
onBlur?: FocusEventHandler;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
suggestedSearch?: string | null;
|
||||
withAsterisk?: boolean;
|
||||
}
|
||||
|
||||
type PreviewState = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export const IconPicker = ({
|
||||
value: propsValue,
|
||||
onChange,
|
||||
@@ -51,188 +64,403 @@ export const IconPicker = ({
|
||||
withAsterisk = true,
|
||||
label,
|
||||
placeholder,
|
||||
suggestedSearch,
|
||||
}: IconPickerProps) => {
|
||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
}, [value]);
|
||||
|
||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange, defaultValue: "" });
|
||||
const [draft, setDraft] = useState(propsValue ?? "");
|
||||
const [hasEdited, setHasEdited] = useState(false);
|
||||
const [keyboardIndex, setKeyboardIndex] = useState(-1);
|
||||
const [previewState, setPreviewState] = useState<PreviewState>("idle");
|
||||
const { data: session } = useSession();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
||||
const [debouncedDraft] = useDebouncedValue(draft, 250);
|
||||
|
||||
const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : "";
|
||||
const directUrl = isDirectImageUrl(draft) ? draft.trim() : null;
|
||||
const isCommittedValueDraft = debouncedDraft === (value ?? "");
|
||||
const searchText = directUrl !== null || isCommittedValueDraft ? "" : debouncedDraft.trim();
|
||||
const canUpload = session?.user.permissions.includes("media-upload") ?? false;
|
||||
|
||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText },
|
||||
{ placeholderData: (prev) => prev },
|
||||
const query = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText, limitPerGroup: 24 },
|
||||
{ enabled: directUrl === null, placeholderData: (previous) => previous },
|
||||
);
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
|
||||
const groups = data?.icons.map((group) => {
|
||||
const options = group.icons.map((item) => (
|
||||
<Combobox.Option
|
||||
key={item.id}
|
||||
value={item.url}
|
||||
p={0}
|
||||
h="calc(2*var(--mantine-spacing-sm)+25px)"
|
||||
w="calc(2*var(--mantine-spacing-sm)+25px)"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
setValue(item.url);
|
||||
setQuery("");
|
||||
combobox.closeDropdown();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} />
|
||||
</Box>
|
||||
</Card>
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
</Combobox.Option>
|
||||
));
|
||||
useEffect(() => {
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Paper p="xs" key={group.slug} pt={2}>
|
||||
<Text mb={8} size="sm" fw="bold">
|
||||
{group.slug}
|
||||
</Text>
|
||||
<Flex gap={8} wrap={"wrap"}>
|
||||
{options}
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
useEffect(() => {
|
||||
setPreviewState(directUrl ? "loading" : "idle");
|
||||
}, [directUrl]);
|
||||
|
||||
const inputValue = query || value || "";
|
||||
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
||||
const placeholderText =
|
||||
placeholder ||
|
||||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
||||
tCommon("iconPicker.headerLoading");
|
||||
const sections = useMemo(
|
||||
() => arrangeIconPickerSections(query.data?.icons ?? [], searchText),
|
||||
[query.data?.icons, searchText],
|
||||
);
|
||||
const orderedOptions = useMemo(
|
||||
() => [...sections.local, ...sections.svg, ...sections.other],
|
||||
[sections.local, sections.svg, sections.other],
|
||||
);
|
||||
const totalOptions = sections.local.length + sections.svg.length + sections.other.length;
|
||||
|
||||
const renderDropdown = () => {
|
||||
if (isError) {
|
||||
const commitValue = (nextValue: string) => {
|
||||
startTransition(() => {
|
||||
setValue(nextValue);
|
||||
setDraft(nextValue);
|
||||
setHasEdited(false);
|
||||
setKeyboardIndex(-1);
|
||||
});
|
||||
};
|
||||
|
||||
const clearValue = () => {
|
||||
commitValue("");
|
||||
combobox.openDropdown("keyboard");
|
||||
};
|
||||
|
||||
const openWithSuggestedSearch = () => {
|
||||
if (!draft && !value && !hasEdited && suggestedSearch?.trim()) {
|
||||
setDraft(suggestedSearch.trim());
|
||||
}
|
||||
combobox.openDropdown();
|
||||
};
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
clearValue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
combobox.closeDropdown("keyboard");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!directUrl && orderedOptions.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
||||
event.preventDefault();
|
||||
combobox.openDropdown("keyboard");
|
||||
setKeyboardIndex((current) => {
|
||||
const next =
|
||||
event.key === "ArrowDown"
|
||||
? current >= orderedOptions.length - 1
|
||||
? 0
|
||||
: current + 1
|
||||
: current <= 0
|
||||
? orderedOptions.length - 1
|
||||
: current - 1;
|
||||
combobox.selectOption(next);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (keyboardIndex >= 0 && orderedOptions[keyboardIndex]) {
|
||||
event.preventDefault();
|
||||
commitValue(orderedOptions[keyboardIndex].url);
|
||||
combobox.closeDropdown("keyboard");
|
||||
} else if (isImageSource(draft)) {
|
||||
event.preventDefault();
|
||||
commitValue(draft.trim());
|
||||
combobox.closeDropdown("keyboard");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderIconOptions = (icons: typeof sections.local, offset: number) => (
|
||||
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6}>
|
||||
{icons.map((icon, index) => (
|
||||
<Tooltip key={icon.id} label={`${icon.name} · ${icon.repositorySlug}`} openDelay={500}>
|
||||
<Combobox.Option
|
||||
value={icon.url}
|
||||
aria-label={icon.name}
|
||||
active={icon.url === value || keyboardIndex === offset + index}
|
||||
className={classes.iconOption}
|
||||
>
|
||||
<Image src={icon.url} alt="" w={30} h={30} fit="contain" fallbackSrc="/logo/logo.png" />
|
||||
{isSvgImage(icon.url) && <span className={classes.formatBadge}>SVG</span>}
|
||||
</Combobox.Option>
|
||||
</Tooltip>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
const renderSearchResults = () => {
|
||||
if (query.isError) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconSearchOff size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.error")}</Text>
|
||||
<Button size="xs" variant="default" onClick={() => refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<PickerMessage
|
||||
icon={<IconSearchOff size={22} />}
|
||||
title={tCommon("iconPicker.error")}
|
||||
action={
|
||||
<Button size="xs" variant="default" onClick={() => query.refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return Array(15)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6} p="xs">
|
||||
{Array.from({ length: 12 }, (_, index) => (
|
||||
<Skeleton key={index} height={52} radius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
if (totalOptions > 0) {
|
||||
return <Stack gap={4}>{groups}</Stack>;
|
||||
|
||||
if (totalOptions === 0) {
|
||||
return (
|
||||
<PickerMessage
|
||||
icon={<IconMoodEmpty size={22} />}
|
||||
title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconMoodEmpty size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
||||
<Stack gap="sm" p="xs">
|
||||
{sections.local.length > 0 && (
|
||||
<PickerSection icon={<IconPhoto size={15} />} label={tCommon("iconPicker.localImages")}>
|
||||
{renderIconOptions(sections.local, 0)}
|
||||
</PickerSection>
|
||||
)}
|
||||
{sections.svg.length > 0 && (
|
||||
<PickerSection label={tCommon("iconPicker.svgIcons")} badge="SVG">
|
||||
{renderIconOptions(sections.svg, sections.local.length)}
|
||||
</PickerSection>
|
||||
)}
|
||||
{sections.other.length > 0 && (
|
||||
<PickerSection icon={<IconCloud size={15} />} label={tCommon("iconPicker.otherImages")}>
|
||||
{renderIconOptions(sections.other, sections.local.length + sections.svg.length)}
|
||||
</PickerSection>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const leftSection = (() => {
|
||||
if (directUrl && previewState === "loading") return <Loader size={15} />;
|
||||
if (directUrl && previewState === "error") return <IconPhotoOff size={17} color="var(--mantine-color-red-6)" />;
|
||||
if (shouldShowPreview(value)) {
|
||||
return (
|
||||
<Image
|
||||
src={value}
|
||||
alt=""
|
||||
w={22}
|
||||
h={22}
|
||||
fit="contain"
|
||||
onLoad={() => directUrl && setPreviewState("ready")}
|
||||
onError={() => directUrl && setPreviewState("error")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <IconPhoto size={17} stroke={1.5} />;
|
||||
})();
|
||||
|
||||
const placeholderText =
|
||||
placeholder ??
|
||||
(query.data
|
||||
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
|
||||
: tCommon("iconPicker.headerLoading"));
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal>
|
||||
<Combobox.Target>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<InputBase
|
||||
flex={1}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={leftSection}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
const trimmedValue = nextValue.trim();
|
||||
setQuery(nextValue);
|
||||
if (isDirectIconUrl(trimmedValue)) {
|
||||
setValue(trimmedValue);
|
||||
combobox.closeDropdown();
|
||||
} else {
|
||||
combobox.openDropdown();
|
||||
}
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setQuery("");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
placeholder={placeholderText}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
startTransition(() => {
|
||||
setValue(first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal
|
||||
resetSelectionOnOptionHover
|
||||
onOptionSubmit={(url) => {
|
||||
commitValue(url);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target withExpandedAttribute>
|
||||
<InputBase
|
||||
flex={1}
|
||||
leftSection={leftSection}
|
||||
rightSection={
|
||||
<Group gap={2} wrap="nowrap">
|
||||
{canUpload && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
commitValue(first.url);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<Tooltip label={tCommon("iconPicker.uploadImage")}>
|
||||
<ActionIcon
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
loading={loading}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
aria-label={tCommon("iconPicker.uploadImage")}
|
||||
>
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
{(draft || value) && (
|
||||
<CloseButton
|
||||
size="sm"
|
||||
aria-label={tCommon("iconPicker.clear")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={clearValue}
|
||||
/>
|
||||
)}
|
||||
{query.isFetching && !directUrl ? <Loader size={14} /> : <Combobox.Chevron />}
|
||||
</Group>
|
||||
}
|
||||
rightSectionWidth={canUpload ? 86 : 56}
|
||||
rightSectionPointerEvents="all"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
setDraft(nextValue);
|
||||
setHasEdited(true);
|
||||
setKeyboardIndex(-1);
|
||||
combobox.openDropdown("keyboard");
|
||||
combobox.updateSelectedOptionIndex();
|
||||
|
||||
if (!nextValue) {
|
||||
setValue("");
|
||||
} else if (isImageSource(nextValue)) {
|
||||
setValue(nextValue.trim());
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={openWithSuggestedSearch}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
openWithSuggestedSearch();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
}}
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
description={tCommon("iconPicker.description")}
|
||||
styles={{
|
||||
description: { color: "var(--mantine-color-text)" },
|
||||
input: { color: "var(--mantine-color-text)" },
|
||||
error: { color: "light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2))" },
|
||||
}}
|
||||
placeholder={placeholderText}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{renderDropdown()}
|
||||
<Combobox.Dropdown className={classes.dropdown}>
|
||||
<Combobox.Options className={classes.options} aria-label={tCommon("iconPicker.results")}>
|
||||
{directUrl ? (
|
||||
<Paper p="sm" m="xs" className={classes.urlPreview}>
|
||||
<Group wrap="nowrap" align="center">
|
||||
<Box className={classes.previewFrame}>
|
||||
{previewState === "loading" && <Loader size={18} className={classes.previewLoader} />}
|
||||
{previewState === "error" ? (
|
||||
<ThemeIcon variant="light" color="red" size={40}>
|
||||
<IconPhotoOff size={21} />
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<Image
|
||||
src={directUrl}
|
||||
alt=""
|
||||
w={40}
|
||||
h={40}
|
||||
fit="contain"
|
||||
data-testid="direct-url-preview"
|
||||
onLoad={() => setPreviewState("ready")}
|
||||
onError={() => setPreviewState("error")}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex={1} miw={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{previewState === "error"
|
||||
? tCommon("iconPicker.urlError")
|
||||
: previewState === "ready"
|
||||
? tCommon("iconPicker.urlReady")
|
||||
: tCommon("iconPicker.urlLoading")}
|
||||
</Text>
|
||||
<Text size="xs" truncate>
|
||||
{directUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
{previewState === "ready" && (
|
||||
<ThemeIcon variant="light" color="green" size="sm" aria-hidden>
|
||||
<IconCheck size={14} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
renderSearchResults()
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
||||
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||
interface PickerSectionProps {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
badge?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PickerSection = ({ label, icon, badge, children }: PickerSectionProps) => (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6} px={2}>
|
||||
{icon}
|
||||
<Text size="xs" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{badge && <span className={classes.sectionBadge}>{badge}</span>}
|
||||
</Group>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
interface PickerMessageProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
const PickerMessage = ({ icon, title, action }: PickerMessageProps) => (
|
||||
<Stack align="center" gap="xs" p="lg" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size="lg">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text size="sm">{title}</Text>
|
||||
{action}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
||||
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
||||
if (!value) return false;
|
||||
return !localizationPathRegex.test(value);
|
||||
};
|
||||
|
||||
const isDirectIconUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { IconGroup } from "./icon-picker.utils";
|
||||
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
|
||||
|
||||
const groups = [
|
||||
{
|
||||
id: "remote",
|
||||
slug: "dashboard-icons",
|
||||
icons: [
|
||||
{ id: "long", name: "home-assistant-alt.svg", url: "https://cdn.example.com/home-assistant-alt.svg" },
|
||||
{ id: "raster", name: "Home Assistant.png", url: "https://cdn.example.com/home-assistant.png" },
|
||||
{ id: "svg", name: "home-assistant.svg", url: "https://cdn.example.com/home-assistant.svg?v=1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
slug: "local",
|
||||
icons: [{ id: "local", name: "Home Assistant.png", url: "/api/user-medias/home-assistant" }],
|
||||
},
|
||||
] as IconGroup[];
|
||||
|
||||
describe("icon picker utilities", () => {
|
||||
test("arranges local images before remote SVG and raster images", () => {
|
||||
const result = arrangeIconPickerSections(groups, "home assistant");
|
||||
|
||||
expect(result.local.map((icon) => icon.id)).toEqual(["local"]);
|
||||
expect(result.svg.map((icon) => icon.id)).toEqual(["svg", "long"]);
|
||||
expect(result.other.map((icon) => icon.id)).toEqual(["raster"]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["https://example.com/icon.png", true],
|
||||
["http://localhost:3000/icon", true],
|
||||
["ftp://example.com/icon.png", false],
|
||||
["javascript:alert(1)", false],
|
||||
["example.com/icon.png", false],
|
||||
])("validates direct image URLs", (value, expected) => {
|
||||
expect(isDirectImageUrl(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test("accepts internal image paths without treating search text as a source", () => {
|
||||
expect(isImageSource("/api/user-medias/123")).toBe(true);
|
||||
expect(isImageSource("shopify")).toBe(false);
|
||||
});
|
||||
|
||||
test("recognizes SVG URLs with query strings case-insensitively", () => {
|
||||
expect(isSvgImage("https://example.com/ICON.SVG?raw=1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
|
||||
|
||||
type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
export type IconGroup = RouterOutput["icon"]["findIcons"]["icons"][number];
|
||||
export type PickerIcon = IconGroup["icons"][number] & { repositorySlug: string };
|
||||
|
||||
export interface IconPickerSections {
|
||||
local: PickerIcon[];
|
||||
svg: PickerIcon[];
|
||||
other: PickerIcon[];
|
||||
}
|
||||
|
||||
export const arrangeIconPickerSections = (iconGroups: IconGroup[], searchTerm = ""): IconPickerSections => {
|
||||
const sections: IconPickerSections = { local: [], svg: [], other: [] };
|
||||
|
||||
for (const group of iconGroups) {
|
||||
for (const icon of group.icons) {
|
||||
const pickerIcon = { ...icon, repositorySlug: group.slug };
|
||||
if (group.slug === "local") {
|
||||
sections.local.push(pickerIcon);
|
||||
} else if (isSvgImage(icon.url)) {
|
||||
sections.svg.push(pickerIcon);
|
||||
} else {
|
||||
sections.other.push(pickerIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSearch = normalizeImageName(searchTerm);
|
||||
if (normalizedSearch) {
|
||||
const compareMatches = (a: PickerIcon, b: PickerIcon) => {
|
||||
const aRank = getImageMatchRank(normalizedSearch, a.name || a.url) ?? Number.MAX_SAFE_INTEGER;
|
||||
const bRank = getImageMatchRank(normalizedSearch, b.name || b.url) ?? Number.MAX_SAFE_INTEGER;
|
||||
return aRank - bRank || a.name.localeCompare(b.name);
|
||||
};
|
||||
return {
|
||||
local: sections.local.toSorted(compareMatches),
|
||||
svg: sections.svg.toSorted(compareMatches),
|
||||
other: sections.other.toSorted(compareMatches),
|
||||
};
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
export const isDirectImageUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isImageSource = (value: string) => isDirectImageUrl(value) || value.trim().startsWith("/");
|
||||
|
||||
export const isSvgImage = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value, "http://homarr.local");
|
||||
return url.pathname.toLowerCase().endsWith(".svg");
|
||||
} catch {
|
||||
return value.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".svg") ?? false;
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@ export * from "./new-app/_app-new-form";
|
||||
export * from "./new-app/_form";
|
||||
|
||||
export * from "./icon-picker/icon-picker";
|
||||
export * from "./new-app/icon-matcher";
|
||||
|
||||
export * from "./upload-media/upload-media";
|
||||
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
import { IconPicker } from "../icon-picker/icon-picker";
|
||||
import { findBestIconMatch } from "./icon-matcher";
|
||||
|
||||
type FormType = z.infer<typeof appManageSchema>;
|
||||
|
||||
@@ -81,9 +79,6 @@ export const AppForm = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialValuesKey]);
|
||||
|
||||
// Debounce the name value with 200ms delay
|
||||
const [debouncedName] = useDebouncedValue(form.values.name, 200);
|
||||
|
||||
const shouldCreateAnother = useRef(false);
|
||||
const handleSubmit = (values: FormType) => {
|
||||
const redirect = !shouldCreateAnother.current;
|
||||
@@ -130,29 +125,13 @@ export const AppForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-select icon based on app name with debounced search
|
||||
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
|
||||
{
|
||||
searchText: debouncedName,
|
||||
},
|
||||
{
|
||||
enabled: debouncedName.length > 3,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedName && !form.values.iconUrl && iconsData?.icons) {
|
||||
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
|
||||
if (bestMatch) {
|
||||
form.setFieldValue("iconUrl", bestMatch);
|
||||
}
|
||||
}
|
||||
}, [debouncedName, iconsData]);
|
||||
|
||||
const formFields = (
|
||||
<Stack>
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<IconPicker
|
||||
{...form.getInputProps("iconUrl")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
<Textarea
|
||||
{...form.getInputProps("description")}
|
||||
label={t("app.field.description.label")}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
|
||||
type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
type IconGroupsOutput = RouterOutput["icon"]["findIcons"]["icons"];
|
||||
|
||||
export const findBestIconMatch = (searchTerm: string, iconGroups: IconGroupsOutput): string | null => {
|
||||
const nameLower = searchTerm.toLowerCase();
|
||||
const allIcons = iconGroups.flatMap((group) => group.icons);
|
||||
|
||||
const getIconPriority = (iconUrl: string) => {
|
||||
const fileName = iconUrl.toLowerCase().split("/").pop()?.split(".")[0];
|
||||
if (!fileName) return -1;
|
||||
|
||||
const isSvg = iconUrl.endsWith(".svg");
|
||||
const isExactMatch = fileName === nameLower;
|
||||
|
||||
if (isExactMatch) return isSvg ? 0 : 1;
|
||||
if (fileName.includes(nameLower)) return isSvg ? 2 : 3;
|
||||
return -1;
|
||||
};
|
||||
|
||||
for (let priority = 0; priority <= 3; priority++) {
|
||||
const match = allIcons.find((icon) => getIconPriority(icon.url) === priority);
|
||||
if (match) return match.url;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -54,6 +54,7 @@ import { UmamiIntegration } from "../umami/umami-integration";
|
||||
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||
import { UnraidIntegration } from "../unraid/unraid-integration";
|
||||
import { WudIntegration } from "../wud/wud-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
|
||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||
@@ -133,6 +134,7 @@ export const integrationCreators = {
|
||||
beszel: BeszelIntegration,
|
||||
bazarr: BazarrIntegration,
|
||||
traefik: TraefikIntegration,
|
||||
wud: WudIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
|
||||
*/
|
||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||
headers: this.getAuthHeaders(),
|
||||
headers: {
|
||||
...this.getAuthHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// General integrations
|
||||
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
|
||||
export { WudIntegration } from "./wud/wud-integration";
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||
@@ -91,6 +92,7 @@ export type {
|
||||
ArchiveTeamWarriorItem,
|
||||
ArchiveTeamWarriorStatus,
|
||||
} from "./archive-team-warrior/archive-team-warrior-types";
|
||||
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
|
||||
|
||||
// Schemas
|
||||
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
|
||||
|
||||
@@ -85,6 +85,11 @@ export class SynologyClient {
|
||||
if (volumesFromV2.length > 0) {
|
||||
return volumesFromV2;
|
||||
}
|
||||
|
||||
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
|
||||
if (legacyVolumesFromV2.length > 0) {
|
||||
return legacyVolumesFromV2;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
|
||||
integrationId: this.integrationId,
|
||||
|
||||
@@ -78,6 +78,7 @@ export const synologyStorageV2VolumeSchema = z
|
||||
|
||||
export const synologyStorageV2DataSchema = z.object({
|
||||
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
|
||||
vol_info: z.array(synologyVolumeInfoSchema).optional(),
|
||||
});
|
||||
|
||||
export const synologyUtilizationCpuSchema = z.object({
|
||||
|
||||
@@ -426,6 +426,60 @@ describe("SynologyIntegration.getSystemInfoAsync", () => {
|
||||
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
|
||||
});
|
||||
|
||||
test("uses vol_info from storage_v2 without falling back to legacy storage", async () => {
|
||||
mockSuccessfulFlow({
|
||||
storageV2: {
|
||||
success: true,
|
||||
data: {
|
||||
vol_info: [
|
||||
{
|
||||
name: "volume_1",
|
||||
used_size: "4000000000",
|
||||
total_size: "10000000000",
|
||||
status: "normal",
|
||||
},
|
||||
{
|
||||
name: "volume_2",
|
||||
used_size: "5000000000",
|
||||
total_size: "20000000000",
|
||||
status: "attention",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toEqual([
|
||||
{
|
||||
deviceName: "volume_1",
|
||||
used: "4000000000",
|
||||
available: "6000000000",
|
||||
percentage: 40,
|
||||
},
|
||||
{
|
||||
deviceName: "volume_2",
|
||||
used: "5000000000",
|
||||
available: "15000000000",
|
||||
percentage: 25,
|
||||
},
|
||||
]);
|
||||
|
||||
const legacyStorageRequests = mockFetch.mock.calls.filter(([url]) => {
|
||||
const urlString = toUrlString(url);
|
||||
|
||||
return (
|
||||
getApiFromUrl(urlString) === "SYNO.Core.System" &&
|
||||
getMethodFromUrl(urlString) === "info" &&
|
||||
getTypeFromUrl(urlString) === "storage"
|
||||
);
|
||||
});
|
||||
|
||||
expect(legacyStorageRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("continues when optional tier-2 calls fail", async () => {
|
||||
mockSuccessfulFlow({ skipOptional: true });
|
||||
const integration = createIntegration();
|
||||
|
||||
@@ -148,6 +148,14 @@ const happyResponder = (method: string) => {
|
||||
];
|
||||
case "pool.query":
|
||||
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
|
||||
case "pool.dataset.query":
|
||||
return [
|
||||
{
|
||||
id: "tank",
|
||||
used: { parsed: 200 },
|
||||
available: { parsed: 300 },
|
||||
},
|
||||
];
|
||||
case "interface.query":
|
||||
return [{ id: "eth0", name: "eth0" }];
|
||||
case "reporting.netdata_get_data":
|
||||
@@ -191,6 +199,18 @@ describe("TrueNasIntegration", () => {
|
||||
// JSON-RPC does not use the legacy "connect" session handshake.
|
||||
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
|
||||
|
||||
const datasetPayload = ws.sentPayloads.find((payload) => payload.method === "pool.dataset.query");
|
||||
expect(datasetPayload).toMatchObject({
|
||||
params: [
|
||||
[["id", "in", ["tank"]]],
|
||||
{
|
||||
extra: {
|
||||
properties: ["used", "available"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
version: "TrueNAS-SCALE-24.10",
|
||||
cpuModelName: "Intel Xeon",
|
||||
@@ -204,12 +224,25 @@ describe("TrueNasIntegration", () => {
|
||||
loadAverage: null,
|
||||
gpu: [],
|
||||
network: { up: 20_000, down: 10_000 },
|
||||
// `available` is the free space left on the pool (free = 500), not its total size.
|
||||
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
||||
// `available` and `used` come from the root dataset, not the physical pool.
|
||||
fileSystem: [{ deviceName: "tank", available: "300", used: "200", percentage: 40 }],
|
||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
||||
});
|
||||
});
|
||||
|
||||
test("reports zero usage for an empty root dataset", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? [{ id: "tank", used: { parsed: 0 }, available: { parsed: 0 } }]
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem[0]?.percentage).toBe(0);
|
||||
});
|
||||
|
||||
test("authenticates with an API key when one is configured", async () => {
|
||||
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
|
||||
|
||||
|
||||
@@ -49,9 +49,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
memUsedInBytes,
|
||||
fileSystem: datasets.map((dataset) => ({
|
||||
deviceName: dataset.name,
|
||||
available: `${dataset.free}`, // free space left on the pool
|
||||
used: `${dataset.allocated}`,
|
||||
percentage: (dataset.allocated / dataset.size) * 100,
|
||||
available: `${dataset.available}`,
|
||||
used: `${dataset.used}`,
|
||||
percentage:
|
||||
dataset.used + dataset.available === 0 ? 0 : (dataset.used / (dataset.used + dataset.available)) * 100,
|
||||
})),
|
||||
availablePkgUpdates: 0,
|
||||
network: {
|
||||
@@ -117,12 +118,40 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
return null;
|
||||
})
|
||||
.filter((pool) => pool !== null);
|
||||
|
||||
if (activePools.length === 0) return [];
|
||||
|
||||
const datasets = await this.requestAsync("pool.dataset.query", [
|
||||
[["id", "in", activePools.map((pool) => pool.name)]],
|
||||
{
|
||||
extra: {
|
||||
properties: ["used", "available"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const parsedDatasets = await poolDatasetSchema.parseAsync(datasets);
|
||||
|
||||
const poolsWithUsableSpace = activePools
|
||||
.map((pool) => {
|
||||
const dataset = parsedDatasets.find((candidate) => candidate.id === pool.name);
|
||||
if (!dataset || dataset.used.parsed === null || dataset.available.parsed === null) return null;
|
||||
|
||||
return {
|
||||
name: pool.name,
|
||||
status: pool.status,
|
||||
healthy: pool.healthy,
|
||||
used: dataset.used.parsed,
|
||||
available: dataset.available.parsed,
|
||||
};
|
||||
})
|
||||
.filter((pool) => pool !== null);
|
||||
|
||||
logger.debug("Retrieved pools", {
|
||||
url: this.integration.url,
|
||||
totalCount: result.length,
|
||||
activeCount: activePools.length,
|
||||
activeCount: poolsWithUsableSpace.length,
|
||||
});
|
||||
return activePools;
|
||||
return poolsWithUsableSpace;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,6 +292,14 @@ const poolSchema = z.array(
|
||||
}),
|
||||
);
|
||||
|
||||
const poolDatasetSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
used: z.object({ parsed: z.number().min(0).nullable() }),
|
||||
available: z.object({ parsed: z.number().min(0).nullable() }),
|
||||
}),
|
||||
);
|
||||
|
||||
const reportingNetDataSchema = z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment node
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { WudIntegration } from "../wud-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://wud.example.com";
|
||||
const TEST_USERNAME = "user";
|
||||
const TEST_PASSWORD = "p@ss";
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const sampleContainersResponse = [
|
||||
{
|
||||
id: "1",
|
||||
name: "homeassistant",
|
||||
displayName: "Home Assistant",
|
||||
updateAvailable: true,
|
||||
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
|
||||
image: { tag: { value: "2023.12.0" } },
|
||||
updateKind: { remoteValue: "2024.1.0" },
|
||||
},
|
||||
{ id: "2", name: "traefik", updateAvailable: false },
|
||||
{
|
||||
id: "3",
|
||||
name: "grafana",
|
||||
updateAvailable: true,
|
||||
image: { tag: { value: "10.0.0" } },
|
||||
result: { tag: "10.1.0" },
|
||||
},
|
||||
];
|
||||
|
||||
const sampleUpdates = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Home Assistant",
|
||||
currentVersion: "2023.12.0",
|
||||
newVersion: "2024.1.0",
|
||||
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "grafana",
|
||||
currentVersion: "10.0.0",
|
||||
newVersion: "10.1.0",
|
||||
link: null,
|
||||
},
|
||||
];
|
||||
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
|
||||
new WudIntegration({
|
||||
id: "test-wud",
|
||||
name: "Test WUD",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
const createIntegrationWithBasicAuth = () =>
|
||||
createIntegration([
|
||||
{ kind: "username", value: TEST_USERNAME },
|
||||
{ kind: "password", value: TEST_PASSWORD },
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("WudIntegration getStatsAsync", () => {
|
||||
test("computes total and available-update counts from the containers list", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats).toStrictEqual({ totalContainers: 3, updatesAvailable: 2, updates: sampleUpdates });
|
||||
});
|
||||
|
||||
test("returns zero counts for an empty containers list", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats).toStrictEqual({ totalContainers: 0, updatesAvailable: 0, updates: [] });
|
||||
});
|
||||
|
||||
test("maps container updates using displayName, falling back to result.tag and null link", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats.updates).toStrictEqual(sampleUpdates);
|
||||
});
|
||||
|
||||
test("throws ParseError when API response is not valid JSON", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response("not-json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
|
||||
if (!(error instanceof IntegrationParseError)) return false;
|
||||
|
||||
const cause = error.cause;
|
||||
return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response");
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ParseError when API response does not match schema", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ invalid: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
|
||||
if (!(error instanceof IntegrationParseError)) return false;
|
||||
|
||||
const cause = error.cause;
|
||||
return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response");
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when API returns an error", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WudIntegration authentication", () => {
|
||||
test("sends no Authorization header when no credentials are configured", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await createIntegration().getStatsAsync();
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sends a Basic auth header when username and password are configured", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await createIntegrationWithBasicAuth().getStatsAsync();
|
||||
|
||||
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
|
||||
const [url, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(String(url)).toContain("/api/containers");
|
||||
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
|
||||
expect(requestInit?.timeout).toBe(10_000);
|
||||
});
|
||||
|
||||
test("does not send credentials over a non-HTTPS URL", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const integration = new WudIntegration({
|
||||
id: "test-wud-http",
|
||||
name: "Test WUD HTTP",
|
||||
url: "http://wud.example.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: TEST_USERNAME },
|
||||
{ kind: "password", value: TEST_PASSWORD },
|
||||
],
|
||||
});
|
||||
|
||||
await integration.getStatsAsync();
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WudIntegration testing endpoint", () => {
|
||||
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
|
||||
|
||||
const invokeTesting = (integration: WudIntegration, input: IntegrationTestingInput) => {
|
||||
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
|
||||
return testing(input);
|
||||
};
|
||||
|
||||
const makeInput = (response: Response): IntegrationTestingInput =>
|
||||
({
|
||||
fetchAsync: vi.fn().mockResolvedValue(response),
|
||||
}) as unknown as IntegrationTestingInput;
|
||||
|
||||
test("returns success when /api/app responds 200", async () => {
|
||||
const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }));
|
||||
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("returns a status-code failure when /api/app responds non-OK", async () => {
|
||||
const input = makeInput(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("passes the Basic auth header through to fetchAsync when configured", async () => {
|
||||
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
|
||||
const input = makeInput(response);
|
||||
|
||||
await invokeTesting(createIntegrationWithBasicAuth(), input);
|
||||
|
||||
const fetchAsyncMock = vi.mocked(input.fetchAsync);
|
||||
const init = fetchAsyncMock.mock.calls[0]?.[1];
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
|
||||
expect(headers.Authorization).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { ParseError, ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { WudStats } from "./wud-types";
|
||||
import { mapWudStats, parseWudContainersResponseAsync } from "./wud-types";
|
||||
|
||||
const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class WudIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/app"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
try {
|
||||
await response.json();
|
||||
} catch (error) {
|
||||
return TestConnectionError.ParseResult(
|
||||
new ParseError("Invalid WUD app response", {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getStatsAsync(): Promise<WudStats> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/containers"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
timeout: CONTAINERS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const containers = await parseWudContainersResponseAsync(response);
|
||||
|
||||
return mapWudStats(containers);
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> | undefined {
|
||||
if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.url("/api/app").protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const WUD_CONTAINERS_PARSE_ERROR_MESSAGE = "Invalid WUD containers response";
|
||||
|
||||
export const wudContainerSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
displayName: z.string().nullable().optional(),
|
||||
updateAvailable: z.boolean(),
|
||||
link: z.string().nullable().optional(),
|
||||
image: z
|
||||
.object({
|
||||
tag: z
|
||||
.object({
|
||||
value: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
updateKind: z
|
||||
.object({
|
||||
remoteValue: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
result: z
|
||||
.object({
|
||||
tag: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const wudContainersResponseSchema = z.array(wudContainerSchema);
|
||||
|
||||
export interface WudContainerUpdate {
|
||||
id: string;
|
||||
name: string;
|
||||
currentVersion: string | null;
|
||||
newVersion: string | null;
|
||||
link: string | null;
|
||||
}
|
||||
|
||||
export interface WudStats {
|
||||
totalContainers: number;
|
||||
updatesAvailable: number;
|
||||
updates: WudContainerUpdate[];
|
||||
}
|
||||
|
||||
export const parseWudContainersResponseAsync = async (response: {
|
||||
json: () => Promise<unknown>;
|
||||
}): Promise<z.infer<typeof wudContainersResponseSchema>> => {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await response.json();
|
||||
} catch (error) {
|
||||
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = await wudContainersResponseSchema.safeParseAsync(json);
|
||||
if (!parseResult.success) {
|
||||
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, { cause: parseResult.error });
|
||||
}
|
||||
|
||||
return parseResult.data;
|
||||
};
|
||||
|
||||
const mapContainerUpdate = (container: z.infer<typeof wudContainerSchema>): WudContainerUpdate => ({
|
||||
id: container.id,
|
||||
name: container.displayName ?? container.name,
|
||||
currentVersion: container.image?.tag?.value ?? null,
|
||||
newVersion: container.updateKind?.remoteValue ?? container.result?.tag ?? null,
|
||||
link: container.link ?? null,
|
||||
});
|
||||
|
||||
export const mapWudStats = (containers: z.infer<typeof wudContainersResponseSchema>): WudStats => ({
|
||||
totalContainers: containers.length,
|
||||
updatesAvailable: containers.filter((container) => container.updateAvailable).length,
|
||||
updates: containers.filter((container) => container.updateAvailable).map(mapContainerUpdate),
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { HomeAssistantIntegration } from "../src";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const integration = new HomeAssistantIntegration({
|
||||
id: "home-assistant",
|
||||
name: "Home Assistant",
|
||||
url: "http://home-assistant.local:8123",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [{ kind: "apiKey", value: "secret" }],
|
||||
});
|
||||
|
||||
describe("HomeAssistantIntegration actions", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue({ ok: true } as never);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["toggle", () => integration.triggerToggleAsync("light.desk"), "/api/services/homeassistant/toggle", "light.desk"],
|
||||
[
|
||||
"automation",
|
||||
() => integration.triggerAutomationAsync("automation.turn_on_lights"),
|
||||
"/api/services/automation/trigger",
|
||||
"automation.turn_on_lights",
|
||||
],
|
||||
])("sends the %s service call as JSON", async (_action, trigger, path, entityId) => {
|
||||
await expect(trigger()).resolves.toBe(true);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(new URL(`http://home-assistant.local:8123${path}`), {
|
||||
headers: {
|
||||
Authorization: "Bearer secret",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ entity_id: entityId }),
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
|
||||
|
||||
export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
||||
SystemHealthMonitoring,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
|
||||
@@ -18,4 +18,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
|
||||
|
||||
return result.data.state;
|
||||
},
|
||||
// The widget already polls every 30 seconds and refetches after toggles.
|
||||
cacheTtlMs: 0,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { WudStats } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const wudStatsRequestHandler = createIntegrationRequestHandler<WudStats, "wud", Record<string, never>>({
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
},
|
||||
});
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Controla quins contenidors Docker tenen actualitzacions d'imatges disponibles."
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "aquí",
|
||||
"iconPicker": {
|
||||
"label": "URL de la icona",
|
||||
"header": "Escriviu un nom o objectes per filtrar icones... Homarr cercarà entre {countIcons} icones per a vosaltres.",
|
||||
"headerLoading": "",
|
||||
"description": "Cerca a la biblioteca, enganxa l'URL d'una imatge o puja un fitxer.",
|
||||
"header": "Cerca {countIcons} imatges per nom",
|
||||
"headerLoading": "Cerca imatges per nom",
|
||||
"clear": "Esborra la imatge",
|
||||
"uploadImage": "Puja una imatge",
|
||||
"results": "Resultats de la cerca d'imatges",
|
||||
"localImages": "Les teves imatges",
|
||||
"svgIcons": "Icones SVG",
|
||||
"otherImages": "Altres imatges",
|
||||
"urlLoading": "Comprovant la imatge…",
|
||||
"urlReady": "La imatge està a punt per utilitzar",
|
||||
"urlError": "No s'ha pogut carregar aquesta imatge. Comprova l'URL i torna-ho a provar.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Mostra quants contenidors supervisats tenen actualitzacions disponibles.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Monitoritzats",
|
||||
"updatesAvailable": "{count, plural, one {# actualització disponible} other {# actualitzacions disponibles}}",
|
||||
"updateAvailable": "Actualització disponible",
|
||||
"openReleaseNotes": "Obre les notes de versió",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Mostra el títol"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Mostra l'anell d'actualització"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Disposició",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horitzontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Mostra la llista d'actualitzacions de contenidors",
|
||||
"description": "Llista cada contenidor monitoritzat que té una actualització d'imatge disponible"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Resum del forat DNS",
|
||||
"description": "Mostra el resum del vostre forat DNS",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1344,6 +1344,10 @@
|
||||
"name": "crwdns5753:0crwdne5753:0",
|
||||
"description": "crwdns5755:0crwdne5755:0"
|
||||
},
|
||||
"wud": {
|
||||
"name": "crwdns6193:0crwdne6193:0",
|
||||
"description": "crwdns6195:0crwdne6195:0"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "crwdns5003:0crwdne5003:0",
|
||||
"description": "crwdns5005:0crwdne5005:0"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "crwdns552:0crwdne552:0",
|
||||
"iconPicker": {
|
||||
"label": "crwdns554:0crwdne554:0",
|
||||
"header": "crwdns556:0{countIcons}crwdne556:0",
|
||||
"headerLoading": "crwdns5681:0crwdne5681:0",
|
||||
"description": "crwdns6225:0crwdne6225:0",
|
||||
"header": "crwdns6227:0{countIcons}crwdne6227:0",
|
||||
"headerLoading": "crwdns6229:0crwdne6229:0",
|
||||
"clear": "crwdns6231:0crwdne6231:0",
|
||||
"uploadImage": "crwdns6233:0crwdne6233:0",
|
||||
"results": "crwdns6235:0crwdne6235:0",
|
||||
"localImages": "crwdns6237:0crwdne6237:0",
|
||||
"svgIcons": "crwdns6239:0crwdne6239:0",
|
||||
"otherImages": "crwdns6241:0crwdne6241:0",
|
||||
"urlLoading": "crwdns6243:0crwdne6243:0",
|
||||
"urlReady": "crwdns6245:0crwdne6245:0",
|
||||
"urlError": "crwdns6247:0crwdne6247:0",
|
||||
"noResults": "crwdns5683:0{search}crwdne5683:0",
|
||||
"error": "crwdns5685:0crwdne5685:0"
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "crwdns6197:0crwdne6197:0",
|
||||
"description": "crwdns6199:0crwdne6199:0",
|
||||
"title": "crwdns6201:0crwdne6201:0",
|
||||
"monitored": "crwdns6203:0crwdne6203:0",
|
||||
"updatesAvailable": "crwdns6205:0count={count}crwdne6205:0",
|
||||
"updateAvailable": "crwdns6207:0crwdne6207:0",
|
||||
"openReleaseNotes": "crwdns6209:0crwdne6209:0",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "crwdns6211:0crwdne6211:0"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "crwdns6213:0crwdne6213:0"
|
||||
},
|
||||
"layout": {
|
||||
"label": "crwdns6215:0crwdne6215:0",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "crwdns6217:0crwdne6217:0"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "crwdns6219:0crwdne6219:0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "crwdns6221:0crwdne6221:0",
|
||||
"description": "crwdns6223:0crwdne6223:0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "crwdns768:0crwdne768:0",
|
||||
"description": "crwdns770:0crwdne770:0",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Co je Docker nahoru",
|
||||
"description": "Sledujte, které z vašich kontejnerů v Dockeru mají k dispozici aktualizace obrázků"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "zde",
|
||||
"iconPicker": {
|
||||
"label": "URL ikony",
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"description": "Vyhledejte knihovnu, vložte URL obrázku, nebo nahrajte soubor.",
|
||||
"header": "Hledat {countIcons} obrázky podle jména",
|
||||
"headerLoading": "Hledat obrázky podle jména",
|
||||
"clear": "Vymazat obrázek",
|
||||
"uploadImage": "Nahrát obrázek",
|
||||
"results": "Výsledky vyhledávání obrázků",
|
||||
"localImages": "Vaše obrázky",
|
||||
"svgIcons": "SVG ikony",
|
||||
"otherImages": "Ostatní obrázky",
|
||||
"urlLoading": "Kontrola obrázku…",
|
||||
"urlReady": "Obrázek je připraven k použití",
|
||||
"urlError": "Tento obrázek nelze načíst. Zkontrolujte URL a zkuste to znovu.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Co je Docker nahoru",
|
||||
"description": "Zobrazit, kolik sledovaných kontejnerů je k dispozici aktualizace.",
|
||||
"title": "Co je Docker nahoru",
|
||||
"monitored": "Monitorováno",
|
||||
"updatesAvailable": "{count, plural, one {K dispozici je # aktualizace} other {# dostupných aktualizací}}",
|
||||
"updateAvailable": "K dispozici je aktualizace",
|
||||
"openReleaseNotes": "Otevřít poznámky k vydání",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Zobrazit název"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Zobrazit vyzvánění aktualizace"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Rozložení",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontální"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikální"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Zobrazit seznam aktualizací kontejneru",
|
||||
"description": "Zobrazí seznam každého monitorovaného kontejneru, který má k dispozici aktualizaci obrázku"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "",
|
||||
"description": "Zobrazí shrnutí vaší DNS Hole",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Hvad Er Opad Docker",
|
||||
"description": "Overvåg hvilke af dine Docker-containere der er tilgængelige billedopdateringer"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumenthåndteringssystem til arkivering og organisering af scannede dokumenter"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "her",
|
||||
"iconPicker": {
|
||||
"label": "Ikon-URL",
|
||||
"description": "Søg i biblioteket, indsæt en billed-URL, eller upload en fil.",
|
||||
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Søg billeder efter navn",
|
||||
"clear": "Ryd billede",
|
||||
"uploadImage": "Upload billede",
|
||||
"results": "Billede søgeresultater",
|
||||
"localImages": "Dine billeder",
|
||||
"svgIcons": "SVG ikoner",
|
||||
"otherImages": "Andre billeder",
|
||||
"urlLoading": "Kontrollerer billede…",
|
||||
"urlReady": "Billedet er klar til brug",
|
||||
"urlError": "Dette billede kunne ikke indlæses. Tjek URL'en og prøv igen.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Hvad Er Opad Docker",
|
||||
"description": "Vis hvor mange overvågede containere der er tilgængelige opdateringer.",
|
||||
"title": "Hvad Er Opad Docker",
|
||||
"monitored": "Overvåget",
|
||||
"updatesAvailable": "{count, plural, one {# opdatering tilgængelig} other {# opdateringer tilgængelige}}",
|
||||
"updateAvailable": "Opdatering tilgængelig",
|
||||
"openReleaseNotes": "Åbn udgivelsesnoter",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Vis titel"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Vis opdateringsring"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horisontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Vis liste over container-opdatering",
|
||||
"description": "Lister hver overvåget beholder, der har en billedopdatering tilgængelig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Oversigt",
|
||||
"description": "Viser oversigten over dit DNS-hul",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "Hier",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.",
|
||||
"headerLoading": "",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"localImages": "",
|
||||
"svgIcons": "",
|
||||
"otherImages": "",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": ""
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
},
|
||||
"layout": {
|
||||
"label": "Ansicht",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": ""
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Zusammenfassung",
|
||||
"description": "Zeigt die Zusammenfassung deines DNS Holes an",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-nativer Reverse-Proxy und Lastverteiler"
|
||||
},
|
||||
"wud": {
|
||||
"name": "Was ist Docker hoch",
|
||||
"description": "Überwachen Sie, welche Docker-Container Bildupdates verfügbar sind"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "Hier",
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"description": "Suchen Sie die Bibliothek, fügen Sie eine Bild-URL ein oder laden Sie eine Datei hoch.",
|
||||
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.",
|
||||
"headerLoading": "Tippe um die Symbole zu durchsuchen…",
|
||||
"clear": "Bild löschen",
|
||||
"uploadImage": "Bild hochladen",
|
||||
"results": "Bildsuchergebnisse",
|
||||
"localImages": "Ihre Bilder",
|
||||
"svgIcons": "SVG-Symbole",
|
||||
"otherImages": "Andere Bilder",
|
||||
"urlLoading": "Bild wird überprüft…",
|
||||
"urlReady": "Bild ist bereit zur Verwendung",
|
||||
"urlError": "Dieses Bild konnte nicht geladen werden. Überprüfen Sie die URL und versuchen Sie es erneut.",
|
||||
"noResults": "Keine Symbole stimmen mit \"{search}\" überein.",
|
||||
"error": "Konnte Symbole nicht laden."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Was ist Docker hoch",
|
||||
"description": "Zeigt an, wie viele überwachte Container über Updates verfügen.",
|
||||
"title": "Was ist Docker hoch",
|
||||
"monitored": "Überwacht",
|
||||
"updatesAvailable": "{count, plural, one {# Update verfügbar} other {# Updates verfügbar}}",
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"openReleaseNotes": "Versionshinweise öffnen",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Titel anzeigen"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Update-Ring anzeigen"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Ansicht",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Container-Update-Liste anzeigen",
|
||||
"description": "Listet jeden überwachten Container auf, der eine Bildaktualisierung zur Verfügung hat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Zusammenfassung",
|
||||
"description": "Zeigt die Zusammenfassung deines DNS Holes an",
|
||||
@@ -2739,10 +2785,10 @@
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "Zeigt die Bitrate an",
|
||||
"description": ""
|
||||
"description": "Zeige die aktuelle Wiedergabe-Bitrate neben dem Ort an"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"label": "Standort anzeigen",
|
||||
"description": "Zeigt an, ob der Stream über das LAN oder WAN läuft"
|
||||
}
|
||||
},
|
||||
@@ -2758,8 +2804,8 @@
|
||||
"status": "Status",
|
||||
"directPlay": "Direkte Wiedergabe",
|
||||
"directStream": "Direkter Stream",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcodeVideo": "Video wird transkodiert",
|
||||
"transcodeAudio": "Audio wird transkodiert",
|
||||
"transcoding": "Transkodierung",
|
||||
"paused": "Pausiert",
|
||||
"remaining": "{minutes} Min. verbleibend",
|
||||
@@ -2966,8 +3012,8 @@
|
||||
}
|
||||
},
|
||||
"showOsVersion": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Betriebssystem-Version anzeigen",
|
||||
"description": "Füge die Betriebssystemversion jedem Verteilungsetikett hinzu, sofern verfügbar"
|
||||
},
|
||||
"showComplianceHero": {
|
||||
"label": "",
|
||||
@@ -4057,11 +4103,11 @@
|
||||
}
|
||||
},
|
||||
"beszelAlerts": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "Beszel-Alarme",
|
||||
"description": "Zeige Beszels Alarmeinstellungen und -verlauf",
|
||||
"option": {
|
||||
"showHistory": {
|
||||
"label": ""
|
||||
"label": "Alarmverlauf anzeigen"
|
||||
},
|
||||
"maxHistoryItems": {
|
||||
"label": ""
|
||||
@@ -4072,9 +4118,9 @@
|
||||
"internalServerError": ""
|
||||
},
|
||||
"history": "Verlauf",
|
||||
"resolved": "",
|
||||
"resolved": "Gelöst",
|
||||
"status": {
|
||||
"triggered": "",
|
||||
"triggered": "Ausgelöst",
|
||||
"ok": "Ok"
|
||||
}
|
||||
},
|
||||
@@ -4083,43 +4129,43 @@
|
||||
"description": "",
|
||||
"option": {
|
||||
"systemId": {
|
||||
"label": "",
|
||||
"label": "System",
|
||||
"description": ""
|
||||
},
|
||||
"timePeriod": {
|
||||
"label": ""
|
||||
"label": "Zeitraum"
|
||||
},
|
||||
"showCpu": {
|
||||
"label": ""
|
||||
"label": "CPU-Diagramm anzeigen"
|
||||
},
|
||||
"showMemory": {
|
||||
"label": ""
|
||||
"label": "RAM-Diagramm anzeigen"
|
||||
},
|
||||
"showDisk": {
|
||||
"label": ""
|
||||
"label": "Festplatten-Diagramm anzeigen"
|
||||
},
|
||||
"showDiskIO": {
|
||||
"label": ""
|
||||
"label": "Festplatten-E/A-Diagramm anzeigen"
|
||||
},
|
||||
"showNetwork": {
|
||||
"label": ""
|
||||
"label": "Netzwerk-Diagramm anzeigen"
|
||||
},
|
||||
"showDockerCpu": {
|
||||
"label": ""
|
||||
"label": "Docker-CPU-Diagramm anzeigen"
|
||||
},
|
||||
"showDockerMemory": {
|
||||
"label": ""
|
||||
"label": "Docker-RAM-Diagramm anzeigen"
|
||||
},
|
||||
"showDockerNetwork": {
|
||||
"label": ""
|
||||
"label": "Docker-Netzwerk-Diagramm anzeigen"
|
||||
}
|
||||
},
|
||||
"selectSystem": "System auswählen",
|
||||
"empty": {
|
||||
"noSystems": ""
|
||||
"noSystems": "Keine Systeme gefunden"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"internalServerError": "Keine Verbindung zu Beszel möglich. Überprüfe deine Integrations-Einstellungen.",
|
||||
"liveConnectionFailed": "",
|
||||
"switchToHistorical": "",
|
||||
"systemNotFound": ""
|
||||
@@ -4139,36 +4185,36 @@
|
||||
"disk": {
|
||||
"title": "Festplattenbelegung",
|
||||
"subtitle": "",
|
||||
"series": ""
|
||||
"series": "Root"
|
||||
},
|
||||
"diskIO": {
|
||||
"title": "",
|
||||
"title": "Festplatten-E/A",
|
||||
"subtitle": "",
|
||||
"read": "",
|
||||
"write": ""
|
||||
"read": "Lesen",
|
||||
"write": "Schreiben"
|
||||
},
|
||||
"storage": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"series": ""
|
||||
"title": "Speicher",
|
||||
"subtitle": "Festplattenverbrauch pro Dateisystem",
|
||||
"series": "Belegt"
|
||||
},
|
||||
"network": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"sent": "",
|
||||
"recv": ""
|
||||
"title": "Bandbreite",
|
||||
"subtitle": "Netzwerkverkehr öffentlicher Schnittstellen",
|
||||
"sent": "Gesendet",
|
||||
"recv": "Empfangen"
|
||||
},
|
||||
"dockerCpu": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Docker-CPU-Nutzung",
|
||||
"subtitle": "Durchschnittliche CPU-Nutzung der Container"
|
||||
},
|
||||
"dockerMemory": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Docker-RAM-Nutzung",
|
||||
"subtitle": "RAM-Nutzung der Docker-Container"
|
||||
},
|
||||
"dockerNetwork": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Docker-Netzwerk-E/A",
|
||||
"subtitle": "Netzwerkverkehr der Docker-Container"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4545,7 +4591,7 @@
|
||||
"text": "Konnte die angeforderte Ressource nicht finden"
|
||||
},
|
||||
"error": {
|
||||
"title": "",
|
||||
"title": "Diese Seite konnte nicht geladen werden",
|
||||
"text": "",
|
||||
"digest": ""
|
||||
},
|
||||
@@ -5110,7 +5156,7 @@
|
||||
"copy": "",
|
||||
"oneClick": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"subtitle": "OAuth wird verwendet. Authentifiziere dich bei der ersten Nutzung im Browser",
|
||||
"vsCode": "In VS Code installieren",
|
||||
"cursor": "In Cursor installieren",
|
||||
"copyUrl": "MCP URL kopieren",
|
||||
@@ -5130,7 +5176,7 @@
|
||||
"button": "Sicherung herunterladen",
|
||||
"error": "Export fehlgeschlagen",
|
||||
"confirm": {
|
||||
"title": "",
|
||||
"title": "Export sensibler Daten",
|
||||
"warningTitle": "Dieser Export enthält Ihre gesamte Datenbank.",
|
||||
"warningBody": "Das Backup umfasst alle Benutzer, Integrationsgeheimnisse (verschlüsselt), API-Keys, Board-Konfigurationen und App-Daten. Speichern Sie die exportierte Datei sicher ab und teilen Sie diese nicht.",
|
||||
"typePrompt": "Geben Sie \"{phrase}\" ein, um fortzufahren",
|
||||
@@ -5140,8 +5186,8 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Datenbank wiederherstellen",
|
||||
"description": "",
|
||||
"warning": "",
|
||||
"description": "Lade ein zuvor exportiertes Backup-ZIP‑File hoch, um deine Datenbank wiederherzustellen. Damit ersetzt du deine gesamte jetzige Datenbank. Falls das Backup mit einem anderen Verschlüsselungs-Key erstellt wurde, werden vertrauliche Daten (Secrets) automatisch neu verschlüsselt.",
|
||||
"warning": "Ein Backup wiederherzustellen, wird deine jetzige Datenbank komplett ersetzen und das Programm neu starten. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"dropzone": {
|
||||
"title": "Ziehen Sie Ihre Backup-ZIP hierher oder klicken Sie zum Durchsuchen",
|
||||
"description": "Nur .zip Dateien aus einer Homarr SQLite-Sicherung werden akzeptiert"
|
||||
@@ -6332,10 +6378,10 @@
|
||||
"header": {
|
||||
"season": "Staffel",
|
||||
"episodes": "Episoden",
|
||||
"status": ""
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"requested": ""
|
||||
"requested": "Angefragt"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -6448,18 +6494,18 @@
|
||||
}
|
||||
},
|
||||
"onboardingTour": {
|
||||
"learnMore": "",
|
||||
"next": "",
|
||||
"prev": "",
|
||||
"done": "",
|
||||
"skip": "",
|
||||
"learnMore": "Lese die Dokumentation",
|
||||
"next": "Weiter",
|
||||
"prev": "Zurück",
|
||||
"done": "Fertig",
|
||||
"skip": "Überspringe die Einführung",
|
||||
"manage": {
|
||||
"welcome": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "Willkommen bei Homarr!",
|
||||
"description": "Das ist dein Kontrollzentrum. Von hier steuerst du Boards, Apps, Integrationen, Benutzer und die Servereinstellungen. Lass uns eine Einführung in jeden Bereich machen."
|
||||
},
|
||||
"boardsList": {
|
||||
"title": "",
|
||||
"title": "Deine Boards",
|
||||
"description": ""
|
||||
},
|
||||
"boardsCreate": {
|
||||
@@ -6493,15 +6539,15 @@
|
||||
},
|
||||
"board": {
|
||||
"welcome": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
"title": "Das ist dein Board",
|
||||
"description": "Ein Drag-and-drop-Dashboard, wo du Apps und Widgets anordnen kannst. Du kannst jederzeit Strg+K drücken, um nach Apps, Boards und Aktionen zu suchen."
|
||||
},
|
||||
"editMode": {
|
||||
"title": "Bearbeitungsmodus",
|
||||
"description": ""
|
||||
"description": "Drücke hier, um in den Bearbeitungsmodus zu kommen. Dort kannst du Widgets, Apps und Abschnitte hinzufügen, welche du durch anschließendes Ziehen neu anordnen und an den Ecken in der Größe anpassen kannst."
|
||||
},
|
||||
"settings": {
|
||||
"title": "",
|
||||
"title": "Board-Einstellungen",
|
||||
"description": ""
|
||||
},
|
||||
"switcher": {
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Τι Είναι Το Docker",
|
||||
"description": "Παρακολούθηση ποια από τα εμπορευματοκιβώτια Docker σας έχουν διαθέσιμες ενημερώσεις εικόνας"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Σύστημα διαχείρισης εγγράφων για αρχειοθέτηση και οργάνωση σαρωμένων εγγράφων"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "εδώ",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"description": "Αναζήτηση στη βιβλιοθήκη, επικόλληση ενός URL εικόνας ή μεταφόρτωση αρχείου.",
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Αναζήτηση εικόνων κατά όνομα",
|
||||
"clear": "Καθαρισμός εικόνας",
|
||||
"uploadImage": "Μεταφόρτωση εικόνας",
|
||||
"results": "Αποτελέσματα αναζήτησης εικόνας",
|
||||
"localImages": "Οι εικόνες σας",
|
||||
"svgIcons": "Εικονίδια SVG",
|
||||
"otherImages": "Άλλες εικόνες",
|
||||
"urlLoading": "",
|
||||
"urlReady": "Η εικόνα είναι έτοιμη προς χρήση",
|
||||
"urlError": "Δεν ήταν δυνατή η φόρτωση αυτής της εικόνας. Ελέγξτε τη διεύθυνση URL και προσπαθήστε ξανά.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Τι Είναι Το Docker",
|
||||
"description": "Δείτε πόσες παρακολουθήσεις δοχείων έχουν διαθέσιμες ενημερώσεις.",
|
||||
"title": "Τι Είναι Το Docker",
|
||||
"monitored": "Ελέγχτηκε",
|
||||
"updatesAvailable": "{count, plural, one {# διαθέσιμη ενημέρωση} other {# διαθέσιμες ενημερώσεις}}",
|
||||
"updateAvailable": "Διαθέσιμη ενημέρωση",
|
||||
"openReleaseNotes": "Άνοιγμα σημειώσεων έκδοσης",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Εμφάνιση τίτλου"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Εμφάνιση κουδουνίσματος ενημέρωσης"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Διάταξη",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Οριζόντια"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Κατακόρυφα"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Εμφάνιση λίστας ενημερώσεων περιέκτη",
|
||||
"description": "Εμφανίζει κάθε δοχείο που παρακολουθείται και διαθέτει διαθέσιμη ενημέρωση εικόνας"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-native reverse proxy and load balancer"
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Document management system for archiving and organizing scanned documents"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "here",
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"description": "",
|
||||
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one.",
|
||||
"headerLoading": "Type to search icons…",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"localImages": "",
|
||||
"svgIcons": "",
|
||||
"otherImages": "",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"noResults": "No icons match \"{search}\".",
|
||||
"error": "Couldn't load icons."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Show title"
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Summary",
|
||||
"description": "Displays the summary of your DNS Hole",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-native reverse proxy and load balancer"
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Monitor which of your Docker containers have image updates available"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Document management system for archiving and organizing scanned documents"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "here",
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||
"headerLoading": "Type to search icons…",
|
||||
"description": "Search the library, paste an image URL, or upload a file.",
|
||||
"header": "Search {countIcons} images by name",
|
||||
"headerLoading": "Search images by name",
|
||||
"clear": "Clear image",
|
||||
"uploadImage": "Upload image",
|
||||
"results": "Image search results",
|
||||
"localImages": "Your images",
|
||||
"svgIcons": "SVG icons",
|
||||
"otherImages": "Other images",
|
||||
"urlLoading": "Checking image…",
|
||||
"urlReady": "Image is ready to use",
|
||||
"urlError": "This image could not be loaded. Check the URL and try again.",
|
||||
"noResults": "No icons match \"{search}\".",
|
||||
"error": "Couldn't load icons."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Show how many watched containers have updates available.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Monitored",
|
||||
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
|
||||
"updateAvailable": "Update available",
|
||||
"openReleaseNotes": "Open release notes",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Show title"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Show update ring"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Show container update list",
|
||||
"description": "Lists each monitored container that has an image update available"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Summary",
|
||||
"description": "Displays the summary of your DNS Hole",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Qué hay de nuevo Docker",
|
||||
"description": "Controlar cuál de sus contenedores Docker tiene actualizaciones de imagen disponibles"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "aquí",
|
||||
"iconPicker": {
|
||||
"label": "URL del icono",
|
||||
"description": "Busca en la biblioteca, pega una URL de imagen o sube un archivo.",
|
||||
"header": "Escribe nombre u objetos para filtrar iconos... Homarr buscará a través de iconos {countIcons} por ti.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Buscar imágenes por nombre",
|
||||
"clear": "Borrar imagen",
|
||||
"uploadImage": "Subir imagen",
|
||||
"results": "Resultados de búsqueda de imágenes",
|
||||
"localImages": "Tus imágenes",
|
||||
"svgIcons": "Iconos SVG",
|
||||
"otherImages": "Otras imágenes",
|
||||
"urlLoading": "Comprobando imagen…",
|
||||
"urlReady": "La imagen está lista para usar",
|
||||
"urlError": "Esta imagen no se pudo cargar. Compruebe la URL e inténtelo de nuevo.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Qué hay de nuevo Docker",
|
||||
"description": "Mostrar cuántos contenedores vistos tienen actualizaciones disponibles.",
|
||||
"title": "Qué hay de nuevo Docker",
|
||||
"monitored": "Monitoreado",
|
||||
"updatesAvailable": "{count, plural, one {# actualización disponible} other {# actualizaciones disponibles}}",
|
||||
"updateAvailable": "Actualización disponible",
|
||||
"openReleaseNotes": "Notas de la versión abierta",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Mostrar título"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Mostrar anillo de actualización"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Diseño",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Mostrar lista de actualización del contenedor",
|
||||
"description": "Lista cada contenedor supervisado que tiene una actualización de imagen disponible"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Resumen del agujero DNS",
|
||||
"description": "Muestra el resumen de tu agujero DNS",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Pilvinatiivi käänteinen välityspalvelin ja kuormantasaaja"
|
||||
},
|
||||
"wud": {
|
||||
"name": "Mikä Ylös Docker",
|
||||
"description": "Tarkkaile missä Docker-säiliöissä on saatavilla kuvapäivityksiä"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Asiakirjojen hallintajärjestelmä skannattujen dokumenttien arkistointiin ja järjestelyyn"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "Kuvakkeen URL-osoite",
|
||||
"description": "Etsi kirjastosta, liitä kuvan URL-osoite tai lataa tiedosto.",
|
||||
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta.",
|
||||
"headerLoading": "Kirjoita hakeaksesi kuvakkeita…",
|
||||
"clear": "Tyhjennä kuva",
|
||||
"uploadImage": "Lataa kuva",
|
||||
"results": "Haun tulokset",
|
||||
"localImages": "Omat kuvat",
|
||||
"svgIcons": "SVG kuvakkeet",
|
||||
"otherImages": "Muut kuvat",
|
||||
"urlLoading": "",
|
||||
"urlReady": "Kuva on valmis käytettäväksi",
|
||||
"urlError": "Kuvaa ei voitu ladata. Tarkista URL-osoite ja yritä uudelleen.",
|
||||
"noResults": "Ei kuvakkeita haulla \"{search}\".",
|
||||
"error": "Kuvakkeiden lataaminen epäonnistui."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Mikä Ylös Docker",
|
||||
"description": "Näytä kuinka monta katsottua astiaa on saatavilla päivityksiä.",
|
||||
"title": "Mikä Ylös Docker",
|
||||
"monitored": "Valvottu",
|
||||
"updatesAvailable": "{count, plural, one {# päivitys saatavilla} other {# päivitystä saatavilla}}",
|
||||
"updateAvailable": "Päivitys saatavilla",
|
||||
"openReleaseNotes": "Avaa julkaisutiedot",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Näytä otsikko"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Näytä päivitysrengas"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Asettelu",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Vaaka"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Pysty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Näytä kontin päivityslista",
|
||||
"description": "Luettele jokaisen seuratun säiliön joka on saatavilla päivityksessä"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
|
||||
@@ -673,7 +673,7 @@
|
||||
},
|
||||
"action": {
|
||||
"select": {
|
||||
"label": "Sélectionner l'app",
|
||||
"label": "Sélectionner une app",
|
||||
"notFound": "Aucune app trouvée",
|
||||
"search": "Rechercher une application",
|
||||
"noResults": "Pas de résultats",
|
||||
@@ -739,7 +739,7 @@
|
||||
"label": "Étiquette",
|
||||
"unit": "Unité",
|
||||
"mappings": "Correspondances",
|
||||
"tablePath": "",
|
||||
"tablePath": "Chemin du tableau JSON",
|
||||
"tablePathHint": "",
|
||||
"columns": "Colonnes",
|
||||
"header": "En-tête",
|
||||
@@ -786,7 +786,7 @@
|
||||
"valuePath": "",
|
||||
"maxPath": "",
|
||||
"goodValues": "",
|
||||
"color": ""
|
||||
"color": "bleu"
|
||||
},
|
||||
"cardStyleOption": {
|
||||
"filled": "",
|
||||
@@ -851,10 +851,10 @@
|
||||
"customJsx": "JSX personnalisé"
|
||||
},
|
||||
"secret": {
|
||||
"apiKey": "",
|
||||
"apiKey": "Clé API / jeton",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"savedPlaceholder": ""
|
||||
"savedPlaceholder": "••••••• (sauvegardé, laisser vide pour garder)"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Aperçu",
|
||||
@@ -1291,10 +1291,10 @@
|
||||
},
|
||||
"patchmonApiKey": {
|
||||
"label": "",
|
||||
"newLabel": ""
|
||||
"newLabel": "Nouvelle clé API"
|
||||
},
|
||||
"patchmonApiSecret": {
|
||||
"label": "",
|
||||
"label": "Clé secrète d'API",
|
||||
"newLabel": ""
|
||||
},
|
||||
"githubAppId": {
|
||||
@@ -1329,7 +1329,7 @@
|
||||
"description": ""
|
||||
},
|
||||
"uptimeKuma": {
|
||||
"name": "",
|
||||
"name": "Uptime Kuma",
|
||||
"description": "",
|
||||
"secret": {
|
||||
"slug": {
|
||||
@@ -1341,9 +1341,13 @@
|
||||
}
|
||||
},
|
||||
"traefik": {
|
||||
"name": "",
|
||||
"name": "Traefik",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Qu'est ce que Docker",
|
||||
"description": "Surveiller lequel de vos conteneurs Docker a des mises à jour d'images disponibles"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "ici",
|
||||
"iconPicker": {
|
||||
"label": "URL de l'icône :",
|
||||
"description": "Recherchez dans la bibliothèque, collez une URL d'image, ou téléchargez un fichier.",
|
||||
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous.",
|
||||
"headerLoading": "Tapez pour rechercher les icônes…",
|
||||
"clear": "Effacer l'image",
|
||||
"uploadImage": "Charger une image",
|
||||
"results": "Résultats de la recherche d'images",
|
||||
"localImages": "Vos images",
|
||||
"svgIcons": "Icônes SVG",
|
||||
"otherImages": "Autres images",
|
||||
"urlLoading": "Vérification de l'image…",
|
||||
"urlReady": "L'image est prête à être utilisée",
|
||||
"urlError": "Cette image n'a pas pu être chargée. Vérifiez l'URL et réessayez.",
|
||||
"noResults": "Aucune icône ne correspond à \"{search}\".",
|
||||
"error": "Impossible de charger les icônes."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Qu'est ce que Docker",
|
||||
"description": "Afficher combien de conteneurs surveillés ont des mises à jour disponibles.",
|
||||
"title": "Qu'est ce que Docker",
|
||||
"monitored": "Surveillé",
|
||||
"updatesAvailable": "{count, plural, one {# mise à jour disponible} other {# mises à jour disponibles}}",
|
||||
"updateAvailable": "Mise à jour disponible",
|
||||
"openReleaseNotes": "Ouvrir les notes de version",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Afficher le titre"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Afficher l'anneau de mise à jour"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Mise en page",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontale"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Afficher la liste de mise à jour du conteneur",
|
||||
"description": "Liste chaque conteneur surveillé qui a une mise à jour d'image disponible"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Résumé du puit DNS",
|
||||
"description": "Affiche le résumé de votre puit DNS",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "כאן",
|
||||
"iconPicker": {
|
||||
"label": "קישור של הסמל",
|
||||
"description": "",
|
||||
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} .",
|
||||
"headerLoading": "",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"localImages": "התמונות שלך",
|
||||
"svgIcons": "",
|
||||
"otherImages": "תמונות אחרות",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": ""
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
},
|
||||
"layout": {
|
||||
"label": "פריסה",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "אופקי"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "אנכי"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "סיכום חור אבטחה DNS",
|
||||
"description": "מציג את הסיכום חור אבטחת DNS שלך",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Pratite koji od vaših Docker kontejnera imaju dostupna ažuriranja slika."
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "ovdje",
|
||||
"iconPicker": {
|
||||
"label": "URL ikone",
|
||||
"description": "Pretražite biblioteku, zalijepite URL slike ili prenesite datoteku.",
|
||||
"header": "Upišite naziv ili objekte za filtriranje ikona... Homarr će pretražiti {countIcons} ikona za vas.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Pretražite slike po nazivu",
|
||||
"clear": "Očisti sliku",
|
||||
"uploadImage": "Prenesi sliku",
|
||||
"results": "Rezultati pretraživanja slika",
|
||||
"localImages": "Vaše slike",
|
||||
"svgIcons": "SVG ikone",
|
||||
"otherImages": "Ostale slike",
|
||||
"urlLoading": "Provjera slike…",
|
||||
"urlReady": "Slika je spremna za upotrebu",
|
||||
"urlError": "Ova se slika nije mogla učitati. Provjerite URL i pokušajte ponovno.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Prikaži koliko praćenih kontejnera ima dostupna ažuriranja.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Praćeno",
|
||||
"updatesAvailable": "{count, plural, one {# dostupno ažuriranje} few {# dostupna ažuriranja} other {# dostupnih ažuriranja}}",
|
||||
"updateAvailable": "Dostupno ažuriranje",
|
||||
"openReleaseNotes": "Otvori bilješke o izdanju",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Prikaži naslov"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Prikaži prsten ažuriranja"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Raspored",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontalno"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Okomito"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Prikaži popis ažuriranja kontejnera",
|
||||
"description": "Nabraja svaki nadzirani kontejner za koji je dostupno ažuriranje slike."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Sažetak DNS rupe",
|
||||
"description": "Prikazuje sažetak vaše DNS rupe",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumentumkezelő rendszer beolvasott dokumentumok archiválására és rendszerezésére"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "itt",
|
||||
"iconPicker": {
|
||||
"label": "Ikonhivatkozás",
|
||||
"description": "",
|
||||
"header": "Írjon be nevet vagy objektumokat az ikonok szűréséhez... A Homarr a {countIcons} ikonok között keres.",
|
||||
"headerLoading": "",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"localImages": "",
|
||||
"svgIcons": "",
|
||||
"otherImages": "",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Cím megjelenítése"
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
},
|
||||
"layout": {
|
||||
"label": "Elrendezés",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Vízszintes"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Függőleges"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Blokkoló Összefoglaló",
|
||||
"description": "Megjeleníti a DNS Blokkoló összefoglalóját",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Cloud-native reverse proxy e bilanciatore del carico"
|
||||
},
|
||||
"wud": {
|
||||
"name": "Cos'è Il Docker",
|
||||
"description": "Monitora quali dei tuoi contenitori Docker sono disponibili aggiornamenti delle immagini"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Sistema di gestione documentale per archiviare e organizzare documenti scansionati"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "qui",
|
||||
"iconPicker": {
|
||||
"label": "Icona URL",
|
||||
"description": "Cerca nella libreria, incolla un URL di immagine, o carica un file.",
|
||||
"header": "Digita il nome o gli oggetti da filtrare per le icone... Homarr cercherà tra le icone {countIcons} per te.",
|
||||
"headerLoading": "Digita per cercare icone…",
|
||||
"clear": "Cancella immagine",
|
||||
"uploadImage": "Carica immagine",
|
||||
"results": "Risultati della ricerca immagini",
|
||||
"localImages": "Le tue immagini",
|
||||
"svgIcons": "Icone SVG",
|
||||
"otherImages": "Altre immagini",
|
||||
"urlLoading": "Controllo immagine…",
|
||||
"urlReady": "L'immagine è pronta per l'uso",
|
||||
"urlError": "Questa immagine non può essere caricata. Controlla l'URL e riprova.",
|
||||
"noResults": "Nessuna icona corrisponde a \"{search}\".",
|
||||
"error": "Impossibile caricare le icone."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Cos'è Il Docker",
|
||||
"description": "Mostra quanti contenitori visti hanno aggiornamenti disponibili.",
|
||||
"title": "Cos'è Il Docker",
|
||||
"monitored": "Monitorato",
|
||||
"updatesAvailable": "{count, plural, one {# aggiornamento disponibile} other {# aggiornamenti disponibili}}",
|
||||
"updateAvailable": "Aggiornamento disponibile",
|
||||
"openReleaseNotes": "Apri note di rilascio",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Mostra titolo"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Mostra anello di aggiornamento"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Orizzontale"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Verticale"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Mostra elenco aggiornamenti container",
|
||||
"description": "Elenca ogni contenitore monitorato che ha un aggiornamento dell'immagine disponibile"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Riepilogo del filtraggio DNS",
|
||||
"description": "Mostra il riepilogo del tuo filtro DNS",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "どのDockerコンテナがイメージアップデートを利用できるかを監視します"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "こちら",
|
||||
"iconPicker": {
|
||||
"label": "アイコン URL",
|
||||
"description": "ライブラリを検索し、画像の URL を貼り付けるか、ファイルをアップロードします。",
|
||||
"header": "アイコンをフィルタするために、名前またはオブジェクトを入力してください... Homarrは {countIcons} アイコンを検索します。",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "名前で画像を検索",
|
||||
"clear": "画像をクリア",
|
||||
"uploadImage": "画像をアップロード",
|
||||
"results": "画像検索結果",
|
||||
"localImages": "あなたの画像",
|
||||
"svgIcons": "SVGアイコン",
|
||||
"otherImages": "その他の画像",
|
||||
"urlLoading": "",
|
||||
"urlReady": "画像を使用する準備ができました",
|
||||
"urlError": "この画像を読み込めませんでした。URLを確認して再度お試し下さい。",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "利用可能な更新があるコンテナの数を表示します。",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "監視中",
|
||||
"updatesAvailable": "{count, plural, one {# アップデートが} other {# アップデートが}}",
|
||||
"updateAvailable": "アップデートがあります",
|
||||
"openReleaseNotes": "リリースノートを開く",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "タイトルを表示"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "更新の着信音を表示"
|
||||
},
|
||||
"layout": {
|
||||
"label": "レイアウト",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "水平方向"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "垂直方向"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "コンテナ更新リストを表示",
|
||||
"description": "利用可能な画像の更新がある各監視対象のコンテナを一覧表示します"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole 概要",
|
||||
"description": "DNS Hole の概要を表示します",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "클라우드 네이티브 리버스 프록시 및 로드 밸런서"
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "스캔된 문서를 보관하고 정리하는 문서 관리 시스템"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "여기",
|
||||
"iconPicker": {
|
||||
"label": "아이콘 URL",
|
||||
"description": "",
|
||||
"header": "이름 또는 주제를 입력해 아이콘을 필터링하세요... Homarr가 {countIcons}에서 아이콘을 검색합니다.",
|
||||
"headerLoading": "아이콘을 검색하려면 입력…",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"localImages": "내 이미지",
|
||||
"svgIcons": "",
|
||||
"otherImages": "기타 이미지",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"noResults": "\"{search}\"에 일치하는 아이콘이 없습니다.",
|
||||
"error": "아이콘을 불러올 수 없습니다."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": ""
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
},
|
||||
"layout": {
|
||||
"label": "레이아웃",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "가로"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "세로"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS 홀 요약",
|
||||
"description": "DNS 홀의 요약을 표시합니다",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Kas naujo, Docker",
|
||||
"description": "Stebėkite, kuriems jūsų Docker konteineriams yra prieinami atvaizdų naujinimai."
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumentų valdymo sistema, skirta nuskaitytų dokumentų archyvavimui ir tvarkymui"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "čia",
|
||||
"iconPicker": {
|
||||
"label": "Piktogramos URL",
|
||||
"description": "Ieškokite bibliotekoje, įklijuokite paveikslėlio URL arba įkelkite failą.",
|
||||
"header": "Įveskite pavadinimą ar objektus, kad filtruoti piktogramas... Homarr ieškos per {countIcons} piktogramas.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Ieškoti paveikslėlių pagal pavadinimą",
|
||||
"clear": "Pašalinti paveikslėlį",
|
||||
"uploadImage": "Įkelti paveikslėlį",
|
||||
"results": "Vaizdų paieškos rezultatai",
|
||||
"localImages": "Jūsų vaizdai",
|
||||
"svgIcons": "SVG piktogramos",
|
||||
"otherImages": "Kiti vaizdai",
|
||||
"urlLoading": "Tikrinamas vaizdas…",
|
||||
"urlReady": "Vaizdas paruoštas naudoti",
|
||||
"urlError": "Šio vaizdo nepavyko įkelti. Patikrinkite URL ir bandykite dar kartą.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Kas naujo, Docker",
|
||||
"description": "Rodo, kiek stebimų konteinerių turi prieinamų naujinimų.",
|
||||
"title": "Kas naujo, Docker",
|
||||
"monitored": "Stebimi",
|
||||
"updatesAvailable": "{count, plural, one {# atnaujinimas prieinamas} few {# atnaujinimai prieinami} many {# atnaujinimų prieinama} other {# atnaujinimo prieinama}}",
|
||||
"updateAvailable": "Galimas naujinimas",
|
||||
"openReleaseNotes": "Atidaryti leidimo pastabas",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Rodyti pavadinimą"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Rodyti naujinimo žiedą"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Išdėstymas",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontalus"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikalus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Rodyti konteinerių naujinimų sąrašą",
|
||||
"description": "Išvardija kiekvieną stebimą konteinerį, kuriam yra prieinamas atvaizdo naujinimas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS skylės suvestinė",
|
||||
"description": "Rodo jūsų DNS skylės suvestinę",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Uzrauga, kuriem no jūsu Docker konteineriem ir pieejami attēlu atjauninājumi"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumentu pārvaldības sistēma skenētu dokumentu arhivēšanai un organizēšanai"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "šeit",
|
||||
"iconPicker": {
|
||||
"label": "Ikonas URL",
|
||||
"description": "Meklējiet bibliotēkā, ielīmējiet attēla URL vai augšupielādējiet failu.",
|
||||
"header": "Rakstiet nosaukumu vai objektus, lai filtrētu ikonas... Homarr pārmeklēs {countIcons} ikonas jūsu vietā.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Meklēt attēlus pēc nosaukuma",
|
||||
"clear": "Notīrīt attēlu",
|
||||
"uploadImage": "Augšupielādēt attēlu",
|
||||
"results": "Attēlu meklēšanas rezultāti",
|
||||
"localImages": "Jūsu attēli",
|
||||
"svgIcons": "SVG ikonas",
|
||||
"otherImages": "Citi attēli",
|
||||
"urlLoading": "Pārbauda attēlu…",
|
||||
"urlReady": "Attēls ir gatavs lietošanai",
|
||||
"urlError": "Šo attēlu nevarēja ielādēt. Pārbaudiet URL un mēģiniet vēlreiz.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Parāda, cik uzraudzītajiem konteineriem ir pieejami atjauninājumi.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Uzraudzītie",
|
||||
"updatesAvailable": "{count, plural, zero {# atjauninājumu pieejami} one {# atjauninājums pieejams} other {# atjauninājumi pieejami}}",
|
||||
"updateAvailable": "Pieejams atjauninājums",
|
||||
"openReleaseNotes": "Atvērt laidiena piezīmes",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Rādīt nosaukumu"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Rādīt atjaunināšanas gredzenu"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Izkārtojums",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontāli"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikāli"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Rādīt konteineru atjauninājumu sarakstu",
|
||||
"description": "Uzskaita katru uzraudzīto konteineru, kuram ir pieejams attēla atjauninājums"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS cauruma kopsavilkums",
|
||||
"description": "Parāda jūsu DNS cauruma kopsavilkumu",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Wat is er een Docker",
|
||||
"description": "Controleer welke van uw Docker containers afbeeldingen updates beschikbaar hebben"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Documentbeheersysteem voor het archiveren en organiseren van gescande documenten"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "hier",
|
||||
"iconPicker": {
|
||||
"label": "Icoon-URL",
|
||||
"description": "Zoek in de bibliotheek, plak een afbeelding-URL of upload een bestand.",
|
||||
"header": "Typ naam of objecten om te filteren op iconen… Homarr doorzoekt {countIcons} iconen voor je.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Afbeeldingen zoeken op naam",
|
||||
"clear": "Verwijder afbeelding",
|
||||
"uploadImage": "Afbeelding uploaden",
|
||||
"results": "Zoekresultaten afbeeldingen",
|
||||
"localImages": "Jouw afbeeldingen",
|
||||
"svgIcons": "SVG iconen",
|
||||
"otherImages": "Andere afbeeldingen",
|
||||
"urlLoading": "Controleren van afbeelding…",
|
||||
"urlReady": "Afbeelding is klaar voor gebruik",
|
||||
"urlError": "Deze afbeelding kan niet worden geladen. Controleer de URL en probeer het opnieuw.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Wat is er een Docker",
|
||||
"description": "Laat zien hoeveel containers er updates beschikbaar hebben.",
|
||||
"title": "Wat is er een Docker",
|
||||
"monitored": "Gecontroleerd",
|
||||
"updatesAvailable": "{count, plural, one {# update beschikbaar} other {# updates zijn beschikbaar}}",
|
||||
"updateAvailable": "Update beschikbaar",
|
||||
"openReleaseNotes": "Open release notities",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Toon titel"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Update ring tonen"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Lay-out",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontaal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Verticaal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Toon container update lijst",
|
||||
"description": "Lijst van alle gemonitorde container met een afbeelding update beschikbaar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS-hole overzicht",
|
||||
"description": "Toont het overzicht van je DNS-hole",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Chmurowy reverse proxy i load balancer"
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Monitoruj, który z twoich kontenerów Docker ma dostępne aktualizacje obrazów"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "System zarządzania dokumentami do archiwizacji i organizacji zeskanowaych dokumnteów"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "tutaj",
|
||||
"iconPicker": {
|
||||
"label": "adres URL ikony",
|
||||
"description": "Przeszukaj bibliotekę, wklej adres URL obrazu lub wgraj plik.",
|
||||
"header": "Wpisz nazwę lub obiekty do filtrowania ikon... Homarr przeszuka dla Ciebie ikony {countIcons}.",
|
||||
"headerLoading": "Wpisz, aby wyszukać ikony…",
|
||||
"clear": "Wyczyść obraz",
|
||||
"uploadImage": "Prześlij obraz",
|
||||
"results": "Wyniki wyszukiwania obrazów",
|
||||
"localImages": "Twoje obrazy",
|
||||
"svgIcons": "Ikony SVG",
|
||||
"otherImages": "Inne obrazy",
|
||||
"urlLoading": "Sprawdzanie obrazu…",
|
||||
"urlReady": "Obraz jest gotowy do użycia",
|
||||
"urlError": "Nie można załadować tego obrazu. Sprawdź adres URL i spróbuj ponownie.",
|
||||
"noResults": "Brak ikon pasujących do \"{search}\".",
|
||||
"error": "Nie udało się załadować ikon."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Pokaż, ile oglądanych kontenerów ma dostępne aktualizacje.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Monitorowane",
|
||||
"updatesAvailable": "{count, plural, one {# dostępna aktualizacja} other {# dostępne aktualizacje}}",
|
||||
"updateAvailable": "Dostępna aktualizacja",
|
||||
"openReleaseNotes": "Otwórz notatki o wydaniu",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Pokaż tytuł"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Pokaż pierścień aktualizacji"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Układ",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Poziomy"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Pionowy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Pokaż listę aktualizacji kontenera",
|
||||
"description": "Wymienia każdy monitorowany kontener z dostępną aktualizacją obrazu"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Podsumowanie DNS Hole",
|
||||
"description": "Wyświetla podsumowanie Twojego DNS Hole",
|
||||
@@ -2430,19 +2476,19 @@
|
||||
"label": "Odwróć sortowanie"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Kolejność kolumn"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Szerokości kolumn"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Nie udało się pobrać statystyk kontenerów",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Nie zapisano układu tabeli",
|
||||
"layoutSaveMessage": "Nie można zapisać układu tabeli Dockera. Spróbuj ponownie przesunąć lub zmienić rozmiar kolumny."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
"noContainers": "Nie znaleziono kontenerów"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2738,17 +2784,17 @@
|
||||
"description": "Wyłączenie tej opcji nie zadziała w przypadku Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Pokaż bitrate",
|
||||
"description": "Pokaż bieżącą przepływność odtwarzania obok lokalizacji"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Pokaż lokalizację",
|
||||
"description": "Pokaż, czy strumień jest w sieci LAN, czy WAN"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
"streams": "{count, plural, one {# aktywny strumień} few {# aktywne strumienie} many {# aktywnych strumieni} other {# aktywnych strumieni}}",
|
||||
"totalBitrate": "Łącznie: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Obecnie odtwarzane",
|
||||
@@ -2757,9 +2803,9 @@
|
||||
"id": "ID",
|
||||
"status": "Status",
|
||||
"directPlay": "Odtwarzanie bezpośrednie",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"directStream": "Bezpośredni strumień",
|
||||
"transcodeVideo": "Transkodowanie wideo",
|
||||
"transcodeAudio": "Transkodowanie audio",
|
||||
"transcoding": "Transkodowanie",
|
||||
"paused": "Zatrzymane",
|
||||
"remaining": "{minutes} min pozostało",
|
||||
@@ -3107,18 +3153,18 @@
|
||||
"description": "To ograniczy liczbę wyświetlanych elementów dla każdej integracji, a nie globalnie."
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Kolejność kolumn"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Szerokości kolumn"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"noColumns": "Wybierz kolumny w elementach",
|
||||
"noCommunications": "Nie można załadować danych z integracji",
|
||||
"noItems": "",
|
||||
"actionFailed": "",
|
||||
"actionFailedMessage": ""
|
||||
"noItems": "Nie znaleziono pobrań",
|
||||
"actionFailed": "Akcja nie powiodła się",
|
||||
"actionFailedMessage": "Nie można ukończyć działania. Spróbuj ponownie."
|
||||
},
|
||||
"items": {
|
||||
"actions": {
|
||||
@@ -3142,7 +3188,7 @@
|
||||
},
|
||||
"id": {
|
||||
"columnTitle": "ID",
|
||||
"detailsTitle": ""
|
||||
"detailsTitle": "Identyfikator elementu"
|
||||
},
|
||||
"integration": {
|
||||
"columnTitle": "Integracja"
|
||||
@@ -3177,7 +3223,7 @@
|
||||
"time": {
|
||||
"columnTitle": "Czas zakończenia",
|
||||
"detailsTitle": "Czas od/do zakończenia",
|
||||
"eta": ""
|
||||
"eta": "Przewidywany czas"
|
||||
},
|
||||
"type": {
|
||||
"columnTitle": "Typ",
|
||||
@@ -3223,21 +3269,21 @@
|
||||
},
|
||||
"globalRatio": "Wskaźnik globalny",
|
||||
"contextMenu": {
|
||||
"copyName": "",
|
||||
"copyId": "",
|
||||
"clearFilters": ""
|
||||
"copyName": "Kopiuj nazwę",
|
||||
"copyId": "Kopiuj ID",
|
||||
"clearFilters": "Wyczyść filtry"
|
||||
},
|
||||
"footer": {
|
||||
"filter": ""
|
||||
"filter": "Filtruj pobierania"
|
||||
},
|
||||
"stats": {
|
||||
"show": "",
|
||||
"hide": "",
|
||||
"overallProgress": "",
|
||||
"totalSize": "",
|
||||
"clients": ""
|
||||
"show": "Pokaż globalne statystyki",
|
||||
"hide": "Ukryj globalne statystyki",
|
||||
"overallProgress": "Ogólny postęp kolejki",
|
||||
"totalSize": "Całkowity rozmiar kolejki",
|
||||
"clients": "{count, plural, one {# klient} few {# klientów} many {# klientów} other {# klientów}}"
|
||||
},
|
||||
"disconnected": ""
|
||||
"disconnected": "(rozłączono)"
|
||||
},
|
||||
"mediaReleases": {
|
||||
"name": "Wydania mediów",
|
||||
@@ -3968,17 +4014,17 @@
|
||||
"label": "Pokaż wersję agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
"label": "Kolejność kolumn"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
"label": "Szerokości kolumn"
|
||||
}
|
||||
},
|
||||
"noRecords": "Nie znaleziono systemów",
|
||||
"error": {
|
||||
"internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
"layoutSaveTitle": "Nie można zapisać układu tabeli",
|
||||
"layoutSaveMessage": "Nie można zapisać kolejności kolumn ani ich szerokości. Spróbuj ponownie."
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5376,7 +5422,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Usuń",
|
||||
"confirm": "",
|
||||
"confirm": "Kliknij ponownie, aby trwale usunąć",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Usunięto kontenery",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "O Docker",
|
||||
"description": "Monitora qual dos seus contêineres Docker tem atualizações de imagem disponíveis"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Sistema de gerenciamento de documentos para arquivamento e organização de documentos digitalizados"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "aqui",
|
||||
"iconPicker": {
|
||||
"label": "URL do ícone",
|
||||
"description": "Procure na biblioteca, cole uma URL da imagem ou envie um arquivo.",
|
||||
"header": "Digite o nome ou os objetos para filtrar os ícones... O Homarr pesquisará {countIcons} ícones para você.",
|
||||
"headerLoading": "Digite para pesquisar ícones…",
|
||||
"clear": "Limpar imagem",
|
||||
"uploadImage": "Upload de imagem",
|
||||
"results": "Resultados da busca por imagem",
|
||||
"localImages": "Suas imagens",
|
||||
"svgIcons": "Ícones SVG",
|
||||
"otherImages": "Outras imagens",
|
||||
"urlLoading": "Verificando imagem…",
|
||||
"urlReady": "A imagem está pronta para uso",
|
||||
"urlError": "Esta imagem não pôde ser carregada. Verifique a URL e tente novamente.",
|
||||
"noResults": "Nenhum ícone corresponde a \"{search}\".",
|
||||
"error": "Não foi possível carregar os ícones."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "O Docker",
|
||||
"description": "Mostrar quantos contêineres assistidos possuem atualizações disponíveis.",
|
||||
"title": "O Docker",
|
||||
"monitored": "Monitorado",
|
||||
"updatesAvailable": "{count, plural, one {# atualização disponível} other {# atualizações disponíveis}}",
|
||||
"updateAvailable": "Atualização disponível",
|
||||
"openReleaseNotes": "Abrir notas de lançamento",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Mostrar título"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Mostrar atualização de toque"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Mostrar lista de atualização do recipiente",
|
||||
"description": "Lista cada container monitorado que tem uma atualização de imagem disponível"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Resumo do DNS Hole",
|
||||
"description": "Exibe o resumo do seu DNS Hole",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "O Docker",
|
||||
"description": "Monitora qual dos seus contêineres Docker tem atualizações de imagem disponíveis"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Sistema de gerenciamento de documentos para arquivamento e organização de documentos digitalizados"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "aqui",
|
||||
"iconPicker": {
|
||||
"label": "URL do ícone",
|
||||
"description": "Procure na biblioteca, cole uma URL da imagem ou envie um arquivo.",
|
||||
"header": "Digite o nome ou os objetos para filtrar os ícones... O Homarr pesquisará {countIcons} ícones para você.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Pesquisar imagens por nome",
|
||||
"clear": "Limpar imagem",
|
||||
"uploadImage": "Upload de imagem",
|
||||
"results": "Resultados da busca por imagem",
|
||||
"localImages": "Suas imagens",
|
||||
"svgIcons": "Ícones SVG",
|
||||
"otherImages": "Outras imagens",
|
||||
"urlLoading": "Verificando imagem…",
|
||||
"urlReady": "A imagem está pronta para uso",
|
||||
"urlError": "Esta imagem não pôde ser carregada. Verifique a URL e tente novamente.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "O Docker",
|
||||
"description": "Mostrar quantos contêineres assistidos possuem atualizações disponíveis.",
|
||||
"title": "O Docker",
|
||||
"monitored": "Monitorado",
|
||||
"updatesAvailable": "{count, plural, one {# atualização disponível} other {# atualizações disponíveis}}",
|
||||
"updateAvailable": "Atualização disponível",
|
||||
"openReleaseNotes": "Abrir notas de lançamento",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Mostrar título"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Mostrar atualização de toque"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Disposição",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontal"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertical"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Mostrar lista de atualização do recipiente",
|
||||
"description": "Lista cada container monitorado que tem uma atualização de imagem disponível"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Resumo do DNS Hole",
|
||||
"description": "Exibe o resumo do seu DNS Hole",
|
||||
|
||||
+2256
-2210
File diff suppressed because it is too large.
Load diff
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Что же докер",
|
||||
"description": "Мониторинг того, какие из контейнеров Docker доступны для обновления изображений"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "здесь",
|
||||
"iconPicker": {
|
||||
"label": "URL иконки",
|
||||
"description": "Поиск в библиотеке, вставьте URL изображения или загрузите файл.",
|
||||
"header": "Введите название или объекты для фильтрации иконок... Homarr найдёт для вас среди {countIcons} иконок.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Поиск изображений по имени",
|
||||
"clear": "Очистить изображение",
|
||||
"uploadImage": "Загрузить изображение",
|
||||
"results": "Результаты поиска изображений",
|
||||
"localImages": "Ваши изображения",
|
||||
"svgIcons": "Значки SVG",
|
||||
"otherImages": "Другие изображения",
|
||||
"urlLoading": "Проверка изображения…",
|
||||
"urlReady": "Изображение готово к использованию",
|
||||
"urlError": "Это изображение не может быть загружено. Проверьте URL и повторите попытку.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Что же докер",
|
||||
"description": "Показывать количество доступных обновлений в просмотренных контейнерах.",
|
||||
"title": "Что же докер",
|
||||
"monitored": "Мониторинг",
|
||||
"updatesAvailable": "{count, plural, one {# доступно обновление} other {# доступно обновлений}}",
|
||||
"updateAvailable": "Доступно обновление",
|
||||
"openReleaseNotes": "Открыть примечания к релизу",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Показать название"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Показать обновление кольца"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Макет",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Горизонтально"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Вертикально"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Показать список обновлений контейнеров",
|
||||
"description": "Список каждого контролируемого контейнера, имеющего доступное обновление изображений"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Сводка DNS-фильтра",
|
||||
"description": "Отображает сводную информацию о вашем DNS-фильтре",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Sledujte, ktoré z vašich Docker kontajnerov majú k dispozícii aktualizácie obrazov."
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Systém správy dokumentov na archiváciu a organizáciu naskenovaných dokumentov"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "tu",
|
||||
"iconPicker": {
|
||||
"label": "URL ikony",
|
||||
"description": "Vyhľadajte v knižnici, prilepte URL obrázka alebo nahrajte súbor.",
|
||||
"header": "Zadajte názov alebo objekty na filtrovanie ikon... Homarr pre vás vyhľadá ikony na stránke {countIcons} .",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Vyhľadať obrázky podľa názvu",
|
||||
"clear": "Odstrániť obrázok",
|
||||
"uploadImage": "Nahrať obrázok",
|
||||
"results": "Výsledky vyhľadávania obrázkov",
|
||||
"localImages": "Vaše obrázky",
|
||||
"svgIcons": "SVG ikony",
|
||||
"otherImages": "Ostatné obrázky",
|
||||
"urlLoading": "Kontrolujem obrázok…",
|
||||
"urlReady": "Obrázok je pripravený na použitie",
|
||||
"urlError": "Tento obrázok sa nepodarilo načítať. Skontrolujte adresu URL a skúste to znova.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Zobrazuje, koľko sledovaných kontajnerov má k dispozícii aktualizácie.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Sledované",
|
||||
"updatesAvailable": "{count, plural, one {# aktualizácia je k dispozícii} few {# aktualizácie sú k dispozícii} many {# aktualizácií je k dispozícii} other {# aktualizácie je k dispozícii}}",
|
||||
"updateAvailable": "Dostupná aktualizácia",
|
||||
"openReleaseNotes": "Otvorte poznámky k vydaniu",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Zobraziť názov"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Zobraziť prstenec aktualizácie"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Rozloženie",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horizontálne"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikálne"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Zobraziť zoznam aktualizácií kontajnerov",
|
||||
"description": "Uvádza každý sledovaný kontajner, ktorý má dostupnú aktualizáciu obrazu."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Zhrnutie dier DNS",
|
||||
"description": "Zobrazí súhrn vašej diery DNS",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Spremljajte, kateri vaši Docker vsebniki imajo na voljo posodobitve slik."
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "tukaj",
|
||||
"iconPicker": {
|
||||
"label": "URL ikone",
|
||||
"description": "Iščite po knjižnici, prilepite URL slike ali naložite datoteko.",
|
||||
"header": "Vnesite ime ali predmete za filtriranje ikon... Homarr bo za vas iskal med {countIcons} ikonami.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Išči slike po imenu",
|
||||
"clear": "Počisti sliko",
|
||||
"uploadImage": "Naloži sliko",
|
||||
"results": "Rezultati iskanja slik",
|
||||
"localImages": "Vaše slike",
|
||||
"svgIcons": "Ikone SVG",
|
||||
"otherImages": "Druge slike",
|
||||
"urlLoading": "Preverjanje slike…",
|
||||
"urlReady": "Slika je pripravljena za uporabo",
|
||||
"urlError": "Te slike ni bilo mogoče naložiti. Preverite URL in poskusite znova.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Prikaže, koliko spremljanih vsebnikov ima na voljo posodobitve.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Spremljani",
|
||||
"updatesAvailable": "{count, plural, one {# posodobitev na voljo} two {# posodobitvi na voljo} few {# posodobitve na voljo} other {# posodobitev na voljo}}",
|
||||
"updateAvailable": "Na voljo je posodobitev",
|
||||
"openReleaseNotes": "Odpri opombe ob izdaji",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Pokaži naslov"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Pokaži obroč posodobitev"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Postavitev",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Vodoravno"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Navpično"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Pokaži seznam posodobitev vsebnikov",
|
||||
"description": "Prikaže seznam vseh nadzorovanih vsebnikov, ki imajo na voljo posodobitev slike."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Povzetek DNS luknje",
|
||||
"description": "Prikaže povzetek vaše DNS luknje",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "Vad är Upp Docker",
|
||||
"description": "Övervaka vilken av dina Docker behållare har bilduppdateringar tillgängliga"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumenthanteringssystem för arkivering och organisering av skannade dokument"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "",
|
||||
"iconPicker": {
|
||||
"label": "",
|
||||
"header": "",
|
||||
"headerLoading": "",
|
||||
"description": "Sök i biblioteket, klistra in en bild-URL eller ladda upp en fil.",
|
||||
"header": "Sök {countIcons} bilder efter namn",
|
||||
"headerLoading": "Sök bilder med namn",
|
||||
"clear": "Rensa bild",
|
||||
"uploadImage": "Ladda upp bild",
|
||||
"results": "Sökresultat för bild",
|
||||
"localImages": "Dina bilder",
|
||||
"svgIcons": "SVG ikoner",
|
||||
"otherImages": "Andra bilder",
|
||||
"urlLoading": "Kontrollerar bild…",
|
||||
"urlReady": "Bilden är klar att användas",
|
||||
"urlError": "Bilden kunde inte laddas. Kontrollera URL:en och försök igen.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Vad är Upp Docker",
|
||||
"description": "Visa hur många bevakade behållare som har uppdateringar tillgängliga.",
|
||||
"title": "Vad är Upp Docker",
|
||||
"monitored": "Övervakad",
|
||||
"updatesAvailable": "{count, plural, one {# uppdatering tillgänglig} other {# uppdateringar tillgängliga}}",
|
||||
"updateAvailable": "Uppdatering tillgänglig",
|
||||
"openReleaseNotes": "Öppna versionsfakta",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Visa titel"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Visa uppdateringsring"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Horisontell"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Vertikal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Visa uppdateringslista för behållare",
|
||||
"description": "Listar varje övervakad behållare som har en bild uppdatering tillgänglig"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Bulut tabanlı ters proxy ve yük dengeleyici"
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Docker konteynerlerinizden hangilerinde imaj güncellemeleri olduğunu izleyin."
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Taranmış belgeleri arşivlemek ve düzenlemek için belge yönetim sistemi"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "buraya",
|
||||
"iconPicker": {
|
||||
"label": "Simge URL'si",
|
||||
"description": "Kütüphanede arayın, bir görsel URL'si yapıştırın veya bir dosya yükleyin.",
|
||||
"header": "Simgeleri filtrelemek için isim veya nesne yazın... Homarr sizin için {countIcons} simge arasında arama yapacaktır.",
|
||||
"headerLoading": "Simge aramak için yazın…",
|
||||
"clear": "Görseli temizle",
|
||||
"uploadImage": "Görsel yükle",
|
||||
"results": "Görsel arama sonuçları",
|
||||
"localImages": "Görselleriniz",
|
||||
"svgIcons": "SVG simgeleri",
|
||||
"otherImages": "Diğer görseller",
|
||||
"urlLoading": "Görsel kontrol ediliyor…",
|
||||
"urlReady": "Görsel kullanıma hazır",
|
||||
"urlError": "Bu görsel yüklenemedi. URL'yi kontrol edip yeniden deneyin.",
|
||||
"noResults": "“{search}” aramasıyla eşleşen simge yok.",
|
||||
"error": "Simgeler yüklenemedi."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "İzlenen konteynerlerin kaçında güncelleme mevcut olduğunu gösterir.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "İzlenen",
|
||||
"updatesAvailable": "{count, plural, one {# güncelleme mevcut} other {# güncelleme mevcut}}",
|
||||
"updateAvailable": "Güncelleme mevcut",
|
||||
"openReleaseNotes": "Sürüm notlarını aç",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Başlığı göster"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Güncelleme halkasını göster"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Düzen",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Yatay"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Dikey"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Konteyner güncelleme listesini göster",
|
||||
"description": "İmaj güncellemesi mevcut olan her izlenen konteyneri listeler"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Çözümleyici Özeti",
|
||||
"description": "DNS Çözümleyici özetini görüntüler",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "Хмарний зворотний проксі-сервер та балансувальник навантаження"
|
||||
},
|
||||
"wud": {
|
||||
"name": "Що таке Docker",
|
||||
"description": "Контролювати контейнери Docker мають доступні оновлення зображення"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Безпаперовий ngx",
|
||||
"description": "Система керування документами для архівування та організації відсканованих документів"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "тут",
|
||||
"iconPicker": {
|
||||
"label": "URL іконки",
|
||||
"description": "Знайдіть бібліотеку, вставте URL-адресу зображення або завантажте файл.",
|
||||
"header": "Введіть назву або об'єкти для фільтрації значків... Homarr виконає пошук серед {countIcons} значків для вас.",
|
||||
"headerLoading": "Введіть для пошуку іконок…",
|
||||
"clear": "Очистити зображення",
|
||||
"uploadImage": "Вивантажити зображення",
|
||||
"results": "Результати пошуку зображень",
|
||||
"localImages": "Ваші зображення",
|
||||
"svgIcons": "Іконки SVG",
|
||||
"otherImages": "Інші зображення",
|
||||
"urlLoading": "Перевірка зображень…",
|
||||
"urlReady": "Зображення можна використовувати",
|
||||
"urlError": "Це зображення не може бути завантажене. Перевірте URL-адресу та повторіть спробу.",
|
||||
"noResults": "Немає іконок, що відповідають{search}\".",
|
||||
"error": "Не вдалося завантажити іконки."
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "Що таке Docker",
|
||||
"description": "Показати скільки переглянутих контейнерів мають доступні оновлення.",
|
||||
"title": "Що таке Docker",
|
||||
"monitored": "Відстежено",
|
||||
"updatesAvailable": "{count, plural, one {# оновлення доступні} other {# оновлень доступні}}",
|
||||
"updateAvailable": "Доступне оновлення",
|
||||
"openReleaseNotes": "Відкрити замітки до випуску",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Показати назву"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Показати кільце оновлення"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Макет",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Горизонтально"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Вертикально"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Показати список оновлень контейнерів",
|
||||
"description": "Відображає список кожного моніторного контейнера, у якого є доступне оновлення зображення"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Звіт DNS-Hole",
|
||||
"description": "Відображає резюме вашого DNS Hole",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Theo dõi các container Docker của bạn có bản cập nhật hình ảnh."
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "tại đây",
|
||||
"iconPicker": {
|
||||
"label": "URL biểu tượng",
|
||||
"description": "Tìm kiếm trong thư viện, dán URL hình ảnh hoặc tải lên tệp.",
|
||||
"header": "Nhập tên hoặc đối tượng để lọc biểu tượng... Homarr sẽ tìm qua {countIcons} biểu tượng cho bạn.",
|
||||
"headerLoading": "",
|
||||
"headerLoading": "Tìm kiếm hình ảnh theo tên",
|
||||
"clear": "Xóa hình ảnh",
|
||||
"uploadImage": "Tải lên hình ảnh",
|
||||
"results": "Kết quả tìm kiếm hình ảnh",
|
||||
"localImages": "Hình ảnh của bạn",
|
||||
"svgIcons": "Biểu tượng SVG",
|
||||
"otherImages": "Hình ảnh khác",
|
||||
"urlLoading": "Đang kiểm tra hình ảnh…",
|
||||
"urlReady": "Hình ảnh đã sẵn sàng để sử dụng",
|
||||
"urlError": "Không thể tải hình ảnh này. Kiểm tra URL và thử lại.",
|
||||
"noResults": "",
|
||||
"error": ""
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "What's Up Docker",
|
||||
"description": "Hiển thị số container được theo dõi có bản cập nhật.",
|
||||
"title": "What's Up Docker",
|
||||
"monitored": "Đã theo dõi",
|
||||
"updatesAvailable": "{count, plural, other {# bản cập nhật có sẵn}}",
|
||||
"updateAvailable": "Có bản cập nhật",
|
||||
"openReleaseNotes": "Mở ghi chú phát hành",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": "Hiển thị tiêu đề"
|
||||
},
|
||||
"showRing": {
|
||||
"label": "Hiển thị vòng cập nhật"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Bố cục",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "Nằm ngang"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "Thẳng đứng"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "Hiển thị danh sách cập nhật container",
|
||||
"description": "Liệt kê từng container được giám sát có bản cập nhật image sẵn có"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "Tổng quan DNS Hole",
|
||||
"description": "Hiển thị tổng quan về DNS Hole của bạn",
|
||||
|
||||
@@ -1344,6 +1344,10 @@
|
||||
"name": "Traefik",
|
||||
"description": "雲原生反向代理和負載平衡器"
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "用於歸檔和組織掃描文件的文件管理系統"
|
||||
@@ -1437,8 +1441,18 @@
|
||||
"here": "此處",
|
||||
"iconPicker": {
|
||||
"label": "Icon 網址",
|
||||
"description": "",
|
||||
"header": "輸入名稱或物件來篩選 icons,Homarr 會自動搜尋 {countIcons}",
|
||||
"headerLoading": "輸入以搜尋圖示…",
|
||||
"clear": "",
|
||||
"uploadImage": "",
|
||||
"results": "",
|
||||
"localImages": "你的圖像",
|
||||
"svgIcons": "",
|
||||
"otherImages": "其他圖像",
|
||||
"urlLoading": "",
|
||||
"urlReady": "",
|
||||
"urlError": "",
|
||||
"noResults": "沒有符合 \"{search}\" 的圖示。",
|
||||
"error": "無法載入圖示。"
|
||||
},
|
||||
@@ -1793,6 +1807,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wud": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"monitored": "",
|
||||
"updatesAvailable": "",
|
||||
"updateAvailable": "",
|
||||
"openReleaseNotes": "",
|
||||
"option": {
|
||||
"showTitle": {
|
||||
"label": ""
|
||||
},
|
||||
"showRing": {
|
||||
"label": ""
|
||||
},
|
||||
"layout": {
|
||||
"label": "顯示布局",
|
||||
"option": {
|
||||
"horizontal": {
|
||||
"label": "橫向"
|
||||
},
|
||||
"vertical": {
|
||||
"label": "垂直"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUpdateList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole 概況",
|
||||
"description": "顯示 DNS Hole 的概況",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import type { FormErrors } from "@mantine/form";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconBrandDocker,
|
||||
@@ -48,7 +47,7 @@ import {
|
||||
normalizeReleaseProviderIdentifier,
|
||||
releaseProviderKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -417,13 +416,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||
// Allows user to not select an icon by removing the url from the input,
|
||||
// will only try and get an icon if the name or identifier changes
|
||||
const [autoSetIcon, setAutoSetIcon] = useState(false);
|
||||
|
||||
// Debounce the name value with 200ms delay
|
||||
const [debouncedName] = useDebouncedValue(tempRepository.name, 800);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -448,25 +440,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
setTempRepository((prev) => ({ ...prev, ...changedValue }));
|
||||
}, []);
|
||||
|
||||
// Auto-select icon based on identifier formatted name with debounced search
|
||||
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
|
||||
{
|
||||
searchText: debouncedName,
|
||||
},
|
||||
{
|
||||
enabled: autoSetIcon && (debouncedName?.length ?? 0) > 3,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSetIcon && debouncedName && !tempRepository.iconUrl && iconsData?.icons) {
|
||||
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
|
||||
if (bestMatch) {
|
||||
handleChange({ iconUrl: bestMatch });
|
||||
}
|
||||
}
|
||||
}, [debouncedName, iconsData, tempRepository, handleChange, autoSetIcon]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
|
||||
@@ -498,8 +471,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
identifier: event.currentTarget.value,
|
||||
name,
|
||||
});
|
||||
|
||||
if (event.currentTarget.value) setAutoSetIcon(true);
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.identifier`]}
|
||||
style={{ flex: 0.7 }}
|
||||
@@ -520,11 +491,7 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
<TextInput
|
||||
label={tRepository("name.label")}
|
||||
value={tempRepository.name ?? ""}
|
||||
onChange={(event) => {
|
||||
handleChange({ name: event.currentTarget.value });
|
||||
|
||||
if (event.currentTarget.value) setAutoSetIcon(true);
|
||||
}}
|
||||
onChange={(event) => handleChange({ name: event.currentTarget.value })}
|
||||
error={formErrors[`${innerProps.fieldPath}.name`]}
|
||||
style={{ flex: 0.3 }}
|
||||
/>
|
||||
@@ -533,9 +500,9 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
value={tempRepository.iconUrl ?? ""}
|
||||
suggestedSearch={tempRepository.name || tempRepository.identifier}
|
||||
onChange={(url) => {
|
||||
if (url === "") {
|
||||
setAutoSetIcon(false);
|
||||
handleChange({ iconUrl: undefined });
|
||||
} else {
|
||||
handleChange({ iconUrl: url });
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
|
||||
@@ -47,7 +45,7 @@ export interface TooltipPayloadItem {
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PortalTooltipProps {
|
||||
interface BeszelTooltipProps {
|
||||
active?: boolean;
|
||||
label?: string;
|
||||
payload?: TooltipPayloadItem[];
|
||||
@@ -55,53 +53,8 @@ interface PortalTooltipProps {
|
||||
showTotal?: boolean;
|
||||
}
|
||||
|
||||
const MARGIN = 14;
|
||||
|
||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const rafRef = useRef(0);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouse = useCallback((e: MouseEvent) => {
|
||||
mouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const el = tooltipRef.current;
|
||||
const mx = mouseRef.current.x;
|
||||
const my = mouseRef.current.y;
|
||||
|
||||
if (!el) {
|
||||
setPos({ x: mx + MARGIN, y: my });
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let x = mx + MARGIN;
|
||||
let y = my - height / 2;
|
||||
|
||||
if (x + width > vw - MARGIN) x = mx - width - MARGIN;
|
||||
if (y < MARGIN) y = MARGIN;
|
||||
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
|
||||
|
||||
setPos({ x, y });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }: BeszelTooltipProps) => {
|
||||
const isActive = active && (payload?.length ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
document.addEventListener("mousemove", handleMouse);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouse);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [isActive, handleMouse]);
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
const deduped = new Map<string, TooltipPayloadItem>();
|
||||
@@ -116,41 +69,30 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
|
||||
const total = sorted.reduce((sum, p) => sum + p.value, 0);
|
||||
|
||||
const portalStyle: CSSProperties = {
|
||||
position: "fixed",
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
zIndex: 10000,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div ref={tooltipRef} style={portalStyle}>
|
||||
<div style={styles.wrapper}>
|
||||
<div style={styles.header}>{tooltipLabel}</div>
|
||||
{sorted.map((item) => (
|
||||
<div key={item.dataKey} style={styles.row}>
|
||||
<div style={{ ...styles.indicator, background: item.color }} />
|
||||
<span style={styles.name}>{item.name}</span>
|
||||
<span style={styles.value}>{formatter(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{showTotal && sorted.length > 1 && (
|
||||
<div style={{ ...styles.row, ...styles.separator }}>
|
||||
<span style={styles.name}>Total</span>
|
||||
<span style={styles.value}>{formatter(total)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
return (
|
||||
<div style={styles.wrapper}>
|
||||
<div style={styles.header}>{tooltipLabel}</div>
|
||||
{sorted.map((item) => (
|
||||
<div key={item.dataKey} style={styles.row}>
|
||||
<div style={{ ...styles.indicator, background: item.color }} />
|
||||
<span style={styles.name}>{item.name}</span>
|
||||
<span style={styles.value}>{formatter(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{showTotal && sorted.length > 1 && (
|
||||
<div style={{ ...styles.row, ...styles.separator }}>
|
||||
<span style={styles.name}>Total</span>
|
||||
<span style={styles.value}>{formatter(total)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
|
||||
content: (props: any) => (
|
||||
<PortalTooltipContent
|
||||
<BeszelTooltipContent
|
||||
active={props.active}
|
||||
label={props.label != null ? String(props.label) : undefined}
|
||||
payload={props.payload}
|
||||
@@ -158,6 +100,6 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
|
||||
showTotal={showTotal}
|
||||
/>
|
||||
),
|
||||
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
|
||||
wrapperStyle: { zIndex: 10000, pointerEvents: "none" as const },
|
||||
isAnimationActive: false,
|
||||
});
|
||||
@@ -107,4 +107,5 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
beszelSystemStats: IconChartAreaLine,
|
||||
traefik: IconRoute,
|
||||
customApi: IconApi,
|
||||
wud: IconBrandDocker,
|
||||
};
|
||||
@@ -113,7 +113,9 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
|
||||
},
|
||||
);
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -64,6 +64,7 @@ import * as beszelSystemStats from "./beszel-system-stats";
|
||||
import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
import * as customApi from "./custom-api";
|
||||
import * as wud from "./wud";
|
||||
|
||||
export type {
|
||||
WidgetDefinition,
|
||||
@@ -129,6 +130,7 @@ export const widgetImports = {
|
||||
beszelSystemStats,
|
||||
traefik,
|
||||
customApi,
|
||||
wud,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user