feat: refine mobile dashboard experience

This commit is contained in:
ajnart committed 2026-08-13 19:55:11 +02:00
1 parent 2e3dca3e87
commit 84db2f9d49
68 files changed
+1425 -1311

No files matched your search

+1 -4
View File
@@ -124,10 +124,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
/>
</Flex>
</Tooltip.Floating>
{options.pingEnabled &&
!settings.forceDisableStatus &&
!board.disableStatus &&
Boolean(app.pingUrl ?? app.href) ? (
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
+1
View File
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
supportsAdvancedFocus: true,
icon: IconBadgeCc,
queryKey: [["widget", "bazarr"]],
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
maxIntegrations: 1,
@@ -16,7 +16,6 @@ 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";
@@ -28,10 +27,6 @@ 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,
@@ -55,64 +50,43 @@ export default function BeszelAlertsWidget({
integrationIds,
isEditMode,
height,
displayMode,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const isAdvanced = displayMode === "advanced";
const alertsInput = useMemo(
() =>
getBeszelAlertsQueryInput(
integrationIds,
{ showHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
isAdvanced,
),
[integrationIds, options.showHistory, options.maxHistoryItems, isAdvanced],
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems],
);
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const runtimeQueries = useMemo(
() => [getQueryKey(clientApi.widget.beszel.getAlerts, alertsInput, "query")],
[alertsInput],
);
useWidgetRuntimeQueries(widgetRuntimeRef, runtimeQueries);
const resultData = getUsableWidgetQueryData(alertsQuery);
const results = useMemo(() => resultData ?? [], [resultData]);
const results = getUsableWidgetQueryData(alertsQuery) ?? [];
const { isPending } = alertsQuery;
const systemNameMap = useMemo(() => buildBeszelSystemNameMap(results), [results]);
const showIntegrationName = isAdvanced || results.length > 1;
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 alerts = useMemo(
() =>
results.flatMap((r) =>
r.alerts.map((a) => ({
...a,
integrationId: r.integrationId,
integrationName: r.integrationName,
key: `${r.integrationId}:${a.id}`,
})),
),
() => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))),
[results],
);
const history = useMemo(() => {
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 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 triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260;
const showHistory = isAdvanced || (options.showHistory && height >= 360);
const showAlertDescriptions = isAdvanced || height >= 190;
const showOkAlerts = triggeredAlerts.length === 0 || height >= 260;
const showHistory = options.showHistory && height >= 360;
const showAlertDescriptions = height >= 190;
if (isPending) {
return (
@@ -125,10 +99,7 @@ export default function BeszelAlertsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<IntegrationErrorIndicator results={results} />
</Box>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
@@ -153,12 +124,11 @@ 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={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered
showDescription={showAlertDescriptions}
/>
@@ -178,12 +148,11 @@ 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={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered={false}
showDescription={showAlertDescriptions}
/>
@@ -202,12 +171,11 @@ export default function BeszelAlertsWidget({
</Text>
</Group>
{history.map((entry) => {
const systemName = getBeszelSystemName(systemNameMap, entry.integrationId, entry.system);
const systemLabel = showIntegrationName ? `${systemName} · ${entry.integrationName}` : systemName;
const systemName = systemNameMap[entry.system] ?? entry.system;
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}
@@ -221,7 +189,7 @@ export default function BeszelAlertsWidget({
{entry.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{systemLabel}
{systemName}
</Text>
</Stack>
</Group>
@@ -250,7 +218,6 @@ interface AlertRowProps {
value: number;
min: number;
systemName: string;
integrationName?: string;
triggered: boolean;
showDescription: boolean;
}
@@ -276,7 +243,7 @@ function formatAlertDescription(name: string, value: number, min: number): strin
return `exceeds ${value}${suffix} over ${min} min`;
}
function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) {
function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) {
const Icon = alertIconMap[name] ?? Server;
const description = formatAlertDescription(name, value, min);
return (
@@ -296,7 +263,7 @@ function AlertRow({ name, value, min, systemName, integrationName, triggered, sh
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{integrationName ? `${systemName} · ${integrationName}` : systemName}
{systemName}
</Text>
<Text size="xs" c="dimmed">
·
+5 -8
View File
@@ -1,22 +1,19 @@
import { IconBell, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
supportsAdvancedFocus: false,
queryKey: [["widget", "beszel", "getAlerts"]],
queryMatcher(query, scope) {
const hasRuntimeAlertsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getAlerts");
if (hasRuntimeAlertsQuery) return matchesWidgetRuntimeQuery(query, scope);
return widgetQueryInputMatches(query.input, {
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
includeHistory: scope.options.showHistory,
maxHistoryItems: scope.options.maxHistoryItems,
});
},
}),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -1,7 +1,7 @@
"use client";
import type { MantineSize } from "@mantine/core";
import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text } from "@mantine/core";
import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core";
import {
Activity,
Battery,
@@ -19,7 +19,6 @@ 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";
@@ -39,10 +38,8 @@ 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;
@@ -120,12 +117,10 @@ 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, minCellWidth = MIN_CELL_WIDTH): number => {
const getColCount = (width: number, _height: number, itemCount: number): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth)));
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
@@ -173,14 +168,12 @@ const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
);
interface SystemCardProps {
system: BeszelSystemRow & { rowKey: string };
system: BeszelSystemRow & { _key: string };
options: WidgetComponentProps<"beszelSystemGrid">["options"];
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
size: SizeConfig;
maxMetrics: number;
itemRadius: MantineSize;
integrationName: string;
isAdvanced: boolean;
onClick?: () => void;
}
@@ -197,8 +190,7 @@ const metricRenderers = [
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showCpu, advanced),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu,
},
{
key: "showMemory",
@@ -212,8 +204,7 @@ const metricRenderers = [
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showMemory, advanced),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory,
},
{
key: "showDisk",
@@ -226,8 +217,7 @@ const metricRenderers = [
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
</Group>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showDisk, advanced),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
},
{
key: "showGpu",
@@ -241,8 +231,7 @@ const metricRenderers = [
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showGpu, advanced, s.gpu > 0),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0,
},
{
key: "showLoadAvg",
@@ -255,8 +244,7 @@ const metricRenderers = [
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showLoadAvg, advanced, s.loadAvg !== null),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null,
},
{
key: "showNet",
@@ -269,8 +257,7 @@ const metricRenderers = [
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showNet, advanced),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet,
},
{
key: "showTemp",
@@ -283,8 +270,7 @@ const metricRenderers = [
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showTemp, advanced, s.temp !== null),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null,
},
{
key: "showBattery",
@@ -301,8 +287,7 @@ const metricRenderers = [
/>
);
},
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showBattery, advanced, s.battery !== null),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null,
},
{
key: "showServices",
@@ -315,8 +300,7 @@ const metricRenderers = [
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showServices, advanced),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices,
},
{
key: "showUptime",
@@ -329,8 +313,7 @@ const metricRenderers = [
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showUptime, advanced),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime,
},
{
key: "showAgent",
@@ -343,23 +326,12 @@ const metricRenderers = [
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showAgent, advanced),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent,
},
] as const;
const SystemCard = ({
system,
options,
t,
size,
maxMetrics,
itemRadius,
integrationName,
isAdvanced,
onClick,
}: SystemCardProps) => {
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options, isAdvanced));
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options));
const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
@@ -396,20 +368,6 @@ const SystemCard = ({
</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>
@@ -422,24 +380,12 @@ const SystemCard = ({
);
};
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();
@@ -448,8 +394,6 @@ 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);
@@ -464,10 +408,7 @@ export default function BeszelSystemGridWidget({
if (filteredSystems.length === 0) {
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<IntegrationErrorIndicator results={results} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -480,23 +421,19 @@ export default function BeszelSystemGridWidget({
);
}
const minimumCellWidth = isAdvanced ? ADVANCED_MIN_CELL_WIDTH : MIN_CELL_WIDTH;
const cols = getColCount(width, height, filteredSystems.length, minimumCellWidth);
const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const minimumCellHeight = isAdvanced ? ADVANCED_MIN_CELL_HEIGHT : MIN_CELL_HEIGHT;
const minimumCellHeight = MIN_CELL_HEIGHT;
const scrollEnabled = rawCellHeight < minimumCellHeight;
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
const maxMetrics = isAdvanced ? Number.POSITIVE_INFINITY : getMaxVisibleMetrics(effectiveCellHeight, size);
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<IntegrationErrorIndicator results={results} />
<Box
h="100%"
style={{
@@ -508,22 +445,19 @@ export default function BeszelSystemGridWidget({
}}
>
{filteredSystems.map((system) => {
const integrationId = system.rowKey.split(":")[0] ?? "";
const integrationName = integrationNames.get(integrationId) ?? "—";
const integrationId = system._key.split(":")[0] ?? "";
const handleClick = isEditMode
? undefined
: () => openModal({ integrationId, systemId: system.id }, { title: system.name });
return (
<SystemCard
key={system.rowKey}
key={system._key}
system={system}
options={options}
t={t}
size={size}
maxMetrics={maxMetrics}
itemRadius={board.itemRadius}
integrationName={integrationName}
isAdvanced={isAdvanced}
onClick={handleClick}
/>
);
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
supportsAdvancedFocus: false,
queryKey: [["widget", "beszel", "getSystems"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
@@ -3,6 +3,7 @@
import { useCallback, useMemo, useRef } from "react";
import { Box, Button, Center, Group, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -18,7 +19,6 @@ 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";
@@ -46,8 +46,7 @@ export default function BeszelSystemStatsWidget({
}),
});
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsData = getUsableWidgetQueryData(systemsQuery);
const systemsResult = useMemo(() => systemsData ?? [], [systemsData]);
const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? [];
const { isPending: systemsPending } = systemsQuery;
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
@@ -68,15 +67,16 @@ export default function BeszelSystemStatsWidget({
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
? [
{
path: ["widget", "beszel", "getSystemStats"],
input: {
getQueryKey(
clientApi.widget.beszel.getSystemStats,
{
integrationIds: [selectedSystem.integrationId],
systemId: selectedSystem.systemId,
timePeriod: options.timePeriod,
timePeriod: options.timePeriod as BeszelTimePeriod,
includeDocker,
},
},
"query",
),
]
: [],
);
@@ -140,10 +140,7 @@ export default function BeszelSystemStatsWidget({
if (systems.length === 0) {
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
<IntegrationErrorIndicator results={systemsResult} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -180,10 +177,7 @@ export default function BeszelSystemStatsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
<IntegrationErrorIndicator results={systemsResult} />
</Box>
<ScrollArea
h="100%"
@@ -16,6 +16,7 @@ const timePeriodOptions = [
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
supportsAdvancedFocus: false,
icon: IconChartAreaLine,
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher(query, scope) {
@@ -20,7 +20,6 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -41,23 +40,15 @@ import {
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { getBeszelTableVisibleMetricKeys } from "./display";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { rowKey: string; integrationName: string };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
const columnAccessors = [
"name",
"integrationName",
"hostname",
"osName",
"cpuModel",
"cores",
"memoryTotal",
"cpu",
"memory",
"disk",
@@ -69,7 +60,7 @@ const columnAccessors = [
"services",
"uptime",
"agentVersion",
] as const satisfies readonly (keyof SystemRowWithKey)[];
] as const satisfies readonly (keyof BeszelSystemRow)[];
interface SizeConfig {
iconSize: number;
@@ -106,7 +97,6 @@ export default function BeszelSystemTableWidget({
boardId,
itemId,
setOptions,
displayMode,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal);
@@ -114,15 +104,26 @@ export default function BeszelSystemTableWidget({
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsData = getUsableWidgetQueryData(systemsQuery);
const results = useMemo(() => systemsData ?? [], [systemsData]);
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
const { isPending } = systemsQuery;
const isAdvanced = displayMode === "advanced";
const size = useMemo(() => getSizeConfig(width), [width]);
const visibleMetricKeys = useMemo(
() => getBeszelTableVisibleMetricKeys(options, width, isAdvanced),
[options, width, isAdvanced],
);
const visibleMetricKeys = useMemo(() => {
const enabled = [
"showCpu",
"showMemory",
"showDisk",
"showGpu",
"showLoadAvg",
"showNet",
"showTemp",
"showBattery",
"showServices",
"showUptime",
"showAgent",
].filter((key) => options[key as keyof typeof options]) as string[];
const budget = width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length;
return new Set(enabled.slice(0, budget));
}, [options, width]);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
@@ -140,18 +141,6 @@ export default function BeszelSystemTableWidget({
});
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const integrationNames = useMemo(
() => new Map(results.map((result) => [result.integrationId, result.integrationName])),
[results],
);
const systemsWithSource = useMemo(
() =>
filteredSystems.map((system) => ({
...system,
integrationName: integrationNames.get(system.rowKey.split(":")[0] ?? "") ?? "—",
})),
[filteredSystems, integrationNames],
);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
columnAccessor: options.sortBy,
@@ -165,16 +154,16 @@ export default function BeszelSystemTableWidget({
}, [options.sortBy, options.sortDirection]);
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof SystemRowWithKey;
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
const dir = directionMultiplier[sortStatus.direction] ?? 1;
return [...systemsWithSource].toSorted((a, b) => {
return [...filteredSystems].toSorted((a, b) => {
const aVal = a[accessor] ?? 0;
const bVal = b[accessor] ?? 0;
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir;
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir;
return 0;
});
}, [systemsWithSource, sortStatus]);
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
@@ -212,54 +201,6 @@ export default function BeszelSystemTableWidget({
</Group>
),
},
isAdvanced && {
accessor: "integrationName",
width: 140,
ellipsis: true,
title: t("column.source"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.integrationName}</Text>,
},
isAdvanced && {
accessor: "hostname",
width: 150,
ellipsis: true,
title: t("column.hostname"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.hostname || "—"}</Text>,
},
isAdvanced && {
accessor: "osName",
width: 160,
ellipsis: true,
title: t("column.os"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.osName || "—"}</Text>,
},
isAdvanced && {
accessor: "cpuModel",
width: 220,
ellipsis: true,
title: t("column.cpuModel"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cpuModel || "—"}</Text>,
},
isAdvanced && {
accessor: "cores",
width: 80,
title: t("column.cores"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cores}</Text>,
},
isAdvanced && {
accessor: "memoryTotal",
width: 120,
title: t("column.memoryTotal"),
sortable: true,
render: (record) => (
<Text size={size.fontSize}>{record.memoryTotal > 0 ? formatBytes(record.memoryTotal) : "—"}</Text>
),
},
visibleMetricKeys.has("showCpu") && {
accessor: "cpu",
width: 140,
@@ -416,7 +357,7 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [t, size, visibleMetricKeys, isAdvanced]);
}, [t, size, visibleMetricKeys]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
@@ -430,7 +371,7 @@ export default function BeszelSystemTableWidget({
const handleRowClick = useCallback(
({ record }: { record: SystemRowWithKey }) => {
const integrationId = record.rowKey.split(":")[0] ?? "";
const integrationId = record._key.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
},
[openModal],
@@ -447,10 +388,7 @@ export default function BeszelSystemTableWidget({
const table = (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<IntegrationErrorIndicator results={results} />
</div>
<HomarrDataTable
isEditMode={isEditMode}
@@ -461,7 +399,7 @@ export default function BeszelSystemTableWidget({
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="rowKey"
idAccessor="_key"
storeColumnsKey={storeKey}
onRowClick={isEditMode ? undefined : handleRowClick}
/>
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
supportsAdvancedFocus: false,
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher: (query, scope) =>
query.path.at(-1) === "getSystems"
@@ -0,0 +1,44 @@
"use client";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
import { useBeszelFilteredSystems } from "./hooks";
interface BeszelSystemsMobileSummaryProps {
integrationIds: string[];
label: string;
statusFilter: string;
}
export const BeszelSystemsMobileSummary = ({
integrationIds,
label,
statusFilter,
}: BeszelSystemsMobileSummaryProps) => {
const t = useI18n();
const { data: results = [], error, isPending } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useBeszelFilteredSystems(results, statusFilter);
const failedResults = results.filter((result) => "error" in result);
if (isPending) return <WidgetMobileLoading />;
if (error) throw error;
if (results.length > 0 && failedResults.length === results.length) {
throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
}
const up = systems.filter((system) => system.status === "up").length;
return (
<WidgetMobileSummary
value={systems.length}
label={label}
description={t("board.mobile.systemStatus", {
up,
attention: systems.length - up,
})}
isStale={failedResults.length > 0}
/>
);
};
+39 -11
View File
@@ -1,29 +1,57 @@
import { Center, Group, Stack, Text } from "@mantine/core";
"use client";
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface WidgetMobileSummaryProps {
value: number | string;
label: string;
description?: string;
isStale?: boolean;
}
export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) {
export function WidgetMobileSummary({ value, label, description, isStale = false }: WidgetMobileSummaryProps) {
const t = useI18n();
return (
<Center h="100%" p="sm">
<Stack align="center" gap={2} w="100%" style={{ minWidth: 0 }}>
<Group gap="xs" justify="center" wrap="nowrap" w="100%">
<Text fz="xl" fw={700} lh={1}>
{value}
</Text>
<Text size="sm" fw={600} lineClamp={1}>
{label}
</Text>
</Group>
<Text fz="xl" fw={700} lh={1.15} ta="center" w="100%" lineClamp={2} style={{ overflowWrap: "anywhere" }}>
{value}
</Text>
<Text size="sm" fw={600} lineClamp={1} ta="center" w="100%">
{label}
</Text>
{description !== undefined && (
<Text c="dimmed" size="xs" lineClamp={1} ta="center" w="100%">
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1} ta="center" w="100%">
{description}
</Text>
)}
{isStale && (
<Group component="output" gap={4} justify="center" wrap="nowrap" w="100%">
<IconAlertTriangle
size={14}
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
aria-hidden
/>
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1}>
{t("board.mobile.dataWarning")}
</Text>
</Group>
)}
</Stack>
</Center>
);
}
export function WidgetMobileLoading() {
const t = useI18n();
return (
<Center component="output" h="100%" aria-label={t("common.action.loading")}>
<Loader size="sm" />
</Center>
);
}
@@ -13,9 +13,10 @@ interface ApplicationsSectionProps {
applications: CoolifyApplicationWithContext[];
baseUrl: string;
isTiny: boolean;
isMobileDetail: boolean;
}
export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) {
export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length;
@@ -34,7 +35,14 @@ export function ApplicationsSection({ applications, baseUrl, isTiny }: Applicati
{applications.length > 0 ? (
<Stack gap={4}>
{applications.map((app) => (
<ResourceRow key={app.uuid} item={app} baseUrl={baseUrl} isTiny={isTiny} resourceType="application" />
<ResourceRow
key={app.uuid}
item={app}
baseUrl={baseUrl}
isTiny={isTiny}
resourceType="application"
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
) : (
+29 -98
View File
@@ -1,26 +1,17 @@
"use client";
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { getUsableWidgetQueryData } from "../common/query-state";
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 { getUsableWidgetQueryData } from "../common/query-state";
import { createWidgetKey } 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,
displayMode,
}: WidgetComponentProps<"coolify">) {
export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -31,18 +22,7 @@ export default function CoolifyWidget({
);
}
const isAdvanced = displayMode === "advanced";
const sectionVisibility = getCoolifySectionVisibility(options, displayMode);
return (
<CoolifyContent
integrationIds={integrationIds}
options={{ ...options, ...sectionVisibility }}
width={width}
height={height}
isAdvanced={isAdvanced}
/>
);
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
}
interface CoolifyContentProps {
@@ -50,24 +30,19 @@ interface CoolifyContentProps {
options: WidgetComponentProps<"coolify">["options"];
width: number;
height: number;
isAdvanced: boolean;
}
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width, height }: 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] = successfulInstances;
const [firstInstance] = instancesData;
const widgetKey = createWidgetKey(integrationIds);
if (isPending) {
@@ -79,77 +54,33 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
</Stack>
);
}
if (successfulInstances.length === 0) {
return (
<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) {
if (instancesData.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>
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
hideFooter={hideFooter}
/>
);
}
return (
<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>
<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>
);
}
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
View File
@@ -6,6 +6,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
supportsAdvancedFocus: true,
icon: IconCloud,
mobile: { supportsDetailView: true },
queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
+19 -42
View File
@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { Accordion, Anchor, Badge, Card, Group, Image, Text, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
@@ -9,7 +8,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils";
import { ServersSection } from "./servers-section";
import { ServicesSection } from "./services-section";
import type { CoolifyOptions, InstanceData } from "./types";
@@ -19,12 +18,11 @@ interface InstanceCardProps {
instance: InstanceData;
options: CoolifyOptions;
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const cardKey = `${widgetKey}-${instance.integrationId}`;
@@ -36,7 +34,6 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
@@ -44,10 +41,9 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
);
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length;
const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length;
const runningApps = instance.instanceInfo.applications.filter(
(a) => parseStatus(a.status ?? "") === "running",
).length;
@@ -80,27 +76,20 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
wrap="nowrap"
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
>
<Group gap={4} wrap="nowrap" miw={0}>
<Group gap={4} wrap="nowrap">
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Stack gap={0} miw={0}>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "10px" : "xs"}
fw={600}
c="inherit"
lineClamp={1}
>
{instance.integrationName}
</Anchor>
{isAdvanced && (
<Text fz="10px" c="dimmed" truncate="end">
{t("source.url", { url: displayUrl })}
</Text>
)}
</Stack>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "10px" : "xs"}
fw={600}
c="inherit"
lineClamp={1}
>
{instance.integrationName}
</Anchor>
</Group>
<Group gap={4} wrap="nowrap">
{isTiny && totalResources > 0 ? (
@@ -136,20 +125,13 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
</Group>
</Group>
<Accordion
variant="filled"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
<Accordion variant="filled" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -158,12 +140,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
)}
</Accordion>
@@ -1,28 +1,20 @@
"use client";
import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
import {
IconCircleCheckFilled,
IconCircleFilled,
IconCircleXFilled,
IconEye,
IconEyeOff,
IconServer,
} from "@tabler/icons-react";
import { IconCircleCheckFilled, 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, getCoolifyServerState, isCoolifyServerOnline } from "./coolify-utils";
import { getBadgeColor } from "./coolify-utils";
interface ServersSectionProps {
servers: CoolifyServer[];
serverResourceCounts: Map<number, { apps: number; services: number }>;
baseUrl: string;
isTiny: boolean;
isAdvanced: boolean;
showIp: boolean;
onToggleIp: () => void;
}
@@ -32,13 +24,12 @@ export function ServersSection({
serverResourceCounts,
baseUrl,
isTiny,
isAdvanced,
showIp,
onToggleIp,
}: ServersSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const onlineServers = servers.filter(isCoolifyServerOnline).length;
const onlineServers = servers.filter((server) => server.is_reachable !== false).length;
return (
<Accordion.Item value="servers">
@@ -76,7 +67,6 @@ export function ServersSection({
}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
/>
))}
@@ -96,28 +86,23 @@ interface ServerRowProps {
counts: { apps: number; services: number };
baseUrl: string;
isTiny: boolean;
isAdvanced: boolean;
showIp: boolean;
}
function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: ServerRowProps) {
function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) {
const t = useScopedI18n("widget.coolify");
const isBuildServer = server.settings?.is_build_server === true;
const reachability = getCoolifyServerState(server, "is_reachable");
const usability = getCoolifyServerState(server, "is_usable");
const isOnline = server.is_reachable !== false;
const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`);
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";
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
return (
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<StatusIcon
aria-label={t(`status.${status}`)}
aria-label={t(isOnline ? "status.online" : "status.offline")}
size={isTiny ? 12 : 16}
color={`var(--mantine-color-${statusColor}-6)`}
color={`var(--mantine-color-${isOnline ? "green" : "red"}-6)`}
/>
<Anchor
className={actionTargetClasses.root}
@@ -144,30 +129,9 @@ function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: Serv
</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,24 +1,21 @@
"use client";
import { Accordion, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
import { Accordion, 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 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 { getBadgeColor, 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, isAdvanced }: ServicesSectionProps) {
export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length;
@@ -37,19 +34,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi
{services.length > 0 ? (
<Stack gap={4}>
{services.map((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>
<ResourceRow key={service.uuid} item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
))}
</Stack>
) : (
@@ -61,45 +46,3 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi
</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>
);
};
@@ -1,6 +1,5 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -19,19 +18,11 @@ interface SingleInstanceLayoutProps {
instance: InstanceData;
options: CoolifyOptions;
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function SingleInstanceLayout({
instance,
options,
isTiny,
isAdvanced,
widgetKey,
hideFooter,
}: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hideFooter }: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -41,7 +32,6 @@ export function SingleInstanceLayout({
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
@@ -62,41 +52,27 @@ export function SingleInstanceLayout({
oolify
</Text>
</Group>
<Stack gap={0} miw={0}>
{isAdvanced && (
<Text fz="xs" fw={600} truncate="end">
{instance.integrationName}
</Text>
)}
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{isAdvanced ? t("source.url", { url: displayUrl }) : displayUrl}
</Anchor>
</Stack>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{displayUrl}
</Anchor>
</Group>
<Accordion
variant="contained"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
<Accordion variant="contained" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -105,12 +81,7 @@ export function SingleInstanceLayout({
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
)}
</Accordion>
+2
View File
@@ -6,6 +6,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
mobile: { supportsDetailView: true },
queryKey: [["widget", "customApi"]],
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
@@ -42,12 +42,21 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
setMinutes(0);
}}
title={t("widget.dnsHoleControls.controls.setTimer")}
closeButtonProps={{
"aria-label": t("common.action.close"),
size: 44,
}}
>
<Flex direction="column" align="center" justify="center">
<Stack align="flex-end">
<Group>
<Text>{t("widget.dnsHoleControls.controls.hours")}</Text>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} −`}
onClick={() => hoursHandlers.current?.decrement()}
>
–
</ActionIcon>
<NumberInput
@@ -58,15 +67,26 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
max={999}
min={0}
step={1}
aria-label={t("widget.dnsHoleControls.controls.hours")}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} +`}
onClick={() => hoursHandlers.current?.increment()}
>
+
</ActionIcon>
</Group>
<Group>
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} −`}
onClick={() => minutesHandlers.current?.decrement()}
>
–
</ActionIcon>
<NumberInput
@@ -77,9 +97,15 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
max={59}
min={0}
step={1}
aria-label={t("widget.dnsHoleControls.controls.minutes")}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} +`}
onClick={() => minutesHandlers.current?.increment()}
>
+
</ActionIcon>
</Group>
@@ -91,7 +117,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
variant="light"
color="red"
leftSection={<IconClockPause size={20} />}
h="2rem"
mih={44}
w="12rem"
onClick={handleSetTimer}
>
@@ -6,7 +6,6 @@ import { useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Flex,
@@ -34,9 +33,7 @@ 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";
@@ -44,15 +41,6 @@ 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,
@@ -67,8 +55,7 @@ export default function DnsHoleControlsWidget({
.filter((id) => integrationIds.includes(id));
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? [];
const summaries = summaryResults.filter(isAvailableDnsSummaryResult);
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending: isSummaryPending } = summaryQuery;
const utils = clientApi.useUtils();
@@ -97,7 +84,7 @@ export default function DnsHoleControlsWidget({
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
item.integration.id === integrationId
? {
...item,
summary: {
@@ -160,14 +147,7 @@ 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")}>
@@ -280,7 +260,7 @@ export default function DnsHoleControlsWidget({
interface ControlsCardProps {
integrationsWithInteractions: string[];
toggleDns: (integrationId: string) => Promise<void>;
data: AvailableDnsSummaryResult;
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: TranslationFunction;
@@ -10,6 +10,7 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconDeviceGamepad,
mobile: { supportsDetailView: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
+7
View File
@@ -25,6 +25,13 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["docker", "getContainers"]],
refetchInterval: 30,
createOptions() {
@@ -24,8 +24,6 @@ import {
IconCpu2,
IconFileReport,
IconInfoCircle,
IconPackages,
IconRefreshAlert,
IconServer,
IconTemperature,
IconVersions,
@@ -43,9 +41,7 @@ import { useI18n } from "@homarr/translation/client";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { CpuRing } from "./rings/cpu-ring";
@@ -64,46 +60,21 @@ export const SystemHealthMonitoring = ({
withScrollArea = true,
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
const t = useI18n();
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 healthData =
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds })) ??
[];
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 (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>
);
}
if (healthData.length === 0) return <WidgetEmptyState />;
const Container = withScrollArea ? ScrollArea : Box;
return (
<Container h={withScrollArea ? "100%" : undefined} pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Container h={withScrollArea ? "100%" : undefined}>
<Stack mih="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const filteredFileSystem = filterStorageVolumes(
@@ -174,18 +145,18 @@ export const SystemHealthMonitoring = ({
</Modal>
</Box>
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
{showCpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{showCpu && (
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{options.cpu && (
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
)}
{showMemory && (
{options.memory && (
<MemoryRing
available={healthInfo.memAvailableInBytes}
used={healthInfo.memUsedInBytes}
isTiny={isTiny}
/>
)}
{showGpu &&
{options.gpu &&
healthInfo.gpu.map((gpu) => (
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
))}
@@ -195,7 +166,7 @@ export const SystemHealthMonitoring = ({
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
</Card>
)}
{showFileSystem &&
{options.fileSystem &&
disksData.map((disk) => {
return (
<Card
@@ -271,9 +242,7 @@ export const SystemHealthMonitoring = ({
);
};
type HealthInfo = NonNullable<
RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"]
>;
type HealthInfo = RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"];
const SystemInformationList = ({
healthInfo,
@@ -316,14 +285,6 @@ 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>
);
@@ -13,6 +13,7 @@ const createOptions = () =>
}));
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
supportsAdvancedFocus: false,
icon: IconReportSearch,
contextActions: ({ widgetRuntimeRef, context }) => [
{
+12 -16
View File
@@ -37,7 +37,6 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import classes from "./component.module.css";
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
@@ -75,17 +74,16 @@ export default function MediaServerWidget({
isEditMode,
displayMode,
}: WidgetComponentProps<"mediaServer">) {
const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
});
const currentStreamsData = getUsableWidgetQueryData(currentStreamsQuery);
const currentStreams = useMemo(() => currentStreamsData ?? [], [currentStreamsData]);
const currentStreams =
getUsableWidgetQueryData(
clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
}),
) ?? [];
const t = useScopedI18n("widget.mediaServer");
const isAdvanced = displayMode === "advanced";
const showLocation = isAdvanced || options.showLocation;
const showBitrate = isAdvanced || options.showBitrate;
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(() => {
const result: MRT_ColumnDef<StreamSession>[] = [
@@ -144,8 +142,8 @@ export default function MediaServerWidget({
if (!currentlyPlaying) return null;
const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding);
const location = showLocation ? currentlyPlaying.location : null;
const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null;
const location = options.showLocation ? currentlyPlaying.location : null;
const bitrateLabel = options.showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null;
return (
<Stack gap={4} align="flex-start">
@@ -177,7 +175,7 @@ export default function MediaServerWidget({
: []),
];
return result;
}, [isAdvanced, selectedRowId, showBitrate, showLocation, t, width]);
}, [isAdvanced, options.showBitrate, options.showLocation, selectedRowId, t, width]);
// Only render the flat list of sessions when the currentStreams change
// Otherwise it will always create a new array reference and cause the table to re-render
@@ -287,7 +285,7 @@ export default function MediaServerWidget({
(sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0),
0,
);
const totalBitrateLabel = showBitrate ? formatBitrate(totalBitrateKbps) : null;
const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null;
const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error));
return (
@@ -302,14 +300,12 @@ export default function MediaServerWidget({
{t("summary.transcoding", { count: transcodingCount })}
</Badge>
<Group ml="auto">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
</Group>
)}
{!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && (
{!isAdvanced && hasFailedIntegrations && (
<Group px="xs" justify="flex-end">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
@@ -2,6 +2,9 @@
import dynamic from "next/dynamic";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
const Notebook = dynamic(() => import("./notebook").then((module) => module.Notebook), {
@@ -9,5 +12,23 @@ const Notebook = dynamic(() => import("./notebook").then((module) => module.Note
});
export default function NotebookWidget(props: WidgetComponentProps<"notebook">) {
const t = useScopedI18n("widget.notebook");
if (props.displayMode === "mobileSummary") {
const excerpt = props.options.content
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&(?:nbsp|#160);/gi, " ")
.replace(/&(?:amp|#38);/gi, "&")
.replace(/&(?:lt|#60);/gi, "<")
.replace(/&(?:gt|#62);/gi, ">")
.replace(/&(?:quot|#34);/gi, '"')
.replace(/\s+/g, " ")
.trim();
return <WidgetMobileSummary value={excerpt || "—"} label={t("name")} />;
}
return <Notebook {...props} />;
}
+6
View File
@@ -6,6 +6,12 @@ import { defaultContent } from "./default-content";
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
icon: IconNotes,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
},
createOptions() {
return optionsBuilder.from(
(factory) => ({
+11 -38
View File
@@ -70,7 +70,6 @@ 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";
@@ -99,7 +98,6 @@ export function Notebook({
boardId,
itemId,
height,
displayMode = "compact",
}: WidgetComponentProps<"notebook">) {
const [content, setContent] = useState(options.content);
const previousContentRef = useRef(options.content);
@@ -341,13 +339,6 @@ 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}>
@@ -384,9 +375,9 @@ export function Notebook({
>
<RichTextEditor.Toolbar
style={{
display: display.showToolbar ? "flex" : "none",
maxHeight: display.toolbarMaxHeight,
overflowY: display.toolbarMaxHeight === undefined ? undefined : "auto",
display: isEditing && !isSaving && options.showToolbar === true ? "flex" : "none",
maxHeight: height < 180 ? "4.5rem" : "10rem",
overflowY: "auto",
}}
>
<RichTextEditor.ControlsGroup>
@@ -514,7 +505,7 @@ export function Notebook({
<RichTextEditor.Content />
</ScrollArea>
</RichTextEditor>
{(saveError || display.showDocumentStats) && (
{(saveError || height >= 180) && (
<Group pos="absolute" bottom={4} right={8} gap="xs" style={{ pointerEvents: saveError ? undefined : "none" }}>
{saveError && (
<Tooltip label={saveError} multiline>
@@ -530,7 +521,7 @@ export function Notebook({
</Text>
</Tooltip>
)}
{display.showDocumentStats && (
{height >= 180 && (
<Text size="xs" c="dimmed">
{t("widget.notebook.documentStats", documentStats)}
</Text>
@@ -882,18 +873,9 @@ function ListIndentIncrease() {
editor?.chain().focus().sinkListItem(itemType).run();
}, [editor, itemType]);
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]);
editor?.on("selectionUpdate", ({ editor }) => {
setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
});
return (
<RichTextEditor.Control
@@ -915,18 +897,9 @@ function ListIndentDecrease() {
editor?.chain().focus().liftListItem(itemType).run();
}, [editor, itemType]);
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]);
editor?.on("selectionUpdate", ({ editor }) => {
setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
});
return (
<RichTextEditor.Control
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
supportsAdvancedFocus: false,
icon: IconFileText,
queryKey: [["widget", "paperlessNgx"]],
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true,
+2
View File
@@ -9,6 +9,8 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
supportsAdvancedFocus: true,
icon: IconShieldCheck,
mobile: { supportsDetailView: true },
queryKey: [["widget", "patchmon"]],
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
maxIntegrations: 1,
+1 -1
View File
@@ -13,7 +13,7 @@ const relativeDateSchema = z
.or(z.literal(""));
export const { definition, componentLoader } = createWidgetDefinition("releases", {
supportsAdvancedFocus: true,
supportsAdvancedFocus: false,
icon: IconRocket,
queryKey: [["widget", "releases", "getLatest"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),
+10 -77
View File
@@ -9,10 +9,8 @@ import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
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 { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { BaseWidgetError } from "../errors/base-component";
import classes from "./component.module.scss";
@@ -29,7 +27,6 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
hasError: false as const,
entries: normalizedData,
failedFeedCount: 0,
isPending: query.isPending,
isStale: Boolean(query.error),
};
}
@@ -38,12 +35,11 @@ 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, displayMode }: WidgetComponentProps<"rssFeed">) {
export default function RssFeed({ options, width, height }: WidgetComponentProps<"rssFeed">) {
const feed = useLiveFeed({
urls: options.feedUrls,
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
@@ -61,7 +57,6 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
/>
);
}
if (feed.isPending) return <WidgetQueryLoadingState />;
const warning = feed.isStale
? t("warning.stale")
@@ -69,35 +64,13 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
? 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 (
@@ -124,7 +97,7 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
title={feedEntry.title}
>
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
<Image
className={classes.poster}
src={feedEntry.enclosure}
@@ -137,29 +110,18 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
)}
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
<Text
dir={languageDir}
fz={isRoomy ? "md" : "sm"}
fw={600}
lh={1.25}
lineClamp={isAdvanced ? undefined : 2}
>
<Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
{feedEntry.title}
</Text>
{entryDisplay.showDescription && feedEntry.description && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={entryDisplay.descriptionLineClamp}>
{!options.hideDescription && feedEntry.description && !isTiny && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
{feedDescriptionToText(feedEntry.description)}
</Text>
)}
<InfoDisplay
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
source={!isDense ? 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>
@@ -189,44 +151,15 @@ export const getHostname = (url: string): string => {
}
};
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"}>
const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => (
<Group gap={5} align="center" wrap="nowrap">
{date && <IconClock size="1rem" color="var(--mantine-color-dimmed)" />}
{date && (
<Text size="xs" c="dimmed">
{date}
</Text>
)}
{timestamp && <Text c="dimmed">•</Text>}
{timestamp && (
<Text size="xs" c="dimmed" ff="monospace">
{timestamp}
</Text>
)}
{(date || timestamp) && source && <Text c="dimmed">•</Text>}
{date && source && <Text c="dimmed">•</Text>}
{source && (
<Text size="xs" c="dimmed" truncate="end">
{source}
+1 -1
View File
@@ -11,7 +11,7 @@ import { optionsBuilder } from "../options";
* - https://www.jsonfeed.org/version/1.1/
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
supportsAdvancedFocus: true,
supportsAdvancedFocus: false,
icon: IconRss,
queryKey: [["widget", "rssFeed", "getFeeds"]],
queryMatcher: ({ input }, scope) =>
+1 -1
View File
@@ -12,7 +12,7 @@ const timeRangeOptions = stockPriceTimeFrames.range;
const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
supportsAdvancedFocus: true,
supportsAdvancedFocus: false,
icon: IconBuildingBank,
queryKey: [["widget", "stockPrice", "getPriceHistory"]],
queryMatcher: ({ input }, scope) =>
+43 -103
View File
@@ -9,21 +9,16 @@ import { formatBytesPair } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
export const getDisplayText = (
item: { used: string; available: string; percentage: number },
displayMode: DiskDisplayMode,
translatedFreeText?: string,
displayMode: DisplayMode,
) => {
switch (displayMode) {
case "percentage":
@@ -32,26 +27,27 @@ export const getDisplayText = (
const usedInBytes = Number(item.used);
const availableInBytes = Number(item.available);
if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
const { used, available: total } = formatBytesPair(usedInBytes, usedInBytes + availableInBytes);
return `${used} / ${total}`;
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
return `${used} / ${available}`;
}
return `${item.used} / ${item.available}`;
}
case "free":
return translatedFreeText ?? `${Math.round(100 - clampPercentage(item.percentage))}% free`;
return `${Math.round(100 - item.percentage)}% free`;
default:
return `${Math.round(item.percentage)}%`;
}
};
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),
});
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}`;
};
interface SystemDiskCardProps {
deviceName: string;
@@ -62,9 +58,6 @@ interface SystemDiskCardProps {
showBackgroundBar: boolean;
integrationName?: string;
secondaryText?: string;
freeText?: string;
smartStatus?: string;
isAdvanced: boolean;
showSecondaryText: boolean;
showTemperature: boolean;
}
@@ -78,9 +71,6 @@ const SystemDiskCard = ({
showBackgroundBar,
integrationName,
secondaryText,
freeText,
smartStatus,
isAdvanced,
showSecondaryText,
showTemperature,
}: SystemDiskCardProps) => {
@@ -108,9 +98,7 @@ const SystemDiskCard = ({
}, [displayText, healthy]);
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
const hasTemperature = temperature !== null && temperature !== undefined;
const temperatureText = hasTemperature ? `${temperature}°C` : "—°C";
const hasHiddenTemperature = hasTemperature && !showTemperature;
const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature;
const hasHiddenSecondaryText = Boolean(secondaryText && secondaryText !== displayText && !showSecondaryText);
const tooltipLabel = [
healthy ? displayText : `${displayText} (${unhealthyLabel})`,
@@ -152,19 +140,11 @@ 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 && (hasTemperature || isAdvanced) ? <Text size="sm">{temperatureText}</Text> : null}
{showTemperature && temperature !== null && temperature !== undefined ? (
<Text size="sm">{temperature}°C</Text>
) : null}
</div>
</Group>
<Box
@@ -189,32 +169,12 @@ export default function SystemResources({
options,
width,
height,
displayMode,
}: WidgetComponentProps<"systemDisks">) {
const t = useI18n();
const queryInput = { integrationIds };
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>
);
const data =
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput)) ?? [];
if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />;
if (data.length === 0) return emptyState;
if (data.length === 0) return <WidgetEmptyState />;
const disks = data.flatMap((entry) => {
const fileSystem = filterStorageVolumes(
@@ -233,52 +193,32 @@ export default function SystemResources({
}));
});
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;
if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError();
if (disks.length === 0) return <WidgetEmptyState />;
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 (
<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>
<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>
);
}
@@ -7,6 +7,13 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
supportsAdvancedFocus: true,
icon: IconServer2,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
@@ -4,13 +4,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
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 { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
import { SystemResourceCPUChart } from "./chart/cpu-chart";
import { SystemResourceGPUChart } from "./chart/gpu-chart";
@@ -19,9 +16,6 @@ import { NetworkTrafficChart } from "./chart/network-traffic";
const COMPACT_HISTORY_SIZE = 15;
const ADVANCED_HISTORY_SIZE = 60;
const ALL_SYSTEM_CHARTS = ["cpu", "memory", "gpu", "network"] as const;
type SystemChart = WidgetComponentProps<"systemResources">["options"]["visibleCharts"][number];
type ChartItem = {
cpu: number;
@@ -57,26 +51,6 @@ export const getCompactChartBudget = (height: number): number => {
return 4;
};
export const getVisibleSystemCharts = ({
configuredCharts,
hasGpu,
hasNetwork,
height,
isAdvanced,
}: {
configuredCharts: readonly SystemChart[];
hasGpu: boolean;
hasNetwork: boolean;
height: number;
isAdvanced: boolean;
}): SystemChart[] => {
const selectedCharts = isAdvanced ? ALL_SYSTEM_CHARTS : configuredCharts;
const availableCharts = selectedCharts.filter(
(chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork),
);
return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height));
};
export default function SystemResources({
integrationIds,
options,
@@ -84,23 +58,13 @@ export default function SystemResources({
height,
displayMode,
}: WidgetComponentProps<"systemResources">) {
const t = useScopedI18n("widget.systemResources");
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
const results = getUsableWidgetQueryData(healthQuery) ?? [];
const data = results.filter(
(entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null,
);
const data = getUsableWidgetQueryData(healthQuery) ?? [];
const { dataUpdatedAt, isPending } = healthQuery;
const isAdvanced = displayMode === "advanced";
const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]);
const [historyByIntegration, setHistoryByIntegration] = useState<Record<string, ChartItem[]>>({});
const previousIntegrationKey = useRef(integrationKey);
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
useEffect(() => {
if (previousIntegrationKey.current === integrationKey) return;
@@ -138,7 +102,6 @@ export default function SystemResources({
key={entry.integrationId}
integrationName={entry.integrationName}
items={history}
hasGpu={entry.healthInfo.gpu.length > 0}
memoryCapacityInBytes={entry.healthInfo.memAvailableInBytes + entry.healthInfo.memUsedInBytes}
options={options}
width={availableWidth}
@@ -157,23 +120,11 @@ export default function SystemResources({
);
}
if (data.length === 0) {
return (
<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 (!isAdvanced && data.length === 1 && data[0]) {
return (
<Box h="100%" p="xs" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Box h="100%" p="xs">
{renderCharts(data[0], width, Math.max(0, height - 20))}
</Box>
);
@@ -182,23 +133,17 @@ export default function SystemResources({
const compactPanelHeight = Math.max(160, Math.floor(height / Math.min(data.length, 2)));
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
{data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))}
</SimpleGrid>
</ScrollArea>
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
{data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))}
</SimpleGrid>
</ScrollArea>
);
}
interface SystemChartsProps {
integrationName: string;
items: ChartItem[];
hasGpu: boolean;
memoryCapacityInBytes: number;
options: WidgetComponentProps<"systemResources">["options"];
width: number;
@@ -210,7 +155,6 @@ interface SystemChartsProps {
const SystemCharts = ({
integrationName,
items,
hasGpu,
memoryCapacityInBytes,
options,
width,
@@ -219,15 +163,9 @@ const SystemCharts = ({
showTitle,
}: SystemChartsProps) => {
const networkItems = getNetworkHistory(items);
const visibleCharts = getVisibleSystemCharts({
configuredCharts: options.visibleCharts,
hasGpu,
hasNetwork: networkItems.length > 0,
height,
isAdvanced,
});
const availableCharts = options.visibleCharts.filter((chart) => chart !== "network" || networkItems.length > 0);
const visibleCharts = isAdvanced ? availableCharts : availableCharts.slice(0, getCompactChartBudget(height));
const showNetwork = visibleCharts.includes("network");
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
const chartCount = visibleCharts.length;
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
const chartHeight = isAdvanced
@@ -254,7 +192,7 @@ const SystemCharts = ({
<SystemResourceCPUChart
cpuUsageOverTime={items.map((item) => item.cpu)}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Box>
)}
@@ -264,7 +202,7 @@ const SystemCharts = ({
memoryUsageOverTime={items.map((item) => item.memory)}
totalCapacityInBytes={memoryCapacityInBytes}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Box>
)}
@@ -273,7 +211,7 @@ const SystemCharts = ({
<SystemResourceGPUChart
gpuUsageOverTime={items.map((item) => item.gpu)}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Box>
)}
@@ -284,13 +222,13 @@ const SystemCharts = ({
usageOverTime={networkItems.map((network) => network.down)}
isUp={false}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.up)}
isUp
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Group>
) : (
@@ -298,7 +236,7 @@ const SystemCharts = ({
<CombinedNetworkTrafficChart
usageOverTime={networkItems}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Box>
))}
+28 -53
View File
@@ -9,10 +9,8 @@ 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";
@@ -130,16 +128,12 @@ interface UptimeKumaContentProps {
function UptimeKumaContent({ integrationIds, options, width, height, displayMode }: UptimeKumaContentProps) {
const t = useScopedI18n("widget.uptimeKuma");
const dashboardQuery = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery);
const dashboardData = getUsableWidgetQueryData(clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds }));
if (!dashboardData) return <WidgetEmptyState />;
const successfulDashboardData = dashboardData.flatMap((item) =>
item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }],
);
const combined = aggregateUptimeKumaDashboards(successfulDashboardData.map((item) => item.dashboard));
const monitors = successfulDashboardData.flatMap((result) =>
const combined = aggregateUptimeKumaDashboards(dashboardData.map((item) => item.dashboard));
const monitors = dashboardData.flatMap((result) =>
result.dashboard.monitors.map((monitor) => ({
...monitor,
key: `${result.integrationId}:${monitor.id}`,
@@ -151,22 +145,25 @@ 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]) => isAdvanced || options[optionKey as keyof typeof options])
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
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 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 visibleStatKeys = prioritizedStatKeys.slice(
0,
isAdvanced ? prioritizedStatKeys.length : getCompactStatLimit(height, showHero, prioritizedStatKeys.length),
displayMode === "advanced"
? prioritizedStatKeys.length
: getCompactStatLimit(height, showHero, prioritizedStatKeys.length),
);
const hasContent = showHero || visibleStatKeys.length > 0;
@@ -248,17 +245,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
</div>
);
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>
);
}
if (displayMode === "compact") return summaryContent;
const monitorList = (
<Stack gap="xs">
@@ -270,7 +257,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
<Text size="sm" fw={600} truncate>
{monitor.name}
</Text>
{(isAdvanced || successfulDashboardData.length > 1) && (
{dashboardData.length > 1 && (
<Text size="xs" c="dimmed">
{monitor.integrationName}
</Text>
@@ -287,32 +274,20 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
if (width < 760) {
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>
<ScrollArea h="100%">
<Stack gap="lg" p="md">
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
{monitorList}
</Stack>
</ScrollArea>
</Box>
<ScrollArea h="100%">
<Stack gap="lg" p="md">
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
{monitorList}
</Stack>
</ScrollArea>
);
}
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>
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
{summaryContent}
<ScrollArea h="100%">{monitorList}</ScrollArea>
</SimpleGrid>
</Box>
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
{summaryContent}
<ScrollArea h="100%">{monitorList}</ScrollArea>
</SimpleGrid>
);
}
+2
View File
@@ -8,6 +8,8 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("vpn", {
supportsAdvancedFocus: false,
icon: IconShieldLock,
mobile: { supportsDetailView: true },
queryKey: [["widget", "vpn"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from(() => ({}));