feat(widgets): add UniFi OS widget

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
1 parent 99282742c6
commit a81894dd8a
8 files changed
+242

No files matched your search

@@ -28,6 +28,7 @@ export const integrationSecretIcons = {
opnsenseApiSecret: IconPassword,
patchmonApiKey: IconKey,
patchmonApiSecret: IconPassword,
siteManagerApiKey: IconKey,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
+33
View File
@@ -1298,6 +1298,10 @@
"label": "API Secret",
"newLabel": "New API Secret"
},
"siteManagerApiKey": {
"label": "Site Manager API Key",
"newLabel": "New Site Manager API Key"
},
"githubAppId": {
"label": "App Id",
"newLabel": "New App Id"
@@ -4218,6 +4222,35 @@
"subtitle": "Network traffic of docker containers"
}
}
},
"unifiOs": {
"name": "UniFi OS",
"description": "Network overview from a UniFi OS console",
"option": {
"showIsp": { "label": "Show ISP details", "description": "Requires a Site Manager API key" },
"showDevices": { "label": "Show devices", "description": "Display how many devices are online" },
"showFirmwareUpdates": {
"label": "Show firmware updates",
"description": "Display how many devices have a pending firmware update"
}
},
"card": {
"internet": "Internet",
"latency": "{value} ms",
"measuredAt": "Measured at {time}",
"clients": "Clients",
"clientBreakdown": "{total} · {wired} wired · {wireless} Wi-Fi",
"devices": "Devices",
"devicesOnline": "{online} / {total} online",
"firmware": "Firmware",
"firmwarePending": "{count} to update",
"version": "Network {version}",
"gatewayUptime": "Gateway up {uptime}"
},
"error": {
"internalServerError": "Could not reach the UniFi OS console",
"noData": "No data available"
}
}
},
"widgetPreview": {
+2
View File
@@ -35,6 +35,7 @@ import {
IconReportSearch,
IconRocket,
IconRoute,
IconRouter,
IconRss,
IconServer2,
IconShieldCheck,
@@ -75,6 +76,7 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
unifiOs: IconRouter,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
+2
View File
@@ -55,6 +55,7 @@ import * as timetable from "./timetable";
import * as traefik from "./traefik";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as unifiOs from "./unifi-os";
import * as ups from "./ups";
import * as vpn from "./vpn";
import * as beszelSystemTable from "./beszel-system-table";
@@ -131,6 +132,7 @@ export const widgetImports = {
traefik,
customApi,
wud,
unifiOs,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
+2
View File
@@ -43,6 +43,7 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
mediaMissing: () => import("./media-missing") as Promise<WidgetModule>,
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
unifiOs: () => import("./unifi-os") as Promise<WidgetModule>,
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
@@ -105,6 +106,7 @@ const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>>
networkControllerSummary: () => import("./network-controller/summary/component") as Promise<WidgetComponentModule>,
networkControllerStatus: () =>
import("./network-controller/network-status/component") as Promise<WidgetComponentModule>,
unifiOs: () => import("./unifi-os/component") as Promise<WidgetComponentModule>,
rssFeed: () => import("./rssFeed/component") as Promise<WidgetComponentModule>,
bookmarks: () => import("./bookmarks/component") as Promise<WidgetComponentModule>,
bazarr: () => import("./bazarr/component") as Promise<WidgetComponentModule>,
@@ -25,6 +25,7 @@ export const widgetQueryRefetchIntervals = [
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
{ queryKey: [["widget", "unifiOs"]], intervalSeconds: 60 },
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
] as const;
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useMemo } from "react";
import { Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconCircleFilled } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
dayjs.extend(relativeTime);
const EMPTY_VALUE = "—";
const formatBitsPerSecond = (bps: number): string => {
const units = ["b/s", "Kb/s", "Mb/s", "Gb/s"];
let value = bps;
let unitIndex = 0;
while (value >= 1000 && unitIndex < units.length - 1) {
value /= 1000;
unitIndex += 1;
}
return `${value.toFixed(value >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
};
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
return days > 0 ? `${days}d ${hours}h` : `${hours}h`;
};
export default function UnifiOsWidget({ integrationIds, options }: WidgetComponentProps<"unifiOs">) {
const t = useI18n();
const { data: results = [] } = clientApi.widget.unifiOs.summary.useQuery({ integrationIds });
const summary = useMemo(() => results[0]?.summary ?? null, [results]);
if (summary === null) {
return (
<Box h="100%" p="sm">
<Text c="dimmed" size="sm">
{t("widget.unifiOs.error.noData")}
</Text>
</Box>
);
}
const wanColor = summary.wan.state === "online" ? "green" : summary.wan.state === "offline" ? "red" : "gray";
return (
<Stack h="100%" p="sm" gap="xs" justify="space-between">
{/* The site name is not decorative: a console can host several sites and
the widget only shows one. Naming it makes that choice visible. */}
<Group gap={6} wrap="nowrap">
<IconCircleFilled size={8} color={`var(--mantine-color-${wanColor}-6)`} />
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{summary.site.name}
</Text>
</Group>
<Stack
gap={4}
p="xs"
style={{ borderRadius: "var(--mantine-radius-md)", background: "var(--mantine-color-default-hover)" }}
>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Group gap={6} wrap="nowrap">
<IconCircleFilled size={10} color={`var(--mantine-color-${wanColor}-6)`} />
<Text fw={600} size="sm">
{t("widget.unifiOs.card.internet")}
</Text>
</Group>
<Text size="sm" ff="monospace">
{summary.wan.throughput === null
? EMPTY_VALUE
: `↑${formatBitsPerSecond(summary.wan.throughput.txBps)} ↓${formatBitsPerSecond(summary.wan.throughput.rxBps)}`}
</Text>
</Group>
{options.showIsp && (
<Group justify="space-between" wrap="nowrap" gap="xs">
{summary.wan.isp === null ? (
<Text size="xs" c="dimmed">
{EMPTY_VALUE}
</Text>
) : (
<>
<Text size="xs" c="dimmed" truncate>
{summary.wan.isp.name}
{summary.wan.isp.externalIp !== null ? ` · ${summary.wan.isp.externalIp}` : ""}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="xs">{t("widget.unifiOs.card.latency", { value: `${summary.wan.isp.latencyMs}` })}</Text>
{/* The cloud metrics behind this badge lag ~27 minutes: it must
never look live, hence the relative-time badge instead of a
bare number. */}
<Tooltip
label={t("widget.unifiOs.card.measuredAt", {
time: dayjs(summary.wan.isp.measuredAt).format("HH:mm"),
})}
>
<Badge size="xs" variant="light" color="yellow">
{dayjs(summary.wan.isp.measuredAt).fromNow()}
</Badge>
</Tooltip>
</Group>
</>
)}
</Group>
)}
</Stack>
<Stack gap={2}>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{t("widget.unifiOs.card.clients")}
</Text>
<Text size="sm" fw={600}>
{summary.clients === null
? EMPTY_VALUE
: t("widget.unifiOs.card.clientBreakdown", {
total: `${summary.clients.total}`,
wired: `${summary.clients.wired}`,
wireless: `${summary.clients.wireless}`,
})}
</Text>
</Group>
{options.showDevices && (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{t("widget.unifiOs.card.devices")}
</Text>
<Text size="sm" fw={600} c={summary.devices !== null && summary.devices.offline > 0 ? "red" : undefined}>
{summary.devices === null
? EMPTY_VALUE
: t("widget.unifiOs.card.devicesOnline", {
online: `${summary.devices.online}`,
total: `${summary.devices.total}`,
})}
</Text>
</Group>
)}
{options.showFirmwareUpdates && (summary.devices === null || summary.devices.firmwareUpdatable > 0) && (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{t("widget.unifiOs.card.firmware")}
</Text>
<Text size="sm" fw={600} c={summary.devices !== null ? "yellow" : undefined}>
{summary.devices === null
? EMPTY_VALUE
: t("widget.unifiOs.card.firmwarePending", { count: `${summary.devices.firmwareUpdatable}` })}
</Text>
</Group>
)}
</Stack>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{t("widget.unifiOs.card.version", { version: summary.site.applicationVersion })}
</Text>
<Text size="xs" c="dimmed">
{t("widget.unifiOs.card.gatewayUptime", {
uptime: summary.gateway?.uptimeSec != null ? formatUptime(summary.gateway.uptimeSec) : EMPTY_VALUE,
})}
</Text>
</Group>
</Stack>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { IconRouter, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("unifiOs", {
icon: IconRouter,
refetchInterval: 60,
supportedIntegrations: ["unifiOs"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
showIsp: factory.switch({ defaultValue: true, withDescription: true }),
showDevices: factory.switch({ defaultValue: true, withDescription: true }),
showFirmwareUpdates: factory.switch({ defaultValue: true, withDescription: true }),
}));
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.unifiOs.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));