feat: refine mobile dashboard experience
This commit is contained in:
1 parent
2e3dca3e87
commit
84db2f9d49
68 files changed
+1425
-1311
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
·
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }) => [
|
||||
{
|
||||
|
||||
@@ -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,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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
Reference in new issue
Block a user