diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts index 1b2ed3c7b..e03e1cc5f 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts @@ -28,6 +28,7 @@ export const integrationSecretIcons = { opnsenseApiSecret: IconPassword, patchmonApiKey: IconKey, patchmonApiSecret: IconPassword, + siteManagerApiKey: IconKey, githubAppId: IconCode, githubInstallationId: IconPlug, privateKey: IconKey, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a17799404..ee17567a6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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": { diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts index a8a185310..c2823e201 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -35,6 +35,7 @@ import { IconReportSearch, IconRocket, IconRoute, + IconRouter, IconRss, IconServer2, IconShieldCheck, @@ -75,6 +76,7 @@ export const widgetCatalogIcons: Record = { minecraftServerStatus: IconBrandMinecraft, networkControllerSummary: IconTopologyFull, networkControllerStatus: IconTopologyFull, + unifiOs: IconRouter, rssFeed: IconRss, bookmarks: IconBookmark, indexerManager: IconReportSearch, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 36a9cbbfc..6259e1637 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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; diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 3fd61a0df..bbc33a3d9 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -43,6 +43,7 @@ const moduleLoaders: Record Promise> = { mediaMissing: () => import("./media-missing") as Promise, networkControllerSummary: () => import("./network-controller/summary") as Promise, networkControllerStatus: () => import("./network-controller/network-status") as Promise, + unifiOs: () => import("./unifi-os") as Promise, rssFeed: () => import("./rssFeed") as Promise, bookmarks: () => import("./bookmarks") as Promise, bazarr: () => import("./bazarr") as Promise, @@ -105,6 +106,7 @@ const componentLoaders: Record Promise> networkControllerSummary: () => import("./network-controller/summary/component") as Promise, networkControllerStatus: () => import("./network-controller/network-status/component") as Promise, + unifiOs: () => import("./unifi-os/component") as Promise, rssFeed: () => import("./rssFeed/component") as Promise, bookmarks: () => import("./bookmarks/component") as Promise, bazarr: () => import("./bazarr/component") as Promise, diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index b1bf0e410..6fd9f156c 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -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; diff --git a/packages/widgets/src/unifi-os/component.tsx b/packages/widgets/src/unifi-os/component.tsx new file mode 100644 index 000000000..33e6405eb --- /dev/null +++ b/packages/widgets/src/unifi-os/component.tsx @@ -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 ( + + + {t("widget.unifiOs.error.noData")} + + + ); + } + + const wanColor = summary.wan.state === "online" ? "green" : summary.wan.state === "offline" ? "red" : "gray"; + + return ( + + {/* The site name is not decorative: a console can host several sites and + the widget only shows one. Naming it makes that choice visible. */} + + + + {summary.site.name} + + + + + + + + + {t("widget.unifiOs.card.internet")} + + + + {summary.wan.throughput === null + ? EMPTY_VALUE + : `↑${formatBitsPerSecond(summary.wan.throughput.txBps)} ↓${formatBitsPerSecond(summary.wan.throughput.rxBps)}`} + + + + {options.showIsp && ( + + {summary.wan.isp === null ? ( + + {EMPTY_VALUE} + + ) : ( + <> + + {summary.wan.isp.name} + {summary.wan.isp.externalIp !== null ? ` · ${summary.wan.isp.externalIp}` : ""} + + + {t("widget.unifiOs.card.latency", { value: `${summary.wan.isp.latencyMs}` })} + {/* The cloud metrics behind this badge lag ~27 minutes: it must + never look live, hence the relative-time badge instead of a + bare number. */} + + + {dayjs(summary.wan.isp.measuredAt).fromNow()} + + + + + )} + + )} + + + + + + {t("widget.unifiOs.card.clients")} + + + {summary.clients === null + ? EMPTY_VALUE + : t("widget.unifiOs.card.clientBreakdown", { + total: `${summary.clients.total}`, + wired: `${summary.clients.wired}`, + wireless: `${summary.clients.wireless}`, + })} + + + + {options.showDevices && ( + + + {t("widget.unifiOs.card.devices")} + + 0 ? "red" : undefined}> + {summary.devices === null + ? EMPTY_VALUE + : t("widget.unifiOs.card.devicesOnline", { + online: `${summary.devices.online}`, + total: `${summary.devices.total}`, + })} + + + )} + + {options.showFirmwareUpdates && (summary.devices === null || summary.devices.firmwareUpdatable > 0) && ( + + + {t("widget.unifiOs.card.firmware")} + + + {summary.devices === null + ? EMPTY_VALUE + : t("widget.unifiOs.card.firmwarePending", { count: `${summary.devices.firmwareUpdatable}` })} + + + )} + + + + + {t("widget.unifiOs.card.version", { version: summary.site.applicationVersion })} + + + {t("widget.unifiOs.card.gatewayUptime", { + uptime: summary.gateway?.uptimeSec != null ? formatUptime(summary.gateway.uptimeSec) : EMPTY_VALUE, + })} + + + + ); +} diff --git a/packages/widgets/src/unifi-os/index.ts b/packages/widgets/src/unifi-os/index.ts new file mode 100644 index 000000000..23288c1f7 --- /dev/null +++ b/packages/widgets/src/unifi-os/index.ts @@ -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"));