Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb0222c04d |
No files matched your search
@@ -206,14 +206,6 @@ 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/ # 56 integration guides (one folder each)
|
||||
├── widgets/ # 43 widget guides (one folder each)
|
||||
├── integrations/ # 55 integration guides (one folder each)
|
||||
├── widgets/ # 42 widget guides (one folder each)
|
||||
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
|
||||
└── community/ # FAQ, donate, license, translations, get-in-touch
|
||||
```
|
||||
|
||||
@@ -9,19 +9,17 @@ tags:
|
||||
---
|
||||
|
||||
## Icon picker
|
||||
|
||||
Icons in Homarr are automatically requested from multiple sources:
|
||||
|
||||
- [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/)
|
||||
- [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/)
|
||||
|
||||
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
|
||||
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.
|
||||
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
|
||||
|
||||
### Finding Missing Icons
|
||||
|
||||
@@ -31,13 +29,6 @@ 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. 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.
|
||||
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.
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
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",
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -1,9 +0,0 @@
|
||||
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. 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.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
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} />
|
||||
@@ -1,37 +0,0 @@
|
||||
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,11 +697,7 @@ 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}
|
||||
suggestedSearch={mode === "create" ? form.values.name : undefined}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
/>
|
||||
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
|
||||
@@ -57,10 +57,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<IconPicker
|
||||
{...form.getInputProps("iconUrl")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Suspense, use, useEffect, useState } from "react";
|
||||
import { Box, Loader, 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;
|
||||
@@ -20,6 +41,8 @@ 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]]);
|
||||
@@ -43,14 +66,60 @@ 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 (
|
||||
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
{avatar}
|
||||
</UpdateIndicator>
|
||||
</UnstyledButton>
|
||||
</UserAvatarMenu>
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary }) =>
|
||||
renderButton(() => {
|
||||
resetErrorBoundary();
|
||||
openMenu();
|
||||
})
|
||||
}
|
||||
>
|
||||
<Suspense fallback={loadingButton}>
|
||||
<LazyUserAvatarMenu
|
||||
availableUpdates={visibleUpdates}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
opened={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
{button}
|
||||
</LazyUserAvatarMenu>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -21,9 +21,17 @@ interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
isDockerEnabled?: boolean;
|
||||
opened: boolean;
|
||||
onOpenChange: (opened: boolean) => void;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({
|
||||
children,
|
||||
availableUpdates,
|
||||
isDockerEnabled,
|
||||
opened,
|
||||
onOpenChange,
|
||||
}: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const session = useSession();
|
||||
|
||||
@@ -40,7 +48,7 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
|
||||
@@ -429,13 +429,6 @@
|
||||
<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,10 +45,7 @@ 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((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
|
||||
timeout: 60_000,
|
||||
waitUntil: "commit",
|
||||
});
|
||||
const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await loginNavigation;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
|
||||
import { and, like } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import { iconsFindSchema } from "@homarr/validation/icons";
|
||||
|
||||
@@ -15,41 +15,38 @@ export const iconsRouter = createTRPCRouter({
|
||||
})
|
||||
.input(iconsFindSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
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}%`)));
|
||||
}
|
||||
|
||||
return {
|
||||
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),
|
||||
})),
|
||||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
countIcons: await ctx.db.$count(icons),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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,5 +44,4 @@ 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,25 +0,0 @@
|
||||
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,34 +28,3 @@ 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, getImageMatchRank, normalizeImageName } from "../string";
|
||||
import { bestMatch, capitalize } from "../string";
|
||||
|
||||
const capitalizeTestCases = [
|
||||
["hello", "Hello"],
|
||||
@@ -54,27 +54,3 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -210,7 +210,6 @@ 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"
|
||||
@@ -277,7 +276,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/whats-up-docker"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
| "/docs/integrations/sabnzbd"
|
||||
@@ -332,7 +330,6 @@ 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"
|
||||
@@ -385,5 +382,4 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/beszel-alerts"
|
||||
| "/docs/widgets/beszel-system-stats"
|
||||
| "/docs/widgets/traefik"
|
||||
| "/docs/widgets/custom-api"
|
||||
| "/docs/widgets/whats-up-docker";
|
||||
| "/docs/widgets/custom-api";
|
||||
@@ -54,6 +54,5 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
beszel: "beszel",
|
||||
traefik: "traefik",
|
||||
archiveTeamWarrior: null,
|
||||
wud: "whats-up-docker",
|
||||
mock: null,
|
||||
};
|
||||
@@ -55,5 +55,4 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
wud: "whats-up-docker",
|
||||
};
|
||||
@@ -468,14 +468,6 @@ 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,9 +24,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
|
||||
firewall: getIntegrationKindsByCategory("firewall"),
|
||||
notifications: getIntegrationKindsByCategory("notifications"),
|
||||
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
|
||||
@@ -52,7 +50,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
traefik: ["traefik"],
|
||||
wud: ["wud"],
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
|
||||
@@ -53,7 +53,6 @@ export const widgetKinds = [
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"wud",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
|
||||
@@ -1,89 +1,3 @@
|
||||
.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"] {
|
||||
[data-combobox-selected="true"] .iconCard {
|
||||
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,84 +4,31 @@ 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, refetch, uploadSelection, sessionPermissions } = vi.hoisted(() => ({
|
||||
findIconsQuery: vi.fn(),
|
||||
refetch: vi.fn(),
|
||||
uploadSelection: vi.fn(),
|
||||
sessionPermissions: { current: [] as string[] },
|
||||
const { findIconsQuery } = vi.hoisted(() => ({
|
||||
findIconsQuery: vi.fn((_query: { searchText: string }) => ({
|
||||
data: { icons: [], countIcons: 0 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
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: (input: { searchText: string; limitPerGroup: number }, options: { enabled: boolean }) => {
|
||||
findIconsQuery(input, options);
|
||||
return { ...queryResult, refetch };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
|
||||
}));
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: { user: { permissions: sessionPermissions.current } } }),
|
||||
useSession: () => ({ data: { user: { permissions: [] } } }),
|
||||
}));
|
||||
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
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" }]);
|
||||
},
|
||||
}),
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
|
||||
|
||||
import { IconPicker } from "./icon-picker";
|
||||
|
||||
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 directIconUrl = "https://cdn.example.com/shopify.svg";
|
||||
|
||||
const setInputValue = (input: HTMLInputElement, value: string) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
@@ -90,18 +37,6 @@ 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;
|
||||
@@ -110,9 +45,9 @@ describe("IconPicker", () => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (media: string) => ({
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
@@ -121,186 +56,54 @@ 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(() => {
|
||||
act(() => root.unmount());
|
||||
root.unmount();
|
||||
container.remove();
|
||||
document.querySelectorAll("[data-combobox-dropdown]").forEach((element) => element.remove());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const renderPicker = async (
|
||||
onChange: (value: string) => void,
|
||||
props: Partial<React.ComponentProps<typeof IconPicker>> = {},
|
||||
) => {
|
||||
const renderPicker = async (onChange: (value: string) => void) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange, ...props })));
|
||||
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
|
||||
});
|
||||
const input = container.querySelector("input");
|
||||
if (!input) throw new Error("Icon picker input was not rendered");
|
||||
return input;
|
||||
};
|
||||
|
||||
test("clears an existing value without restoring it", async () => {
|
||||
test("keeps a direct URL visible without searching the icon repositories", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange, { value: directImageUrl });
|
||||
const input = await renderPicker(onChange);
|
||||
|
||||
await act(async () => setInputValue(input, ""));
|
||||
await act(async () => {
|
||||
setInputValue(input, directIconUrl);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(input.value).toBe("");
|
||||
expect(onChange).toHaveBeenCalledWith(directIconUrl);
|
||||
expect(input.value).toBe(directIconUrl);
|
||||
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
|
||||
});
|
||||
|
||||
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 });
|
||||
test("keeps icon names in repository search mode", async () => {
|
||||
const onChange = vi.fn();
|
||||
const input = await renderPicker(onChange);
|
||||
|
||||
await act(async () => pressKey(input, "Backspace", modifier));
|
||||
await act(async () => {
|
||||
setInputValue(input, "shopify");
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
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(onChange).not.toHaveBeenCalled();
|
||||
expect(input.value).toBe("shopify");
|
||||
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,36 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler, KeyboardEventHandler } from "react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
CloseButton,
|
||||
Card,
|
||||
Combobox,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Indicator,
|
||||
InputBase,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCloud,
|
||||
IconMoodEmpty,
|
||||
IconPhoto,
|
||||
IconPhotoOff,
|
||||
IconSearchOff,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -38,7 +29,6 @@ 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 {
|
||||
@@ -49,12 +39,9 @@ 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,
|
||||
@@ -64,403 +51,188 @@ export const IconPicker = ({
|
||||
withAsterisk = true,
|
||||
label,
|
||||
placeholder,
|
||||
suggestedSearch,
|
||||
}: IconPickerProps) => {
|
||||
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 [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
}, [value]);
|
||||
|
||||
const { data: session } = useSession();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [debouncedDraft] = useDebouncedValue(draft, 250);
|
||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
||||
|
||||
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 searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : "";
|
||||
|
||||
const query = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText, limitPerGroup: 24 },
|
||||
{ enabled: directUrl === null, placeholderData: (previous) => previous },
|
||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText },
|
||||
{ placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value ?? "");
|
||||
setHasEdited(false);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewState(directUrl ? "loading" : "idle");
|
||||
}, [directUrl]);
|
||||
|
||||
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 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 (
|
||||
<PickerMessage
|
||||
icon={<IconSearchOff size={22} />}
|
||||
title={tCommon("iconPicker.error")}
|
||||
action={
|
||||
<Button size="xs" variant="default" onClick={() => query.refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<PickerMessage
|
||||
icon={<IconMoodEmpty size={22} />}
|
||||
title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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>
|
||||
));
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
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 renderDropdown = () => {
|
||||
if (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>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return Array(15)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
));
|
||||
}
|
||||
if (totalOptions > 0) {
|
||||
return <Stack gap={4}>{groups}</Stack>;
|
||||
}
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconMoodEmpty size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
||||
</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
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
{(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}
|
||||
/>
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Combobox.Target>
|
||||
|
||||
<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.Dropdown>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{renderDropdown()}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
// 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
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
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,6 +2,7 @@ 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,15 +3,17 @@
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useDebouncedValue, 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>;
|
||||
|
||||
@@ -79,6 +81,9 @@ 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;
|
||||
@@ -125,13 +130,29 @@ 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")}
|
||||
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
|
||||
/>
|
||||
<IconPicker {...form.getInputProps("iconUrl")} />
|
||||
<Textarea
|
||||
{...form.getInputProps("description")}
|
||||
label={t("app.field.description.label")}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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,7 +54,6 @@ 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>(
|
||||
@@ -134,7 +133,6 @@ 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> = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// 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";
|
||||
@@ -92,7 +91,6 @@ 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,11 +85,6 @@ 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,7 +78,6 @@ 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,60 +426,6 @@ 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();
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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),
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
|
||||
|
||||
export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
||||
SystemHealthMonitoring,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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();
|
||||
},
|
||||
});
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "点击时触发请求的按钮",
|
||||
"customJsx": "使用白名单 Mantine 组件的自定义 JSX 布局,支持完整 API 数据绑定"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "删除",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "空",
|
||||
"bearer": "Bearer 令牌",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "crwdns5493:0crwdne5493:0",
|
||||
"customJsx": "crwdns5495:0crwdne5495:0"
|
||||
},
|
||||
"method": {
|
||||
"GET": "crwdns5497:0crwdne5497:0",
|
||||
"POST": "crwdns5499:0crwdne5499:0",
|
||||
"PUT": "crwdns5501:0crwdne5501:0",
|
||||
"DELETE": "crwdns5503:0crwdne5503:0",
|
||||
"PATCH": "crwdns5505:0crwdne5505:0"
|
||||
},
|
||||
"authType": {
|
||||
"none": "crwdns5507:0crwdne5507:0",
|
||||
"bearer": "crwdns5509:0crwdne5509:0",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Eine Schaltfläche welche eine Anfrage auslöst wenn sie angeklickt wird",
|
||||
"customJsx": "Benutzerdefiniertes JSX-Layout mit Whitelist Mantine Komponenten und vollständiger API-Datenbindung"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Keine",
|
||||
"bearer": "Bearer Token",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "A button that triggers a request when clicked",
|
||||
"customJsx": "Custom JSX layout using whitelisted Mantine components with full API data binding"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "None",
|
||||
"bearer": "Bearer Token",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "A button that triggers a request when clicked",
|
||||
"customJsx": "Custom JSX layout using whitelisted Mantine components with full API data binding"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "None",
|
||||
"bearer": "Bearer Token",
|
||||
@@ -1344,10 +1337,6 @@
|
||||
"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"
|
||||
@@ -1441,18 +1430,8 @@
|
||||
"here": "here",
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"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.",
|
||||
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||
"headerLoading": "Type to search icons…",
|
||||
"noResults": "No icons match \"{search}\".",
|
||||
"error": "Couldn't load icons."
|
||||
},
|
||||
@@ -1807,38 +1786,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Painike, joka laukaisee pyynnön napsautettaessa",
|
||||
"customJsx": "Mukautettu JSX-asettelu käyttäen sallittuja Mantine-komponentteja täydellä API-tietojen sidonnalla"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Ei mitään",
|
||||
"bearer": "Bearer-tunniste",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Un bouton qui déclenche une requête lorsqu'elle est cliquée",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Aucun",
|
||||
"bearer": "Jeton Bearer",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Egy gomb, ami kérés végrehajtását váltja ki kattintásra",
|
||||
"customJsx": "Egyéni JSX elrendezés engedélyezett Mantine komponensekkel és teljes API adatkötéssel"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Nincs",
|
||||
"bearer": "Bearer token",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Un pulsante che attiva una richiesta al clic",
|
||||
"customJsx": "Layout JSX personalizzato che usa componenti Mantine consentiti con binding completo dei dati API"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Nessuno",
|
||||
"bearer": "Token Bearer",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "클릭 시 요청을 트리거하는 버튼",
|
||||
"customJsx": "전체 API 데이터 바인딩과 함께 허용된 Mantine 구성 요소를 사용하는 사용자 정의 JSX 레이아웃"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "없음",
|
||||
"bearer": "Bearer 토큰",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Przycisk, który wywołuje zapytanie po kliknięciu",
|
||||
"customJsx": "Własny układ JSX za pomocą komponentów Mantine z białej listy z pełnym powiązaniem danych API"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "None",
|
||||
"bearer": "Bearer Token",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Um botão que aciona uma solicitação quando clicado",
|
||||
"customJsx": "Layout JSX personalizado usando componentes Mantine na lista branca com ligação completa de dados de API"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Nenhum",
|
||||
"bearer": "Token Bearer",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Tlačidlo, ktoré po kliknutí spustí požiadavku",
|
||||
"customJsx": "Vlastné rozloženie JSX pomocou whitelistovaných komponentov Mantine s plnou väzbou API dát"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Žiadna",
|
||||
"bearer": "Bearer token",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Tıklandığında istek başlatan bir düğme",
|
||||
"customJsx": "Tam API veri bağlamasıyla izin verilen Mantine bileşenlerini kullanan özel JSX düzeni"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Yok",
|
||||
"bearer": "Bearer Token",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "Кнопка яка виконує запит при натисканні",
|
||||
"customJsx": "Користувацький макет JSX з пов'язування повним API даних з білого списку"
|
||||
},
|
||||
"method": {
|
||||
"GET": "ОТРИМАТИ",
|
||||
"POST": "СТВОРИТИ",
|
||||
"PUT": "Розміщення",
|
||||
"DELETE": "ВИДАЛИТИ",
|
||||
"PATCH": "ПАТЧ"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Немає",
|
||||
"bearer": "Пред'явити токен",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,13 +824,6 @@
|
||||
"actionButton": "點擊時觸發請求的按鈕",
|
||||
"customJsx": "使用白名單 Mantine 組件的自訂 JSX 佈局,具有完整的 API 資料綁定"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "無",
|
||||
"bearer": "Bearer 令牌",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import type { FormErrors } from "@mantine/form";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconBrandDocker,
|
||||
@@ -47,7 +48,7 @@ import {
|
||||
normalizeReleaseProviderIdentifier,
|
||||
releaseProviderKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -416,6 +417,13 @@ 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);
|
||||
|
||||
@@ -440,6 +448,25 @@ 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}>
|
||||
@@ -471,6 +498,8 @@ 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 }}
|
||||
@@ -491,7 +520,11 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
<TextInput
|
||||
label={tRepository("name.label")}
|
||||
value={tempRepository.name ?? ""}
|
||||
onChange={(event) => handleChange({ name: event.currentTarget.value })}
|
||||
onChange={(event) => {
|
||||
handleChange({ name: event.currentTarget.value });
|
||||
|
||||
if (event.currentTarget.value) setAutoSetIcon(true);
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.name`]}
|
||||
style={{ flex: 0.3 }}
|
||||
/>
|
||||
@@ -500,9 +533,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,6 +1,8 @@
|
||||
"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";
|
||||
|
||||
@@ -45,7 +47,7 @@ export interface TooltipPayloadItem {
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface BeszelTooltipProps {
|
||||
interface PortalTooltipProps {
|
||||
active?: boolean;
|
||||
label?: string;
|
||||
payload?: TooltipPayloadItem[];
|
||||
@@ -53,8 +55,53 @@ interface BeszelTooltipProps {
|
||||
showTotal?: boolean;
|
||||
}
|
||||
|
||||
const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }: BeszelTooltipProps) => {
|
||||
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 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>();
|
||||
@@ -69,30 +116,41 @@ const BeszelTooltipContent = ({ 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);
|
||||
|
||||
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>
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
|
||||
content: (props: any) => (
|
||||
<BeszelTooltipContent
|
||||
<PortalTooltipContent
|
||||
active={props.active}
|
||||
label={props.label != null ? String(props.label) : undefined}
|
||||
payload={props.payload}
|
||||
@@ -100,6 +158,6 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
|
||||
showTotal={showTotal}
|
||||
/>
|
||||
),
|
||||
wrapperStyle: { zIndex: 10000, pointerEvents: "none" as const },
|
||||
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
|
||||
isAnimationActive: false,
|
||||
});
|
||||
@@ -107,5 +107,4 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
beszelSystemStats: IconChartAreaLine,
|
||||
traefik: IconRoute,
|
||||
customApi: IconApi,
|
||||
wud: IconBrandDocker,
|
||||
};
|
||||
@@ -113,9 +113,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
|
||||
},
|
||||
);
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -64,7 +64,6 @@ 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,
|
||||
@@ -130,7 +129,6 @@ export const widgetImports = {
|
||||
beszelSystemStats,
|
||||
traefik,
|
||||
customApi,
|
||||
wud,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -76,7 +76,6 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||
beszelSystemStats: () => import("./beszel-system-stats") as Promise<WidgetModule>,
|
||||
traefik: () => import("./traefik") as Promise<WidgetModule>,
|
||||
customApi: () => import("./custom-api") as Promise<WidgetModule>,
|
||||
wud: () => import("./wud") as Promise<WidgetModule>,
|
||||
};
|
||||
|
||||
// Keep these imports explicit so Turbopack can create one discoverable chunk per
|
||||
@@ -138,7 +137,6 @@ const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>>
|
||||
beszelSystemStats: () => import("./beszel-system-stats/component") as Promise<WidgetComponentModule>,
|
||||
traefik: () => import("./traefik/component") as Promise<WidgetComponentModule>,
|
||||
customApi: () => import("./custom-api/component") as Promise<WidgetComponentModule>,
|
||||
wud: () => import("./wud/component") as Promise<WidgetComponentModule>,
|
||||
};
|
||||
|
||||
const definitionPromises = new Map<WidgetKind, Promise<WidgetDefinition>>();
|
||||
|
||||
@@ -26,5 +26,4 @@ export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
|
||||
] as const;
|
||||
@@ -1,7 +0,0 @@
|
||||
[data-mantine-color-scheme="light"] .card {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Card, Center, Group, RingProgress, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconBrandDocker, IconExternalLink, IconServer } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import type { WudContainerUpdate } from "@homarr/integrations";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function WudWidget({ integrationIds, options, width }: WidgetComponentProps<"wud">) {
|
||||
const integrationId = integrationIds[0];
|
||||
if (!integrationId) return null;
|
||||
return <WudWidgetContent integrationId={integrationId} options={options} width={width} />;
|
||||
}
|
||||
|
||||
const WudWidgetContent = ({
|
||||
integrationId,
|
||||
options,
|
||||
width,
|
||||
}: {
|
||||
integrationId: string;
|
||||
options: WidgetComponentProps<"wud">["options"];
|
||||
width: number;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.wud");
|
||||
const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const isTiny = width < 256;
|
||||
const stats = data.stats;
|
||||
|
||||
if (stats.totalContainers === 0) return <WidgetEmptyState />;
|
||||
|
||||
const updatePercentage = Math.round((stats.updatesAvailable / stats.totalContainers) * 100);
|
||||
const badgeColor = stats.updatesAvailable === 0 ? "green" : progressColor(updatePercentage);
|
||||
const showUpdateList = options.showUpdateList && stats.updates.length > 0;
|
||||
|
||||
const ring = (
|
||||
<RingProgress
|
||||
roundCaps
|
||||
size={isTiny ? 50 : 100}
|
||||
thickness={isTiny ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size={isTiny ? "8px" : "xs"} fw={700}>
|
||||
{updatePercentage}%
|
||||
</Text>
|
||||
<IconBrandDocker size={isTiny ? 8 : 16} />
|
||||
</Center>
|
||||
}
|
||||
sections={[{ value: updatePercentage, color: progressColor(updatePercentage) }]}
|
||||
/>
|
||||
);
|
||||
|
||||
const summary = (
|
||||
<Stack gap={4} align={options.layout === "vertical" ? "center" : "flex-start"}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconServer size="1rem" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.totalContainers} {t("monitored")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge color={badgeColor} variant="light">
|
||||
{t("updatesAvailable", { count: stats.updatesAvailable })}
|
||||
</Badge>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const tinyContent = options.showRing ? (
|
||||
<Tooltip
|
||||
label={
|
||||
<Stack gap={2}>
|
||||
<Text size="xs">
|
||||
{stats.totalContainers} {t("monitored")}
|
||||
</Text>
|
||||
<Text size="xs">{t("updatesAvailable", { count: stats.updatesAvailable })}</Text>
|
||||
</Stack>
|
||||
}
|
||||
events={{ hover: true, focus: true, touch: false }}
|
||||
>
|
||||
<Center
|
||||
tabIndex={0}
|
||||
aria-label={`${stats.totalContainers} ${t("monitored")}, ${t("updatesAvailable", { count: stats.updatesAvailable })}`}
|
||||
>
|
||||
{ring}
|
||||
</Center>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Center>
|
||||
<Badge color={badgeColor} variant="light">
|
||||
{t("updatesAvailable", { count: stats.updatesAvailable })}
|
||||
</Badge>
|
||||
</Center>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="xs" h="100%">
|
||||
{options.showTitle && !isTiny && (
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
|
||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||
<Avatar size={20} radius="sm" src={getIconUrl("wud")} />
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
</Group>
|
||||
{stats.updatesAvailable > 0 && (
|
||||
<Badge color={badgeColor} variant="filled" circle={stats.updatesAvailable < 10}>
|
||||
{stats.updatesAvailable}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{isTiny ? (
|
||||
tinyContent
|
||||
) : options.layout === "horizontal" ? (
|
||||
<Group justify="center" wrap="nowrap" gap="md">
|
||||
{options.showRing && ring}
|
||||
{summary}
|
||||
</Group>
|
||||
) : (
|
||||
<Stack align="center" gap="xs">
|
||||
{options.showRing && ring}
|
||||
{summary}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{showUpdateList && (
|
||||
<Stack gap={4} style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||||
{stats.updates.map((update) => (
|
||||
<UpdateCard key={update.id} update={update} radius={board.itemRadius} className={classes.card} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const progressColor = (percentage: number) => {
|
||||
if (percentage < 40) return "green";
|
||||
else if (percentage < 60) return "yellow";
|
||||
else if (percentage < 90) return "orange";
|
||||
else return "red";
|
||||
};
|
||||
|
||||
const SHA256_DIGEST_PREFIX = "sha256:";
|
||||
const SHA256_DIGEST_LENGTH = SHA256_DIGEST_PREFIX.length + 64;
|
||||
const GENERIC_MAX_LENGTH = 20;
|
||||
const GENERIC_DISPLAY_LENGTH = 17;
|
||||
|
||||
const isDigestVersion = (value: string | null): boolean =>
|
||||
value !== null && value.startsWith(SHA256_DIGEST_PREFIX) && value.length === SHA256_DIGEST_LENGTH;
|
||||
|
||||
const truncateVersion = (value: string | null): string | null => {
|
||||
if (!value) return value;
|
||||
|
||||
if (value.length > GENERIC_MAX_LENGTH) {
|
||||
return `${value.slice(0, GENERIC_DISPLAY_LENGTH)}…`;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const buildVersionText = (currentVersion: string | null, newVersion: string | null) =>
|
||||
currentVersion && newVersion ? `${currentVersion} → ${newVersion}` : newVersion;
|
||||
|
||||
const UpdateCard = ({
|
||||
update,
|
||||
radius,
|
||||
className,
|
||||
}: {
|
||||
update: WudContainerUpdate;
|
||||
radius: string | undefined;
|
||||
className: string | undefined;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.wud");
|
||||
const isDigestUpdate = isDigestVersion(update.newVersion);
|
||||
const fullVersionText = buildVersionText(update.currentVersion, update.newVersion);
|
||||
const versionText = isDigestUpdate
|
||||
? t("updateAvailable")
|
||||
: buildVersionText(truncateVersion(update.currentVersion), truncateVersion(update.newVersion));
|
||||
|
||||
return (
|
||||
<Card className={combineClasses(className)} radius={radius} p="xs" style={{ overflow: "visible" }}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs" miw={0}>
|
||||
<Text size="xs" fw={500} lineClamp={1} miw={0}>
|
||||
{update.name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{versionText && (
|
||||
<Tooltip label={fullVersionText} disabled={isDigestUpdate || versionText === fullVersionText}>
|
||||
<Badge size="xs" variant="light" color="gray" style={{ whiteSpace: "nowrap" }}>
|
||||
{versionText}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{update.link && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={update.link}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("openReleaseNotes")}
|
||||
>
|
||||
<IconExternalLink size="0.9rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { IconBrandDocker } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("wud", {
|
||||
icon: IconBrandDocker,
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["wud"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showTitle: factory.switch({ defaultValue: true }),
|
||||
showRing: factory.switch({ defaultValue: true }),
|
||||
layout: factory.select({
|
||||
options: (["horizontal", "vertical"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.wud.option.layout.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "horizontal",
|
||||
}),
|
||||
showUpdateList: factory.switch({ defaultValue: false, withDescription: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
Reference in new issue
Block a user