feat: expand advanced widget experiences
This commit is contained in:
1 parent
9437d211c8
commit
c099a8eef6
119 files changed
+3624
-844
No files matched your search
@@ -7,22 +7,36 @@ const advancedFocusWidgetKinds = [
|
||||
"anchorNote",
|
||||
"archiveTeamWarrior",
|
||||
"audioStats",
|
||||
"bazarr",
|
||||
"beszelAlerts",
|
||||
"beszelSystemGrid",
|
||||
"beszelSystemTable",
|
||||
"bookmarks",
|
||||
"calendar",
|
||||
"clock",
|
||||
"coolify",
|
||||
"downloads",
|
||||
"firewall",
|
||||
"healthMonitoring",
|
||||
"immich-albumCarousel",
|
||||
"immich-serverStats",
|
||||
"indexerManager",
|
||||
"mediaMissing",
|
||||
"mediaReleases",
|
||||
"mediaServer",
|
||||
"mediaTranscoding",
|
||||
"networkControllerStatus",
|
||||
"networkControllerSummary",
|
||||
"notebook",
|
||||
"notifications",
|
||||
"patchmon",
|
||||
"releases",
|
||||
"rssFeed",
|
||||
"smartHome-entityState",
|
||||
"smartHome-executeAutomation",
|
||||
"speedtestTracker",
|
||||
"stockPrice",
|
||||
"systemDisks",
|
||||
"systemResources",
|
||||
"timetable",
|
||||
"tracearr",
|
||||
@@ -31,6 +45,7 @@ const advancedFocusWidgetKinds = [
|
||||
"ups",
|
||||
"uptimeKuma",
|
||||
"weather",
|
||||
"wud",
|
||||
] as const;
|
||||
|
||||
describe("advanced focus support", () => {
|
||||
|
||||
@@ -124,7 +124,10 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
/>
|
||||
</Flex>
|
||||
</Tooltip.Floating>
|
||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
||||
{options.pingEnabled &&
|
||||
!settings.forceDisableStatus &&
|
||||
!board.disableStatus &&
|
||||
Boolean(app.pingUrl ?? app.href) ? (
|
||||
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
|
||||
<PingIndicator appId={app.id} />
|
||||
</Suspense>
|
||||
|
||||
@@ -106,8 +106,35 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{status.project?.id && (
|
||||
<Badge size="xs" variant="light">
|
||||
{t("projectId", { id: status.project.id })}
|
||||
</Badge>
|
||||
)}
|
||||
{status.bandwidth?.received !== undefined && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("sessionReceived", { value: formatBandwidth(status.bandwidth.received) })}
|
||||
</Text>
|
||||
)}
|
||||
{status.bandwidth?.sent !== undefined && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("sessionSent", { value: formatBandwidth(status.bandwidth.sent) })}
|
||||
</Text>
|
||||
)}
|
||||
{status.bandwidth?.session_id && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("sessionId", { id: status.bandwidth.session_id })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack gap={4}>
|
||||
{status.items.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("emptyItems")}
|
||||
</Text>
|
||||
)}
|
||||
{status.items.map((item) => {
|
||||
const itemStatusKey = getStatusKey(item.status);
|
||||
return (
|
||||
@@ -120,6 +147,11 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.project ?? projectName}
|
||||
</Text>
|
||||
{item.startTime !== undefined && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{t("startedAt", { date: new Date(item.startTime * 1000).toLocaleString(locale) })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Badge size="xs" color={getStatusColor(itemStatusKey)}>
|
||||
{t(`status.${itemStatusKey}`)}
|
||||
|
||||
@@ -36,14 +36,22 @@ interface AudioStatsContentProps {
|
||||
backend: AudioStatsBackend;
|
||||
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
|
||||
options: AudioStatsDisplayOptions;
|
||||
showAllStats?: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function AudioStatsContent({ backend, stats, options, width, height }: AudioStatsContentProps) {
|
||||
export function AudioStatsContent({
|
||||
backend,
|
||||
stats,
|
||||
options,
|
||||
showAllStats = false,
|
||||
width,
|
||||
height,
|
||||
}: AudioStatsContentProps) {
|
||||
const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE);
|
||||
const compact = options.compactMode ?? false;
|
||||
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats), compact);
|
||||
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact);
|
||||
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
|
||||
const compactKey = String(compact) as keyof typeof rootClassByCompact;
|
||||
const gridCols = getGridCols(width, visibleStats.length, compact);
|
||||
|
||||
@@ -96,6 +96,21 @@ describe("getVisibleStats", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns every backend-relevant stat when advanced disclosure is enabled", () => {
|
||||
expect(getVisibleStats("navidrome", {}, navidromeStats, true).map((stat) => stat.statKey)).toEqual([
|
||||
"artists",
|
||||
"albums",
|
||||
"songs",
|
||||
]);
|
||||
expect(getVisibleStats("audiobookshelf", {}, audiobookshelfStats, true).map((stat) => stat.statKey)).toEqual([
|
||||
"libraryCount",
|
||||
"audiobooks",
|
||||
"podcasts",
|
||||
"listeningTime",
|
||||
"activeSessions",
|
||||
]);
|
||||
});
|
||||
|
||||
test("formats listening time as duration string", () => {
|
||||
const result = getVisibleStats("audiobookshelf", { showListeningTime: true }, audiobookshelfStats);
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
@@ -133,13 +133,14 @@ export const getVisibleStats = (
|
||||
backend: AudioStatsBackend,
|
||||
options: AudioStatsDisplayOptions,
|
||||
stats: NavidromeDashboardData | AudiobookshelfDashboardData,
|
||||
showAll = false,
|
||||
): VisibleStat[] => {
|
||||
const visibilityOptions = statVisibilityOptionsByBackend[backend];
|
||||
const statIcons = statIconsByBackend[backend];
|
||||
const statValues = statValueGetterByBackend[backend](stats);
|
||||
|
||||
return Object.entries(visibilityOptions)
|
||||
.filter(([optionKey]) => Boolean(options[optionKey as keyof typeof visibilityOptions]))
|
||||
.filter(([optionKey]) => showAll || Boolean(options[optionKey as keyof typeof visibilityOptions]))
|
||||
.map(([optionKey, statKey]) => ({
|
||||
optionKey,
|
||||
statKey,
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
color: var(--mantine-primary-color-filled);
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.statValueWarning {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconDeviceTv, IconMovie, IconPlugConnected } from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconBell, IconDeviceTv, IconMovie, IconPlugConnected } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -11,18 +11,34 @@ import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
const statVisibilityByOption = {
|
||||
showMissingEpisodes: "episodes",
|
||||
showMissingMovies: "movies",
|
||||
showProviderIssues: "providers",
|
||||
showHealthIssues: "status",
|
||||
} as const;
|
||||
const compactStatOptions = [
|
||||
{ option: "showMissingEpisodes", stat: "episodes" },
|
||||
{ option: "showMissingMovies", stat: "movies" },
|
||||
{ option: "showProviderIssues", stat: "providers" },
|
||||
{ option: "showHealthIssues", stat: "status" },
|
||||
] as const;
|
||||
|
||||
const allStatKeys = [
|
||||
"episodes",
|
||||
"movies",
|
||||
"providers",
|
||||
"status",
|
||||
"sonarrSignalr",
|
||||
"radarrSignalr",
|
||||
"announcements",
|
||||
] as const;
|
||||
|
||||
type BazarrStatKey = (typeof allStatKeys)[number];
|
||||
type BazarrVisibilityOptions = Record<(typeof compactStatOptions)[number]["option"], boolean>;
|
||||
|
||||
const statIcons = {
|
||||
episodes: IconDeviceTv,
|
||||
movies: IconMovie,
|
||||
providers: IconPlugConnected,
|
||||
status: IconAlertTriangle,
|
||||
sonarrSignalr: IconPlugConnected,
|
||||
radarrSignalr: IconPlugConnected,
|
||||
announcements: IconBell,
|
||||
} as const;
|
||||
|
||||
const gridColsByWidth = [
|
||||
@@ -37,7 +53,13 @@ const iconSizeByWidth = [
|
||||
{ minWidth: 0, size: 16 },
|
||||
] as const;
|
||||
|
||||
export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) {
|
||||
export default function BazarrWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bazarr">) {
|
||||
const t = useScopedI18n("widget.bazarr");
|
||||
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
@@ -52,11 +74,12 @@ export default function BazarrWidget({ integrationIds, options, width, height }:
|
||||
movies: badges.movies,
|
||||
providers: badges.providers,
|
||||
status: badges.status,
|
||||
} as const;
|
||||
sonarrSignalr: badges.sonarr_signalr,
|
||||
radarrSignalr: badges.radarr_signalr,
|
||||
announcements: badges.announcements,
|
||||
} satisfies Record<BazarrStatKey, number | string>;
|
||||
|
||||
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
const visibleStatKeys = getVisibleBazarrStatKeys(options, displayMode);
|
||||
|
||||
const gridCols = getGridCols(width, height, visibleStatKeys.length);
|
||||
const iconSize = getIconSize(Math.min(width, height));
|
||||
@@ -83,12 +106,17 @@ export default function BazarrWidget({ integrationIds, options, width, height }:
|
||||
{visibleStatKeys.map((statKey) => {
|
||||
const Icon = statIcons[statKey];
|
||||
const value = statValues[statKey];
|
||||
const isWarning = (statKey === "providers" || statKey === "status") && value > 0;
|
||||
const isWarning =
|
||||
typeof value === "number" &&
|
||||
(statKey === "providers" || statKey === "status" || statKey === "announcements") &&
|
||||
value > 0;
|
||||
|
||||
return (
|
||||
<div key={statKey} className={`${classes.statTile} ${isWarning ? classes.statTileWarning : ""}`}>
|
||||
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>{value}</span>
|
||||
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>
|
||||
{typeof value === "string" && value.trim() === "" ? "—" : value}
|
||||
</span>
|
||||
<span className={classes.statLabel} title={t(statKey)}>
|
||||
{t(statKey)}
|
||||
</span>
|
||||
@@ -100,6 +128,14 @@ export default function BazarrWidget({ integrationIds, options, width, height }:
|
||||
);
|
||||
}
|
||||
|
||||
export const getVisibleBazarrStatKeys = (
|
||||
options: BazarrVisibilityOptions,
|
||||
displayMode?: "compact" | "advanced",
|
||||
): BazarrStatKey[] => {
|
||||
if (displayMode === "advanced") return [...allStatKeys];
|
||||
return compactStatOptions.filter(({ option }) => options[option]).map(({ stat }) => stat);
|
||||
};
|
||||
|
||||
export function getGridCols(width: number, height: number, itemCount: number): number {
|
||||
if (itemCount <= 1) return 1;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconBadgeCc,
|
||||
supportedIntegrations: ["bazarr"],
|
||||
integrationsRequired: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getGridCols, getIconSize } from "./component";
|
||||
import { getGridCols, getIconSize, getVisibleBazarrStatKeys } from "./component";
|
||||
|
||||
describe("Bazarr responsive layout", () => {
|
||||
test("uses two columns for a short widget so all stats remain visible", () => {
|
||||
@@ -15,4 +15,18 @@ describe("Bazarr responsive layout", () => {
|
||||
expect(getIconSize(180)).toBe(16);
|
||||
expect(getIconSize(340)).toBe(22);
|
||||
});
|
||||
|
||||
test("advanced mode includes hidden summaries and all fetched status fields", () => {
|
||||
expect(
|
||||
getVisibleBazarrStatKeys(
|
||||
{
|
||||
showMissingEpisodes: false,
|
||||
showMissingMovies: false,
|
||||
showProviderIssues: false,
|
||||
showHealthIssues: false,
|
||||
},
|
||||
"advanced",
|
||||
),
|
||||
).toEqual(["episodes", "movies", "providers", "status", "sonarrSignalr", "radarrSignalr", "announcements"]);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -27,6 +28,10 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { getBeszelAlertsQueryInput } from "./display";
|
||||
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
|
||||
|
||||
const alertIconMap: Record<string, LucideIcon> = {
|
||||
CPU: Cpu,
|
||||
@@ -50,43 +55,64 @@ export default function BeszelAlertsWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
height,
|
||||
displayMode,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"beszelAlerts">) {
|
||||
const t = useScopedI18n("widget.beszelAlerts");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const alertsInput = useMemo(
|
||||
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
|
||||
[integrationIds, options.showHistory, options.maxHistoryItems],
|
||||
() =>
|
||||
getBeszelAlertsQueryInput(
|
||||
integrationIds,
|
||||
{ showHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
|
||||
isAdvanced,
|
||||
),
|
||||
[integrationIds, options.showHistory, options.maxHistoryItems, isAdvanced],
|
||||
);
|
||||
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
|
||||
const results = getUsableWidgetQueryData(alertsQuery) ?? [];
|
||||
const runtimeQueries = useMemo(
|
||||
() => [getQueryKey(clientApi.widget.beszel.getAlerts, alertsInput, "query")],
|
||||
[alertsInput],
|
||||
);
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, runtimeQueries);
|
||||
const resultData = getUsableWidgetQueryData(alertsQuery);
|
||||
const results = useMemo(() => resultData ?? [], [resultData]);
|
||||
const { isPending } = alertsQuery;
|
||||
|
||||
const systemNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of results) {
|
||||
if (r.systemNameMap) {
|
||||
Object.assign(map, r.systemNameMap);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [results]);
|
||||
const systemNameMap = useMemo(() => buildBeszelSystemNameMap(results), [results]);
|
||||
const showIntegrationName = isAdvanced || results.length > 1;
|
||||
|
||||
const alerts = useMemo(
|
||||
() => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))),
|
||||
() =>
|
||||
results.flatMap((r) =>
|
||||
r.alerts.map((a) => ({
|
||||
...a,
|
||||
integrationId: r.integrationId,
|
||||
integrationName: r.integrationName,
|
||||
key: `${r.integrationId}:${a.id}`,
|
||||
})),
|
||||
),
|
||||
[results],
|
||||
);
|
||||
|
||||
const history = useMemo(() => {
|
||||
const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` })));
|
||||
return all
|
||||
.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||
.slice(0, options.maxHistoryItems);
|
||||
}, [results, options.maxHistoryItems]);
|
||||
const all = results.flatMap((r) =>
|
||||
r.history.map((h) => ({
|
||||
...h,
|
||||
integrationId: r.integrationId,
|
||||
integrationName: r.integrationName,
|
||||
key: `${r.integrationId}:${h.id}`,
|
||||
})),
|
||||
);
|
||||
const sorted = all.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
||||
return isAdvanced ? sorted : sorted.slice(0, options.maxHistoryItems);
|
||||
}, [results, options.maxHistoryItems, isAdvanced]);
|
||||
|
||||
const triggeredAlerts = alerts.filter((a) => a.triggered);
|
||||
const okAlerts = alerts.filter((a) => !a.triggered);
|
||||
const showOkAlerts = triggeredAlerts.length === 0 || height >= 260;
|
||||
const showHistory = options.showHistory && height >= 360;
|
||||
const showAlertDescriptions = height >= 190;
|
||||
const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260;
|
||||
const showHistory = isAdvanced || (options.showHistory && height >= 360);
|
||||
const showAlertDescriptions = isAdvanced || height >= 190;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -99,7 +125,10 @@ export default function BeszelAlertsWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<Group gap={0}>
|
||||
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
</Box>
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="sm" p="sm">
|
||||
@@ -124,11 +153,12 @@ export default function BeszelAlertsWidget({
|
||||
</Group>
|
||||
{triggeredAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
key={alert.key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
|
||||
integrationName={showIntegrationName ? alert.integrationName : undefined}
|
||||
triggered
|
||||
showDescription={showAlertDescriptions}
|
||||
/>
|
||||
@@ -148,11 +178,12 @@ export default function BeszelAlertsWidget({
|
||||
</Group>
|
||||
{okAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
key={alert.key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
|
||||
integrationName={showIntegrationName ? alert.integrationName : undefined}
|
||||
triggered={false}
|
||||
showDescription={showAlertDescriptions}
|
||||
/>
|
||||
@@ -171,11 +202,12 @@ export default function BeszelAlertsWidget({
|
||||
</Text>
|
||||
</Group>
|
||||
{history.map((entry) => {
|
||||
const systemName = systemNameMap[entry.system] ?? entry.system;
|
||||
const systemName = getBeszelSystemName(systemNameMap, entry.integrationId, entry.system);
|
||||
const systemLabel = showIntegrationName ? `${systemName} · ${entry.integrationName}` : systemName;
|
||||
const isResolved = !!entry.resolved;
|
||||
const HistoryIcon = alertIconMap[entry.name] ?? Server;
|
||||
return (
|
||||
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
||||
<Group key={entry.key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={3}
|
||||
@@ -189,7 +221,7 @@ export default function BeszelAlertsWidget({
|
||||
{entry.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{systemName}
|
||||
{systemLabel}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -218,6 +250,7 @@ interface AlertRowProps {
|
||||
value: number;
|
||||
min: number;
|
||||
systemName: string;
|
||||
integrationName?: string;
|
||||
triggered: boolean;
|
||||
showDescription: boolean;
|
||||
}
|
||||
@@ -243,7 +276,7 @@ function formatAlertDescription(name: string, value: number, min: number): strin
|
||||
return `exceeds ${value}${suffix} over ${min} min`;
|
||||
}
|
||||
|
||||
function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) {
|
||||
function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) {
|
||||
const Icon = alertIconMap[name] ?? Server;
|
||||
const description = formatAlertDescription(name, value, min);
|
||||
return (
|
||||
@@ -263,7 +296,7 @@ function AlertRow({ name, value, min, systemName, triggered, showDescription }:
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{systemName}
|
||||
{integrationName ? `${systemName} · ${integrationName}` : systemName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getBeszelAlertsQueryInput } from "./display";
|
||||
|
||||
describe("Beszel alerts query input", () => {
|
||||
test("preserves compact history settings", () => {
|
||||
expect(getBeszelAlertsQueryInput(["beszel-a"], { showHistory: false, maxHistoryItems: 10 }, false)).toEqual({
|
||||
integrationIds: ["beszel-a"],
|
||||
includeHistory: false,
|
||||
maxHistoryItems: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test("requests a useful bounded history in advanced mode", () => {
|
||||
expect(getBeszelAlertsQueryInput(["beszel-a"], { showHistory: false, maxHistoryItems: 10 }, true)).toEqual({
|
||||
integrationIds: ["beszel-a"],
|
||||
includeHistory: true,
|
||||
maxHistoryItems: 50,
|
||||
});
|
||||
expect(getBeszelAlertsQueryInput(["beszel-a"], { showHistory: true, maxHistoryItems: 150 }, true)).toEqual({
|
||||
integrationIds: ["beszel-a"],
|
||||
includeHistory: true,
|
||||
maxHistoryItems: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
export const BESZEL_ALERTS_ADVANCED_MIN_HISTORY_ITEMS = 50;
|
||||
const BESZEL_ALERTS_MAX_HISTORY_ITEMS = 100;
|
||||
|
||||
interface BeszelAlertsDisplayOptions {
|
||||
showHistory: boolean;
|
||||
maxHistoryItems: number;
|
||||
}
|
||||
|
||||
export const getBeszelAlertsQueryInput = (
|
||||
integrationIds: string[],
|
||||
options: BeszelAlertsDisplayOptions,
|
||||
isAdvanced: boolean,
|
||||
) => ({
|
||||
integrationIds,
|
||||
includeHistory: isAdvanced || options.showHistory,
|
||||
maxHistoryItems: isAdvanced
|
||||
? Math.min(
|
||||
BESZEL_ALERTS_MAX_HISTORY_ITEMS,
|
||||
Math.max(BESZEL_ALERTS_ADVANCED_MIN_HISTORY_ITEMS, options.maxHistoryItems),
|
||||
)
|
||||
: options.maxHistoryItems,
|
||||
});
|
||||
@@ -1,19 +1,23 @@
|
||||
import { IconBell, IconServerOff } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
|
||||
icon: IconBell,
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "beszel", "getAlerts"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
widgetQueryInputMatches(input, {
|
||||
queryMatcher(query, scope) {
|
||||
const hasRuntimeAlertsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getAlerts");
|
||||
if (hasRuntimeAlertsQuery) return matchesWidgetRuntimeQuery(query, scope);
|
||||
|
||||
return widgetQueryInputMatches(query.input, {
|
||||
integrationIds: scope.integrationIds,
|
||||
includeHistory: scope.options.showHistory,
|
||||
maxHistoryItems: scope.options.maxHistoryItems,
|
||||
}),
|
||||
});
|
||||
},
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
|
||||
|
||||
describe("Beszel alert system names", () => {
|
||||
test("keeps identical system IDs scoped to their integration", () => {
|
||||
const systemNames = buildBeszelSystemNameMap([
|
||||
{ integrationId: "beszel-a", systemNameMap: { shared: "Alpha server" } },
|
||||
{ integrationId: "beszel-b", systemNameMap: { shared: "Beta server" } },
|
||||
]);
|
||||
|
||||
expect(getBeszelSystemName(systemNames, "beszel-a", "shared")).toBe("Alpha server");
|
||||
expect(getBeszelSystemName(systemNames, "beszel-b", "shared")).toBe("Beta server");
|
||||
});
|
||||
|
||||
test("falls back to the system ID when no name is available", () => {
|
||||
expect(getBeszelSystemName(new Map(), "beszel-a", "unknown")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
interface BeszelSystemNamesByIntegration {
|
||||
integrationId: string;
|
||||
systemNameMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export const getBeszelSystemNameKey = (integrationId: string, systemId: string) => `${integrationId}:${systemId}`;
|
||||
|
||||
export const buildBeszelSystemNameMap = (results: readonly BeszelSystemNamesByIntegration[]) => {
|
||||
const systemNames = new Map<string, string>();
|
||||
|
||||
for (const result of results) {
|
||||
for (const [systemId, systemName] of Object.entries(result.systemNameMap)) {
|
||||
systemNames.set(getBeszelSystemNameKey(result.integrationId, systemId), systemName);
|
||||
}
|
||||
}
|
||||
|
||||
return systemNames;
|
||||
};
|
||||
|
||||
export const getBeszelSystemName = (
|
||||
systemNames: ReadonlyMap<string, string>,
|
||||
integrationId: string,
|
||||
systemId: string,
|
||||
) => systemNames.get(getBeszelSystemNameKey(integrationId, systemId)) ?? systemId;
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core";
|
||||
import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
@@ -19,6 +19,7 @@ import { IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -38,8 +39,10 @@ import {
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
import { isBeszelGridMetricVisible } from "./display";
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
@@ -117,10 +120,12 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
|
||||
|
||||
const MIN_CELL_WIDTH = 140;
|
||||
const MIN_CELL_HEIGHT = 80;
|
||||
const ADVANCED_MIN_CELL_WIDTH = 320;
|
||||
const ADVANCED_MIN_CELL_HEIGHT = 420;
|
||||
|
||||
const getColCount = (width: number, _height: number, itemCount: number): number => {
|
||||
const getColCount = (width: number, _height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
|
||||
if (itemCount <= 1) return 1;
|
||||
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
|
||||
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth)));
|
||||
|
||||
let best = 1;
|
||||
for (let c = 1; c <= maxCols; c++) {
|
||||
@@ -168,12 +173,14 @@ const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
|
||||
);
|
||||
|
||||
interface SystemCardProps {
|
||||
system: BeszelSystemRow & { _key: string };
|
||||
system: BeszelSystemRow & { rowKey: string };
|
||||
options: WidgetComponentProps<"beszelSystemGrid">["options"];
|
||||
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
|
||||
size: SizeConfig;
|
||||
maxMetrics: number;
|
||||
itemRadius: MantineSize;
|
||||
integrationName: string;
|
||||
isAdvanced: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -190,7 +197,8 @@ const metricRenderers = [
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showCpu, advanced),
|
||||
},
|
||||
{
|
||||
key: "showMemory",
|
||||
@@ -204,7 +212,8 @@ const metricRenderers = [
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showMemory, advanced),
|
||||
},
|
||||
{
|
||||
key: "showDisk",
|
||||
@@ -217,7 +226,8 @@ const metricRenderers = [
|
||||
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
|
||||
</Group>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showDisk, advanced),
|
||||
},
|
||||
{
|
||||
key: "showGpu",
|
||||
@@ -231,7 +241,8 @@ const metricRenderers = [
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showGpu, advanced, s.gpu > 0),
|
||||
},
|
||||
{
|
||||
key: "showLoadAvg",
|
||||
@@ -244,7 +255,8 @@ const metricRenderers = [
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showLoadAvg, advanced, s.loadAvg !== null),
|
||||
},
|
||||
{
|
||||
key: "showNet",
|
||||
@@ -257,7 +269,8 @@ const metricRenderers = [
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showNet, advanced),
|
||||
},
|
||||
{
|
||||
key: "showTemp",
|
||||
@@ -270,7 +283,8 @@ const metricRenderers = [
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showTemp, advanced, s.temp !== null),
|
||||
},
|
||||
{
|
||||
key: "showBattery",
|
||||
@@ -287,7 +301,8 @@ const metricRenderers = [
|
||||
/>
|
||||
);
|
||||
},
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showBattery, advanced, s.battery !== null),
|
||||
},
|
||||
{
|
||||
key: "showServices",
|
||||
@@ -300,7 +315,8 @@ const metricRenderers = [
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showServices, advanced),
|
||||
},
|
||||
{
|
||||
key: "showUptime",
|
||||
@@ -313,7 +329,8 @@ const metricRenderers = [
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showUptime, advanced),
|
||||
},
|
||||
{
|
||||
key: "showAgent",
|
||||
@@ -326,12 +343,23 @@ const metricRenderers = [
|
||||
size={sz}
|
||||
/>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent,
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
||||
isBeszelGridMetricVisible(o.showAgent, advanced),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
|
||||
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options));
|
||||
const SystemCard = ({
|
||||
system,
|
||||
options,
|
||||
t,
|
||||
size,
|
||||
maxMetrics,
|
||||
itemRadius,
|
||||
integrationName,
|
||||
isAdvanced,
|
||||
onClick,
|
||||
}: SystemCardProps) => {
|
||||
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options, isAdvanced));
|
||||
const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
|
||||
const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
|
||||
|
||||
@@ -368,6 +396,20 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{isAdvanced && (
|
||||
<SimpleGrid cols={2} spacing={4} verticalSpacing={2} mb={4}>
|
||||
<SystemMetadata label={t("metadata.hostname")} value={system.hostname || "—"} />
|
||||
<SystemMetadata label={t("metadata.os")} value={system.osName || "—"} />
|
||||
<SystemMetadata label={t("metadata.cpuModel")} value={system.cpuModel || "—"} />
|
||||
<SystemMetadata label={t("metadata.cores")} value={String(system.cores)} />
|
||||
<SystemMetadata
|
||||
label={t("metadata.memoryTotal")}
|
||||
value={system.memoryTotal > 0 ? formatBytes(system.memoryTotal) : "—"}
|
||||
/>
|
||||
<SystemMetadata label={t("metadata.source")} value={integrationName} />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
|
||||
{visibleMetrics.map((m) => m.render(system, t, size))}
|
||||
</Stack>
|
||||
@@ -380,12 +422,24 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
|
||||
);
|
||||
};
|
||||
|
||||
const SystemMetadata = ({ label, value }: { label: string; value: string }) => (
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="10px" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={500} truncate title={value}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default function BeszelSystemGridWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||
const t = useScopedI18n("widget.beszelSystemGrid");
|
||||
const board = useRequiredBoard();
|
||||
@@ -394,6 +448,8 @@ export default function BeszelSystemGridWidget({
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
|
||||
const { isPending } = systemsQuery;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const integrationNames = new Map(results.map((result) => [result.integrationId, result.integrationName]));
|
||||
|
||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||
|
||||
@@ -408,7 +464,10 @@ export default function BeszelSystemGridWidget({
|
||||
if (filteredSystems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
@@ -421,19 +480,23 @@ export default function BeszelSystemGridWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const cols = getColCount(width, height, filteredSystems.length);
|
||||
const minimumCellWidth = isAdvanced ? ADVANCED_MIN_CELL_WIDTH : MIN_CELL_WIDTH;
|
||||
const cols = getColCount(width, height, filteredSystems.length, minimumCellWidth);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
const minimumCellHeight = MIN_CELL_HEIGHT;
|
||||
const minimumCellHeight = isAdvanced ? ADVANCED_MIN_CELL_HEIGHT : MIN_CELL_HEIGHT;
|
||||
const scrollEnabled = rawCellHeight < minimumCellHeight;
|
||||
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
|
||||
const cellWidth = width / cols;
|
||||
const size = getSizeConfig(cellWidth, effectiveCellHeight);
|
||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
const maxMetrics = isAdvanced ? Number.POSITIVE_INFINITY : getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
<Box
|
||||
h="100%"
|
||||
style={{
|
||||
@@ -445,19 +508,22 @@ export default function BeszelSystemGridWidget({
|
||||
}}
|
||||
>
|
||||
{filteredSystems.map((system) => {
|
||||
const integrationId = system._key.split(":")[0] ?? "";
|
||||
const integrationId = system.rowKey.split(":")[0] ?? "";
|
||||
const integrationName = integrationNames.get(integrationId) ?? "—";
|
||||
const handleClick = isEditMode
|
||||
? undefined
|
||||
: () => openModal({ integrationId, systemId: system.id }, { title: system.name });
|
||||
return (
|
||||
<SystemCard
|
||||
key={system._key}
|
||||
key={system.rowKey}
|
||||
system={system}
|
||||
options={options}
|
||||
t={t}
|
||||
size={size}
|
||||
maxMetrics={maxMetrics}
|
||||
itemRadius={board.itemRadius}
|
||||
integrationName={integrationName}
|
||||
isAdvanced={isAdvanced}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { isBeszelGridMetricVisible } from "./display";
|
||||
|
||||
describe("Beszel grid metric visibility", () => {
|
||||
test("honors compact configuration", () => {
|
||||
expect(isBeszelGridMetricVisible(false, false)).toBe(false);
|
||||
expect(isBeszelGridMetricVisible(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
test("forces configured-off metrics in advanced mode when applicable", () => {
|
||||
expect(isBeszelGridMetricVisible(false, true)).toBe(true);
|
||||
expect(isBeszelGridMetricVisible(false, true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const isBeszelGridMetricVisible = (configured: boolean, isAdvanced: boolean, applicable = true) =>
|
||||
applicable && (isAdvanced || configured);
|
||||
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
|
||||
icon: IconLayoutGrid,
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "beszel", "getSystems"]],
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
@@ -45,7 +46,8 @@ export default function BeszelSystemStatsWidget({
|
||||
}),
|
||||
});
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? [];
|
||||
const systemsData = getUsableWidgetQueryData(systemsQuery);
|
||||
const systemsResult = useMemo(() => systemsData ?? [], [systemsData]);
|
||||
const { isPending: systemsPending } = systemsQuery;
|
||||
|
||||
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
|
||||
@@ -138,7 +140,10 @@ export default function BeszelSystemStatsWidget({
|
||||
if (systems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
</Group>
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
@@ -175,7 +180,10 @@ export default function BeszelSystemStatsWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
<Group gap={0}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
</Group>
|
||||
</Box>
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseColumnOrder, parseColumnWidths } from "../common/use-persisted-table-layout";
|
||||
import { beszelTableMetricOptionKeys, getBeszelTableVisibleMetricKeys } from "./display";
|
||||
|
||||
const columnAccessors = ["name", "cpu", "memory", "disk", "temp"];
|
||||
|
||||
@@ -32,3 +33,23 @@ describe("Beszel table column layout options", () => {
|
||||
).toEqual({ cpu: 140 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Beszel table metric visibility", () => {
|
||||
const enabledOptions = Object.fromEntries(beszelTableMetricOptionKeys.map((key) => [key, true])) as Record<
|
||||
(typeof beszelTableMetricOptionKeys)[number],
|
||||
boolean
|
||||
>;
|
||||
|
||||
test("keeps the compact width budget", () => {
|
||||
expect([...getBeszelTableVisibleMetricKeys(enabledOptions, 350, false)]).toEqual(["showCpu"]);
|
||||
expect(getBeszelTableVisibleMetricKeys(enabledOptions, 600, false).size).toBe(4);
|
||||
});
|
||||
|
||||
test("shows every metric in advanced mode regardless of visibility options", () => {
|
||||
const disabledOptions = Object.fromEntries(
|
||||
beszelTableMetricOptionKeys.map((key) => [key, false]),
|
||||
) as typeof enabledOptions;
|
||||
|
||||
expect([...getBeszelTableVisibleMetricKeys(disabledOptions, 200, true)]).toEqual(beszelTableMetricOptionKeys);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export const beszelTableMetricOptionKeys = [
|
||||
"showCpu",
|
||||
"showMemory",
|
||||
"showDisk",
|
||||
"showGpu",
|
||||
"showLoadAvg",
|
||||
"showNet",
|
||||
"showTemp",
|
||||
"showBattery",
|
||||
"showServices",
|
||||
"showUptime",
|
||||
"showAgent",
|
||||
] as const;
|
||||
|
||||
type BeszelTableMetricOptions = Record<(typeof beszelTableMetricOptionKeys)[number], boolean>;
|
||||
|
||||
export const getBeszelTableVisibleMetricKeys = (
|
||||
options: BeszelTableMetricOptions,
|
||||
width: number,
|
||||
isAdvanced: boolean,
|
||||
) => {
|
||||
const enabled = isAdvanced
|
||||
? [...beszelTableMetricOptionKeys]
|
||||
: beszelTableMetricOptionKeys.filter((key) => options[key]);
|
||||
const budget = isAdvanced ? enabled.length : width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length;
|
||||
|
||||
return new Set(enabled.slice(0, budget));
|
||||
};
|
||||
@@ -25,7 +25,7 @@ const sortDirectionOptions = [
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
|
||||
icon: IconTable,
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
|
||||
queryMatcher: (query, scope) =>
|
||||
query.path.at(-1) === "getSystems"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo } from "react";
|
||||
|
||||
import type { BeszelSystemRow } from "./types";
|
||||
|
||||
type SystemRowWithKey = BeszelSystemRow & { _key: string };
|
||||
type SystemRowWithKey = BeszelSystemRow & { rowKey: string };
|
||||
|
||||
export const useBeszelFilteredSystems = (
|
||||
results: { integrationId: string; systems: BeszelSystemRow[] }[],
|
||||
@@ -13,7 +13,7 @@ export const useBeszelFilteredSystems = (
|
||||
const allSystems = useMemo(
|
||||
() =>
|
||||
results
|
||||
.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` })))
|
||||
.flatMap((r) => r.systems.map((s) => ({ ...s, rowKey: `${r.integrationId}:${s.id}` })))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[results],
|
||||
);
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { createWidgetKey } from "./coolify-utils";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
|
||||
import { InstanceCard } from "./instance-card";
|
||||
import { SingleInstanceLayout } from "./single-instance-layout";
|
||||
import type { InstanceData } from "./types";
|
||||
|
||||
export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) {
|
||||
export default function CoolifyWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"coolify">) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
@@ -22,7 +31,18 @@ export default function CoolifyWidget({ options, integrationIds, width, height }
|
||||
);
|
||||
}
|
||||
|
||||
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const sectionVisibility = getCoolifySectionVisibility(options, displayMode);
|
||||
|
||||
return (
|
||||
<CoolifyContent
|
||||
integrationIds={integrationIds}
|
||||
options={{ ...options, ...sectionVisibility }}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CoolifyContentProps {
|
||||
@@ -30,19 +50,24 @@ interface CoolifyContentProps {
|
||||
options: WidgetComponentProps<"coolify">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) {
|
||||
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
|
||||
const t = useScopedI18n("common");
|
||||
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const instancesData = getUsableWidgetQueryData(instancesQuery) ?? [];
|
||||
const { isPending } = instancesQuery;
|
||||
const successfulInstances = instancesData.filter(
|
||||
(instance): instance is InstanceData => instance.instanceInfo !== null,
|
||||
);
|
||||
const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null);
|
||||
|
||||
const isTiny = width < 256 || height < 144;
|
||||
const hideFooter = height < 112;
|
||||
const [firstInstance] = instancesData;
|
||||
const [firstInstance] = successfulInstances;
|
||||
const widgetKey = createWidgetKey(integrationIds);
|
||||
|
||||
if (isPending) {
|
||||
@@ -54,33 +79,77 @@ function CoolifyContent({ integrationIds, options, width, height }: CoolifyConte
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (instancesData.length === 1 && firstInstance) {
|
||||
if (successfulInstances.length === 0) {
|
||||
return (
|
||||
<SingleInstanceLayout
|
||||
instance={firstInstance}
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
<Stack h="100%" gap={0}>
|
||||
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
|
||||
<Box style={{ minHeight: 0, flex: 1 }}>
|
||||
<WidgetEmptyState />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (successfulInstances.length === 1 && firstInstance) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
|
||||
<div style={{ minHeight: 0, flex: 1 }}>
|
||||
<SingleInstanceLayout
|
||||
instance={firstInstance}
|
||||
options={options}
|
||||
isTiny={!isAdvanced && isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
widgetKey={widgetKey}
|
||||
hideFooter={!isAdvanced && hideFooter}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
|
||||
{instancesData.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.integrationId}
|
||||
instance={instance}
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
<Stack h="100%" gap={0}>
|
||||
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
|
||||
<ScrollArea style={{ minHeight: 0, flex: 1 }}>
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
|
||||
{successfulInstances.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.integrationId}
|
||||
instance={instance}
|
||||
options={options}
|
||||
isTiny={!isAdvanced && isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
widgetKey={widgetKey}
|
||||
hideFooter={!isAdvanced && hideFooter}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const FailedSourceBadges = ({
|
||||
sources,
|
||||
queryError,
|
||||
}: {
|
||||
sources: { integrationId: string; integrationName: string }[];
|
||||
queryError: unknown;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
if (sources.length === 0 && !queryError) return null;
|
||||
|
||||
return (
|
||||
<Group gap={4} p="xs" pb={0} wrap="wrap">
|
||||
{sources.map((source) => (
|
||||
<Badge key={source.integrationId} color="red" variant="light" size="xs">
|
||||
{t("source.unavailable", { source: source.integrationName })}
|
||||
</Badge>
|
||||
))}
|
||||
<Box ml="auto">
|
||||
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,13 @@ import dayjs from "dayjs";
|
||||
import frenchLocale from "dayjs/locale/fr";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { formatRelativeTime, getResourceTimestamp } from "./coolify-utils";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getCoolifySectionVisibility,
|
||||
getCoolifyServerState,
|
||||
getResourceTimestamp,
|
||||
isCoolifyServerOnline,
|
||||
} from "./coolify-utils";
|
||||
|
||||
describe("Coolify time formatting", () => {
|
||||
beforeEach(() => {
|
||||
@@ -27,3 +33,51 @@ describe("Coolify time formatting", () => {
|
||||
expect(getResourceTimestamp({ status: "running", updated_at: "2026-08-02T11:00:00Z" }, "service")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coolify advanced display", () => {
|
||||
it("forces every non-sensitive section without changing compact settings", () => {
|
||||
const options = { showServers: false, showApplications: true, showServices: false };
|
||||
|
||||
expect(getCoolifySectionVisibility(options, "compact")).toEqual(options);
|
||||
expect(getCoolifySectionVisibility(options, "advanced")).toEqual({
|
||||
showServers: true,
|
||||
showApplications: true,
|
||||
showServices: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses top-level server state before the settings fallback", () => {
|
||||
const server = {
|
||||
id: null,
|
||||
uuid: "server-1",
|
||||
name: "Build server",
|
||||
ip: null,
|
||||
is_reachable: false,
|
||||
is_usable: null,
|
||||
settings: {
|
||||
server_id: null,
|
||||
is_build_server: null,
|
||||
is_reachable: true,
|
||||
is_usable: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(getCoolifyServerState(server, "is_reachable")).toBe(false);
|
||||
expect(getCoolifyServerState(server, "is_usable")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not count unknown reachability as online", () => {
|
||||
const server = {
|
||||
id: null,
|
||||
uuid: "server-unknown",
|
||||
name: "Unknown server",
|
||||
ip: null,
|
||||
is_reachable: null,
|
||||
is_usable: null,
|
||||
settings: null,
|
||||
};
|
||||
|
||||
expect(getCoolifyServerState(server, "is_reachable")).toBeUndefined();
|
||||
expect(isCoolifyServerOnline(server)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -110,3 +110,26 @@ export function createStorageKey(widgetKey: string, integrationId: string, type:
|
||||
const cardKey = widgetKey.includes("-") ? `${widgetKey}-${integrationId}` : widgetKey;
|
||||
return `coolify-${type}-${cardKey}`;
|
||||
}
|
||||
|
||||
interface CoolifySectionOptions {
|
||||
showServers: boolean;
|
||||
showApplications: boolean;
|
||||
showServices: boolean;
|
||||
}
|
||||
|
||||
export function getCoolifySectionVisibility(
|
||||
options: CoolifySectionOptions,
|
||||
displayMode?: "compact" | "advanced",
|
||||
): CoolifySectionOptions {
|
||||
if (displayMode === "advanced") {
|
||||
return { showServers: true, showApplications: true, showServices: true };
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function getCoolifyServerState(server: CoolifyServer, field: "is_reachable" | "is_usable"): boolean | undefined {
|
||||
return server[field] ?? server.settings?.[field] ?? undefined;
|
||||
}
|
||||
|
||||
export const isCoolifyServerOnline = (server: CoolifyServer): boolean =>
|
||||
getCoolifyServerState(server, "is_reachable") === true;
|
||||
@@ -4,7 +4,7 @@ import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconCloud,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconCircleCheckFilled, IconCircleXFilled, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCircleCheckFilled,
|
||||
IconCircleFilled,
|
||||
IconCircleXFilled,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconServer,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { CoolifyServer } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getBadgeColor } from "./coolify-utils";
|
||||
import { getBadgeColor, getCoolifyServerState, isCoolifyServerOnline } from "./coolify-utils";
|
||||
|
||||
interface ServersSectionProps {
|
||||
servers: CoolifyServer[];
|
||||
serverResourceCounts: Map<number, { apps: number; services: number }>;
|
||||
baseUrl: string;
|
||||
isTiny: boolean;
|
||||
isAdvanced: boolean;
|
||||
showIp: boolean;
|
||||
onToggleIp: () => void;
|
||||
}
|
||||
@@ -24,12 +32,13 @@ export function ServersSection({
|
||||
serverResourceCounts,
|
||||
baseUrl,
|
||||
isTiny,
|
||||
isAdvanced,
|
||||
showIp,
|
||||
onToggleIp,
|
||||
}: ServersSectionProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const onlineServers = servers.filter((server) => server.is_reachable !== false).length;
|
||||
const onlineServers = servers.filter(isCoolifyServerOnline).length;
|
||||
|
||||
return (
|
||||
<Accordion.Item value="servers">
|
||||
@@ -67,6 +76,7 @@ export function ServersSection({
|
||||
}
|
||||
baseUrl={baseUrl}
|
||||
isTiny={isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
showIp={showIp}
|
||||
/>
|
||||
))}
|
||||
@@ -86,23 +96,28 @@ interface ServerRowProps {
|
||||
counts: { apps: number; services: number };
|
||||
baseUrl: string;
|
||||
isTiny: boolean;
|
||||
isAdvanced: boolean;
|
||||
showIp: boolean;
|
||||
}
|
||||
|
||||
function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) {
|
||||
function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: ServerRowProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const isBuildServer = server.settings?.is_build_server === true;
|
||||
const isOnline = server.is_reachable !== false;
|
||||
const reachability = getCoolifyServerState(server, "is_reachable");
|
||||
const usability = getCoolifyServerState(server, "is_usable");
|
||||
const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`);
|
||||
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
|
||||
const status = reachability === true ? "online" : reachability === false ? "offline" : "unknown";
|
||||
const StatusIcon =
|
||||
reachability === true ? IconCircleCheckFilled : reachability === false ? IconCircleXFilled : IconCircleFilled;
|
||||
const statusColor = reachability === true ? "green" : reachability === false ? "red" : "gray";
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
|
||||
<StatusIcon
|
||||
aria-label={t(isOnline ? "status.online" : "status.offline")}
|
||||
aria-label={t(`status.${status}`)}
|
||||
size={isTiny ? 12 : 16}
|
||||
color={`var(--mantine-color-${isOnline ? "green" : "red"}-6)`}
|
||||
color={`var(--mantine-color-${statusColor}-6)`}
|
||||
/>
|
||||
<Anchor
|
||||
className={actionTargetClasses.root}
|
||||
@@ -129,9 +144,30 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
</Group>
|
||||
<Group wrap="nowrap" gap={4} ml={16}>
|
||||
<Text fz="10px" c="dimmed">
|
||||
{showIp ? server.ip : "***.***.***.***"}
|
||||
{showIp ? (server.ip ?? "—") : "***.***.***.***"}
|
||||
</Text>
|
||||
</Group>
|
||||
{isAdvanced && (
|
||||
<Group gap={4} ml={16} wrap="wrap">
|
||||
<ServerStateBadge field="reachability" value={reachability} />
|
||||
<ServerStateBadge field="usability" value={usability} />
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const ServerStateBadge = ({ field, value }: { field: "reachability" | "usability"; value: boolean | undefined }) => {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const status =
|
||||
value === undefined
|
||||
? t("status.unknown")
|
||||
: field === "reachability"
|
||||
? t(value ? "status.reachable" : "status.unreachable")
|
||||
: t(value ? "status.usable" : "status.unusable");
|
||||
return (
|
||||
<Badge size="xs" variant="light" color={value === true ? "green" : value === false ? "red" : "gray"}>
|
||||
{t(`server.${field}`)}: {status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { Accordion, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconStack2 } from "@tabler/icons-react";
|
||||
|
||||
import type { CoolifyServiceWithContext } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getBadgeColor, parseStatus } from "./coolify-utils";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { cleanFqdn, getBadgeColor, getStatusColor, parseStatus } from "./coolify-utils";
|
||||
import { ResourceRow } from "./resource-row";
|
||||
|
||||
interface ServicesSectionProps {
|
||||
services: CoolifyServiceWithContext[];
|
||||
baseUrl: string;
|
||||
isTiny: boolean;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) {
|
||||
export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: ServicesSectionProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length;
|
||||
@@ -34,7 +37,19 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr
|
||||
{services.length > 0 ? (
|
||||
<Stack gap={4}>
|
||||
{services.map((service) => (
|
||||
<ResourceRow key={service.uuid} item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
|
||||
<Stack key={service.uuid} gap={2}>
|
||||
<ResourceRow item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
|
||||
{isAdvanced && service.applications && service.applications.length > 0 && (
|
||||
<Stack gap={2} ml="lg">
|
||||
<Text fz="10px" c="dimmed">
|
||||
{tCommon("applications")}
|
||||
</Text>
|
||||
{service.applications.map((application) => (
|
||||
<ServiceApplicationRow key={application.uuid} application={application} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
@@ -46,3 +61,45 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr
|
||||
</Accordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type ServiceApplication = NonNullable<CoolifyServiceWithContext["applications"]>[number];
|
||||
|
||||
const ServiceApplicationRow = ({ application }: { application: ServiceApplication }) => {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const status = parseStatus(application.status ?? "");
|
||||
const publicUrl = getSafeApplicationUrl(cleanFqdn(application.fqdn));
|
||||
const statusLabel =
|
||||
status === "running" ||
|
||||
status === "stopped" ||
|
||||
status === "exited" ||
|
||||
status === "starting" ||
|
||||
status === "restarting"
|
||||
? t(`status.${status}`)
|
||||
: t("status.unknown");
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" variant="dot" color={getStatusColor(status)}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{publicUrl ? (
|
||||
<Anchor
|
||||
className={actionTargetClasses.root}
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
fz="xs"
|
||||
c="inherit"
|
||||
truncate="end"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
{application.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text fz="xs" truncate="end">
|
||||
{application.name}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
@@ -33,7 +34,9 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
import actionTargetClasses from "../../common/action-target.module.css";
|
||||
import classes from "./component.module.css";
|
||||
import TimerModal from "./TimerModal";
|
||||
@@ -41,6 +44,15 @@ import TimerModal from "./TimerModal";
|
||||
const dnsLightStatus = (enabled: boolean | undefined) =>
|
||||
`var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`;
|
||||
|
||||
type DnsSummaryResult = RouterOutputs["widget"]["dnsHole"]["summary"][number];
|
||||
type AvailableDnsSummaryResult = DnsSummaryResult & {
|
||||
summary: NonNullable<DnsSummaryResult["summary"]>;
|
||||
integration: DnsSummaryResult["integration"] & { updatedAt: Date };
|
||||
};
|
||||
|
||||
const isAvailableDnsSummaryResult = (result: DnsSummaryResult): result is AvailableDnsSummaryResult =>
|
||||
result.summary !== null && result.integration.updatedAt !== undefined;
|
||||
|
||||
export default function DnsHoleControlsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
@@ -55,7 +67,8 @@ export default function DnsHoleControlsWidget({
|
||||
.filter((id) => integrationIds.includes(id));
|
||||
|
||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const summaries = summaryResults.filter(isAvailableDnsSummaryResult);
|
||||
const { isPending: isSummaryPending } = summaryQuery;
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
@@ -84,7 +97,7 @@ export default function DnsHoleControlsWidget({
|
||||
if (!prevData) return [];
|
||||
|
||||
return prevData.map((item) =>
|
||||
item.integration.id === integrationId
|
||||
item.integration.id === integrationId && item.summary
|
||||
? {
|
||||
...item,
|
||||
summary: {
|
||||
@@ -147,7 +160,14 @@ export default function DnsHoleControlsWidget({
|
||||
p="sm"
|
||||
gap="sm"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
pos="relative"
|
||||
>
|
||||
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={summaryResults} />
|
||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.dnsHoleControls.name")} />
|
||||
</Group>
|
||||
</Box>
|
||||
{controlAllButtonsVisible && (
|
||||
<Flex className="dns-hole-controls-buttons" gap="sm">
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
|
||||
@@ -260,7 +280,7 @@ export default function DnsHoleControlsWidget({
|
||||
interface ControlsCardProps {
|
||||
integrationsWithInteractions: string[];
|
||||
toggleDns: (integrationId: string) => Promise<void>;
|
||||
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
|
||||
data: AvailableDnsSummaryResult;
|
||||
setSelectedIntegrationIds: (integrationId: string[]) => void;
|
||||
open: () => void;
|
||||
t: TranslationFunction;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
@@ -29,7 +30,9 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
|
||||
export default function DnsHoleSummaryWidget({
|
||||
options,
|
||||
@@ -45,9 +48,10 @@ export default function DnsHoleSummaryWidget({
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const data = summaries.flatMap(({ summary }) => summary);
|
||||
const successfulSummaries = summaries.filter(({ summary }) => summary !== null);
|
||||
const data = successfulSummaries.flatMap(({ summary }) => (summary ? [summary] : []));
|
||||
const layoutProps = boxPropsByLayout(options.layout);
|
||||
const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220;
|
||||
const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -60,7 +64,13 @@ export default function DnsHoleSummaryWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
<Stack h="100%" gap={0} pos="relative">
|
||||
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={summaries} />
|
||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.dnsHoleSummary.name")} />
|
||||
</Group>
|
||||
</Box>
|
||||
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
|
||||
{data.length > 0 ? (
|
||||
stats.map((item) => (
|
||||
@@ -78,7 +88,7 @@ export default function DnsHoleSummaryWidget({
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
>
|
||||
<AvatarGroup spacing="md">
|
||||
{summaries.map(({ integration }) => (
|
||||
{successfulSummaries.map(({ integration }) => (
|
||||
<Tooltip key={integration.id} label={integration.name}>
|
||||
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
|
||||
</Tooltip>
|
||||
@@ -93,14 +103,14 @@ export default function DnsHoleSummaryWidget({
|
||||
{showSourceStatuses && (
|
||||
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
{successfulSummaries.map(({ integration, summary }) => (
|
||||
<Badge
|
||||
key={integration.id}
|
||||
variant="light"
|
||||
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
|
||||
color={summary?.status === "enabled" ? "green" : summary?.status === "disabled" ? "red" : "gray"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}` as never)}
|
||||
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary?.status ?? "unknown"}` as never)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { columnWidthsToRecord, filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
|
||||
import {
|
||||
DOWNLOAD_COLUMN_ACCESSORS,
|
||||
filterDownloadItemsByStatus,
|
||||
getAvailableDownloadStates,
|
||||
getDownloadColumnAccessors,
|
||||
} from "./helpers";
|
||||
|
||||
describe("downloads filters", () => {
|
||||
const eligibleItems = [{ state: "downloading" as const }, { state: "seeding" as const }];
|
||||
@@ -18,11 +23,9 @@ describe("downloads filters", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloads column widths", () => {
|
||||
test("serializes numeric and pixel widths while ignoring automatic widths", () => {
|
||||
expect(columnWidthsToRecord([{ name: 240 }, { progress: "120px" }, { state: "auto" }])).toEqual({
|
||||
name: 240,
|
||||
progress: 120,
|
||||
});
|
||||
describe("downloads advanced columns", () => {
|
||||
test("uses every column in advanced mode without changing the compact selection", () => {
|
||||
expect(getDownloadColumnAccessors(["name"], true)).toEqual(DOWNLOAD_COLUMN_ACCESSORS);
|
||||
expect(getDownloadColumnAccessors(["name"], false)).toEqual(["name"]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,33 @@ import type { ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||
|
||||
type DownloadState = ExtendedDownloadClientItem["state"];
|
||||
|
||||
export const DOWNLOAD_COLUMN_ACCESSORS = [
|
||||
"name",
|
||||
"progress",
|
||||
"size",
|
||||
"downSpeed",
|
||||
"upSpeed",
|
||||
"time",
|
||||
"state",
|
||||
"added",
|
||||
"ratio",
|
||||
"received",
|
||||
"sent",
|
||||
"category",
|
||||
"integration",
|
||||
"index",
|
||||
"type",
|
||||
] as const satisfies readonly (keyof ExtendedDownloadClientItem)[];
|
||||
|
||||
type DownloadColumnAccessor = (typeof DOWNLOAD_COLUMN_ACCESSORS)[number];
|
||||
|
||||
export function getDownloadColumnAccessors(
|
||||
configuredColumns: readonly DownloadColumnAccessor[],
|
||||
isAdvanced: boolean,
|
||||
): readonly DownloadColumnAccessor[] {
|
||||
return isAdvanced ? DOWNLOAD_COLUMN_ACCESSORS : configuredColumns;
|
||||
}
|
||||
|
||||
export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(
|
||||
items: readonly T[],
|
||||
statusFilter: readonly string[],
|
||||
|
||||
@@ -2,28 +2,10 @@ import { IconDownload } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const columnsList = [
|
||||
"name",
|
||||
"progress",
|
||||
"size",
|
||||
"downSpeed",
|
||||
"upSpeed",
|
||||
"time",
|
||||
"state",
|
||||
"added",
|
||||
"ratio",
|
||||
"received",
|
||||
"sent",
|
||||
"category",
|
||||
"integration",
|
||||
"index",
|
||||
"type",
|
||||
] as const satisfies (keyof ExtendedDownloadClientItem)[];
|
||||
import { DOWNLOAD_COLUMN_ACCESSORS } from "./helpers";
|
||||
|
||||
const sortColumns = [
|
||||
"name",
|
||||
@@ -38,7 +20,7 @@ const sortColumns = [
|
||||
"sent",
|
||||
"index",
|
||||
"type",
|
||||
] as const satisfies readonly (typeof columnsList)[number][];
|
||||
] as const satisfies readonly (typeof DOWNLOAD_COLUMN_ACCESSORS)[number][];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
@@ -55,7 +37,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
||||
(factory) => ({
|
||||
columns: factory.multiSelect({
|
||||
defaultValue: ["name", "progress", "downSpeed", "time", "state"],
|
||||
options: columnsList.map((value) => ({
|
||||
options: DOWNLOAD_COLUMN_ACCESSORS.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
||||
})),
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
IconCpu2,
|
||||
IconFileReport,
|
||||
IconInfoCircle,
|
||||
IconPackages,
|
||||
IconRefreshAlert,
|
||||
IconServer,
|
||||
IconTemperature,
|
||||
IconVersions,
|
||||
@@ -41,7 +43,9 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { CpuRing } from "./rings/cpu-ring";
|
||||
@@ -60,21 +64,46 @@ export const SystemHealthMonitoring = ({
|
||||
withScrollArea = true,
|
||||
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
|
||||
const t = useI18n();
|
||||
const healthData =
|
||||
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds })) ??
|
||||
[];
|
||||
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
|
||||
const healthResults = getUsableWidgetQueryData(healthQuery) ?? [];
|
||||
const healthData = healthResults.filter(
|
||||
(entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null,
|
||||
);
|
||||
const [openedIntegrationId, setOpenedIntegrationId] = useState<string | null>(null);
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
const showCpu = isAdvanced || options.cpu;
|
||||
const showMemory = isAdvanced || options.memory;
|
||||
const showGpu = isAdvanced || options.gpu;
|
||||
const showFileSystem = isAdvanced || options.fileSystem;
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={healthResults} />
|
||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("widget.healthMonitoring.name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (healthData.length === 0) return <WidgetEmptyState />;
|
||||
if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />;
|
||||
if (healthData.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<WidgetEmptyState />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = withScrollArea ? ScrollArea : Box;
|
||||
|
||||
return (
|
||||
<Container h={withScrollArea ? "100%" : undefined}>
|
||||
<Container h={withScrollArea ? "100%" : undefined} pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<Stack mih="100%" gap="sm" className="health-monitoring">
|
||||
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
|
||||
const filteredFileSystem = filterStorageVolumes(
|
||||
@@ -145,18 +174,18 @@ export const SystemHealthMonitoring = ({
|
||||
</Modal>
|
||||
</Box>
|
||||
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
|
||||
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
|
||||
{options.cpu && (
|
||||
{showCpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
|
||||
{showCpu && (
|
||||
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
|
||||
)}
|
||||
{options.memory && (
|
||||
{showMemory && (
|
||||
<MemoryRing
|
||||
available={healthInfo.memAvailableInBytes}
|
||||
used={healthInfo.memUsedInBytes}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
)}
|
||||
{options.gpu &&
|
||||
{showGpu &&
|
||||
healthInfo.gpu.map((gpu) => (
|
||||
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
|
||||
))}
|
||||
@@ -166,7 +195,7 @@ export const SystemHealthMonitoring = ({
|
||||
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
|
||||
</Card>
|
||||
)}
|
||||
{options.fileSystem &&
|
||||
{showFileSystem &&
|
||||
disksData.map((disk) => {
|
||||
return (
|
||||
<Card
|
||||
@@ -242,7 +271,9 @@ export const SystemHealthMonitoring = ({
|
||||
);
|
||||
};
|
||||
|
||||
type HealthInfo = RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"];
|
||||
type HealthInfo = NonNullable<
|
||||
RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"]
|
||||
>;
|
||||
|
||||
const SystemInformationList = ({
|
||||
healthInfo,
|
||||
@@ -285,6 +316,14 @@ const SystemInformationList = ({
|
||||
{healthInfo.loadAverage["5min"]}% / {healthInfo.loadAverage["15min"]}%
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item className="health-monitoring-information-updates" icon={<IconPackages size={compact ? 18 : 30} />}>
|
||||
{t("widget.healthMonitoring.popover.updatesAvailable", { count: healthInfo.availablePkgUpdates })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-reboot" icon={<IconRefreshAlert size={compact ? 18 : 30} />}>
|
||||
{healthInfo.rebootRequired
|
||||
? t("widget.healthMonitoring.popover.rebootRequired")
|
||||
: t("widget.healthMonitoring.popover.rebootNotRequired")}
|
||||
</List.Item>
|
||||
</List>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getImmichStatsLayout } from "./component";
|
||||
import { getImmichStatsLayout, getImmichStatVisibility } from "./component";
|
||||
|
||||
describe("Immich server statistics compact layout", () => {
|
||||
test("keeps four enabled stats legible in a short widget", () => {
|
||||
@@ -12,4 +12,16 @@ describe("Immich server statistics compact layout", () => {
|
||||
expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 2, dense: false });
|
||||
expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 2, dense: true });
|
||||
});
|
||||
|
||||
test("advanced mode shows all four headline stats", () => {
|
||||
const options = { showUsers: false, showPhotos: false, showVideos: false, showStorage: false };
|
||||
|
||||
expect(getImmichStatVisibility(options, true)).toEqual({
|
||||
showUsers: true,
|
||||
showPhotos: true,
|
||||
showVideos: true,
|
||||
showStorage: true,
|
||||
});
|
||||
expect(getImmichStatVisibility(options, false)).toEqual(options);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon, Anchor, Badge, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeActions } from "../runtime-hooks";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import classes from "./component.module.css";
|
||||
import { getIndexerDisplayStatus } from "./status";
|
||||
|
||||
const statusPresentation = {
|
||||
healthy: { color: "green" },
|
||||
unhealthy: { color: "red" },
|
||||
disabled: { color: "gray" },
|
||||
} as const;
|
||||
|
||||
export default function IndexerManagerWidget({
|
||||
options,
|
||||
@@ -23,25 +47,41 @@ export default function IndexerManagerWidget({
|
||||
height,
|
||||
isEditMode,
|
||||
widgetRuntimeRef,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"indexerManager">) {
|
||||
const t = useI18n();
|
||||
const indexersData =
|
||||
getUsableWidgetQueryData(clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds })) ?? [];
|
||||
const indexersQuery = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
|
||||
const isInitialPending = isInitialWidgetQueryPending(indexersQuery);
|
||||
const indexersData = getUsableWidgetQueryData(indexersQuery) ?? [];
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation({
|
||||
const {
|
||||
mutate: testAll,
|
||||
isPending,
|
||||
error: testAllError,
|
||||
} = clientApi.widget.indexerManager.testAllIndexers.useMutation({
|
||||
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
|
||||
});
|
||||
const interactIntegrationIds = new Set(useIntegrationsWithInteractAccess().map(({ id }) => id));
|
||||
const canInteract = integrationIds.length > 0 && integrationIds.every((id) => interactIntegrationIds.has(id));
|
||||
const board = useRequiredBoard();
|
||||
const hasSmallWidth = width < 256;
|
||||
const isDense = width < 280 || height < 180;
|
||||
const showHealthCounts = width >= 200 && height >= 100;
|
||||
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const hasSmallWidth = !isAdvanced && width < 256;
|
||||
const isDense = !isAdvanced && (width < 280 || height < 180);
|
||||
const showHealthCounts = isAdvanced || (width >= 200 && height >= 100);
|
||||
const allIndexers = indexersData.flatMap((entry) => entry.indexers);
|
||||
const hasSourceError = indexersData.some((entry) => Boolean(entry.error));
|
||||
const unavailableCount = allIndexers.filter(
|
||||
(indexer) => indexer.status === false || indexer.enabled === false,
|
||||
).length;
|
||||
|
||||
useWidgetRuntimeActions(widgetRuntimeRef, { testAllIndexers: () => testAll({ integrationIds }) });
|
||||
const testAllIndexers = useCallback(() => {
|
||||
if (!canInteract || isPending) return;
|
||||
testAll({ integrationIds });
|
||||
}, [canInteract, integrationIds, isPending, testAll]);
|
||||
|
||||
const runtimeActions = useMemo(() => ({ testAllIndexers }), [testAllIndexers]);
|
||||
useWidgetRuntimeActions(widgetRuntimeRef, runtimeActions);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -52,6 +92,7 @@ export default function IndexerManagerWidget({
|
||||
p={isDense ? "xs" : "sm"}
|
||||
>
|
||||
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap" w="100%">
|
||||
{testAllError && <VisuallyHidden role="alert">{t("common.error")}</VisuallyHidden>}
|
||||
<Tooltip label={t("widget.indexerManager.title")} disabled={!hasSmallWidth}>
|
||||
<IconReportSearch
|
||||
className="indexer-manager-title-icon"
|
||||
@@ -76,17 +117,19 @@ export default function IndexerManagerWidget({
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Tooltip label={t("widget.indexerManager.testAll")}>
|
||||
<WidgetQueryErrorIndicator error={indexersQuery.error} label={t("widget.indexerManager.title")} />
|
||||
<Tooltip label={testAllError ? t("common.error") : t("widget.indexerManager.testAll")}>
|
||||
<ActionIcon
|
||||
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
variant="light"
|
||||
color={testAllError ? "red" : undefined}
|
||||
loading={isPending}
|
||||
disabled={isEditMode}
|
||||
disabled={isEditMode || !canInteract}
|
||||
loaderProps={{ type: "dots" }}
|
||||
onClick={() => {
|
||||
testAll({ integrationIds });
|
||||
testAllIndexers();
|
||||
}}
|
||||
aria-label={t("widget.indexerManager.testAll")}
|
||||
>
|
||||
@@ -101,67 +144,81 @@ export default function IndexerManagerWidget({
|
||||
radius={board.itemRadius}
|
||||
flex={1}
|
||||
>
|
||||
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
|
||||
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
|
||||
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
|
||||
{indexersData.length > 1 && (
|
||||
<Group justify="space-between" wrap="nowrap" py={4}>
|
||||
<Text size="xs" fw={600} truncate="end">
|
||||
{integrationName}
|
||||
</Text>
|
||||
{error && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{t("common.error")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{indexers.map((indexer) => {
|
||||
const href = getSafeApplicationUrl(indexer.url);
|
||||
return (
|
||||
<Group
|
||||
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
|
||||
key={indexer.id}
|
||||
justify="space-between"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Anchor
|
||||
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
|
||||
component={href ? "a" : "span"}
|
||||
href={href}
|
||||
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && options.openIndexerSiteInNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
title={href}
|
||||
>
|
||||
<Text
|
||||
className="indexer-manager-line-anchor-text"
|
||||
c="dimmed"
|
||||
size={hasSmallWidth ? "xs" : "sm"}
|
||||
truncate="end"
|
||||
>
|
||||
{indexer.name}
|
||||
</Text>
|
||||
</Anchor>
|
||||
{indexer.status === false || indexer.enabled === false ? (
|
||||
<IconCircleX
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
|
||||
color="var(--mantine-color-red-6)"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
/>
|
||||
) : (
|
||||
<IconCircleCheck
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
|
||||
color="var(--mantine-color-green-6)"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
/>
|
||||
{isInitialPending ? (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : allIndexers.length === 0 && !hasSourceError ? (
|
||||
<WidgetEmptyState />
|
||||
) : (
|
||||
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
|
||||
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
|
||||
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
|
||||
{(isAdvanced || indexersData.length > 1 || error) && (
|
||||
<Group justify="space-between" wrap="nowrap" py={4}>
|
||||
<Text size="xs" fw={600} truncate="end">
|
||||
{integrationName}
|
||||
</Text>
|
||||
{error && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{t("common.error")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
))}
|
||||
</ScrollArea>
|
||||
)}
|
||||
{indexers.map((indexer) => {
|
||||
const href = getSafeApplicationUrl(indexer.url);
|
||||
const displayStatus = getIndexerDisplayStatus(indexer);
|
||||
const presentation = statusPresentation[displayStatus];
|
||||
return (
|
||||
<Group
|
||||
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
|
||||
key={indexer.id}
|
||||
justify="space-between"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Anchor
|
||||
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
|
||||
component={href ? "a" : "span"}
|
||||
href={href}
|
||||
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && options.openIndexerSiteInNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
title={href}
|
||||
>
|
||||
<Text
|
||||
className="indexer-manager-line-anchor-text"
|
||||
c="dimmed"
|
||||
size={hasSmallWidth ? "xs" : "sm"}
|
||||
truncate="end"
|
||||
>
|
||||
{indexer.name}
|
||||
</Text>
|
||||
</Anchor>
|
||||
{isAdvanced ? (
|
||||
<Badge size="xs" color={presentation.color} variant="light">
|
||||
{t(`widget.indexerManager.status.${displayStatus}`)}
|
||||
</Badge>
|
||||
) : displayStatus === "healthy" ? (
|
||||
<IconCircleCheck
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
|
||||
color="var(--mantine-color-green-6)"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
/>
|
||||
) : (
|
||||
<IconCircleX
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
|
||||
color="var(--mantine-color-red-6)"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
))}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ const createOptions = () =>
|
||||
}));
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconReportSearch,
|
||||
contextActions: ({ widgetRuntimeRef, context }) => [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getIndexerDisplayStatus } from "./status";
|
||||
|
||||
describe("Indexer display status", () => {
|
||||
test("distinguishes disabled indexers from unhealthy enabled indexers", () => {
|
||||
expect(getIndexerDisplayStatus({ enabled: false, status: false })).toBe("disabled");
|
||||
expect(getIndexerDisplayStatus({ enabled: false, status: true })).toBe("disabled");
|
||||
expect(getIndexerDisplayStatus({ enabled: true, status: false })).toBe("unhealthy");
|
||||
expect(getIndexerDisplayStatus({ enabled: true, status: true })).toBe("healthy");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export type IndexerDisplayStatus = "healthy" | "unhealthy" | "disabled";
|
||||
|
||||
export const getIndexerDisplayStatus = ({
|
||||
enabled,
|
||||
status,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
status: boolean;
|
||||
}): IndexerDisplayStatus => {
|
||||
if (!enabled) return "disabled";
|
||||
return status ? "healthy" : "unhealthy";
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import { OverflowBadge } from "@homarr/ui";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import classes from "./component.module.css";
|
||||
@@ -44,36 +45,43 @@ export default function MediaReleasesWidget({
|
||||
}: WidgetComponentProps<"mediaReleases">) {
|
||||
const t = useI18n();
|
||||
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
|
||||
const releases = getUsableWidgetQueryData(releasesQuery);
|
||||
const response = getUsableWidgetQueryData(releasesQuery);
|
||||
|
||||
if (isInitialWidgetQueryPending(releasesQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!releases || releases.length === 0) return <WidgetEmptyState />;
|
||||
if (!response) return <WidgetEmptyState />;
|
||||
|
||||
const { releases, failedIntegrations } = response;
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={`${item.integration.id}:${item.id}`}>
|
||||
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
|
||||
</Fragment>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
{releasesQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
{releases.length === 0 ? (
|
||||
<WidgetEmptyState />
|
||||
) : (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={`${item.integration.id}:${item.id}`}>
|
||||
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
|
||||
</Fragment>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{(failedIntegrations.length > 0 || releasesQuery.error) && (
|
||||
<Group pos="absolute" top={4} right={4} gap={0}>
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("widget.mediaReleases.name")} />
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
|
||||
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number];
|
||||
options: WidgetComponentProps<"mediaReleases">["options"];
|
||||
isAdvanced: boolean;
|
||||
width: number;
|
||||
@@ -211,7 +219,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
/>
|
||||
)}
|
||||
{isAdvanced && item.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
@@ -230,9 +238,17 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{(isAdvanced || options.showSource) && (
|
||||
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
|
||||
)}
|
||||
{(isAdvanced || options.showSource) &&
|
||||
(isAdvanced ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt="" />
|
||||
<Text size="xs" fw={500} lineClamp={1}>
|
||||
{item.integration.name}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import { getNetworkControllerStatusLayout } from "./layout";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
import { WiredVariant } from "./variants/wired-variant";
|
||||
@@ -36,7 +37,7 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
const t = useScopedI18n("widget.networkControllerStatus");
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => summary), [summaries]);
|
||||
const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => (summary ? [summary] : [])), [summaries]);
|
||||
const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0);
|
||||
const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0);
|
||||
const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0);
|
||||
@@ -56,7 +57,10 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={layout.padding} h="100%">
|
||||
<Box p={layout.padding} h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={summaries ?? []} />
|
||||
</Box>
|
||||
<SimpleGrid cols={layout.columns} h="100%" spacing="sm">
|
||||
{layout.showWifi && (
|
||||
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
|
||||
|
||||
@@ -8,11 +8,16 @@ import objectSupport from "dayjs/plugin/objectSupport";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import { getBinaryStatusKey } from "../../common/locale";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
||||
import type { NetworkControllerMatrixMetricKey, NetworkControllerMatrixSectionKey } from "./display";
|
||||
import { getNetworkControllerMatrix } from "./display";
|
||||
|
||||
dayjs.extend(objectSupport);
|
||||
dayjs.extend(relativeTime);
|
||||
@@ -22,14 +27,20 @@ export default function NetworkControllerSummaryWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"networkControllerSummary">) {
|
||||
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const results = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const summaries = results.filter(
|
||||
(result): result is typeof result & { summary: NonNullable<typeof result.summary>; updatedAt: Date } =>
|
||||
result.summary !== null && result.updatedAt !== undefined,
|
||||
);
|
||||
const { isPending } = summaryQuery;
|
||||
|
||||
const t = useI18n();
|
||||
const locale = useCurrentIntlLocale();
|
||||
const statusLabels = {
|
||||
enabled: t("widget.dnsHoleSummary.status.enabled"),
|
||||
disabled: t("widget.dnsHoleSummary.status.disabled"),
|
||||
@@ -39,19 +50,106 @@ export default function NetworkControllerSummaryWidget({
|
||||
const isDense = height < 160;
|
||||
const showDetails = height >= 180 && width >= 240;
|
||||
const columns = width >= 720 ? 2 : 1;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const queryIndicators = (
|
||||
<Group gap={0} justify="flex-end">
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.networkControllerSummary.name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (isPending || summaries.length === 0) {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{isPending ? t("common.action.loading") : t("widget.networkControllerSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Center>
|
||||
<Stack h="100%" gap={0}>
|
||||
{queryIndicators}
|
||||
<Center p="sm" style={{ flex: 1 }}>
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{isPending
|
||||
? t("common.action.loading")
|
||||
: t("widget.networkControllerSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Center>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdvanced) {
|
||||
const controllerColumns = width >= 960 ? 2 : 1;
|
||||
const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p="sm" pos="relative">
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={controllerColumns} spacing="sm">
|
||||
{summaries.map(({ integration, summary, updatedAt }) => (
|
||||
<Card key={integration.id} p="sm" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{integration.kind}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("widget.networkControllerSummary.advanced.updated")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{new Date(updatedAt).toLocaleString(locale)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<SimpleGrid cols={matrixColumns} spacing="xs">
|
||||
{getNetworkControllerMatrix(summary).map((section) => (
|
||||
<Card key={section.key} p="xs" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<StatusIcon
|
||||
status={section.status}
|
||||
label={statusLabels[getBinaryStatusKey(section.status)]}
|
||||
size={16}
|
||||
/>
|
||||
<Text size="sm" fw={600}>
|
||||
{getMatrixSectionLabel(section.key, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{statusLabels[getBinaryStatusKey(section.status)]}
|
||||
</Text>
|
||||
</Group>
|
||||
{section.metrics.length > 0 && (
|
||||
<SimpleGrid cols={2} spacing={4} verticalSpacing={2}>
|
||||
{section.metrics.map((metric) => (
|
||||
<Stack key={metric.key} gap={0}>
|
||||
<Text size="10px" c="dimmed">
|
||||
{getMatrixMetricLabel(metric.key, t)}
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{formatMatrixMetric(metric.key, metric.value)}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p={isDense ? "xs" : "sm"}>
|
||||
{queryIndicators}
|
||||
<SimpleGrid cols={columns} spacing="sm">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Card key={integration.id} p={isDense ? "xs" : "sm"} withBorder={summaries.length > 1}>
|
||||
@@ -144,6 +242,20 @@ export default function NetworkControllerSummaryWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const getMatrixMetricLabel = (key: NetworkControllerMatrixMetricKey, t: TranslationFunction) =>
|
||||
t(`widget.networkControllerSummary.advanced.metric.${key}`);
|
||||
|
||||
const formatMatrixMetric = (key: NetworkControllerMatrixMetricKey, value: number) => {
|
||||
if (key === "latency" || key === "ping") return `${value}ms`;
|
||||
if (key === "uptime") return dayjs.duration(value, "seconds").humanize();
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getMatrixSectionLabel = (key: NetworkControllerMatrixSectionKey, t: TranslationFunction) => {
|
||||
if (key === "vpn") return t("widget.networkControllerSummary.card.vpn.label");
|
||||
return t(`widget.networkControllerSummary.card.${key}`);
|
||||
};
|
||||
|
||||
const StatusIcon = ({ status, label, size }: { status?: "enabled" | "disabled"; label: string; size: number }) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getNetworkControllerMatrix } from "./display";
|
||||
|
||||
describe("Network controller advanced matrix", () => {
|
||||
test("retains every summary field without aggregating users and guests", () => {
|
||||
expect(
|
||||
getNetworkControllerMatrix({
|
||||
wanStatus: "enabled",
|
||||
www: { status: "disabled", latency: 22, ping: 14, uptime: 86400 },
|
||||
wifi: { status: "enabled", users: 12, guests: 2 },
|
||||
lan: { status: "enabled", users: 18, guests: 5 },
|
||||
vpn: { status: "disabled", users: 3 },
|
||||
}),
|
||||
).toEqual([
|
||||
{ key: "wan", status: "enabled", metrics: [] },
|
||||
{
|
||||
key: "web",
|
||||
status: "disabled",
|
||||
metrics: [
|
||||
{ key: "latency", value: 22 },
|
||||
{ key: "ping", value: 14 },
|
||||
{ key: "uptime", value: 86400 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "wifi",
|
||||
status: "enabled",
|
||||
metrics: [
|
||||
{ key: "users", value: 12 },
|
||||
{ key: "guests", value: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "lan",
|
||||
status: "enabled",
|
||||
metrics: [
|
||||
{ key: "users", value: 18 },
|
||||
{ key: "guests", value: 5 },
|
||||
],
|
||||
},
|
||||
{ key: "vpn", status: "disabled", metrics: [{ key: "users", value: 3 }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
|
||||
export type NetworkControllerMatrixSectionKey = "wan" | "web" | "wifi" | "lan" | "vpn";
|
||||
export type NetworkControllerMatrixMetricKey = "latency" | "ping" | "uptime" | "users" | "guests";
|
||||
|
||||
export interface NetworkControllerMatrixSection {
|
||||
key: NetworkControllerMatrixSectionKey;
|
||||
status: "enabled" | "disabled";
|
||||
metrics: { key: NetworkControllerMatrixMetricKey; value: number }[];
|
||||
}
|
||||
|
||||
export const getNetworkControllerMatrix = (summary: NetworkControllerSummary): NetworkControllerMatrixSection[] => [
|
||||
{ key: "wan", status: summary.wanStatus, metrics: [] },
|
||||
{
|
||||
key: "web",
|
||||
status: summary.www.status,
|
||||
metrics: [
|
||||
{ key: "latency", value: summary.www.latency },
|
||||
{ key: "ping", value: summary.www.ping },
|
||||
{ key: "uptime", value: summary.www.uptime },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "wifi",
|
||||
status: summary.wifi.status,
|
||||
metrics: [
|
||||
{ key: "users", value: summary.wifi.users },
|
||||
{ key: "guests", value: summary.wifi.guests },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "lan",
|
||||
status: summary.lan.status,
|
||||
metrics: [
|
||||
{ key: "users", value: summary.lan.users },
|
||||
{ key: "guests", value: summary.lan.guests },
|
||||
],
|
||||
},
|
||||
{ key: "vpn", status: summary.vpn.status, metrics: [{ key: "users", value: summary.vpn.users }] },
|
||||
];
|
||||
@@ -6,7 +6,7 @@ import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconTopologyFull,
|
||||
queryKey: [["widget", "networkController"]],
|
||||
refetchInterval: null,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getNotebookDisplay } from "./display";
|
||||
|
||||
describe("Notebook advanced disclosure", () => {
|
||||
test("shows the complete toolbar and document stats regardless of compact preferences", () => {
|
||||
expect(
|
||||
getNotebookDisplay({ height: 100, isAdvanced: true, isEditing: true, isSaving: false, showToolbar: false }),
|
||||
).toEqual({ showToolbar: true, toolbarMaxHeight: undefined, showDocumentStats: true });
|
||||
});
|
||||
|
||||
test("does not expose editing controls outside an active editable session", () => {
|
||||
expect(
|
||||
getNotebookDisplay({ height: 300, isAdvanced: true, isEditing: false, isSaving: false, showToolbar: true })
|
||||
.showToolbar,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
interface NotebookDisplayInput {
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
isEditing: boolean;
|
||||
isSaving: boolean;
|
||||
showToolbar: boolean;
|
||||
}
|
||||
|
||||
export const getNotebookDisplay = ({ height, isAdvanced, isEditing, isSaving, showToolbar }: NotebookDisplayInput) => ({
|
||||
showToolbar: isEditing && !isSaving && (isAdvanced || showToolbar),
|
||||
toolbarMaxHeight: isAdvanced ? undefined : height < 180 ? "4.5rem" : "10rem",
|
||||
showDocumentStats: isAdvanced || height >= 180,
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
import { defaultContent } from "./default-content";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconNotes,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -70,6 +70,7 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
|
||||
import { getNotebookDisplay } from "./display";
|
||||
import { NotebookTextDirection, setTextDirection } from "./text-direction";
|
||||
|
||||
import "@mantine/tiptap/styles.css";
|
||||
@@ -98,6 +99,7 @@ export function Notebook({
|
||||
boardId,
|
||||
itemId,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"notebook">) {
|
||||
const [content, setContent] = useState(options.content);
|
||||
const previousContentRef = useRef(options.content);
|
||||
@@ -339,6 +341,13 @@ export function Notebook({
|
||||
characters: documentText.length,
|
||||
words: documentText.length === 0 ? 0 : documentText.split(/\s+/).length,
|
||||
};
|
||||
const display = getNotebookDisplay({
|
||||
height,
|
||||
isAdvanced: displayMode === "advanced",
|
||||
isEditing,
|
||||
isSaving,
|
||||
showToolbar: options.showToolbar,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="homarr-notebook" h="100%" onDoubleClick={handleDoubleClick}>
|
||||
@@ -375,9 +384,9 @@ export function Notebook({
|
||||
>
|
||||
<RichTextEditor.Toolbar
|
||||
style={{
|
||||
display: isEditing && !isSaving && options.showToolbar === true ? "flex" : "none",
|
||||
maxHeight: height < 180 ? "4.5rem" : "10rem",
|
||||
overflowY: "auto",
|
||||
display: display.showToolbar ? "flex" : "none",
|
||||
maxHeight: display.toolbarMaxHeight,
|
||||
overflowY: display.toolbarMaxHeight === undefined ? undefined : "auto",
|
||||
}}
|
||||
>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
@@ -505,7 +514,7 @@ export function Notebook({
|
||||
<RichTextEditor.Content />
|
||||
</ScrollArea>
|
||||
</RichTextEditor>
|
||||
{(saveError || height >= 180) && (
|
||||
{(saveError || display.showDocumentStats) && (
|
||||
<Group pos="absolute" bottom={4} right={8} gap="xs" style={{ pointerEvents: saveError ? undefined : "none" }}>
|
||||
{saveError && (
|
||||
<Tooltip label={saveError} multiline>
|
||||
@@ -521,7 +530,7 @@ export function Notebook({
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
{height >= 180 && (
|
||||
{display.showDocumentStats && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.notebook.documentStats", documentStats)}
|
||||
</Text>
|
||||
@@ -873,9 +882,18 @@ function ListIndentIncrease() {
|
||||
editor?.chain().focus().sinkListItem(itemType).run();
|
||||
}, [editor, itemType]);
|
||||
|
||||
editor?.on("selectionUpdate", ({ editor }) => {
|
||||
setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => {
|
||||
setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem");
|
||||
};
|
||||
|
||||
editor.on("selectionUpdate", handleSelectionUpdate);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handleSelectionUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<RichTextEditor.Control
|
||||
@@ -897,9 +915,18 @@ function ListIndentDecrease() {
|
||||
editor?.chain().focus().liftListItem(itemType).run();
|
||||
}, [editor, itemType]);
|
||||
|
||||
editor?.on("selectionUpdate", ({ editor }) => {
|
||||
setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => {
|
||||
setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem");
|
||||
};
|
||||
|
||||
editor.on("selectionUpdate", handleSelectionUpdate);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handleSelectionUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<RichTextEditor.Control
|
||||
|
||||
@@ -9,21 +9,25 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getNotificationDisplay } from "./display";
|
||||
|
||||
export default function NotificationsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"notifications">) {
|
||||
const notificationsQuery = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
...options,
|
||||
integrationIds,
|
||||
});
|
||||
const notificationIntegrations = getUsableWidgetQueryData(notificationsQuery) ?? [];
|
||||
const notificationData = getUsableWidgetQueryData(notificationsQuery);
|
||||
const notificationIntegrations = useMemo(() => notificationData ?? [], [notificationData]);
|
||||
const { isPending } = notificationsQuery;
|
||||
|
||||
const t = useI18n();
|
||||
@@ -49,12 +53,23 @@ export default function NotificationsWidget({
|
||||
const isDense = width < 280 || height < 180;
|
||||
const isRoomy = width >= 360 && height >= 220;
|
||||
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
|
||||
const notificationDisplay = getNotificationDisplay({
|
||||
displayMode,
|
||||
hideLogos: options.hideLogos,
|
||||
isRoomy,
|
||||
bodyLineClamp,
|
||||
});
|
||||
const columns = width >= 720 ? 2 : 1;
|
||||
const spacing = isRoomy ? "sm" : "xs";
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
|
||||
<Stack w="100%" gap="xs">
|
||||
{notificationsQuery.error && (
|
||||
<Group justify="flex-end">
|
||||
<WidgetQueryErrorIndicator error={notificationsQuery.error} label={t("widget.notifications.name")} />
|
||||
</Group>
|
||||
)}
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{failedIntegrations.map((integration) => (
|
||||
@@ -95,7 +110,7 @@ export default function NotificationsWidget({
|
||||
}}
|
||||
>
|
||||
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
|
||||
{!options.hideLogos && notification.source?.iconUrl && (
|
||||
{notificationDisplay.showLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
@@ -113,7 +128,7 @@ export default function NotificationsWidget({
|
||||
<Text
|
||||
c="dimmed"
|
||||
size={isDense ? "xs" : "sm"}
|
||||
lineClamp={bodyLineClamp}
|
||||
lineClamp={notificationDisplay.bodyLineClamp}
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
>
|
||||
{notification.body}
|
||||
@@ -121,7 +136,11 @@ export default function NotificationsWidget({
|
||||
|
||||
<InfoDisplay
|
||||
date={notification.time}
|
||||
source={isRoomy ? (notification.source?.name ?? notification.integrationName) : undefined}
|
||||
source={
|
||||
notificationDisplay.showSource
|
||||
? (notification.source?.name ?? notification.integrationName)
|
||||
: undefined
|
||||
}
|
||||
dense={isDense}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getNotificationDisplay } from "./display";
|
||||
|
||||
describe("notification display", () => {
|
||||
test("advanced mode exposes logos, sources, and full bodies", () => {
|
||||
expect(
|
||||
getNotificationDisplay({ displayMode: "advanced", hideLogos: true, isRoomy: false, bodyLineClamp: 1 }),
|
||||
).toEqual({
|
||||
showLogos: true,
|
||||
showSource: true,
|
||||
bodyLineClamp: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("compact mode respects presentation constraints", () => {
|
||||
expect(
|
||||
getNotificationDisplay({ displayMode: "compact", hideLogos: true, isRoomy: false, bodyLineClamp: 2 }),
|
||||
).toEqual({
|
||||
showLogos: false,
|
||||
showSource: false,
|
||||
bodyLineClamp: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
interface NotificationDisplayInput {
|
||||
displayMode?: "compact" | "advanced";
|
||||
hideLogos: boolean;
|
||||
isRoomy: boolean;
|
||||
bodyLineClamp: number;
|
||||
}
|
||||
|
||||
export const getNotificationDisplay = ({
|
||||
displayMode,
|
||||
hideLogos,
|
||||
isRoomy,
|
||||
bodyLineClamp,
|
||||
}: NotificationDisplayInput) => {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
return {
|
||||
showLogos: isAdvanced || !hideLogos,
|
||||
showSource: isAdvanced || isRoomy,
|
||||
bodyLineClamp: isAdvanced ? undefined : bodyLineClamp,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
.root {
|
||||
position: relative;
|
||||
container-type: size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9,6 +10,32 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.advancedContent {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.advancedContent .statsSection {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.advancedContent .grid {
|
||||
height: auto;
|
||||
grid-auto-rows: minmax(92px, auto);
|
||||
}
|
||||
|
||||
.advancedContent .osSectionWrapper {
|
||||
flex: none;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.queryIndicator {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.compactRoot {
|
||||
padding: clamp(4px, 3cqi, 8px);
|
||||
gap: 2px;
|
||||
|
||||
@@ -20,26 +20,17 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
import { getGridCols, shouldShowComplianceHeroText } from "./layout-utils";
|
||||
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "./layout-utils";
|
||||
import { OsDistributionSection } from "./os-distribution-section";
|
||||
import type { PatchMonStatKey } from "./stat-colors";
|
||||
import { resolveStatColor, severityToIconColor, severityToMantineBgColor, severityToMantineColor } from "./stat-colors";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const statVisibilityByOption = {
|
||||
showTotalHosts: "totalHosts",
|
||||
showHostsNeedingUpdates: "hostsNeedingUpdates",
|
||||
showSecurityUpdates: "securityUpdates",
|
||||
showUpToDateHosts: "upToDateHosts",
|
||||
showHostsWithSecurityUpdates: "hostsWithSecurityUpdates",
|
||||
showRecentUpdates24h: "recentUpdates24h",
|
||||
showTotalOutdatedPackages: "totalOutdatedPackages",
|
||||
showTotalRepos: "totalRepos",
|
||||
} as const;
|
||||
|
||||
const statIcons: Record<PatchMonStatKey, TablerIcon> = {
|
||||
totalHosts: IconServer,
|
||||
hostsNeedingUpdates: IconRefreshAlert,
|
||||
@@ -75,15 +66,19 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
|
||||
return "comfortable";
|
||||
};
|
||||
|
||||
export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) {
|
||||
export default function PatchMonWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"patchmon">) {
|
||||
const t = useScopedI18n("widget.patchmon");
|
||||
const integrationId = integrationIds[0] ?? "";
|
||||
const { data: stats, error } = clientApi.widget.patchmon.getStats.useQuery(
|
||||
{ integrationId },
|
||||
{ staleTime: 60 * 1000 },
|
||||
);
|
||||
const statsQuery = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
|
||||
const stats = getUsableWidgetQueryData(statsQuery);
|
||||
|
||||
if (error && !stats) throw error;
|
||||
if (isInitialWidgetQueryPending(statsQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!stats) return <WidgetEmptyState />;
|
||||
|
||||
const statValues: Record<PatchMonStatKey, number> = {
|
||||
@@ -97,24 +92,24 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
totalRepos: stats.totalRepos,
|
||||
};
|
||||
|
||||
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const visibleStatKeys = getVisiblePatchMonStatKeys(options, isAdvanced);
|
||||
|
||||
const colorContext = { totalHosts: stats.totalHosts };
|
||||
const layout = getLayoutMode(width, height);
|
||||
const layout = isAdvanced ? "spacious" : getLayoutMode(width, height);
|
||||
const isLandscape = width / height > 2.5;
|
||||
const isTinySquare = width < 160 && height < 160;
|
||||
const isShortLandscape = width >= 160 && height < 160;
|
||||
const isCompactSurface = isTinySquare || isShortLandscape;
|
||||
const isCompactSurface = !isAdvanced && (isTinySquare || isShortLandscape);
|
||||
const isNarrow = width < 180;
|
||||
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
|
||||
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
|
||||
const showOsData = (isAdvanced || options.showOsDistribution) && stats.osDistribution.length > 0;
|
||||
|
||||
const showIcons = layout !== "mini";
|
||||
const showLabels = layout !== "mini";
|
||||
const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
|
||||
const showHeroText = showHero && shouldShowComplianceHeroText(width);
|
||||
const showIcons = isAdvanced || layout !== "mini";
|
||||
const showLabels = isAdvanced || layout !== "mini";
|
||||
const showHero =
|
||||
isAdvanced || (options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90);
|
||||
const showHeroText = showHero && (isAdvanced || shouldShowComplianceHeroText(width));
|
||||
const showHeroRingOnly = showHero && !showHeroText;
|
||||
const compactPrimaryContent = isCompactSurface
|
||||
? getCompactPrimaryContent({
|
||||
@@ -124,16 +119,18 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
})
|
||||
: null;
|
||||
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
|
||||
const showOsSection = showOsData && !isCompactSurface && height > 200;
|
||||
const showOsSection = showOsData && !isCompactSurface && (isAdvanced || height > 200);
|
||||
const showCompactFooter = isCompactSurface && height >= 110;
|
||||
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
|
||||
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
|
||||
const showFooter = isAdvanced || (isCompactSurface ? showCompactFooter : !isLandscape && height > 220);
|
||||
const showOsLegend = osDisplayMode === "donut" && (isAdvanced || (width >= 380 && height >= 300));
|
||||
|
||||
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
|
||||
|
||||
const gridCols = isLandscape
|
||||
? Math.min(visibleStatKeys.length, 4)
|
||||
: Math.min(getGridCols(width), visibleStatKeys.length || 1);
|
||||
const gridCols = isAdvanced
|
||||
? Math.min(visibleStatKeys.length, width >= 500 ? 4 : width >= 220 ? 2 : 1)
|
||||
: isLandscape
|
||||
? Math.min(visibleStatKeys.length, 4)
|
||||
: Math.min(getGridCols(width), visibleStatKeys.length || 1);
|
||||
const iconSize = getIconSize(width);
|
||||
const ringSize = getRingSize(width, height, showHeroRingOnly);
|
||||
|
||||
@@ -266,8 +263,8 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
<div className={`${classes.osSectionWrapper} ${visibleStatKeys.length > 0 ? classes.osSectionWithStats : ""}`}>
|
||||
<OsDistributionSection
|
||||
entries={stats.osDistribution}
|
||||
limit={Number(options.osDistributionLimit)}
|
||||
showOsVersion={options.showOsVersion}
|
||||
limit={isAdvanced ? 0 : Number(options.osDistributionLimit)}
|
||||
showOsVersion={isAdvanced || options.showOsVersion}
|
||||
displayMode={osDisplayMode}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -276,10 +273,13 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
</div>
|
||||
);
|
||||
|
||||
const needsScroll = layout === "mini" || layout === "compact";
|
||||
const needsScroll = isAdvanced || layout === "mini" || layout === "compact";
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.queryIndicator}>
|
||||
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
|
||||
</div>
|
||||
{showHero && (
|
||||
<ComplianceHero
|
||||
compliancePercent={compliancePercent}
|
||||
@@ -296,7 +296,7 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
{(statGridContent || osContent) &&
|
||||
(needsScroll ? (
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }} scrollbars="y">
|
||||
<div className={classes.content}>
|
||||
<div className={`${classes.content} ${isAdvanced ? classes.advancedContent : ""}`}>
|
||||
{statGridContent}
|
||||
{osContent}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors";
|
||||
import { patchmonOptionsSuperRefine } from "./threshold-validation";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconShieldCheck,
|
||||
supportedIntegrations: ["patchmon"],
|
||||
integrationsRequired: true,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PatchMonStatKey } from "./stat-colors";
|
||||
|
||||
const gridColsByWidth = [
|
||||
{ minWidth: 500, cols: 4 },
|
||||
{ minWidth: 380, cols: 3 },
|
||||
@@ -5,6 +7,26 @@ const gridColsByWidth = [
|
||||
{ minWidth: 0, cols: 1 },
|
||||
] as const;
|
||||
|
||||
const statVisibilityByOption = [
|
||||
["showTotalHosts", "totalHosts"],
|
||||
["showHostsNeedingUpdates", "hostsNeedingUpdates"],
|
||||
["showSecurityUpdates", "securityUpdates"],
|
||||
["showUpToDateHosts", "upToDateHosts"],
|
||||
["showHostsWithSecurityUpdates", "hostsWithSecurityUpdates"],
|
||||
["showRecentUpdates24h", "recentUpdates24h"],
|
||||
["showTotalOutdatedPackages", "totalOutdatedPackages"],
|
||||
["showTotalRepos", "totalRepos"],
|
||||
] as const satisfies readonly (readonly [string, PatchMonStatKey])[];
|
||||
|
||||
type PatchMonStatVisibilityOptions = Partial<Record<(typeof statVisibilityByOption)[number][0], boolean>>;
|
||||
|
||||
export function getVisiblePatchMonStatKeys(
|
||||
options: PatchMonStatVisibilityOptions,
|
||||
isAdvanced: boolean,
|
||||
): PatchMonStatKey[] {
|
||||
return statVisibilityByOption.filter(([optionKey]) => isAdvanced || options[optionKey]).map(([, statKey]) => statKey);
|
||||
}
|
||||
|
||||
export function getGridCols(width: number): number {
|
||||
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.cols ?? 1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getGridCols, shouldShowComplianceHeroText } from "../layout-utils";
|
||||
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "../layout-utils";
|
||||
|
||||
describe("getGridCols", () => {
|
||||
test("returns 1 column for narrow widths", () => {
|
||||
@@ -34,3 +34,31 @@ describe("shouldShowComplianceHeroText", () => {
|
||||
expect(shouldShowComplianceHeroText(384)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisiblePatchMonStatKeys", () => {
|
||||
test("keeps compact visibility choices", () => {
|
||||
expect(
|
||||
getVisiblePatchMonStatKeys(
|
||||
{
|
||||
showTotalHosts: true,
|
||||
showSecurityUpdates: true,
|
||||
showTotalRepos: false,
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toEqual(["totalHosts", "securityUpdates"]);
|
||||
});
|
||||
|
||||
test("returns every owned statistic in advanced mode", () => {
|
||||
expect(getVisiblePatchMonStatKeys({}, true)).toEqual([
|
||||
"totalHosts",
|
||||
"hostsNeedingUpdates",
|
||||
"securityUpdates",
|
||||
"upToDateHosts",
|
||||
"hostsWithSecurityUpdates",
|
||||
"recentUpdates24h",
|
||||
"totalOutdatedPackages",
|
||||
"totalRepos",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,13 @@ import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/defin
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.scss";
|
||||
import { selectReleaseRepositoriesForDisplay } from "./display";
|
||||
import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository";
|
||||
import { getReleasesQueryStaleTimeMs } from "./query-options";
|
||||
|
||||
@@ -49,7 +53,7 @@ const formatRelativeDate = (value: string): string => {
|
||||
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
|
||||
};
|
||||
|
||||
export default function ReleasesWidget({ options, itemId, width }: WidgetComponentProps<"releases">) {
|
||||
export default function ReleasesWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"releases">) {
|
||||
const t = useScopedI18n("widget.releases");
|
||||
const now = useNow();
|
||||
const formatter = useFormatter();
|
||||
@@ -60,6 +64,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
|
||||
key: "releases-viewed-versions",
|
||||
defaultValue: {},
|
||||
});
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const columns = Math.max(1, Math.floor(width / 420));
|
||||
|
||||
const relativeDateOptions = useMemo(
|
||||
@@ -123,6 +128,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
|
||||
() => queryResults.map((q) => q.data).filter((d): d is NonNullable<typeof d> => d != null),
|
||||
[queryResults],
|
||||
);
|
||||
const isInitialLoading = queryResults.some(isInitialWidgetQueryPending);
|
||||
|
||||
const repositories = useMemo(() => {
|
||||
const formattedResults = normalizedRepositories
|
||||
@@ -147,22 +153,19 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
|
||||
viewed: releasesViewedList[repository.id] === release.latestRelease,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(repository) =>
|
||||
"error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease,
|
||||
)
|
||||
.toSorted((repoA, repoB) => {
|
||||
if ("error" in repoA) return -1;
|
||||
if ("error" in repoB) return 1;
|
||||
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
|
||||
}) as ReleasesRepositoryResponse[];
|
||||
|
||||
if (typeof options.topReleases !== "string" && options.topReleases > 0) {
|
||||
return formattedResults.slice(0, options.topReleases);
|
||||
}
|
||||
|
||||
return formattedResults;
|
||||
return selectReleaseRepositoriesForDisplay(formattedResults, {
|
||||
displayMode,
|
||||
showOnlyHighlighted: options.showOnlyHighlighted,
|
||||
topReleases: options.topReleases,
|
||||
});
|
||||
}, [
|
||||
displayMode,
|
||||
results,
|
||||
normalizedRepositories,
|
||||
options.showOnlyHighlighted,
|
||||
@@ -186,6 +189,9 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
|
||||
[setReleasesViewedList],
|
||||
);
|
||||
|
||||
if (isInitialLoading) return <WidgetQueryLoadingState />;
|
||||
if (repositories.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" className="releases">
|
||||
<SimpleGrid cols={columns} spacing={columns > 1 ? "sm" : 0} p={columns > 1 ? "xs" : 0}>
|
||||
@@ -241,7 +247,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
|
||||
miw={0}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{!options.showOnlyIcon && (
|
||||
{(isAdvanced || !options.showOnlyIcon) && (
|
||||
<Text className="releases-repository-header-name" size="xs">
|
||||
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
|
||||
{repository.name || repository.identifier}
|
||||
@@ -321,7 +327,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{options.showDetails && (
|
||||
{(isAdvanced || options.showDetails) && (
|
||||
<DetailsDisplay
|
||||
repository={repository}
|
||||
isExpanded={isActive}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { selectReleaseRepositoriesForDisplay } from "./display";
|
||||
|
||||
const repositories = [
|
||||
{ id: "ordinary", isNewRelease: false, isStaleRelease: false },
|
||||
{ id: "new", isNewRelease: true, isStaleRelease: false },
|
||||
{ id: "stale", isNewRelease: false, isStaleRelease: true },
|
||||
{ id: "error", error: { code: "provider" } },
|
||||
];
|
||||
|
||||
describe("selectReleaseRepositoriesForDisplay", () => {
|
||||
test("applies highlight and top limits in compact mode", () => {
|
||||
const result = selectReleaseRepositoriesForDisplay(repositories, {
|
||||
displayMode: "compact",
|
||||
showOnlyHighlighted: true,
|
||||
topReleases: 2,
|
||||
});
|
||||
|
||||
expect(result.map(({ id }) => id)).toEqual(["new", "stale"]);
|
||||
});
|
||||
|
||||
test("keeps every configured repository in advanced mode", () => {
|
||||
const result = selectReleaseRepositoriesForDisplay(repositories, {
|
||||
displayMode: "advanced",
|
||||
showOnlyHighlighted: true,
|
||||
topReleases: 1,
|
||||
});
|
||||
|
||||
expect(result.map(({ id }) => id)).toEqual(["ordinary", "new", "stale", "error"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
interface ReleasePresentationItem {
|
||||
error?: unknown;
|
||||
isNewRelease?: boolean;
|
||||
isStaleRelease?: boolean;
|
||||
}
|
||||
|
||||
interface ReleasePresentationOptions {
|
||||
displayMode?: "compact" | "advanced";
|
||||
showOnlyHighlighted: boolean;
|
||||
topReleases: number | string;
|
||||
}
|
||||
|
||||
export const selectReleaseRepositoriesForDisplay = <T extends ReleasePresentationItem>(
|
||||
repositories: readonly T[],
|
||||
options: ReleasePresentationOptions,
|
||||
): T[] => {
|
||||
const visibleRepositories =
|
||||
options.displayMode === "advanced" || !options.showOnlyHighlighted
|
||||
? [...repositories]
|
||||
: repositories.filter(
|
||||
(repository) =>
|
||||
repository.error !== undefined || repository.isNewRelease === true || repository.isStaleRelease === true,
|
||||
);
|
||||
|
||||
if (options.displayMode === "advanced" || typeof options.topReleases === "string" || options.topReleases <= 0) {
|
||||
return visibleRepositories;
|
||||
}
|
||||
|
||||
return visibleRepositories.slice(0, options.topReleases);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ const relativeDateSchema = z
|
||||
.or(z.literal(""));
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("releases", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconRocket,
|
||||
queryKey: [["widget", "releases", "getLatest"]],
|
||||
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),
|
||||
|
||||
@@ -9,8 +9,10 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { BaseWidgetError } from "../errors/base-component";
|
||||
import classes from "./component.module.scss";
|
||||
|
||||
@@ -27,6 +29,7 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
|
||||
hasError: false as const,
|
||||
entries: normalizedData,
|
||||
failedFeedCount: 0,
|
||||
isPending: query.isPending,
|
||||
isStale: Boolean(query.error),
|
||||
};
|
||||
}
|
||||
@@ -35,11 +38,12 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
|
||||
hasError: false as const,
|
||||
entries: normalizedData?.entries ?? [],
|
||||
failedFeedCount: normalizedData?.failedFeedCount ?? 0,
|
||||
isPending: query.isPending,
|
||||
isStale: Boolean(query.error),
|
||||
};
|
||||
};
|
||||
|
||||
export default function RssFeed({ options, width, height }: WidgetComponentProps<"rssFeed">) {
|
||||
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) {
|
||||
const feed = useLiveFeed({
|
||||
urls: options.feedUrls,
|
||||
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
|
||||
@@ -57,6 +61,7 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (feed.isPending) return <WidgetQueryLoadingState />;
|
||||
|
||||
const warning = feed.isStale
|
||||
? t("warning.stale")
|
||||
@@ -64,13 +69,35 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps
|
||||
? t("warning.partial", { count: feed.failedFeedCount })
|
||||
: undefined;
|
||||
|
||||
if (feed.entries.length === 0) {
|
||||
return (
|
||||
<Flex direction="column" h="100%" p="xs">
|
||||
{warning && (
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs">
|
||||
<output>{warning}</output>
|
||||
</Alert>
|
||||
)}
|
||||
<WidgetEmptyState />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const languageDir = options.enableRtl ? "RTL" : "LTR";
|
||||
|
||||
const isDense = width < 420 || height < 180;
|
||||
const isTiny = width < 260 || height < 110;
|
||||
const isRoomy = width >= 420 && height >= 240;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const columns = width >= 720 && height >= 260 ? 2 : 1;
|
||||
const descriptionLines = isRoomy ? Math.max(options.textLinesClamp, 6) : isDense ? 1 : options.textLinesClamp;
|
||||
const entryDisplay = getRssEntryDisplay({
|
||||
isAdvanced,
|
||||
isDense,
|
||||
isTiny,
|
||||
hideDescription: options.hideDescription,
|
||||
showPosterImage: options.showPosterImage,
|
||||
descriptionLines,
|
||||
});
|
||||
const spacing = isRoomy ? "sm" : "xs";
|
||||
|
||||
return (
|
||||
@@ -97,7 +124,7 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps
|
||||
title={feedEntry.title}
|
||||
>
|
||||
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
|
||||
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
|
||||
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
|
||||
<Image
|
||||
className={classes.poster}
|
||||
src={feedEntry.enclosure}
|
||||
@@ -110,18 +137,29 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps
|
||||
)}
|
||||
|
||||
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
|
||||
<Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
|
||||
<Text
|
||||
dir={languageDir}
|
||||
fz={isRoomy ? "md" : "sm"}
|
||||
fw={600}
|
||||
lh={1.25}
|
||||
lineClamp={isAdvanced ? undefined : 2}
|
||||
>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{!options.hideDescription && feedEntry.description && !isTiny && (
|
||||
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
|
||||
{entryDisplay.showDescription && feedEntry.description && (
|
||||
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={entryDisplay.descriptionLineClamp}>
|
||||
{feedDescriptionToText(feedEntry.description)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<InfoDisplay
|
||||
source={!isDense ? getHostname(feedEntry.feedUrl) : undefined}
|
||||
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
|
||||
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
|
||||
timestamp={
|
||||
isAdvanced && feedEntry.published
|
||||
? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</Group>
|
||||
@@ -151,15 +189,44 @@ export const getHostname = (url: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => (
|
||||
<Group gap={5} align="center" wrap="nowrap">
|
||||
interface RssEntryDisplayInput {
|
||||
isAdvanced: boolean;
|
||||
isDense: boolean;
|
||||
isTiny: boolean;
|
||||
hideDescription: boolean;
|
||||
showPosterImage: boolean;
|
||||
descriptionLines: number;
|
||||
}
|
||||
|
||||
export const getRssEntryDisplay = ({
|
||||
isAdvanced,
|
||||
isDense,
|
||||
isTiny,
|
||||
hideDescription,
|
||||
showPosterImage,
|
||||
descriptionLines,
|
||||
}: RssEntryDisplayInput) => ({
|
||||
showDescription: isAdvanced || (!hideDescription && !isTiny),
|
||||
showImage: isAdvanced || (showPosterImage && !isTiny),
|
||||
showSource: isAdvanced || !isDense,
|
||||
descriptionLineClamp: isAdvanced ? undefined : descriptionLines,
|
||||
});
|
||||
|
||||
const InfoDisplay = ({ date, timestamp, source }: { date?: string; timestamp?: string; source?: string }) => (
|
||||
<Group gap={5} align="center" wrap={timestamp ? "wrap" : "nowrap"}>
|
||||
{date && <IconClock size="1rem" color="var(--mantine-color-dimmed)" />}
|
||||
{date && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{date}
|
||||
</Text>
|
||||
)}
|
||||
{date && source && <Text c="dimmed">•</Text>}
|
||||
{timestamp && <Text c="dimmed">•</Text>}
|
||||
{timestamp && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{timestamp}
|
||||
</Text>
|
||||
)}
|
||||
{(date || timestamp) && source && <Text c="dimmed">•</Text>}
|
||||
{source && (
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{source}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { feedDescriptionToText, getHostname } from "./component";
|
||||
import { feedDescriptionToText, getHostname, getRssEntryDisplay } from "./component";
|
||||
|
||||
describe("RSS feed display helpers", () => {
|
||||
test("renders feed markup as inert text", () => {
|
||||
@@ -13,4 +13,22 @@ describe("RSS feed display helpers", () => {
|
||||
expect(getHostname("not a URL")).toBe("not a URL");
|
||||
expect(getHostname("https://example.com/feed.xml")).toBe("example.com");
|
||||
});
|
||||
|
||||
test("advanced mode exposes all fetched entry details", () => {
|
||||
expect(
|
||||
getRssEntryDisplay({
|
||||
isAdvanced: true,
|
||||
isDense: true,
|
||||
isTiny: true,
|
||||
hideDescription: true,
|
||||
showPosterImage: false,
|
||||
descriptionLines: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
showDescription: true,
|
||||
showImage: true,
|
||||
showSource: true,
|
||||
descriptionLineClamp: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { optionsBuilder } from "../options";
|
||||
* - https://www.jsonfeed.org/version/1.1/
|
||||
*/
|
||||
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconRss,
|
||||
queryKey: [["widget", "rssFeed", "getFeeds"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
|
||||
@@ -157,6 +157,11 @@ export default function SmartHomeEntityStateWidget({
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("advanced.entityId", { id: entity.entity_id })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("advanced.lastChanged", {
|
||||
date: new Date(entity.last_changed).toLocaleString(locale),
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("advanced.lastUpdated", {
|
||||
date: new Date(entity.last_updated).toLocaleString(locale),
|
||||
|
||||
@@ -138,23 +138,30 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
{options.displayName}
|
||||
</Text>
|
||||
{displayMode === "advanced" && (
|
||||
<>
|
||||
{error ? (
|
||||
<Stack gap={2} align="center">
|
||||
<Text ta="center" size="xs" c="dimmed">
|
||||
{t("widget.smartHome-executeAutomation.advanced.automationId", { id: options.automationId })}
|
||||
</Text>
|
||||
<Text ta="center" size="xs" c={canInteract ? "dimmed" : "orange"}>
|
||||
{t(
|
||||
canInteract
|
||||
? "widget.smartHome-executeAutomation.advanced.ready"
|
||||
: "widget.smartHome-executeAutomation.advanced.noPermission",
|
||||
)}
|
||||
</Text>
|
||||
{lastExecutedAt && (
|
||||
<Text ta="center" size="xs" c="dimmed">
|
||||
{t("widget.smartHome-executeAutomation.advanced.lastExecuted", {
|
||||
time: lastExecutedAt.toLocaleTimeString(locale),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
{error && (
|
||||
<Text ta="center" size="xs" c="red">
|
||||
{t("widget.smartHome-executeAutomation.error.executeFailed")}
|
||||
</Text>
|
||||
) : (
|
||||
<Text ta="center" size="xs" c="dimmed">
|
||||
{lastExecutedAt
|
||||
? t("widget.smartHome-executeAutomation.advanced.lastExecuted", {
|
||||
time: lastExecutedAt.toLocaleTimeString(locale),
|
||||
})
|
||||
: t("widget.smartHome-executeAutomation.advanced.automationId", {
|
||||
id: options.automationId,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { AveragesSection } from "./averages";
|
||||
import { combineSpeedtestDashboards, getCompactSections } from "./helpers";
|
||||
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
|
||||
import { LatestResultSection } from "./latest-result";
|
||||
import { RecentResultsSection } from "./recent-results";
|
||||
|
||||
const emptyDashboardData = [] as const;
|
||||
|
||||
export default function SpeedtestTrackerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
@@ -22,33 +26,36 @@ export default function SpeedtestTrackerWidget({
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"speedtestTracker">) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
const dashboardData =
|
||||
getUsableWidgetQueryData(clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds })) ?? [];
|
||||
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
|
||||
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
|
||||
const [advancedRange, setAdvancedRange] = useState("24");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
|
||||
|
||||
const combined = useMemo(
|
||||
() => combineSpeedtestDashboards(dashboardData.map((item) => item.dashboard)),
|
||||
[dashboardData],
|
||||
() => combineSpeedtestDashboards(successfulDashboards.map(({ dashboard }) => dashboard)),
|
||||
[successfulDashboards],
|
||||
);
|
||||
|
||||
const rangeHours = displayMode === "advanced" ? Number(advancedRange) : 12;
|
||||
const rangeHours = isAdvanced ? Number(advancedRange) : 12;
|
||||
const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
|
||||
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
|
||||
|
||||
const hasStatSection =
|
||||
(options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null);
|
||||
const hasChart = options.showRecentResults && recentFiltered.length > 0;
|
||||
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
||||
const showLatestResult = isAdvanced || options.showLatestResult;
|
||||
const showStats = isAdvanced || options.showStats;
|
||||
const showRecentResults = isAdvanced || options.showRecentResults;
|
||||
const showPingGraph = isAdvanced || options.showPingGraph;
|
||||
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
|
||||
const hasChart = showRecentResults && recentFiltered.length > 0;
|
||||
const noSectionsEnabled =
|
||||
!isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults;
|
||||
const advancedSectionWidth = width >= 720 ? width / 2 : width;
|
||||
|
||||
const latest = options.showLatestResult && combined.latestResult && (
|
||||
<LatestResultSection
|
||||
result={combined.latestResult}
|
||||
width={displayMode === "advanced" ? advancedSectionWidth : width}
|
||||
/>
|
||||
const latest = showLatestResult && combined.latestResult && (
|
||||
<LatestResultSection result={combined.latestResult} width={isAdvanced ? advancedSectionWidth : width} />
|
||||
);
|
||||
const averages = options.showStats && combined.stats && (
|
||||
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? advancedSectionWidth : width} />
|
||||
const averages = showStats && combined.stats && (
|
||||
<AveragesSection stats={combined.stats} width={isAdvanced ? advancedSectionWidth : width} />
|
||||
);
|
||||
const compactSections = getCompactSections(height, {
|
||||
latest: Boolean(latest),
|
||||
@@ -57,27 +64,90 @@ export default function SpeedtestTrackerWidget({
|
||||
});
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
|
||||
const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32);
|
||||
const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="md" p="md" style={{ overflow: "hidden" }}>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={advancedRange}
|
||||
onChange={setAdvancedRange}
|
||||
data={[
|
||||
{ value: "12", label: t("range.twelveHours") },
|
||||
{ value: "24", label: t("range.oneDay") },
|
||||
{ value: "0", label: t("range.all") },
|
||||
]}
|
||||
/>
|
||||
{hasStatSection && (
|
||||
<SimpleGrid cols={width >= 720 ? 2 : 1} spacing="md">
|
||||
{latest}
|
||||
{averages}
|
||||
</SimpleGrid>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={advancedRange}
|
||||
onChange={setAdvancedRange}
|
||||
data={[
|
||||
{ value: "12", label: t("range.twelveHours") },
|
||||
{ value: "24", label: t("range.oneDay") },
|
||||
{ value: "0", label: t("range.lastThirty") },
|
||||
]}
|
||||
/>
|
||||
{successfulDashboards.length === 0 ? (
|
||||
<WidgetEmptyState />
|
||||
) : (
|
||||
<SimpleGrid cols={sourceColumns} spacing="md">
|
||||
{successfulDashboards.map(({ integrationId, integrationName, dashboard }) => {
|
||||
const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff);
|
||||
const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null;
|
||||
const sourceHasChart = sourceRecent.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={integrationId} data-integration-id={integrationId} p="sm" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{integrationName}
|
||||
</Text>
|
||||
{sourceHasStats && (
|
||||
<SimpleGrid cols={sourcePanelWidth >= 720 ? 2 : 1} spacing="md">
|
||||
{dashboard.latestResult && (
|
||||
<LatestResultSection result={dashboard.latestResult} width={sourceSectionWidth} />
|
||||
)}
|
||||
{dashboard.stats && <AveragesSection stats={dashboard.stats} width={sourceSectionWidth} />}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{sourceHasChart && (
|
||||
<Box h={Math.max(260, Math.min(420, height / 2))}>
|
||||
<RecentResultsSection results={sourceRecent} showPingGraph />
|
||||
</Box>
|
||||
)}
|
||||
{!sourceHasStats && !sourceHasChart && <WidgetEmptyState />}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
|
||||
{dashboardData.length > 1 && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
{hasChart && (
|
||||
<div style={{ flex: 1, minHeight: Math.max(220, height / 2) }}>
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
|
||||
{(compactSections.latest || compactSections.averages) && (
|
||||
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
|
||||
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
|
||||
</Stack>
|
||||
)}
|
||||
{compactSections.chart && (
|
||||
<div style={{ flex: 2, minHeight: 0 }}>
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={showPingGraph} />
|
||||
</div>
|
||||
)}
|
||||
{noSectionsEnabled && (
|
||||
@@ -87,28 +157,6 @@ export default function SpeedtestTrackerWidget({
|
||||
)}
|
||||
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
|
||||
{(compactSections.latest || compactSections.averages) && (
|
||||
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
|
||||
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
|
||||
</Stack>
|
||||
)}
|
||||
{compactSections.chart && (
|
||||
<div style={{ flex: 2, minHeight: 0 }}>
|
||||
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
|
||||
</div>
|
||||
)}
|
||||
{noSectionsEnabled && (
|
||||
<Text c="dimmed" ta="center" size="sm">
|
||||
{t("noSectionsEnabled")}
|
||||
</Text>
|
||||
)}
|
||||
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
|
||||
|
||||
import { combineSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
|
||||
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
|
||||
|
||||
const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({
|
||||
ping: { avg: average, min: average, max: average },
|
||||
@@ -27,6 +27,23 @@ describe("speedtest dashboard aggregation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("speedtest source ownership", () => {
|
||||
it("keeps integration identity when upstream result ids overlap", () => {
|
||||
const result = { id: 1, ping: 1, download_bits: 1, upload_bits: 1, healthy: true, created_at: new Date(1) };
|
||||
const dashboard = { latestResult: result, stats: null, recentResults: [result] };
|
||||
|
||||
expect(
|
||||
getAvailableSpeedtestDashboards([
|
||||
{ integrationId: "speed-a", integrationName: "Speed A", dashboard },
|
||||
{ integrationId: "speed-b", integrationName: "Speed B", dashboard },
|
||||
]).map(({ integrationId, integrationName }) => ({ integrationId, integrationName })),
|
||||
).toEqual([
|
||||
{ integrationId: "speed-a", integrationName: "Speed A" },
|
||||
{ integrationId: "speed-b", integrationName: "Speed B" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCompactSections", () => {
|
||||
const available = { latest: true, chart: true, averages: true };
|
||||
|
||||
|
||||
@@ -5,6 +5,23 @@ import type {
|
||||
SpeedtestTrackerStats,
|
||||
} from "@homarr/integrations/types";
|
||||
|
||||
export interface SpeedtestDashboardSource {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
dashboard: SpeedtestTrackerDashboardData;
|
||||
}
|
||||
|
||||
export const getAvailableSpeedtestDashboards = (
|
||||
results: readonly {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
dashboard: SpeedtestTrackerDashboardData | null;
|
||||
}[],
|
||||
): SpeedtestDashboardSource[] =>
|
||||
results.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
dashboard === null ? [] : [{ integrationId, integrationName, dashboard }],
|
||||
);
|
||||
|
||||
export const mergeStats = (
|
||||
statsA: SpeedtestTrackerDashboardData["stats"],
|
||||
statsB: SpeedtestTrackerDashboardData["stats"],
|
||||
|
||||
@@ -10,11 +10,25 @@ describe("stock summary", () => {
|
||||
it("does not divide by a zero previous close", () => {
|
||||
expect(getStockSummary([0, 5], 0)).toMatchObject({
|
||||
currentPrice: 5,
|
||||
previousClose: 0,
|
||||
change: 5,
|
||||
changePercentage: null,
|
||||
minimum: 0,
|
||||
maximum: 5,
|
||||
graphValues: [50, 55],
|
||||
});
|
||||
});
|
||||
|
||||
it("derives every advanced metric from the owned price series", () => {
|
||||
expect(getStockSummary([90, 110, 105], 100)).toMatchObject({
|
||||
currentPrice: 105,
|
||||
previousClose: 100,
|
||||
change: 5,
|
||||
changePercentage: 5,
|
||||
minimum: 90,
|
||||
maximum: 110,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stock layout", () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkline } from "@mantine/charts";
|
||||
import { Flex, Stack, Text, Title, useMantineTheme } from "@mantine/core";
|
||||
import { Badge, Box, Flex, Group, Paper, SimpleGrid, Stack, Text, Title, useMantineTheme } from "@mantine/core";
|
||||
import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
function round(value: number) {
|
||||
@@ -25,8 +27,11 @@ function calculateChangePercentage(currentPrice: number, previousClose: number)
|
||||
|
||||
export interface StockSummary {
|
||||
currentPrice: number;
|
||||
previousClose: number;
|
||||
change: number;
|
||||
changePercentage: number | null;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
graphValues: number[];
|
||||
}
|
||||
|
||||
@@ -55,20 +60,29 @@ export function getStockSummary(priceHistory: number[], previousClose: number):
|
||||
const minimum = Math.min(...priceHistory);
|
||||
return {
|
||||
currentPrice,
|
||||
previousClose,
|
||||
change: round(calculateChange(currentPrice, previousClose)),
|
||||
changePercentage: calculateChangePercentage(currentPrice, previousClose),
|
||||
minimum,
|
||||
maximum: Math.max(...priceHistory),
|
||||
graphValues: priceHistory.map((value) => value - minimum + 50),
|
||||
};
|
||||
}
|
||||
|
||||
export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) {
|
||||
export default function StockPriceWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"stockPrice">) {
|
||||
const t = useScopedI18n("widget.stockPrice");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const numberFormatter = new Intl.NumberFormat(locale);
|
||||
const theme = useMantineTheme();
|
||||
const { data: result, error } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
|
||||
const stockQuery = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
|
||||
const result = getUsableWidgetQueryData(stockQuery);
|
||||
|
||||
if (error && !result) throw error;
|
||||
if (isInitialWidgetQueryPending(stockQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!result) return <WidgetEmptyState />;
|
||||
const { data } = result;
|
||||
|
||||
@@ -79,57 +93,126 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
|
||||
const stockGraphValues = summary.graphValues;
|
||||
const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6";
|
||||
const layout = getStockLayout(width, height);
|
||||
const formatValue = (value: number) => numberFormatter.format(round(value));
|
||||
const formatSignedValue = (value: number) => `${value > 0 ? "+" : ""}${formatValue(value)}`;
|
||||
|
||||
return (
|
||||
<Flex h="100%" w="100%">
|
||||
<Sparkline
|
||||
pos="absolute"
|
||||
bottom={10}
|
||||
w="100%"
|
||||
h={layout.graphHeight}
|
||||
data={stockGraphValues}
|
||||
curveType="linear"
|
||||
color={trendColor}
|
||||
fillOpacity={0.6}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
const content =
|
||||
displayMode === "advanced" ? (
|
||||
<Stack h="100%" w="100%" gap="md" p="md" style={{ overflow: "hidden" }}>
|
||||
<Group justify="space-between" align="flex-start" gap="sm" wrap="wrap">
|
||||
<Stack gap={2}>
|
||||
<Title order={2}>{data.symbol}</Title>
|
||||
<Text c="dimmed">{data.shortName}</Text>
|
||||
</Stack>
|
||||
<Group gap="xs" wrap="wrap" justify="flex-end">
|
||||
<Badge variant="light">
|
||||
{t("option.timeRange.label")}: {t(`option.timeRange.option.${options.timeRange}.label`)}
|
||||
</Badge>
|
||||
<Badge variant="light">
|
||||
{t("option.timeInterval.label")}: {t(`option.timeInterval.option.${options.timeInterval}.label`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack pos="absolute" top={10} left={10}>
|
||||
<Text size="xl" fw={700} lh="0.715">
|
||||
{stockValuesChange > 0 ? (
|
||||
<IconTrendingUp size="1.5rem" color={theme.colors.green[7]} />
|
||||
) : stockValuesChange < 0 ? (
|
||||
<IconTrendingDown size="1.5rem" color={theme.colors.red[7]} />
|
||||
) : (
|
||||
<IconMinus size="1.5rem" color={theme.colors.gray[6]} />
|
||||
<SimpleGrid cols={width >= 840 ? 6 : width >= 480 ? 3 : 2} spacing="xs">
|
||||
<StockMetric label={t("advanced.currentPrice")} value={formatValue(summary.currentPrice)} />
|
||||
<StockMetric label={t("advanced.previousClose")} value={formatValue(summary.previousClose)} />
|
||||
<StockMetric label={t("advanced.change")} value={formatSignedValue(summary.change)} color={trendColor} />
|
||||
<StockMetric
|
||||
label={t("advanced.changePercentage")}
|
||||
value={summary.changePercentage === null ? "—" : `${formatSignedValue(summary.changePercentage)}%`}
|
||||
color={trendColor}
|
||||
/>
|
||||
<StockMetric label={t("advanced.minimum")} value={formatValue(summary.minimum)} />
|
||||
<StockMetric label={t("advanced.maximum")} value={formatValue(summary.maximum)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 160 }}>
|
||||
<Sparkline
|
||||
w="100%"
|
||||
h="100%"
|
||||
data={data.priceHistory}
|
||||
curveType="linear"
|
||||
color={trendColor}
|
||||
fillOpacity={0.35}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Flex h="100%" w="100%">
|
||||
<Sparkline
|
||||
pos="absolute"
|
||||
bottom={10}
|
||||
w="100%"
|
||||
h={layout.graphHeight}
|
||||
data={stockGraphValues}
|
||||
curveType="linear"
|
||||
color={trendColor}
|
||||
fillOpacity={0.6}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
|
||||
<Stack pos="absolute" top={10} left={10}>
|
||||
<Text size="xl" fw={700} lh="0.715">
|
||||
{stockValuesChange > 0 ? (
|
||||
<IconTrendingUp size="1.5rem" color={theme.colors.green[7]} />
|
||||
) : stockValuesChange < 0 ? (
|
||||
<IconTrendingDown size="1.5rem" color={theme.colors.red[7]} />
|
||||
) : (
|
||||
<IconMinus size="1.5rem" color={theme.colors.gray[6]} />
|
||||
)}
|
||||
{data.symbol}
|
||||
</Text>
|
||||
{layout.showName && (
|
||||
<Text size="md" lh="1">
|
||||
{data.shortName}
|
||||
</Text>
|
||||
)}
|
||||
{data.symbol}
|
||||
</Text>
|
||||
{layout.showName && (
|
||||
<Text size="md" lh="1">
|
||||
{data.shortName}
|
||||
</Stack>
|
||||
|
||||
<Title pos="absolute" bottom={10} right={10} order={layout.priceOrder} fw={700}>
|
||||
{formatValue(summary.currentPrice)}
|
||||
</Title>
|
||||
|
||||
{layout.showChange && (
|
||||
<Text pos="absolute" top={10} right={10} size="xl" fw={700}>
|
||||
{numberFormatter.format(stockValuesChange)}
|
||||
{stockValuesChangePercentage === null
|
||||
? null
|
||||
: ` (${stockValuesChange > 0 ? "+" : ""}${numberFormatter.format(stockValuesChangePercentage)}%)`}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Title pos="absolute" bottom={10} right={10} order={layout.priceOrder} fw={700}>
|
||||
{numberFormatter.format(round(summary.currentPrice))}
|
||||
</Title>
|
||||
{layout.showRange && (
|
||||
<Text pos="absolute" bottom={10} left={10} fw={700}>
|
||||
{t(`option.timeRange.option.${options.timeRange}.label`)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
{layout.showChange && (
|
||||
<Text pos="absolute" top={10} right={10} size="xl" fw={700}>
|
||||
{numberFormatter.format(stockValuesChange)}
|
||||
{stockValuesChangePercentage === null
|
||||
? null
|
||||
: ` (${stockValuesChange > 0 ? "+" : ""}${numberFormatter.format(stockValuesChangePercentage)}%)`}
|
||||
</Text>
|
||||
return (
|
||||
<Box h="100%" w="100%" pos="relative">
|
||||
{content}
|
||||
{stockQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={stockQuery.error} label={t("name")} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{layout.showRange && (
|
||||
<Text pos="absolute" bottom={10} left={10} fw={700}>
|
||||
{t(`option.timeRange.option.${options.timeRange}.label`)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StockMetric({ label, value, color }: { label: string; value: string; color?: string }) {
|
||||
return (
|
||||
<Paper p="sm" radius="md">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="lg" fw={700} c={color} truncate title={value}>
|
||||
{value}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const timeRangeOptions = stockPriceTimeFrames.range;
|
||||
const timeIntervalOptions = stockPriceTimeFrames.interval;
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconBuildingBank,
|
||||
queryKey: [["widget", "stockPrice", "getPriceHistory"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
|
||||
@@ -9,16 +9,21 @@ import { formatBytesPair } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
|
||||
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
|
||||
type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
|
||||
|
||||
export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
|
||||
|
||||
export const getDisplayText = (
|
||||
item: { used: string; available: string; percentage: number },
|
||||
displayMode: DisplayMode,
|
||||
displayMode: DiskDisplayMode,
|
||||
translatedFreeText?: string,
|
||||
) => {
|
||||
switch (displayMode) {
|
||||
case "percentage":
|
||||
@@ -27,27 +32,26 @@ export const getDisplayText = (
|
||||
const usedInBytes = Number(item.used);
|
||||
const availableInBytes = Number(item.available);
|
||||
if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
|
||||
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
|
||||
return `${used} / ${available}`;
|
||||
const { used, available: total } = formatBytesPair(usedInBytes, usedInBytes + availableInBytes);
|
||||
return `${used} / ${total}`;
|
||||
}
|
||||
return `${item.used} / ${item.available}`;
|
||||
}
|
||||
case "free":
|
||||
return `${Math.round(100 - item.percentage)}% free`;
|
||||
return translatedFreeText ?? `${Math.round(100 - clampPercentage(item.percentage))}% free`;
|
||||
default:
|
||||
return `${Math.round(item.percentage)}%`;
|
||||
}
|
||||
};
|
||||
|
||||
export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
|
||||
|
||||
const getAbsoluteText = (item: { used: string; available: string }) => {
|
||||
const usedInBytes = Number(item.used);
|
||||
const availableInBytes = Number(item.available);
|
||||
if (!Number.isFinite(usedInBytes) || !Number.isFinite(availableInBytes)) return `${item.used} / ${item.available}`;
|
||||
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
|
||||
return `${used} / ${available}`;
|
||||
};
|
||||
export const getAdvancedDisplayTexts = (
|
||||
item: { used: string; available: string; percentage: number },
|
||||
translatedFreeText?: string,
|
||||
) => ({
|
||||
percentage: getDisplayText(item, "percentage"),
|
||||
absolute: getDisplayText(item, "absolute"),
|
||||
free: getDisplayText(item, "free", translatedFreeText),
|
||||
});
|
||||
|
||||
interface SystemDiskCardProps {
|
||||
deviceName: string;
|
||||
@@ -58,6 +62,9 @@ interface SystemDiskCardProps {
|
||||
showBackgroundBar: boolean;
|
||||
integrationName?: string;
|
||||
secondaryText?: string;
|
||||
freeText?: string;
|
||||
smartStatus?: string;
|
||||
isAdvanced: boolean;
|
||||
showSecondaryText: boolean;
|
||||
showTemperature: boolean;
|
||||
}
|
||||
@@ -71,6 +78,9 @@ const SystemDiskCard = ({
|
||||
showBackgroundBar,
|
||||
integrationName,
|
||||
secondaryText,
|
||||
freeText,
|
||||
smartStatus,
|
||||
isAdvanced,
|
||||
showSecondaryText,
|
||||
showTemperature,
|
||||
}: SystemDiskCardProps) => {
|
||||
@@ -98,7 +108,9 @@ const SystemDiskCard = ({
|
||||
}, [displayText, healthy]);
|
||||
|
||||
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
|
||||
const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature;
|
||||
const hasTemperature = temperature !== null && temperature !== undefined;
|
||||
const temperatureText = hasTemperature ? `${temperature}°C` : "—°C";
|
||||
const hasHiddenTemperature = hasTemperature && !showTemperature;
|
||||
const hasHiddenSecondaryText = Boolean(secondaryText && secondaryText !== displayText && !showSecondaryText);
|
||||
const tooltipLabel = [
|
||||
healthy ? displayText : `${displayText} (${unhealthyLabel})`,
|
||||
@@ -140,11 +152,19 @@ const SystemDiskCard = ({
|
||||
{secondaryText}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && freeText && freeText !== displayText && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{freeText}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<Text size="xs" c={healthy ? "dimmed" : "red"}>
|
||||
{t("widget.systemDisks.status.smart")}: {smartStatus?.trim() || "—"}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
{showTemperature && temperature !== null && temperature !== undefined ? (
|
||||
<Text size="sm">{temperature}°C</Text>
|
||||
) : null}
|
||||
{showTemperature && (hasTemperature || isAdvanced) ? <Text size="sm">{temperatureText}</Text> : null}
|
||||
</div>
|
||||
</Group>
|
||||
<Box
|
||||
@@ -169,12 +189,32 @@ export default function SystemResources({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"systemDisks">) {
|
||||
const t = useI18n();
|
||||
const queryInput = { integrationIds };
|
||||
const data =
|
||||
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput)) ?? [];
|
||||
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput);
|
||||
const results = getUsableWidgetQueryData(healthQuery) ?? [];
|
||||
const data = results.filter(
|
||||
(entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null,
|
||||
);
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("widget.systemDisks.name")} />
|
||||
</Group>
|
||||
);
|
||||
const emptyState = (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<WidgetEmptyState />
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (data.length === 0) return <WidgetEmptyState />;
|
||||
if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />;
|
||||
if (data.length === 0) return emptyState;
|
||||
|
||||
const disks = data.flatMap((entry) => {
|
||||
const fileSystem = filterStorageVolumes(
|
||||
@@ -193,32 +233,52 @@ export default function SystemResources({
|
||||
}));
|
||||
});
|
||||
|
||||
if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError();
|
||||
if (disks.length === 0) return <WidgetEmptyState />;
|
||||
const hasNoFileSystems = data.every((entry) => entry.healthInfo.fileSystem.length === 0);
|
||||
const hasFailedSource = results.some((entry) => Boolean(entry.error));
|
||||
if (hasNoFileSystems && !hasFailedSource && !healthQuery.error) throw new NoIntegrationDataError();
|
||||
if (hasNoFileSystems || disks.length === 0) return emptyState;
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const minimumCardWidth = 260;
|
||||
const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth)));
|
||||
const cellWidth = width / columns;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={columns} spacing="xs" p="xs">
|
||||
{disks.map(({ integrationId, integrationName, item, smartItem }) => (
|
||||
<SystemDiskCard
|
||||
key={`${integrationId}:${item.deviceName}`}
|
||||
deviceName={item.deviceName}
|
||||
percentage={item.percentage}
|
||||
displayText={getDisplayText(item, options.displayMode)}
|
||||
temperature={options.showTemperatureIfAvailable ? smartItem?.temperature : undefined}
|
||||
healthy={smartItem?.healthy ?? true} // fall back to healthy if no information is available
|
||||
showBackgroundBar={options.showBackgroundBar}
|
||||
integrationName={data.length > 1 ? integrationName : undefined}
|
||||
secondaryText={getAbsoluteText(item)}
|
||||
showSecondaryText={height >= 160 && cellWidth >= 260}
|
||||
showTemperature={height >= 100 && cellWidth >= 220}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={columns} spacing="xs" p="xs">
|
||||
{disks.map(({ integrationId, integrationName, item, smartItem }) => {
|
||||
const freeText = t("widget.systemDisks.status.free", {
|
||||
percentage: String(Math.round(100 - clampPercentage(item.percentage))),
|
||||
});
|
||||
const advancedDisplayTexts = getAdvancedDisplayTexts(item, freeText);
|
||||
|
||||
return (
|
||||
<SystemDiskCard
|
||||
key={`${integrationId}:${item.deviceName}`}
|
||||
deviceName={item.deviceName}
|
||||
percentage={item.percentage}
|
||||
displayText={
|
||||
isAdvanced ? advancedDisplayTexts.percentage : getDisplayText(item, options.displayMode, freeText)
|
||||
}
|
||||
temperature={isAdvanced || options.showTemperatureIfAvailable ? smartItem?.temperature : undefined}
|
||||
healthy={smartItem?.healthy ?? true} // fall back to healthy if no information is available
|
||||
showBackgroundBar={isAdvanced || options.showBackgroundBar}
|
||||
integrationName={isAdvanced || data.length > 1 ? integrationName : undefined}
|
||||
secondaryText={advancedDisplayTexts.absolute}
|
||||
freeText={advancedDisplayTexts.free}
|
||||
smartStatus={smartItem?.overallStatus}
|
||||
isAdvanced={isAdvanced}
|
||||
showSecondaryText={isAdvanced || (height >= 160 && cellWidth >= 260)}
|
||||
showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { clampPercentage, getDisplayText } from "./component";
|
||||
import { clampPercentage, getAdvancedDisplayTexts, getDisplayText } from "./component";
|
||||
|
||||
describe("system disk display", () => {
|
||||
test("clamps malformed utilization before sizing the background", () => {
|
||||
@@ -11,4 +11,18 @@ describe("system disk display", () => {
|
||||
test("falls back to provider values when sizes are already formatted", () => {
|
||||
expect(getDisplayText({ used: "5 GiB", available: "10 GiB", percentage: 50 }, "absolute")).toBe("5 GiB / 10 GiB");
|
||||
});
|
||||
|
||||
test("provides every value used by the advanced view", () => {
|
||||
expect(
|
||||
getAdvancedDisplayTexts({ used: `${5 * 1024 ** 3}`, available: `${5 * 1024 ** 3}`, percentage: 50 }),
|
||||
).toEqual({
|
||||
percentage: "50%",
|
||||
absolute: "5.0 GiB / 10.0 GiB",
|
||||
free: "50% free",
|
||||
});
|
||||
});
|
||||
|
||||
test("clamps free space derived from malformed utilization", () => {
|
||||
expect(getDisplayText({ used: "11", available: "10", percentage: 108 }, "free")).toBe("0% free");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconServer2,
|
||||
queryKey: [["widget", "healthMonitoring"]],
|
||||
refetchInterval: 5,
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAdvancedColumnWidth, getCompactSectionVisibility } from "./component";
|
||||
import { getAdvancedColumnWidth, getCompactSectionVisibility, getTracearrSectionVisibility } from "./component";
|
||||
import { attachTracearrSource } from "./source";
|
||||
|
||||
describe("Tracearr source ownership", () => {
|
||||
it("uses integration-scoped keys for overlapping upstream ids", () => {
|
||||
const first = attachTracearrSource([{ id: "same" }], {
|
||||
integrationId: "tracearr-a",
|
||||
integrationName: "Tracearr A",
|
||||
});
|
||||
const second = attachTracearrSource([{ id: "same" }], {
|
||||
integrationId: "tracearr-b",
|
||||
integrationName: "Tracearr B",
|
||||
});
|
||||
|
||||
expect([first[0]?.key, second[0]?.key]).toEqual(["tracearr-a:same", "tracearr-b:same"]);
|
||||
expect(second[0]?.integrationName).toBe("Tracearr B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tracearr advanced layout", () => {
|
||||
it("subtracts grid padding and the inter-column gap", () => {
|
||||
@@ -38,3 +55,14 @@ describe("Tracearr compact disclosure", () => {
|
||||
).toEqual({ violations: true, recentActivity: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tracearr advanced disclosure", () => {
|
||||
it("shows all sections even when compact presentation toggles are disabled", () => {
|
||||
expect(
|
||||
getTracearrSectionVisibility(
|
||||
{ showStats: false, showStreams: false, showViolations: false, showRecentActivity: false },
|
||||
true,
|
||||
),
|
||||
).toEqual({ stats: true, streams: true, violations: true, recentActivity: true });
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { TracearrDashboardData } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import { formatTotalTracearrBitrate } from "./bitrate";
|
||||
import { RecentActivityList } from "./recent-activity-section";
|
||||
import { attachTracearrSource } from "./source";
|
||||
import { StatsBar } from "./stats-section";
|
||||
import { StreamsList } from "./streams-section";
|
||||
import { ViolationsList } from "./violations-section";
|
||||
@@ -19,6 +22,18 @@ const ADVANCED_GRID_BREAKPOINT = 800;
|
||||
const ADVANCED_GRID_GAP_PX = 16;
|
||||
const ADVANCED_GRID_PADDING_PX = 16;
|
||||
|
||||
type TracearrSectionOptions = Pick<
|
||||
WidgetComponentProps<"tracearr">["options"],
|
||||
"showStats" | "showStreams" | "showViolations" | "showRecentActivity"
|
||||
>;
|
||||
|
||||
export const getTracearrSectionVisibility = (options: TracearrSectionOptions, isAdvanced: boolean) => ({
|
||||
stats: isAdvanced || options.showStats,
|
||||
streams: isAdvanced || options.showStreams,
|
||||
violations: isAdvanced || options.showViolations,
|
||||
recentActivity: isAdvanced || options.showRecentActivity,
|
||||
});
|
||||
|
||||
export const getCompactSectionVisibility = ({
|
||||
height,
|
||||
showStreams,
|
||||
@@ -68,13 +83,16 @@ interface TracearrContentProps {
|
||||
|
||||
function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) {
|
||||
const t = useScopedI18n("widget.tracearr");
|
||||
const dashboardData =
|
||||
getUsableWidgetQueryData(clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds })) ?? [];
|
||||
const dashboardQuery = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds });
|
||||
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? [];
|
||||
const successfulDashboards = dashboardData.flatMap((item) =>
|
||||
item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }],
|
||||
);
|
||||
|
||||
// Merge data from all integrations
|
||||
const combinedData = dashboardData.reduce<TracearrDashboardData>(
|
||||
(acc, item) => {
|
||||
const { stats, streams, violations, recentActivity } = item.dashboard;
|
||||
const combinedData = successfulDashboards.reduce<TracearrDashboardData>(
|
||||
(acc, { dashboard }) => {
|
||||
const { stats, streams, violations, recentActivity } = dashboard;
|
||||
const vData = violations ?? { data: [], meta: { total: 0, page: 1, pageSize: 5 } };
|
||||
const aData = recentActivity ?? { data: [], meta: { total: 0, page: 1, pageSize: 5 } };
|
||||
|
||||
@@ -136,60 +154,101 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
||||
},
|
||||
};
|
||||
|
||||
const noSectionsEnabled =
|
||||
!options.showStats && !options.showStreams && !options.showRecentActivity && !options.showViolations;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const sectionVisibility = getTracearrSectionVisibility(options, isAdvanced);
|
||||
const noSectionsEnabled = Object.values(sectionVisibility).every((isVisible) => !isVisible);
|
||||
|
||||
const compactLimit = height < 220 ? 2 : height < 360 ? 4 : 8;
|
||||
const streams = displayMode === "advanced" ? combined.streams.data : combined.streams.data.slice(0, compactLimit);
|
||||
const violations = combined.violations?.data ?? [];
|
||||
const recentActivity = combined.recentActivity?.data ?? [];
|
||||
const sourcedStreams = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
attachTracearrSource(dashboard.streams.data, { integrationId, integrationName }),
|
||||
);
|
||||
const sourcedViolations = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
attachTracearrSource(dashboard.violations?.data ?? [], { integrationId, integrationName }),
|
||||
);
|
||||
const sourcedRecentActivity = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
attachTracearrSource(dashboard.recentActivity?.data ?? [], { integrationId, integrationName }),
|
||||
);
|
||||
const streams = isAdvanced ? sourcedStreams : sourcedStreams.slice(0, compactLimit);
|
||||
const showSource = isAdvanced || dashboardData.length > 1;
|
||||
const compactSections = getCompactSectionVisibility({
|
||||
height,
|
||||
showStreams: options.showStreams,
|
||||
showViolations: options.showViolations,
|
||||
hasViolations: violations.length > 0,
|
||||
showRecentActivity: options.showRecentActivity,
|
||||
showStreams: sectionVisibility.streams,
|
||||
showViolations: sectionVisibility.violations,
|
||||
hasViolations: sourcedViolations.length > 0,
|
||||
showRecentActivity: sectionVisibility.recentActivity,
|
||||
});
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
if (isAdvanced) {
|
||||
const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT;
|
||||
const columnWidth = getAdvancedColumnWidth(width, isTwoColumn);
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid
|
||||
cols={isTwoColumn ? 2 : 1}
|
||||
spacing={`${ADVANCED_GRID_GAP_PX}px`}
|
||||
p={`${ADVANCED_GRID_PADDING_PX}px`}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{options.showStats && (
|
||||
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={columnWidth} />
|
||||
)}
|
||||
{options.showStreams && <StreamsList streams={streams} width={columnWidth} />}
|
||||
</Stack>
|
||||
<Stack gap="sm">
|
||||
{options.showViolations && <ViolationsList violations={violations} />}
|
||||
{options.showRecentActivity && <RecentActivityList sessions={recentActivity} />}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid
|
||||
cols={isTwoColumn ? 2 : 1}
|
||||
spacing={`${ADVANCED_GRID_GAP_PX}px`}
|
||||
p={`${ADVANCED_GRID_PADDING_PX}px`}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
|
||||
</Text>
|
||||
{sectionVisibility.stats && (
|
||||
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={columnWidth} />
|
||||
)}
|
||||
{sectionVisibility.streams && (
|
||||
<StreamsList streams={streams} width={columnWidth} showSource={showSource} />
|
||||
)}
|
||||
</Stack>
|
||||
<Stack gap="sm">
|
||||
{sectionVisibility.violations && (
|
||||
<ViolationsList violations={sourcedViolations} showSource={showSource} />
|
||||
)}
|
||||
{sectionVisibility.recentActivity && (
|
||||
<RecentActivityList sessions={sourcedRecentActivity} showSource={showSource} />
|
||||
)}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs" p="xs">
|
||||
{options.showStats && <StatsBar stats={combined.stats} summary={combined.streams.summary} width={width} />}
|
||||
{compactSections.violations && <ViolationsList violations={violations.slice(0, compactLimit)} />}
|
||||
{options.showStreams && <StreamsList streams={streams} width={width} />}
|
||||
{compactSections.recentActivity && <RecentActivityList sessions={recentActivity.slice(0, compactLimit)} />}
|
||||
{noSectionsEnabled && (
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("noSectionsEnabled")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs" p="xs">
|
||||
{showSource && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
{sectionVisibility.stats && (
|
||||
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={width} />
|
||||
)}
|
||||
{compactSections.violations && (
|
||||
<ViolationsList violations={sourcedViolations.slice(0, compactLimit)} showSource={showSource} />
|
||||
)}
|
||||
{sectionVisibility.streams && <StreamsList streams={streams} width={width} showSource={showSource} />}
|
||||
{compactSections.recentActivity && (
|
||||
<RecentActivityList sessions={sourcedRecentActivity.slice(0, compactLimit)} showSource={showSource} />
|
||||
)}
|
||||
{noSectionsEnabled && (
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("noSectionsEnabled")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,17 @@ import { Avatar, Badge, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import type { TracearrHistorySession } from "@homarr/integrations/types";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export function RecentActivityList({ sessions }: { sessions: TracearrHistorySession[] }) {
|
||||
import type { SourcedTracearrItem } from "./source";
|
||||
|
||||
type SourcedTracearrHistorySession = SourcedTracearrItem<TracearrHistorySession>;
|
||||
|
||||
export function RecentActivityList({
|
||||
sessions,
|
||||
showSource,
|
||||
}: {
|
||||
sessions: SourcedTracearrHistorySession[];
|
||||
showSource: boolean;
|
||||
}) {
|
||||
const t = useScopedI18n("widget.tracearr");
|
||||
const locale = useCurrentIntlLocale();
|
||||
|
||||
@@ -25,7 +35,7 @@ export function RecentActivityList({ sessions }: { sessions: TracearrHistorySess
|
||||
: session.mediaTitle;
|
||||
|
||||
return (
|
||||
<Paper key={session.id} p="sm" radius="lg">
|
||||
<Paper key={session.key} p="sm" radius="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<Avatar src={session.user.avatarUrl} alt={session.user.username} radius="xl" size="sm" />
|
||||
@@ -35,6 +45,7 @@ export function RecentActivityList({ sessions }: { sessions: TracearrHistorySess
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{session.user.username} • {session.serverName}
|
||||
{showSource ? ` • ${session.integrationName}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface TracearrSource {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
}
|
||||
|
||||
export type SourcedTracearrItem<T extends { id: string }> = T &
|
||||
TracearrSource & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export const attachTracearrSource = <T extends { id: string }>(
|
||||
items: readonly T[],
|
||||
source: TracearrSource,
|
||||
): SourcedTracearrItem<T>[] =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
...source,
|
||||
key: `${source.integrationId}:${item.id}`,
|
||||
}));
|
||||
@@ -5,7 +5,19 @@ import { formatDuration } from "@homarr/common";
|
||||
import type { TracearrStream } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export function StreamsList({ streams, width }: { streams: TracearrStream[]; width: number }) {
|
||||
import type { SourcedTracearrItem } from "./source";
|
||||
|
||||
type SourcedTracearrStream = SourcedTracearrItem<TracearrStream>;
|
||||
|
||||
export function StreamsList({
|
||||
streams,
|
||||
width,
|
||||
showSource,
|
||||
}: {
|
||||
streams: SourcedTracearrStream[];
|
||||
width: number;
|
||||
showSource: boolean;
|
||||
}) {
|
||||
const t = useScopedI18n("widget.tracearr");
|
||||
|
||||
if (streams.length === 0) {
|
||||
@@ -19,13 +31,21 @@ export function StreamsList({ streams, width }: { streams: TracearrStream[]; wid
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{streams.map((stream) => (
|
||||
<StreamCard key={stream.id} stream={stream} compact={width < 300} />
|
||||
<StreamCard key={stream.key} stream={stream} compact={width < 300} showSource={showSource} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamCard({ stream, compact }: { stream: TracearrStream; compact: boolean }) {
|
||||
function StreamCard({
|
||||
stream,
|
||||
compact,
|
||||
showSource,
|
||||
}: {
|
||||
stream: SourcedTracearrStream;
|
||||
compact: boolean;
|
||||
showSource: boolean;
|
||||
}) {
|
||||
const t = useScopedI18n("widget.tracearr");
|
||||
const progressPercent =
|
||||
stream.durationMs && stream.durationMs > 0 ? (stream.progressMs / stream.durationMs) * 100 : 0;
|
||||
@@ -115,13 +135,13 @@ function StreamCard({ stream, compact }: { stream: TracearrStream; compact: bool
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{stream.device && (
|
||||
{(stream.device || showSource) && (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{stream.player ?? stream.device}
|
||||
{[stream.player ?? stream.device, showSource ? stream.integrationName : null].filter(Boolean).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,17 @@ import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import type { TracearrViolation } from "@homarr/integrations/types";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export function ViolationsList({ violations }: { violations: TracearrViolation[] }) {
|
||||
import type { SourcedTracearrItem } from "./source";
|
||||
|
||||
type SourcedTracearrViolation = SourcedTracearrItem<TracearrViolation>;
|
||||
|
||||
export function ViolationsList({
|
||||
violations,
|
||||
showSource,
|
||||
}: {
|
||||
violations: SourcedTracearrViolation[];
|
||||
showSource: boolean;
|
||||
}) {
|
||||
const t = useScopedI18n("widget.tracearr");
|
||||
const locale = useCurrentIntlLocale();
|
||||
|
||||
@@ -20,7 +30,7 @@ export function ViolationsList({ violations }: { violations: TracearrViolation[]
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{violations.map((violation) => (
|
||||
<Paper key={violation.id} p="xs" radius="lg">
|
||||
<Paper key={violation.key} p="xs" radius="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<IconAlertTriangle
|
||||
@@ -40,6 +50,7 @@ export function ViolationsList({ violations }: { violations: TracearrViolation[]
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{t("violations.rule")}: {violation.rule.name}
|
||||
{showSource ? ` · ${violation.integrationName}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getTraefikProtocolKeys, getTraefikSourceDetails, getVisibleTraefikEntryPoints } from "./component";
|
||||
|
||||
const emptyProtocol = {
|
||||
routers: { total: 0, enabled: 0, warnings: 0, errors: 0 },
|
||||
services: { total: 0, enabled: 0, warnings: 0, errors: 0 },
|
||||
middlewares: { total: 0, enabled: 0, warnings: 0, errors: 0 },
|
||||
};
|
||||
|
||||
describe("Traefik source ownership", () => {
|
||||
test("scopes identical resource and endpoint keys to their integrations", () => {
|
||||
const dashboard = {
|
||||
version: "3.0",
|
||||
entryPoints: [],
|
||||
resources: [
|
||||
{ protocol: "http" as const, type: "router" as const, name: "api", provider: null, status: "enabled" as const },
|
||||
],
|
||||
failedEndpoints: ["/api/http/services"],
|
||||
http: emptyProtocol,
|
||||
tcp: emptyProtocol,
|
||||
udp: { routers: emptyProtocol.routers, services: emptyProtocol.services },
|
||||
};
|
||||
|
||||
const details = getTraefikSourceDetails([
|
||||
{ integrationId: "traefik-a", integrationName: "Traefik A", dashboard },
|
||||
{ integrationId: "traefik-b", integrationName: "Traefik B", dashboard },
|
||||
]);
|
||||
|
||||
expect(details.resources.map(({ key }) => key)).toEqual([
|
||||
"traefik-a:http:router:api:0",
|
||||
"traefik-b:http:router:api:0",
|
||||
]);
|
||||
expect(details.failedEndpoints.map(({ key }) => key)).toEqual([
|
||||
"traefik-a:/api/http/services:0",
|
||||
"traefik-b:/api/http/services:0",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Traefik advanced disclosure", () => {
|
||||
test("shows every protocol regardless of compact toggles and height", () => {
|
||||
expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, 100, true)).toEqual(["http", "tcp", "udp"]);
|
||||
});
|
||||
|
||||
test("shows every unique entry point regardless of compact width limits", () => {
|
||||
const entryPoints = ["web", "websecure", "metrics", "admin", "postgres", "dns", "ssh", "smtp", "web"];
|
||||
|
||||
expect(getVisibleTraefikEntryPoints(entryPoints, 100, true)).toEqual(entryPoints.slice(0, -1));
|
||||
expect(getVisibleTraefikEntryPoints(entryPoints, 100, false)).toEqual(["web", "websecure"]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
@@ -16,8 +16,10 @@ import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSumma
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -50,6 +52,47 @@ const emptyDashboard: TraefikDashboardData = {
|
||||
type ProtocolKey = "http" | "tcp" | "udp";
|
||||
type ResourceKey = "routers" | "services" | "middlewares";
|
||||
|
||||
interface TraefikDashboardSource {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
dashboard: TraefikDashboardData;
|
||||
}
|
||||
|
||||
export const getTraefikSourceDetails = (sources: readonly TraefikDashboardSource[]) => ({
|
||||
resources: sources.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
dashboard.resources.map((resource, index) => ({
|
||||
...resource,
|
||||
integrationId,
|
||||
integrationName,
|
||||
key: `${integrationId}:${resource.protocol}:${resource.type}:${resource.name}:${index}`,
|
||||
})),
|
||||
),
|
||||
failedEndpoints: sources.flatMap(({ integrationId, integrationName, dashboard }) =>
|
||||
dashboard.failedEndpoints.map((endpoint, index) => ({
|
||||
endpoint,
|
||||
integrationId,
|
||||
integrationName,
|
||||
key: `${integrationId}:${endpoint}:${index}`,
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
export function getTraefikProtocolKeys(
|
||||
options: Pick<WidgetComponentProps<"traefik">["options"], "showTcp" | "showUdp">,
|
||||
height: number,
|
||||
isAdvanced: boolean,
|
||||
): ProtocolKey[] {
|
||||
const protocols: ProtocolKey[] = ["http"];
|
||||
if (isAdvanced || (options.showTcp && height >= 220)) protocols.push("tcp");
|
||||
if (isAdvanced || (options.showUdp && height >= 300)) protocols.push("udp");
|
||||
return protocols;
|
||||
}
|
||||
|
||||
export function getVisibleTraefikEntryPoints(values: string[], width: number, isAdvanced: boolean): string[] {
|
||||
const entryPoints = dedupe(values);
|
||||
return isAdvanced ? entryPoints : entryPoints.slice(0, getEntryPointLimit(width));
|
||||
}
|
||||
|
||||
export default function TraefikWidget(props: WidgetComponentProps<"traefik">) {
|
||||
if (props.integrationIds.length === 0) {
|
||||
throw new NoIntegrationDataError();
|
||||
@@ -66,11 +109,15 @@ function TraefikWidgetContent({
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"traefik">) {
|
||||
const t = useScopedI18n("widget.traefik");
|
||||
const data = getUsableWidgetQueryData(clientApi.widget.traefik.getDashboard.useQuery({ integrationIds }));
|
||||
const dashboardQuery = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds });
|
||||
const data = getUsableWidgetQueryData(dashboardQuery);
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
const successfulData = data.flatMap((item) =>
|
||||
item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }],
|
||||
);
|
||||
|
||||
const combined = data.reduce<TraefikDashboardData>(
|
||||
const combined = successfulData.reduce<TraefikDashboardData>(
|
||||
(acc, item) => ({
|
||||
version: acc.version ?? item.dashboard.version,
|
||||
entryPoints: [...acc.entryPoints, ...item.dashboard.entryPoints],
|
||||
@@ -86,6 +133,7 @@ function TraefikWidgetContent({
|
||||
emptyDashboard,
|
||||
);
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const totalRouters = combined.http.routers.total + combined.tcp.routers.total + combined.udp.routers.total;
|
||||
const totalErrors =
|
||||
getProtocolErrors(combined.http) +
|
||||
@@ -94,20 +142,38 @@ function TraefikWidgetContent({
|
||||
combined.failedEndpoints.length;
|
||||
const totalWarnings =
|
||||
getProtocolWarnings(combined.http) + getProtocolWarnings(combined.tcp) + getUdpWarnings(combined.udp);
|
||||
const protocolKeys: ProtocolKey[] = ["http"];
|
||||
if (options.showTcp && (displayMode === "advanced" || height >= 220)) protocolKeys.push("tcp");
|
||||
if (options.showUdp && (displayMode === "advanced" || height >= 300)) protocolKeys.push("udp");
|
||||
const protocolKeys = getTraefikProtocolKeys(options, height, isAdvanced);
|
||||
const entryPoints = getVisibleTraefikEntryPoints(combined.entryPoints, width, isAdvanced);
|
||||
const displayedVersion = successfulData.length === 1 ? successfulData[0]?.dashboard.version : null;
|
||||
|
||||
const summary = (
|
||||
<div className={classes.root}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<div className={classes.titleBlock}>
|
||||
<Text className={classes.title}>{data.length === 1 ? data[0]?.integrationName : t("instances")}</Text>
|
||||
<Text className={classes.subtitle}>{combined.version ? `v${combined.version}` : t("versionUnknown")}</Text>
|
||||
<Text className={classes.title}>
|
||||
{successfulData.length === 1 ? successfulData[0]?.integrationName : t("instances")}
|
||||
</Text>
|
||||
<Text className={classes.subtitle}>
|
||||
{displayedVersion
|
||||
? `v${displayedVersion}`
|
||||
: successfulData.length > 1
|
||||
? successfulData.map(({ integrationName }) => integrationName).join(" · ")
|
||||
: t("versionUnknown")}
|
||||
</Text>
|
||||
</div>
|
||||
<HealthBadge errors={totalErrors} warnings={totalWarnings} />
|
||||
</Group>
|
||||
|
||||
{isAdvanced && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{successfulData.map(({ integrationId, integrationName, dashboard }) => (
|
||||
<Badge key={integrationId} variant="outline" size="sm" radius="sm">
|
||||
{integrationName} · {dashboard.version ? `v${dashboard.version}` : t("versionUnknown")}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<div className={classes.hero}>
|
||||
<SummaryMetric
|
||||
icon={<IconRoute size={getHeroIconSize(width)} />}
|
||||
@@ -127,46 +193,51 @@ function TraefikWidgetContent({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{options.showEntryPoints && combined.entryPoints.length > 0 && (displayMode === "advanced" || height >= 340) && (
|
||||
{(isAdvanced || options.showEntryPoints) && entryPoints.length > 0 && (isAdvanced || height >= 340) && (
|
||||
<div className={classes.entryPoints}>
|
||||
{dedupe(combined.entryPoints)
|
||||
.slice(0, getEntryPointLimit(width))
|
||||
.map((entryPoint) => (
|
||||
<Badge key={entryPoint} variant="light" size="sm" radius="sm">
|
||||
{entryPoint}
|
||||
</Badge>
|
||||
))}
|
||||
{entryPoints.map((entryPoint) => (
|
||||
<Badge key={entryPoint} variant="light" size="sm" radius="sm">
|
||||
{entryPoint}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (displayMode === "compact") return <ScrollArea h="100%">{summary}</ScrollArea>;
|
||||
if (!isAdvanced) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const resources = [...combined.resources].toSorted(
|
||||
const sourceDetails = getTraefikSourceDetails(successfulData);
|
||||
const resources = sourceDetails.resources.toSorted(
|
||||
(left, right) =>
|
||||
resourceStatusOrder[left.status] - resourceStatusOrder[right.status] || left.name.localeCompare(right.name),
|
||||
);
|
||||
const resourceList = (
|
||||
<Stack gap="xs">
|
||||
{combined.failedEndpoints.map((endpoint) => (
|
||||
<Badge key={endpoint} color="red" variant="light">
|
||||
{t("failedEndpoint", { endpoint })}
|
||||
{sourceDetails.failedEndpoints.map(({ endpoint, integrationName, key }) => (
|
||||
<Badge key={key} color="red" variant="light">
|
||||
{integrationName} · {t("failedEndpoint", { endpoint })}
|
||||
</Badge>
|
||||
))}
|
||||
{resources.map((resource, index) => (
|
||||
<Group
|
||||
key={`${resource.protocol}:${resource.type}:${resource.name}:${index}`}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
>
|
||||
{resources.map((resource) => (
|
||||
<Group key={resource.key} justify="space-between" wrap="nowrap" p="xs">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{resource.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"}
|
||||
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"} ·{" "}
|
||||
{resource.integrationName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge
|
||||
@@ -189,20 +260,32 @@ function TraefikWidgetContent({
|
||||
|
||||
if (width < 900) {
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
{summary}
|
||||
{resourceList}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md" p="md">
|
||||
{summary}
|
||||
{resourceList}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
<ScrollArea h="100%">{resourceList}</ScrollArea>
|
||||
</SimpleGrid>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
<ScrollArea h="100%">{resourceList}</ScrollArea>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getUpsDevices } from "./component";
|
||||
|
||||
describe("UPS source ownership", () => {
|
||||
it("uses integration-scoped keys for overlapping device ids", () => {
|
||||
const devices = getUpsDevices([
|
||||
{
|
||||
integrationId: "ups-a",
|
||||
integrationName: "UPS A",
|
||||
summaries: [{ id: "same", status: "online" as const }],
|
||||
},
|
||||
{
|
||||
integrationId: "ups-b",
|
||||
integrationName: "UPS B",
|
||||
summaries: [{ id: "same", status: "online" as const }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(devices.map(({ key, integrationName }) => ({ key, integrationName }))).toEqual([
|
||||
{ key: "ups-a:same", integrationName: "UPS A" },
|
||||
{ key: "ups-b:same", integrationName: "UPS B" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,10 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
|
||||
|
||||
const statusColors: Record<UpsStatus, string> = {
|
||||
@@ -67,18 +69,24 @@ interface UpsContentProps {
|
||||
|
||||
function UpsContent({ integrationIds, options, width, height, displayMode }: UpsContentProps) {
|
||||
const t = useScopedI18n("widget.ups");
|
||||
const data = getUsableWidgetQueryData(clientApi.widget.ups.getSummaries.useQuery({ integrationIds }));
|
||||
const summariesQuery = clientApi.widget.ups.getSummaries.useQuery({ integrationIds });
|
||||
const data = getUsableWidgetQueryData(summariesQuery);
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
|
||||
const devices = data
|
||||
.flatMap((instance) =>
|
||||
instance.summaries.map((summary) => ({ key: `${instance.integrationId}:${summary.id}`, summary })),
|
||||
)
|
||||
.toSorted((left, right) => statusPriority[left.summary.status] - statusPriority[right.summary.status]);
|
||||
const devices = getUpsDevices(data);
|
||||
const showSource = displayMode === "advanced" || data.length > 1;
|
||||
|
||||
if (devices.length === 0) {
|
||||
return <WidgetEmptyState />;
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<WidgetEmptyState />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Pick a layout from the available width so the widget stays useful at any size. The surrounding
|
||||
@@ -89,10 +97,11 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
|
||||
const layout: UpsLayout =
|
||||
displayMode === "advanced" ? "full" : width < 150 || height < 110 ? "mini" : width < 256 ? "compact" : "full";
|
||||
|
||||
const cards = devices.map(({ key, summary }) => (
|
||||
const cards = devices.map(({ key, integrationName, summary }) => (
|
||||
<UpsDeviceCard
|
||||
key={key}
|
||||
summary={summary}
|
||||
sourceName={showSource ? integrationName : undefined}
|
||||
options={options}
|
||||
layout={layout}
|
||||
advanced={displayMode === "advanced"}
|
||||
@@ -101,17 +110,23 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
|
||||
));
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
{displayMode === "advanced" ? (
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="md" p="md">
|
||||
{cards}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack gap="xs" p="xs">
|
||||
{cards}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={data} />
|
||||
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
{displayMode === "advanced" ? (
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="md" p="md">
|
||||
{cards}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack gap="xs" p="xs">
|
||||
{cards}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,16 +138,38 @@ const statusPriority: Record<UpsStatus, number> = {
|
||||
online: 4,
|
||||
};
|
||||
|
||||
export const getUpsDevices = <T extends Pick<UpsSummary, "id" | "status">>(
|
||||
instances: readonly {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
summaries: readonly T[];
|
||||
}[],
|
||||
) =>
|
||||
instances
|
||||
.flatMap(({ integrationId, integrationName, summaries }) =>
|
||||
summaries.map((summary) => ({
|
||||
key: `${integrationId}:${summary.id}`,
|
||||
integrationId,
|
||||
integrationName,
|
||||
summary,
|
||||
})),
|
||||
)
|
||||
.toSorted((left, right) => statusPriority[left.summary.status] - statusPriority[right.summary.status]);
|
||||
|
||||
interface UpsDeviceCardProps {
|
||||
summary: UpsSummary;
|
||||
sourceName?: string;
|
||||
options: WidgetComponentProps<"ups">["options"];
|
||||
layout: UpsLayout;
|
||||
advanced: boolean;
|
||||
t: ScopedTranslationFunction<"widget.ups">;
|
||||
}
|
||||
|
||||
function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardProps) {
|
||||
const showRing = options.showBattery && summary.batteryCharge !== null;
|
||||
function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: UpsDeviceCardProps) {
|
||||
const showBattery = advanced || options.showBattery;
|
||||
const showLoad = advanced || options.showLoad;
|
||||
const showVoltage = advanced || options.showVoltage;
|
||||
const showRing = showBattery && summary.batteryCharge !== null;
|
||||
const statusBadge = (
|
||||
<Badge
|
||||
color={statusColors[summary.status]}
|
||||
@@ -163,6 +200,11 @@ function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardP
|
||||
{summary.name}
|
||||
</Text>
|
||||
</Group>
|
||||
{sourceName && (
|
||||
<Text size="10px" c="dimmed" ta="center" truncate>
|
||||
{sourceName}
|
||||
</Text>
|
||||
)}
|
||||
{showRing && summary.batteryCharge !== null && (
|
||||
<Group justify="center">
|
||||
<RingProgress
|
||||
@@ -203,6 +245,11 @@ function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardP
|
||||
<Text fw={600} size="xs" truncate>
|
||||
{summary.name}
|
||||
</Text>
|
||||
{sourceName && (
|
||||
<Text size="10px" c="dimmed" truncate>
|
||||
{sourceName}
|
||||
</Text>
|
||||
)}
|
||||
{statusBadge}
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -213,9 +260,16 @@ function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardP
|
||||
return (
|
||||
<Card p="xs" radius="md">
|
||||
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{summary.name}
|
||||
</Text>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{summary.name}
|
||||
</Text>
|
||||
{sourceName && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{sourceName}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{statusBadge}
|
||||
</Group>
|
||||
|
||||
@@ -235,11 +289,11 @@ function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardP
|
||||
)}
|
||||
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{options.showBattery && summary.batteryRuntime !== null && (
|
||||
{showBattery && summary.batteryRuntime !== null && (
|
||||
<StatRow label={t("field.runtime")} value={formatDuration(summary.batteryRuntime * 1000)} />
|
||||
)}
|
||||
|
||||
{options.showLoad && summary.load !== null && (
|
||||
{showLoad && summary.load !== null && (
|
||||
<Box>
|
||||
<Group justify="space-between" gap="xs" mb={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -251,7 +305,7 @@ function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardP
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{options.showVoltage && (summary.inputVoltage !== null || summary.outputVoltage !== null) && (
|
||||
{showVoltage && (summary.inputVoltage !== null || summary.outputVoltage !== null) && (
|
||||
<Group gap="md">
|
||||
{summary.inputVoltage !== null && (
|
||||
<StatRow label={t("field.input")} value={`${Math.round(summary.inputVoltage)} V`} />
|
||||
|
||||
@@ -9,8 +9,10 @@ import { formatNumber } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
import { aggregateUptimeKumaDashboards } from "./aggregate";
|
||||
@@ -128,12 +130,16 @@ interface UptimeKumaContentProps {
|
||||
|
||||
function UptimeKumaContent({ integrationIds, options, width, height, displayMode }: UptimeKumaContentProps) {
|
||||
const t = useScopedI18n("widget.uptimeKuma");
|
||||
const dashboardData = getUsableWidgetQueryData(clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds }));
|
||||
const dashboardQuery = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds });
|
||||
const dashboardData = getUsableWidgetQueryData(dashboardQuery);
|
||||
|
||||
if (!dashboardData) return <WidgetEmptyState />;
|
||||
const successfulDashboardData = dashboardData.flatMap((item) =>
|
||||
item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }],
|
||||
);
|
||||
|
||||
const combined = aggregateUptimeKumaDashboards(dashboardData.map((item) => item.dashboard));
|
||||
const monitors = dashboardData.flatMap((result) =>
|
||||
const combined = aggregateUptimeKumaDashboards(successfulDashboardData.map((item) => item.dashboard));
|
||||
const monitors = successfulDashboardData.flatMap((result) =>
|
||||
result.dashboard.monitors.map((monitor) => ({
|
||||
...monitor,
|
||||
key: `${result.integrationId}:${monitor.id}`,
|
||||
@@ -145,25 +151,22 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
const ringSize = getRingSize(width);
|
||||
const iconSize = getIconSize(width);
|
||||
const gridCols = getGridCols(width);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
const enabledStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.filter(([optionKey]) => isAdvanced || options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
|
||||
const showHero = options.showAverageUptime;
|
||||
const showRing = options.showUptimeRing && (displayMode === "advanced" || (width >= 170 && height >= 110));
|
||||
const prioritizedStatKeys =
|
||||
displayMode === "advanced"
|
||||
? enabledStatKeys
|
||||
: enabledStatKeys.toSorted(
|
||||
(left, right) =>
|
||||
getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]),
|
||||
);
|
||||
const showHero = isAdvanced || options.showAverageUptime;
|
||||
const showRing = isAdvanced || (options.showUptimeRing && width >= 170 && height >= 110);
|
||||
const prioritizedStatKeys = isAdvanced
|
||||
? enabledStatKeys
|
||||
: enabledStatKeys.toSorted(
|
||||
(left, right) => getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]),
|
||||
);
|
||||
const visibleStatKeys = prioritizedStatKeys.slice(
|
||||
0,
|
||||
displayMode === "advanced"
|
||||
? prioritizedStatKeys.length
|
||||
: getCompactStatLimit(height, showHero, prioritizedStatKeys.length),
|
||||
isAdvanced ? prioritizedStatKeys.length : getCompactStatLimit(height, showHero, prioritizedStatKeys.length),
|
||||
);
|
||||
const hasContent = showHero || visibleStatKeys.length > 0;
|
||||
|
||||
@@ -245,7 +248,17 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
</div>
|
||||
);
|
||||
|
||||
if (displayMode === "compact") return summaryContent;
|
||||
if (displayMode === "compact") {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
{summaryContent}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const monitorList = (
|
||||
<Stack gap="xs">
|
||||
@@ -257,7 +270,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{monitor.name}
|
||||
</Text>
|
||||
{dashboardData.length > 1 && (
|
||||
{(isAdvanced || successfulDashboardData.length > 1) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{monitor.integrationName}
|
||||
</Text>
|
||||
@@ -274,20 +287,32 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
|
||||
if (width < 760) {
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="lg" p="md">
|
||||
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
|
||||
{monitorList}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="lg" p="md">
|
||||
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
|
||||
{monitorList}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
{summaryContent}
|
||||
<ScrollArea h="100%">{monitorList}</ScrollArea>
|
||||
</SimpleGrid>
|
||||
<Box h="100%" pos="relative">
|
||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={dashboardData} />
|
||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
{summaryContent}
|
||||
<ScrollArea h="100%">{monitorList}</ScrollArea>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,36 +11,48 @@ import type { WudContainerUpdate } from "@homarr/integrations";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function WudWidget({ integrationIds, options, width }: WidgetComponentProps<"wud">) {
|
||||
export default function WudWidget({ integrationIds, options, width, displayMode }: WidgetComponentProps<"wud">) {
|
||||
const integrationId = integrationIds[0];
|
||||
if (!integrationId) return null;
|
||||
return <WudWidgetContent integrationId={integrationId} options={options} width={width} />;
|
||||
return (
|
||||
<WudWidgetContent
|
||||
integrationId={integrationId}
|
||||
options={options}
|
||||
width={width}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const WudWidgetContent = ({
|
||||
integrationId,
|
||||
options,
|
||||
width,
|
||||
isAdvanced,
|
||||
}: {
|
||||
integrationId: string;
|
||||
options: WidgetComponentProps<"wud">["options"];
|
||||
width: number;
|
||||
isAdvanced: boolean;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.wud");
|
||||
const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const isTiny = width < 256;
|
||||
const isTiny = !isAdvanced && 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 showTitle = isAdvanced || options.showTitle;
|
||||
const showRing = isAdvanced || options.showRing;
|
||||
const showUpdateList = (isAdvanced || options.showUpdateList) && stats.updates.length > 0;
|
||||
|
||||
const ring = (
|
||||
<RingProgress
|
||||
@@ -73,7 +85,7 @@ const WudWidgetContent = ({
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const tinyContent = options.showRing ? (
|
||||
const tinyContent = showRing ? (
|
||||
<Tooltip
|
||||
label={
|
||||
<Stack gap={2}>
|
||||
@@ -102,7 +114,7 @@ const WudWidgetContent = ({
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="xs" h="100%">
|
||||
{options.showTitle && !isTiny && (
|
||||
{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")} />
|
||||
@@ -122,12 +134,12 @@ const WudWidgetContent = ({
|
||||
tinyContent
|
||||
) : options.layout === "horizontal" ? (
|
||||
<Group justify="center" wrap="nowrap" gap="md">
|
||||
{options.showRing && ring}
|
||||
{showRing && ring}
|
||||
{summary}
|
||||
</Group>
|
||||
) : (
|
||||
<Stack align="center" gap="xs">
|
||||
{options.showRing && ring}
|
||||
{showRing && ring}
|
||||
{summary}
|
||||
</Stack>
|
||||
)}
|
||||
@@ -181,6 +193,7 @@ const UpdateCard = ({
|
||||
className: string | undefined;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.wud");
|
||||
const href = getSafeApplicationUrl(update.link);
|
||||
const isDigestUpdate = isDigestVersion(update.newVersion);
|
||||
const fullVersionText = buildVersionText(update.currentVersion, update.newVersion);
|
||||
const versionText = isDigestUpdate
|
||||
@@ -201,12 +214,12 @@ const UpdateCard = ({
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{update.link && (
|
||||
{href && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={update.link}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
|
||||
@@ -5,8 +5,11 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("wud", {
|
||||
icon: IconBrandDocker,
|
||||
supportsAdvancedFocus: true,
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["wud"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showTitle: factory.switch({ defaultValue: true }),
|
||||
|
||||
Reference in new issue
Block a user