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 77eaf1487..e19fbeaf4 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 f1a4b8306..6537ed32e 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1735,6 +1735,10 @@ "wazuhDashboardUrl": { "label": "Dashboard URL (optional)", "newLabel": "New dashboard URL" + }, + "siteManagerApiKey": { + "label": "Site Manager API Key", + "newLabel": "New Site Manager API Key" } } }, @@ -5749,6 +5753,41 @@ "sourceIps": "Source IPs", "ipTooltip": "{ip}: {count} failures", "bruteForceCount": "{count} brute force" + }, + "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/ui/src/widget-icons.ts b/packages/ui/src/widget-icons.ts index e1eb2dc46..820cfa989 100644 --- a/packages/ui/src/widget-icons.ts +++ b/packages/ui/src/widget-icons.ts @@ -39,6 +39,7 @@ import { IconRocket, IconRobot, IconRoute, + IconRouter, IconRss, IconServer2, IconShieldCheck, @@ -92,6 +93,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/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index 78398aa21..8a97390e7 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -42,6 +42,7 @@ export const widgetQueryRefetchIntervals = [ { queryKey: [["widget", "umami", "getMultiEventTimeSeries"]], intervalSeconds: null }, { queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null }, { queryKey: [["widget", "umami", "getTopReferrers"]], 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/registry.ts b/packages/widgets/src/registry.ts index a2ad8a024..818e01e29 100644 --- a/packages/widgets/src/registry.ts +++ b/packages/widgets/src/registry.ts @@ -54,6 +54,7 @@ import type * as timer from "./timer"; import type * as tracearr from "./tracearr"; import type * as traefik from "./traefik"; import type * as umami from "./umami"; +import type * as unifiOs from "./unifi-os"; import type * as ups from "./ups"; import type * as uptimeKuma from "./uptime-kuma"; import type * as video from "./video"; @@ -123,6 +124,7 @@ export const widgetModuleLoaders = { uptimeKuma: () => import("./uptime-kuma"), audioStats: () => import("./audio-stats"), umami: () => import("./umami"), + unifiOs: () => import("./unifi-os"), vpn: () => import("./vpn"), ups: () => import("./ups"), beszelSystemTable: () => import("./beszel-system-table"), @@ -195,6 +197,7 @@ export type WidgetImports = { uptimeKuma: typeof uptimeKuma; audioStats: typeof audioStats; umami: typeof umami; + unifiOs: typeof unifiOs; vpn: typeof vpn; ups: typeof ups; beszelSystemTable: typeof beszelSystemTable; @@ -274,6 +277,7 @@ export const widgetComponentLoaders = { uptimeKuma: () => import("./uptime-kuma/component"), audioStats: () => import("./audio-stats/component"), umami: () => import("./umami/component"), + unifiOs: () => import("./unifi-os/component"), vpn: () => import("./vpn/component"), ups: () => import("./ups/component"), beszelSystemTable: () => import("./beszel-system-table/component"), 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"));