Skip to content

Commit 0182a86

Browse files
committed
feat: add disks widget
1 parent 51c8074 commit 0182a86

File tree

8 files changed

+158
-4
lines changed

8 files changed

+158
-4
lines changed

packages/definitions/src/widget.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ export const widgetKinds = [
2929
"firewall",
3030
"notifications",
3131
"systemResources",
32+
"systemDisks"
3233
] as const;
3334
export type WidgetKind = (typeof widgetKinds)[number];

packages/integrations/src/dashdot/dashdot-integration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
5555
cpuTemp: cpuLoad.averageTemperature,
5656
availablePkgUpdates: 0,
5757
rebootRequired: false,
58-
smart: [],
58+
smart: [], // API endpoint does not provide S.M.A.R.T data.
5959
uptime: info.uptime,
6060
version: `${info.operatingSystemVersion}`,
6161
loadAverage: {

packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface SystemHealthMonitoring {
2828
smart: {
2929
deviceName: string;
3030
temperature: number | null;
31-
overallStatus: string;
31+
healthy: boolean;
3232
}[];
3333
}
3434

packages/integrations/src/truenas/truenas-integration.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,27 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
112112
});
113113
}
114114

115+
private async getPoolsAsync() {
116+
localLogger.debug("Retrieving pools", {
117+
url: this.wsUrl(),
118+
});
119+
120+
const response = await this.requestAsync("pool.query", [
121+
[],
122+
{
123+
extra: {
124+
is_upgraded: true,
125+
},
126+
},
127+
]);
128+
const result = await poolSchema.parseAsync(response);
129+
localLogger.debug("Retrieved pools", {
130+
url: this.wsUrl(),
131+
count: result.length,
132+
});
133+
return result;
134+
}
135+
115136
/**
116137
* Retrieves data using the reporting method
117138
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
@@ -225,6 +246,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
225246
const cpuData = this.extractLatestReportingData(reporting, "cpu");
226247
const cpuTempData = this.extractLatestReportingData(reporting, "cputemp");
227248
const memoryData = this.extractLatestReportingData(reporting, "memory");
249+
const datasets = await this.getPoolsAsync();
228250

229251
const netdata = await this.getReportingNetdataAsync();
230252

@@ -236,14 +258,23 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
236258
cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
237259
memAvailableInBytes: systemInformation.physmem,
238260
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
239-
fileSystem: [],
261+
fileSystem: datasets.map((dataset) => ({
262+
deviceName: dataset.name,
263+
available: `${dataset.size}`, // TODO: can we use number instead of string here?
264+
used: `${dataset.allocated}`,
265+
percentage: (dataset.allocated / dataset.size) * 100,
266+
})),
240267
availablePkgUpdates: 0,
241268
network: {
242269
up: upload * NETWORK_MULTIPLIER,
243270
down: download * NETWORK_MULTIPLIER,
244271
},
245272
loadAverage: null,
246-
smart: [],
273+
smart: datasets.map((dataset) => ({
274+
deviceName: dataset.name,
275+
healthy: dataset.healthy,
276+
temperature: null,
277+
})),
247278
uptime: systemInformation.uptime_seconds,
248279
version: systemInformation.version,
249280
cpuModelName: systemInformation.model,
@@ -351,6 +382,14 @@ const reportingItemSchema = z.object({
351382

352383
type ReportingItem = z.infer<typeof reportingItemSchema>;
353384

385+
const poolSchema = z.array(z.object({
386+
name: z.string(),
387+
healthy: z.boolean(),
388+
free: z.number().min(0),
389+
size: z.number(),
390+
allocated: z.number()
391+
}))
392+
354393
const reportingNetDataSchema = z.array(
355394
z.object({
356395
name: z.string(),

packages/translation/src/lang/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2591,6 +2591,15 @@
25912591
"up": "UP",
25922592
"down": "DOWN"
25932593
}
2594+
},
2595+
"systemDisks": {
2596+
"name": "System disks",
2597+
"description": "Disk usage of your system",
2598+
"option": {
2599+
"showTemperatureIfAvailable": {
2600+
"label": "Show temperature if available"
2601+
}
2602+
}
25942603
}
25952604
},
25962605
"widgetPreview": {

packages/widgets/src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import * as smartHomeEntityState from "./smart-home/entity-state";
3838
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
3939
import * as stockPrice from "./stocks";
4040
import * as systemResources from "./system-resources";
41+
import * as systemDisks from "./system-disks";
4142
import * as video from "./video";
4243
import * as weather from "./weather";
4344

@@ -75,6 +76,7 @@ export const widgetImports = {
7576
notifications,
7677
mediaReleases,
7778
systemResources,
79+
systemDisks
7880
} satisfies WidgetImportRecord;
7981

8082
export type WidgetImports = typeof widgetImports;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { Box, Card, Center, Group, Stack, useMantineColorScheme } from "@mantine/core";
5+
6+
import { clientApi } from "@homarr/api/client";
7+
import { useRequiredBoard } from "@homarr/boards/context";
8+
9+
import type { WidgetComponentProps } from "../definition";
10+
import { NoIntegrationDataError } from "../errors/no-data-integration";
11+
12+
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) {
13+
const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery({
14+
integrationIds,
15+
});
16+
17+
const board = useRequiredBoard();
18+
const scheme = useMantineColorScheme();
19+
20+
const lastItem = data.at(-1);
21+
22+
console.log("last item", lastItem);
23+
24+
if (!lastItem) return null;
25+
26+
const [disks, setDisks] = useState<{
27+
fileSystem: { deviceName: string; used: string; available: string; percentage: number }[];
28+
smart: { deviceName: string; temperature: number | null; healthy: boolean }[];
29+
}>({
30+
fileSystem: lastItem.healthInfo.fileSystem,
31+
smart: lastItem.healthInfo.smart,
32+
});
33+
34+
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
35+
{
36+
integrationIds,
37+
},
38+
{
39+
onData(data) {
40+
setDisks({
41+
fileSystem: data.healthInfo.fileSystem,
42+
smart: data.healthInfo.smart,
43+
});
44+
},
45+
},
46+
);
47+
48+
if (disks.fileSystem.length === 0) {
49+
throw new NoIntegrationDataError();
50+
}
51+
52+
return (
53+
<Stack gap="xs" p="xs" h="100%">
54+
{disks.fileSystem.map((item, index) => {
55+
const smart = disks.smart.find((smart) => smart.deviceName === item.deviceName);
56+
const healthy = smart?.healthy ?? true; // fall back to healthy if no information is available
57+
58+
return (
59+
<Card
60+
radius={board.itemRadius}
61+
py={"xs"}
62+
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"}
63+
key={`disk-${index}`}
64+
style={{ overflow: "hidden", position: "relative" }}
65+
>
66+
<Group justify="space-between" style={{ zIndex: 1 }}>
67+
<div>
68+
<p style={{ margin: 0 }}>
69+
<b>{item.deviceName}</b>
70+
</p>
71+
<p style={{ margin: 0 }}>
72+
<span>{Math.round(item.percentage)}%</span>
73+
{!healthy && <span style={{ marginLeft: 5 }}>Unhealthy</span>}
74+
</p>
75+
</div>
76+
<div>
77+
{smart?.temperature && options.showTemperatureIfAvailable && <p style={{ margin: 0 }}>{smart.temperature}°C</p>}
78+
</div>
79+
</Group>
80+
<Box
81+
bg={healthy ? "green" : "red"}
82+
style={{ position: "absolute", top: 0, left: 0, width: `${item.percentage}%`, height: "100%", zIndex: 0 }}
83+
></Box>
84+
</Card>
85+
);
86+
})}
87+
</Stack>
88+
);
89+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { IconServer2 } from "@tabler/icons-react";
2+
3+
import { createWidgetDefinition } from "../definition";
4+
import { optionsBuilder } from "../options";
5+
6+
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
7+
icon: IconServer2,
8+
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"],
9+
createOptions() {
10+
return optionsBuilder.from((factory) => ({
11+
showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
12+
}));
13+
},
14+
}).withDynamicImport(() => import("./component"));

0 commit comments

Comments
 (0)