feat: add PeaNUT integration and UPS monitoring widget (#5862)
This commit is contained in:
1 parent
dd7a4908a3
commit
0dc2558faf
19 files changed
+770
-1
No files matched your search
@@ -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)),
|
||||
});
|
||||
@@ -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());
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"));
|
||||
Reference in new issue
Block a user