From 0dc2558fafedcbba246c2e968d00cc541ab9f84e Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Fri, 5 Jun 2026 16:51:09 +0200 Subject: [PATCH] feat: add PeaNUT integration and UPS monitoring widget (#5862) --- packages/api/src/router/widgets/index.ts | 1 + packages/api/src/router/widgets/ups.ts | 51 ++++ packages/definitions/src/integration.ts | 9 + packages/definitions/src/widget.ts | 1 + packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 1 + .../ups-summary/ups-summary-integration.ts | 5 + .../ups-summary/ups-summary-types.ts | 32 +++ .../integrations/src/mock/data/ups-summary.ts | 41 +++ .../integrations/src/mock/mock-integration.ts | 9 +- .../src/peanut/peanut-integration.ts | 103 +++++++ .../integrations/src/peanut/peanut-types.ts | 10 + .../peanut/test/peanut-integration.spec.ts | 166 ++++++++++++ packages/integrations/src/types.ts | 1 + packages/request-handler/src/ups.ts | 20 ++ packages/translation/src/lang/en.json | 31 +++ packages/widgets/src/index.tsx | 2 + packages/widgets/src/ups/component.tsx | 256 ++++++++++++++++++ packages/widgets/src/ups/index.ts | 30 ++ 19 files changed, 770 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/router/widgets/ups.ts create mode 100644 packages/integrations/src/interfaces/ups-summary/ups-summary-integration.ts create mode 100644 packages/integrations/src/interfaces/ups-summary/ups-summary-types.ts create mode 100644 packages/integrations/src/mock/data/ups-summary.ts create mode 100644 packages/integrations/src/peanut/peanut-integration.ts create mode 100644 packages/integrations/src/peanut/peanut-types.ts create mode 100644 packages/integrations/src/peanut/test/peanut-integration.spec.ts create mode 100644 packages/request-handler/src/ups.ts create mode 100644 packages/widgets/src/ups/component.tsx create mode 100644 packages/widgets/src/ups/index.ts diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index e40c3f000..15f0963b4 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -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)), }); diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts new file mode 100644 index 000000000..f1c27c434 --- /dev/null +++ b/packages/api/src/router/widgets/ups.ts @@ -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()); + }; + }); + }), +}); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index a56827f15..5f2a9c41b 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -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]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index d6305a08e..fec9eb719 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -38,5 +38,6 @@ export const widgetKinds = [ "tracearr", "speedtestTracker", "umami", + "ups", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 9e2466bea..20688b708 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -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 Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index c7451265c..198a17671 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -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"; diff --git a/packages/integrations/src/interfaces/ups-summary/ups-summary-integration.ts b/packages/integrations/src/interfaces/ups-summary/ups-summary-integration.ts new file mode 100644 index 000000000..21510954d --- /dev/null +++ b/packages/integrations/src/interfaces/ups-summary/ups-summary-integration.ts @@ -0,0 +1,5 @@ +import type { UpsSummary } from "./ups-summary-types"; + +export interface IUpsSummaryIntegration { + getUpsSummariesAsync(): Promise; +} diff --git a/packages/integrations/src/interfaces/ups-summary/ups-summary-types.ts b/packages/integrations/src/interfaces/ups-summary/ups-summary-types.ts new file mode 100644 index 000000000..6979de023 --- /dev/null +++ b/packages/integrations/src/interfaces/ups-summary/ups-summary-types.ts @@ -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; +} diff --git a/packages/integrations/src/mock/data/ups-summary.ts b/packages/integrations/src/mock/data/ups-summary.ts new file mode 100644 index 000000000..36dce662a --- /dev/null +++ b/packages/integrations/src/mock/data/ups-summary.ts @@ -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 { + 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, + }, + ]); + } +} diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts index fd1cbe0ea..b435dbd32 100644 --- a/packages/integrations/src/mock/mock-integration.ts +++ b/packages/integrations/src/mock/mock-integration.ts @@ -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 { 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); } diff --git a/packages/integrations/src/peanut/peanut-integration.ts b/packages/integrations/src/peanut/peanut-integration.ts new file mode 100644 index 000000000..ad3fe6296 --- /dev/null +++ b/packages/integrations/src/peanut/peanut-integration.ts @@ -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 { + 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 { + 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 { + if (this.hasSecretValue("username") && this.hasSecretValue("password")) { + const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString( + "base64", + ); + return { Authorization: `Basic ${credentials}` }; + } + + return {}; + } +} diff --git a/packages/integrations/src/peanut/peanut-types.ts b/packages/integrations/src/peanut/peanut-types.ts new file mode 100644 index 000000000..fab0626f0 --- /dev/null +++ b/packages/integrations/src/peanut/peanut-types.ts @@ -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; diff --git a/packages/integrations/src/peanut/test/peanut-integration.spec.ts b/packages/integrations/src/peanut/test/peanut-integration.spec.ts new file mode 100644 index 000000000..0fdb8e460 --- /dev/null +++ b/packages/integrations/src/peanut/test/peanut-integration.spec.ts @@ -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[]) => { + mockFetch.mockImplementation( + () => + Promise.resolve( + new Response(JSON.stringify(devices), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as unknown as ReturnType, + ); +}; + +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({}); + }); +}); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 10ae96ee4..fcb8a77ae 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -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"; diff --git a/packages/request-handler/src/ups.ts b/packages/request-handler/src/ups.ts new file mode 100644 index 000000000..acc97eb6d --- /dev/null +++ b/packages/request-handler/src/ups.ts @@ -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 +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getUpsSummariesAsync(); + }, + cacheDuration: dayjs.duration(1, "minute"), + queryKey: "upsSummaries", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 1fe82d169..c231636e6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 572f70aa4..5e66efc4f 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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; diff --git a/packages/widgets/src/ups/component.tsx b/packages/widgets/src/ups/component.tsx new file mode 100644 index 000000000..2b98a1437 --- /dev/null +++ b/packages/widgets/src/ups/component.tsx @@ -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 = { + 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 ; +} + +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 ( + + + {devices.map(({ key, summary }) => ( + + ))} + + + ); +} + +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 = ( + + {t(`status.${summary.status}`)} + + ); + + if (layout === "mini") { + return ( + + + + + {summary.name} + + + {showRing && summary.batteryCharge !== null && ( + + + {Math.round(summary.batteryCharge)}% + + } + /> + + )} + + ); + } + + if (layout === "compact") { + return ( + + + {showRing && summary.batteryCharge !== null && ( + + {Math.round(summary.batteryCharge)}% + + } + /> + )} + + + {summary.name} + + {statusBadge} + + + + ); + } + + return ( + + + + {summary.name} + + {statusBadge} + + + + {showRing && summary.batteryCharge !== null && ( + + {Math.round(summary.batteryCharge)}% + + } + /> + )} + + + {options.showBattery && summary.batteryRuntime !== null && ( + + )} + + {options.showLoad && summary.load !== null && ( + + + + {t("field.load")} + + {Math.round(summary.load)}% + + + + )} + + {options.showVoltage && (summary.inputVoltage !== null || summary.outputVoltage !== null) && ( + + {summary.inputVoltage !== null && ( + + )} + {summary.outputVoltage !== null && ( + + )} + + )} + + + + ); +} + +function StatRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +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"; +} diff --git a/packages/widgets/src/ups/index.ts b/packages/widgets/src/ups/index.ts new file mode 100644 index 000000000..681daf417 --- /dev/null +++ b/packages/widgets/src/ups/index.ts @@ -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"));