Skip to content

Commit 0dcef87

Browse files
committed
feat: add disks widget
1 parent 51c8074 commit 0dcef87

File tree

7 files changed

+143
-4
lines changed

7 files changed

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