Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/definitions/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ export const widgetKinds = [
"firewall",
"notifications",
"systemResources",
"systemDisks"
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
2 changes: 1 addition & 1 deletion packages/integrations/src/dashdot/dashdot-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface SystemHealthMonitoring {
smart: {
deviceName: string;
temperature: number | null;
overallStatus: string;
healthy: boolean;
}[];
}

Expand Down
43 changes: 41 additions & 2 deletions packages/integrations/src/truenas/truenas-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,27 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
});
}

private async getPoolsAsync() {
localLogger.debug("Retrieving pools", {
url: this.wsUrl(),
});

const response = await this.requestAsync("pool.query", [
[],
{
extra: {
is_upgraded: true,
},
},
]);
const result = await poolSchema.parseAsync(response);
localLogger.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
Expand Down Expand Up @@ -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();

Expand All @@ -236,14 +258,23 @@ 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,
temperature: null,
})),
uptime: systemInformation.uptime_seconds,
version: systemInformation.version,
cpuModelName: systemInformation.model,
Expand Down Expand Up @@ -351,6 +382,14 @@ const reportingItemSchema = z.object({

type ReportingItem = z.infer<typeof reportingItemSchema>;

const poolSchema = z.array(z.object({
name: 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(),
Expand Down
9 changes: 9 additions & 0 deletions packages/translation/src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2591,6 +2591,15 @@
"up": "UP",
"down": "DOWN"
}
},
"systemDisks": {
"name": "System disks",
"description": "Disk usage of your system",
"option": {
"showTemperatureIfAvailable": {
"label": "Show temperature if available"
}
}
}
},
"widgetPreview": {
Expand Down
2 changes: 2 additions & 0 deletions packages/widgets/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as stockPrice from "./stocks";
import * as systemResources from "./system-resources";
import * as systemDisks from "./system-disks";
import * as video from "./video";
import * as weather from "./weather";

Expand Down Expand Up @@ -75,6 +76,7 @@ export const widgetImports = {
notifications,
mediaReleases,
systemResources,
systemDisks
} satisfies WidgetImportRecord;

export type WidgetImports = typeof widgetImports;
Expand Down
89 changes: 89 additions & 0 deletions packages/widgets/src/system-disks/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client";

import { useState } from "react";
import { Box, Card, Center, Group, Stack, useMantineColorScheme } from "@mantine/core";

import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";

import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";

export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) {
const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery({
integrationIds,
});

const board = useRequiredBoard();
const scheme = useMantineColorScheme();

const lastItem = data.at(-1);

console.log("last item", lastItem);

if (!lastItem) return null;

const [disks, setDisks] = useState<{
fileSystem: { deviceName: string; used: string; available: string; percentage: number }[];
smart: { deviceName: string; temperature: number | null; healthy: boolean }[];
}>({
fileSystem: lastItem.healthInfo.fileSystem,
smart: lastItem.healthInfo.smart,
});

clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
{
integrationIds,
},
{
onData(data) {
setDisks({
fileSystem: data.healthInfo.fileSystem,
smart: data.healthInfo.smart,
});
},
},
);

if (disks.fileSystem.length === 0) {
throw new NoIntegrationDataError();
}

return (
<Stack gap="xs" p="xs" h="100%">
{disks.fileSystem.map((item, index) => {
const smart = disks.smart.find((smart) => smart.deviceName === item.deviceName);
const healthy = smart?.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-${index}`}
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>{Math.round(item.percentage)}%</span>
{!healthy && <span style={{ marginLeft: 5 }}>Unhealthy</span>}
</p>
</div>
<div>
{smart?.temperature && options.showTemperatureIfAvailable && <p style={{ margin: 0 }}>{smart.temperature}°C</p>}
</div>
</Group>
<Box
bg={healthy ? "green" : "red"}
style={{ position: "absolute", top: 0, left: 0, width: `${item.percentage}%`, height: "100%", zIndex: 0 }}
></Box>
</Card>
);
})}
</Stack>
);
}
14 changes: 14 additions & 0 deletions packages/widgets/src/system-disks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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 }),
}));
},
}).withDynamicImport(() => import("./component"));