feat(widgets): add disks widget (#4445)
Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
11 files changed
+206
-3
No files matched your search
@@ -29,5 +29,6 @@ export const widgetKinds = [
|
||||
"firewall",
|
||||
"notifications",
|
||||
"systemResources",
|
||||
"systemDisks",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
@@ -55,7 +55,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
|
||||
cpuTemp: cpuLoad.averageTemperature,
|
||||
availablePkgUpdates: 0,
|
||||
rebootRequired: false,
|
||||
smart: [],
|
||||
smart: [], // API endpoint does not provide S.M.A.R.T data.
|
||||
uptime: info.uptime,
|
||||
version: `${info.operatingSystemVersion}`,
|
||||
loadAverage: {
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface SystemHealthMonitoring {
|
||||
deviceName: string;
|
||||
temperature: number | null;
|
||||
overallStatus: string;
|
||||
healthy: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export class SystemHealthMonitoringMockService implements ISystemHealthMonitorin
|
||||
deviceName: "Mock Device",
|
||||
temperature: Math.floor(Math.random() * 100), // Random temperature between 0 and 99
|
||||
overallStatus: "OK",
|
||||
healthy: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -62,6 +62,8 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea
|
||||
const smart = smartResult.data.response.map((smart) => ({
|
||||
deviceName: smart.devicename,
|
||||
temperature: smart.temperature,
|
||||
// https://github.com/openmediavault/openmediavault/blob/2eb871d05cec6ff49603134d3acead561da01e4d/deb/openmediavault/usr/share/php/openmediavault/system/storage/smartinformation.inc#L592-L605
|
||||
healthy: smart.overallstatus === "GOOD",
|
||||
overallStatus: smart.overallstatus,
|
||||
}));
|
||||
|
||||
|
||||
@@ -112,6 +112,27 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
});
|
||||
}
|
||||
|
||||
private async getPoolsAsync() {
|
||||
logger.debug("Retrieving pools", {
|
||||
url: this.wsUrl(),
|
||||
});
|
||||
|
||||
const response = await this.requestAsync("pool.query", [
|
||||
[],
|
||||
{
|
||||
extra: {
|
||||
is_upgraded: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const result = await poolSchema.parseAsync(response);
|
||||
logger.debug("Retrieved pools", {
|
||||
url: this.wsUrl(),
|
||||
count: result.length,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves data using the reporting method
|
||||
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
|
||||
@@ -225,6 +246,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
const cpuData = this.extractLatestReportingData(reporting, "cpu");
|
||||
const cpuTempData = this.extractLatestReportingData(reporting, "cputemp");
|
||||
const memoryData = this.extractLatestReportingData(reporting, "memory");
|
||||
const datasets = await this.getPoolsAsync();
|
||||
|
||||
const netdata = await this.getReportingNetdataAsync();
|
||||
|
||||
@@ -236,14 +258,24 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
|
||||
memAvailableInBytes: systemInformation.physmem,
|
||||
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
||||
fileSystem: [],
|
||||
fileSystem: datasets.map((dataset) => ({
|
||||
deviceName: dataset.name,
|
||||
available: `${dataset.size}`, // TODO: can we use number instead of string here?
|
||||
used: `${dataset.allocated}`,
|
||||
percentage: (dataset.allocated / dataset.size) * 100,
|
||||
})),
|
||||
availablePkgUpdates: 0,
|
||||
network: {
|
||||
up: upload * NETWORK_MULTIPLIER,
|
||||
down: download * NETWORK_MULTIPLIER,
|
||||
},
|
||||
loadAverage: null,
|
||||
smart: [],
|
||||
smart: datasets.map((dataset) => ({
|
||||
deviceName: dataset.name,
|
||||
healthy: dataset.healthy,
|
||||
overallStatus: dataset.status,
|
||||
temperature: null,
|
||||
})),
|
||||
uptime: systemInformation.uptime_seconds,
|
||||
version: systemInformation.version,
|
||||
cpuModelName: systemInformation.model,
|
||||
@@ -351,6 +383,17 @@ const reportingItemSchema = z.object({
|
||||
|
||||
type ReportingItem = z.infer<typeof reportingItemSchema>;
|
||||
|
||||
const poolSchema = z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
healthy: z.boolean(),
|
||||
free: z.number().min(0),
|
||||
size: z.number(),
|
||||
allocated: z.number(),
|
||||
}),
|
||||
);
|
||||
|
||||
const reportingNetDataSchema = z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
|
||||
@@ -67,6 +67,8 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
deviceName: disk.name,
|
||||
temperature: disk.temp ?? null,
|
||||
overallStatus: disk.status,
|
||||
// See ArrayDiskStatus from https://studio.apollographql.com/public/Unraid-API/variant/current/explorer
|
||||
healthy: disk.status === "DISK_OK",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2608,6 +2608,35 @@
|
||||
"up": "UP",
|
||||
"down": "DOWN"
|
||||
}
|
||||
},
|
||||
"systemDisks": {
|
||||
"name": "System disks",
|
||||
"description": "Disk usage of your system",
|
||||
"option": {
|
||||
"showTemperatureIfAvailable": {
|
||||
"label": "Show temperature if available"
|
||||
},
|
||||
"displayMode": {
|
||||
"label": "Display mode",
|
||||
"option": {
|
||||
"percentage": {
|
||||
"label": "Percentage (e.g., 76%)"
|
||||
},
|
||||
"absolute": {
|
||||
"label": "Absolute values (e.g., 800GB / 1TB)"
|
||||
},
|
||||
"free": {
|
||||
"label": "Free space percentage (e.g., 24% free)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showBackgroundBar": {
|
||||
"label": "Show background usage bar"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"unhealthy": "Unhealthy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetPreview": {
|
||||
|
||||
@@ -37,6 +37,7 @@ import * as rssFeed from "./rssFeed";
|
||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||
import * as stockPrice from "./stocks";
|
||||
import * as systemDisks from "./system-disks";
|
||||
import * as systemResources from "./system-resources";
|
||||
import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
@@ -75,6 +76,7 @@ export const widgetImports = {
|
||||
notifications,
|
||||
mediaReleases,
|
||||
systemResources,
|
||||
systemDisks,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Card, Group, Stack, useMantineColorScheme } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
|
||||
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
|
||||
|
||||
const getDisplayText = (item: { used: string; available: string; percentage: number }, displayMode: DisplayMode) => {
|
||||
switch (displayMode) {
|
||||
case "percentage":
|
||||
return `${Math.round(item.percentage)}%`;
|
||||
case "absolute":
|
||||
return `${item.used} / ${item.available}`;
|
||||
case "free":
|
||||
return `${Math.round(100 - item.percentage)}% free`;
|
||||
default:
|
||||
return `${Math.round(item.percentage)}%`;
|
||||
}
|
||||
};
|
||||
|
||||
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) {
|
||||
const queryInput = { integrationIds };
|
||||
const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery(queryInput);
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const board = useRequiredBoard();
|
||||
const scheme = useMantineColorScheme();
|
||||
const t = useI18n();
|
||||
|
||||
const lastItem = data.at(-1);
|
||||
|
||||
if (!lastItem) return null;
|
||||
const { fileSystem, smart } = lastItem.healthInfo;
|
||||
|
||||
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(queryInput, {
|
||||
onData(data) {
|
||||
utils.widget.healthMonitoring.getSystemHealthStatus.setData(queryInput, (oldData) =>
|
||||
oldData?.map((item) => (item.integrationId === data.integrationId ? { ...item, ...data } : item)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (fileSystem.length === 0) {
|
||||
throw new NoIntegrationDataError();
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs" p="xs" h="100%">
|
||||
{fileSystem.map((item) => {
|
||||
const smartItem = smart.find((smart) => smart.deviceName === item.deviceName);
|
||||
const healthy = smartItem?.healthy ?? true; // fall back to healthy if no information is available
|
||||
|
||||
return (
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
py={"xs"}
|
||||
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"}
|
||||
key={`disk-${item.deviceName}`}
|
||||
style={{ overflow: "hidden", position: "relative" }}
|
||||
>
|
||||
<Group justify="space-between" style={{ zIndex: 1 }}>
|
||||
<div>
|
||||
<p style={{ margin: 0 }}>
|
||||
<b>{item.deviceName}</b>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
<span>{getDisplayText(item, options.displayMode)}</span>
|
||||
{!healthy && <span style={{ marginLeft: 5 }}>{t("widget.systemDisks.status.unhealthy")}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{smartItem?.temperature && options.showTemperatureIfAvailable ? (
|
||||
<p style={{ margin: 0 }}>{smartItem.temperature}°C</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
<Box
|
||||
bg={healthy ? "green" : "red"}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${item.percentage}%`,
|
||||
height: "100%",
|
||||
zIndex: 0,
|
||||
display: options.showBackgroundBar ? "block" : "none",
|
||||
}}
|
||||
></Box>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IconServer2 } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
|
||||
icon: IconServer2,
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
|
||||
displayMode: factory.select({
|
||||
options: (["percentage", "absolute", "free"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "percentage",
|
||||
}),
|
||||
showBackgroundBar: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
Reference in new issue
Block a user