merge latest widget catalog updates
This commit is contained in:
31 files changed
+922
-6
No files matched your search
@@ -109,4 +109,5 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
beszelSystemStats: IconChartAreaLine,
|
||||
traefik: IconRoute,
|
||||
customApi: IconApi,
|
||||
wud: IconBrandDocker,
|
||||
};
|
||||
@@ -114,7 +114,9 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
|
||||
},
|
||||
);
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
@@ -65,6 +65,7 @@ import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
import * as customApi from "./custom-api";
|
||||
import * as assistant from "./assistant";
|
||||
import * as wud from "./wud";
|
||||
|
||||
export type {
|
||||
NormalizedWidgetQuery,
|
||||
@@ -146,6 +147,7 @@ export const widgetImports = {
|
||||
traefik,
|
||||
customApi,
|
||||
assistant,
|
||||
wud,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -77,6 +77,7 @@ 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
|
||||
@@ -139,6 +140,7 @@ 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>>();
|
||||
|
||||
@@ -32,4 +32,5 @@ export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "umami", "getTopReferrers"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
|
||||
] as const;
|
||||
@@ -0,0 +1,7 @@
|
||||
[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);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
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