feat(widgets): add UniFi OS widget
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99282742c6
commit
a81894dd8a
8 files changed
+242
No files matched your search
+1
@@ -28,6 +28,7 @@ export const integrationSecretIcons = {
|
||||
opnsenseApiSecret: IconPassword,
|
||||
patchmonApiKey: IconKey,
|
||||
patchmonApiSecret: IconPassword,
|
||||
siteManagerApiKey: IconKey,
|
||||
githubAppId: IconCode,
|
||||
githubInstallationId: IconPlug,
|
||||
privateKey: IconKey,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
Reference in new issue
Block a user