feat: add PeaNUT integration and UPS monitoring widget (#5862)

This commit is contained in:
Afonso Jorge Ramos authored and GitHub committed 2026-06-05 16:51:09 +02:00
1 parent dd7a4908a3
commit 0dc2558faf
19 files changed
+770 -1

No files matched your search

+1
View File
@@ -31,4 +31,5 @@ export const widgetRouter = createTRPCRouter({
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
});
+51
View File
@@ -0,0 +1,51 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { UpsSummary } from "@homarr/integrations/types";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
return innerHandler.subscribe((summaries) => {
emit.next({
integrationId: integration.id,
summaries,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
+9
View File
@@ -427,6 +427,13 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
defaultPort: 3000,
},
peaNut: {
name: "PeaNUT",
secretKinds: [["username", "password"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
category: ["ups"],
documentationUrl: null,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -445,6 +452,7 @@ export const integrationDefs = {
"networkController",
"notifications",
"smartHomeServer",
"ups",
],
documentationUrl: null,
},
@@ -532,6 +540,7 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"ups",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
+1
View File
@@ -38,5 +38,6 @@ export const widgetKinds = [
"tracearr",
"speedtestTracker",
"umami",
"ups",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -36,6 +36,7 @@ import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PeaNutIntegration } from "../peanut/peanut-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
@@ -123,6 +124,7 @@ export const integrationCreators = {
immich: ImmichIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
peaNut: PeaNutIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
+1
View File
@@ -34,6 +34,7 @@ export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -0,0 +1,5 @@
import type { UpsSummary } from "./ups-summary-types";
export interface IUpsSummaryIntegration {
getUpsSummariesAsync(): Promise<UpsSummary[]>;
}
@@ -0,0 +1,32 @@
/**
* Normalized UPS status, resolved from the raw NUT status flags by the integration so that
* widgets and future UPS integrations share a single, typed vocabulary.
*/
export const upsStatuses = ["online", "charging", "onBattery", "lowBattery", "unknown"] as const;
export type UpsStatus = (typeof upsStatuses)[number];
export interface UpsSummary {
/** Stable identifier of the UPS device on the NUT server */
id: string;
/** Human readable name, derived from the manufacturer and model */
name: string;
manufacturer: string | null;
model: string | null;
serial: string | null;
/** Normalized status, resolved from the raw NUT `ups.status` flags */
status: UpsStatus;
/** Battery charge in percent */
batteryCharge: number | null;
/** Estimated remaining runtime on battery, in seconds */
batteryRuntime: number | null;
batteryVoltage: number | null;
/** UPS load in percent */
load: number | null;
inputVoltage: number | null;
outputVoltage: number | null;
/** Real power drawn by the load, in watts */
power: number | null;
/** UPS temperature in degrees celsius */
temperature: number | null;
}
@@ -0,0 +1,41 @@
import type { IUpsSummaryIntegration } from "../../interfaces/ups-summary/ups-summary-integration";
import type { UpsSummary } from "../../types";
export class UpsSummaryMockService implements IUpsSummaryIntegration {
public async getUpsSummariesAsync(): Promise<UpsSummary[]> {
return await Promise.resolve([
{
id: "ups-rack-1",
name: "CyberPower OR1500",
manufacturer: "CyberPower",
model: "OR1500LCDRM1U",
serial: "CPS1500-001",
status: "online",
batteryCharge: 100,
batteryRuntime: 2280,
batteryVoltage: 27.3,
load: 42,
inputVoltage: 230,
outputVoltage: 230,
power: 315,
temperature: 29,
},
{
id: "ups-rack-2",
name: "APC Back-UPS Pro",
manufacturer: "APC",
model: "BR1500G",
serial: "APC1500-002",
status: "onBattery",
batteryCharge: 64,
batteryRuntime: 840,
batteryVoltage: 25.1,
load: 58,
inputVoltage: 0,
outputVoltage: 229,
power: 410,
temperature: 31,
},
]);
}
}
@@ -15,6 +15,7 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
import { CalendarMockService } from "./data/calendar";
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
import { DnsHoleMockService } from "./data/dns-hole";
@@ -28,6 +29,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
import { NotificationsMockService } from "./data/notifications";
import { SmartHomeMockService } from "./data/smart-home";
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
import { UpsSummaryMockService } from "./data/ups-summary";
export class MockIntegration
extends Integration
@@ -43,7 +45,8 @@ export class MockIntegration
IMediaServerIntegration,
IMediaTranscodingIntegration,
NetworkControllerSummaryIntegration,
ISmartHomeIntegration
ISmartHomeIntegration,
IUpsSummaryIntegration
{
private static readonly dnsHole = new DnsHoleMockService();
private static readonly calendar = new CalendarMockService();
@@ -58,6 +61,7 @@ export class MockIntegration
private static readonly networkController = new NetworkControllerSummaryMockService();
private static readonly notifications = new NotificationsMockService();
private static readonly smartHome = new SmartHomeMockService();
private static readonly upsSummary = new UpsSummaryMockService();
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
return await Promise.resolve({
@@ -123,4 +127,7 @@ export class MockIntegration
getEntityStateAsync = MockIntegration.smartHome.getEntityStateAsync.bind(MockIntegration.smartHome);
triggerAutomationAsync = MockIntegration.smartHome.triggerAutomationAsync.bind(MockIntegration.smartHome);
triggerToggleAsync = MockIntegration.smartHome.triggerToggleAsync.bind(MockIntegration.smartHome);
// UpsSummaryIntegration
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
}
@@ -0,0 +1,103 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
import type { UpsStatus, UpsSummary } from "../interfaces/ups-summary/ups-summary-types";
import type { PeaNutDevice } from "./peanut-types";
import { peaNutDevicesSchema } from "./peanut-types";
export class PeaNutIntegration extends Integration implements IUpsSummaryIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v1/devices"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return { success: true };
}
public async getUpsSummariesAsync(): Promise<UpsSummary[]> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/devices", { meta: true }), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
const devices = peaNutDevicesSchema.parse(await response.json());
return devices.map((device, index) => this.mapDeviceToSummary(device, index));
}
private mapDeviceToSummary(device: PeaNutDevice, index: number): UpsSummary {
const manufacturer = this.readString(device, "device.mfr") ?? this.readString(device, "ups.mfr");
const model = this.readString(device, "device.model") ?? this.readString(device, "ups.model");
const id = this.readString(device, "peanut.device_id") ?? `ups-${index}`;
const name = [manufacturer, model].filter(Boolean).join(" ") || id;
return {
id,
name,
manufacturer,
model,
serial: this.readString(device, "device.serial"),
status: this.parseStatus(this.readString(device, "ups.status")),
batteryCharge: this.readNumber(device, "battery.charge"),
batteryRuntime: this.readNumber(device, "battery.runtime"),
batteryVoltage: this.readNumber(device, "battery.voltage"),
load: this.readNumber(device, "ups.load"),
inputVoltage: this.readNumber(device, "input.voltage"),
outputVoltage: this.readNumber(device, "output.voltage"),
power: this.readNumber(device, "ups.realpower") ?? this.readNumber(device, "ups.power"),
temperature: this.readNumber(device, "ups.temperature"),
};
}
/**
* Resolves the raw NUT `ups.status` flags (e.g. "OL CHRG", "OB DISCHRG LB") into a single
* normalized status. The flags are checked in priority order so the most critical state wins.
*/
private parseStatus(rawStatus: string | null): UpsStatus {
const flags = (rawStatus ?? "").toUpperCase().split(/\s+/).filter(Boolean);
if (flags.includes("LB")) return "lowBattery";
if (flags.includes("OB")) return "onBattery";
if (flags.includes("OL")) {
return flags.includes("CHRG") ? "charging" : "online";
}
return "unknown";
}
private readString(device: PeaNutDevice, key: string): string | null {
const value = device[key];
if (value === undefined || value === null) return null;
const stringValue = String(value).trim();
return stringValue.length > 0 ? stringValue : null;
}
private readNumber(device: PeaNutDevice, key: string): number | null {
const value = device[key];
if (value === undefined || value === null || value === "") return null;
const numberValue = typeof value === "number" ? value : Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
private getAuthHeaders(): Record<string, string> {
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
return { Authorization: `Basic ${credentials}` };
}
return {};
}
}
@@ -0,0 +1,10 @@
import { z } from "zod/v4";
// PeaNUT returns each device as a flat map of NUT variables (e.g. "ups.status",
// "battery.charge"). Values are either strings or numbers depending on the
// variable, so we accept both and coerce them when mapping to a UpsSummary.
export const peaNutDeviceSchema = z.record(z.string(), z.union([z.string(), z.number()]));
export const peaNutDevicesSchema = z.array(peaNutDeviceSchema);
export type PeaNutDevice = z.infer<typeof peaNutDeviceSchema>;
@@ -0,0 +1,166 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { PeaNutIntegration } from "../peanut-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://peanut.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const mockDevicesResponse = (devices: Record<string, string | number>[]) => {
mockFetch.mockImplementation(
() =>
Promise.resolve(
new Response(JSON.stringify(devices), {
status: 200,
headers: { "content-type": "application/json" },
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>,
);
};
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new PeaNutIntegration({
id: "test-peanut",
name: "Test PeaNUT",
url: TEST_URL,
externalUrl: null,
decryptedSecrets,
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("PeaNutIntegration getUpsSummariesAsync", () => {
test("maps NUT variables to a UPS summary and coerces string values to numbers", async () => {
mockDevicesResponse([
{
"device.mfr": "CyberPower",
"device.model": "CP1500EPFCLCD",
"device.serial": "ABC123",
"ups.status": "OL CHRG",
"ups.load": "23",
"ups.realpower": "120",
"ups.temperature": "31.5",
"battery.charge": "100",
"battery.runtime": "3720",
"battery.voltage": "27.3",
"input.voltage": "230",
"output.voltage": "229",
"peanut.device_id": "ups",
"peanut.server": "localhost:3493",
},
]);
const [summary] = await createIntegration().getUpsSummariesAsync();
expect(summary).toStrictEqual({
id: "ups",
name: "CyberPower CP1500EPFCLCD",
manufacturer: "CyberPower",
model: "CP1500EPFCLCD",
serial: "ABC123",
status: "charging",
batteryCharge: 100,
batteryRuntime: 3720,
batteryVoltage: 27.3,
load: 23,
inputVoltage: 230,
outputVoltage: 229,
power: 120,
temperature: 31.5,
});
});
test("falls back when optional variables are missing", async () => {
mockDevicesResponse([
{
"ups.status": "OB DISCHRG LB",
"ups.power": "90",
"battery.charge": "15",
},
]);
const [summary] = await createIntegration().getUpsSummariesAsync();
expect(summary).toMatchObject({
id: "ups-0",
name: "ups-0",
manufacturer: null,
model: null,
status: "lowBattery",
batteryCharge: 15,
batteryRuntime: null,
load: null,
inputVoltage: null,
outputVoltage: null,
power: 90,
temperature: null,
});
});
test("returns a summary per device", async () => {
mockDevicesResponse([
{ "peanut.device_id": "ups-a", "ups.status": "OL" },
{ "peanut.device_id": "ups-b", "ups.status": "OB" },
]);
const summaries = await createIntegration().getUpsSummariesAsync();
expect(summaries.map((summary) => summary.id)).toStrictEqual(["ups-a", "ups-b"]);
});
test("normalizes NUT status flags into a typed status", async () => {
mockDevicesResponse([
{ "ups.status": "OL" },
{ "ups.status": "OL CHRG" },
{ "ups.status": "OB DISCHRG" },
{ "ups.status": "OB DISCHRG LB" },
{ "ups.status": "OL LB" },
{ "ups.status": "" },
]);
const summaries = await createIntegration().getUpsSummariesAsync();
expect(summaries.map((summary) => summary.status)).toStrictEqual([
"online",
"charging",
"onBattery",
"lowBattery",
"lowBattery",
"unknown",
]);
});
});
describe("PeaNutIntegration authentication", () => {
test("sends a Basic auth header when username and password are configured", async () => {
mockDevicesResponse([]);
await createIntegration([
{ kind: "username", value: "admin" },
{ kind: "password", value: "secret" },
]).getUpsSummariesAsync();
const expected = `Basic ${Buffer.from("admin:secret").toString("base64")}`;
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
});
test("omits the auth header when no credentials are configured", async () => {
mockDevicesResponse([]);
await createIntegration().getUpsSummariesAsync();
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(requestInit?.headers).toStrictEqual({});
});
});
+1
View File
@@ -3,6 +3,7 @@ export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/ups-summary/ups-summary-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
export * from "./base/searchable-integration";
+20
View File
@@ -0,0 +1,20 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UpsSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler<
UpsSummary[],
IntegrationKindByCategory<"ups">,
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getUpsSummariesAsync();
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "upsSummaries",
});
+31
View File
@@ -2150,6 +2150,37 @@
"title": "Instances"
}
},
"ups": {
"name": "UPS",
"description": "Monitor the status of your UPS devices through a NUT server",
"option": {
"showBattery": {
"label": "Show battery"
},
"showLoad": {
"label": "Show load"
},
"showVoltage": {
"label": "Show voltage"
}
},
"status": {
"online": "Online",
"onBattery": "On battery",
"lowBattery": "Low battery",
"charging": "Charging",
"unknown": "Unknown"
},
"field": {
"runtime": "Runtime",
"load": "Load",
"input": "Input",
"output": "Output"
},
"error": {
"internalServerError": "Failed to fetch UPS data"
}
},
"tracearr": {
"name": "Tracearr",
"description": "Enhanced media server monitoring with detailed stream and transcoding data",
+2
View File
@@ -47,6 +47,7 @@ import * as systemResources from "./system-resources";
import * as timetable from "./timetable";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as ups from "./ups";
import * as video from "./video";
import * as weather from "./weather";
@@ -93,6 +94,7 @@ export const widgetImports = {
tracearr,
speedtestTracker,
umami,
ups,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { Badge, Box, Card, Group, Progress, RingProgress, ScrollArea, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common";
import type { UpsStatus, UpsSummary } from "@homarr/integrations/types";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
const statusColors: Record<UpsStatus, string> = {
online: "green",
charging: "blue",
onBattery: "yellow",
lowBattery: "red",
unknown: "gray",
};
type UpsLayout = "mini" | "compact" | "full";
export default function UpsWidget({ options, integrationIds, isEditMode, width }: WidgetComponentProps<"ups">) {
if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError();
}
return <UpsContent integrationIds={integrationIds} options={options} isEditMode={isEditMode} width={width} />;
}
interface UpsContentProps {
integrationIds: string[];
options: WidgetComponentProps<"ups">["options"];
isEditMode: boolean;
width: number;
}
function UpsContent({ integrationIds, options, isEditMode, width }: UpsContentProps) {
const t = useScopedI18n("widget.ups");
const [data] = clientApi.widget.ups.getSummaries.useSuspenseQuery({ integrationIds });
const utils = clientApi.useUtils();
clientApi.widget.ups.subscribeSummaries.useSubscription(
{ integrationIds },
{
enabled: !isEditMode,
onData(newData) {
utils.widget.ups.getSummaries.setData({ integrationIds }, (prevData) => {
if (!prevData) return prevData;
return prevData.map((instance) =>
instance.integrationId === newData.integrationId
? { ...instance, summaries: newData.summaries, updatedAt: newData.timestamp }
: instance,
);
});
},
},
);
const devices = data.flatMap((instance) =>
instance.summaries.map((summary) => ({ key: `${instance.integrationId}:${summary.id}`, summary })),
);
if (devices.length === 0) {
throw new NoIntegrationDataError();
}
// Pick a layout from the available width so the widget stays useful at any size. The surrounding
// ScrollArea keeps every device reachable at any height (e.g. a tall, narrow 1x3 widget scrolls).
// - full: ring beside a column of stats (runtime, load, voltage)
// - compact (~2 columns): ring beside the name and a status badge
// - mini (~1 column): a text badge no longer fits, so status becomes a colour dot next to the name
const layout: UpsLayout = width < 150 ? "mini" : width < 256 ? "compact" : "full";
return (
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
{devices.map(({ key, summary }) => (
<UpsDeviceCard key={key} summary={summary} options={options} layout={layout} t={t} />
))}
</Stack>
</ScrollArea>
);
}
interface UpsDeviceCardProps {
summary: UpsSummary;
options: WidgetComponentProps<"ups">["options"];
layout: UpsLayout;
t: ScopedTranslationFunction<"widget.ups">;
}
function UpsDeviceCard({ summary, options, layout, t }: UpsDeviceCardProps) {
const showRing = options.showBattery && summary.batteryCharge !== null;
const statusBadge = (
<Badge
color={statusColors[summary.status]}
variant="light"
size={layout === "full" ? "sm" : "xs"}
style={{ flexShrink: 0 }}
>
{t(`status.${summary.status}`)}
</Badge>
);
if (layout === "mini") {
return (
<Card p={6} radius="md">
<Group gap={4} wrap="nowrap" justify="center" mb={showRing ? 4 : 0}>
<Box
w={8}
h={8}
style={{
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${statusColors[summary.status]}-6)`,
flexShrink: 0,
}}
/>
<Text fw={600} size="xs" truncate>
{summary.name}
</Text>
</Group>
{showRing && summary.batteryCharge !== null && (
<Group justify="center">
<RingProgress
size={44}
thickness={4}
roundCaps
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
label={
<Text ta="center" size="10px" fw={700}>
{Math.round(summary.batteryCharge)}%
</Text>
}
/>
</Group>
)}
</Card>
);
}
if (layout === "compact") {
return (
<Card p={6} radius="md">
<Group gap={8} wrap="nowrap" align="center">
{showRing && summary.batteryCharge !== null && (
<RingProgress
size={40}
thickness={4}
roundCaps
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
label={
<Text ta="center" size="9px" fw={700}>
{Math.round(summary.batteryCharge)}%
</Text>
}
/>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} size="xs" truncate>
{summary.name}
</Text>
{statusBadge}
</Stack>
</Group>
</Card>
);
}
return (
<Card p="xs" radius="md">
<Group justify="space-between" wrap="nowrap" mb={4}>
<Text fw={600} size="sm" truncate>
{summary.name}
</Text>
{statusBadge}
</Group>
<Group align="center" wrap="nowrap" gap="md">
{showRing && summary.batteryCharge !== null && (
<RingProgress
size={68}
thickness={6}
roundCaps
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
label={
<Text ta="center" size="xs" fw={700}>
{Math.round(summary.batteryCharge)}%
</Text>
}
/>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
{options.showBattery && summary.batteryRuntime !== null && (
<StatRow label={t("field.runtime")} value={formatDuration(summary.batteryRuntime * 1000)} />
)}
{options.showLoad && summary.load !== null && (
<Box>
<Group justify="space-between" gap="xs" mb={2}>
<Text size="xs" c="dimmed">
{t("field.load")}
</Text>
<Text size="xs">{Math.round(summary.load)}%</Text>
</Group>
<Progress value={clampPercent(summary.load)} size="sm" color={getLoadColor(summary.load)} />
</Box>
)}
{options.showVoltage && (summary.inputVoltage !== null || summary.outputVoltage !== null) && (
<Group gap="md">
{summary.inputVoltage !== null && (
<StatRow label={t("field.input")} value={`${Math.round(summary.inputVoltage)} V`} />
)}
{summary.outputVoltage !== null && (
<StatRow label={t("field.output")} value={`${Math.round(summary.outputVoltage)} V`} />
)}
</Group>
)}
</Stack>
</Group>
</Card>
);
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={500}>
{value}
</Text>
</Group>
);
}
function clampPercent(value: number): number {
return Math.min(100, Math.max(0, value));
}
function getChargeColor(charge: number): string {
if (charge > 50) return "green";
if (charge > 20) return "yellow";
return "red";
}
function getLoadColor(load: number): string {
if (load > 90) return "red";
if (load > 70) return "yellow";
return "blue";
}
+30
View File
@@ -0,0 +1,30 @@
import { IconBatteryCharging, IconBatteryOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("ups", {
icon: IconBatteryCharging,
createOptions() {
return optionsBuilder.from((factory) => ({
showBattery: factory.switch({
defaultValue: true,
}),
showLoad: factory.switch({
defaultValue: true,
}),
showVoltage: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("ups"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconBatteryOff,
message: (t) => t("widget.ups.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));