// --- BEGIN UNIFI THEME --- // Reusable UniFi-style UI components for GenieACS // These are new files and do not conflict with upstream components. import { ClosureComponent, Children } from "mithril"; import { m } from "./components.ts"; // ─── Status helpers ──────────────────────────────────────────────────────── export type DeviceStatus = "online" | "warning" | "offline" | "unknown"; /** * Determines the online status of a device based on its _lastInform timestamp. * < 5 min → online * < 15 min → warning * ≥ 15 min → offline * missing → unknown */ export function getDeviceStatus( device: Record, ): DeviceStatus { const lastInform = device["_lastInform"] as number | undefined; if (!lastInform) return "unknown"; const age = Date.now() - lastInform; if (age < 5 * 60 * 1000) return "online"; if (age < 15 * 60 * 1000) return "warning"; return "offline"; } const STATUS_LABEL: Record = { online: "Online", warning: "Recent", offline: "Offline", unknown: "Never", }; // ─── TR-069 parameter accessor ───────────────────────────────────────────── /** * Safely reads the first value from a TR-069 parameter object. * Tries multiple path keys and returns the first non-null hit. */ export function getParam( device: Record, ...paths: string[] ): string | null { for (const path of paths) { const entry = device[path]; if (entry == null) continue; if ( typeof entry === "object" && (entry as Record)["value"] != null ) { const v = (entry as Record)["value"]; if (Array.isArray(v) && v[0] != null) return String(v[0]); } } return null; } /** * Returns a human-readable device name (alias → serial → device ID). */ export function getDeviceName(device: Record): string { return ( getParam(device, "_deviceId._Alias") ?? getParam(device, "DeviceID.SerialNumber") ?? (device["DeviceID.ID"] as string) ?? "Unknown Device" ); } /** * Returns the device model string (ProductClass → ModelName → Manufacturer). */ export function getDeviceModel(device: Record): string { const mfr = getParam(device, "DeviceID.Manufacturer") ?? ""; const product = getParam( device, "DeviceID.ProductClass", "InternetGatewayDevice.DeviceInfo.ModelName", "Device.DeviceInfo.ModelName", ) ?? ""; return [mfr, product].filter(Boolean).join(" ") || "Unknown Model"; } /** * Returns a relative human-readable time label (e.g. "2 minutes ago"). */ export function timeAgo(ts: number): string { const seconds = Math.floor((Date.now() - ts) / 1000); if (seconds < 5) return "just now"; if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } // ─── StatusDot ───────────────────────────────────────────────────────────── interface StatusDotAttrs { status: DeviceStatus; } export const statusDot: ClosureComponent = () => ({ view: (vnode) => m(`span.unifi-status-dot.unifi-status-dot--${vnode.attrs.status}`), }); // ─── StatusBadge ─────────────────────────────────────────────────────────── interface StatusBadgeAttrs { status: DeviceStatus; } export const statusBadge: ClosureComponent = () => ({ view: (vnode) => { const s = vnode.attrs.status; return m( `span.unifi-status-badge.unifi-status-badge--${s}`, m(`span.unifi-status-dot.unifi-status-dot--${s}`, { style: "width:7px;height:7px;box-shadow:none;", }), STATUS_LABEL[s], ); }, }); // ─── StatCard ────────────────────────────────────────────────────────────── interface StatCardAttrs { label: string; value: number | string; colorClass?: string; } export const statCard: ClosureComponent = () => ({ view: (vnode) => m( "div.unifi-stat-card", m("div.unifi-stat-card__label", vnode.attrs.label), m( `div.unifi-stat-card__value${vnode.attrs.colorClass ? "." + vnode.attrs.colorClass : ""}`, vnode.attrs.value, ), ), }); // ─── DeviceCard ──────────────────────────────────────────────────────────── interface DeviceCardAttrs { device: Record; onclick?: (e: MouseEvent) => void; } export const deviceCard: ClosureComponent = () => ({ view: (vnode) => { const device = vnode.attrs.device; const status = getDeviceStatus(device); const name = getDeviceName(device); const model = getDeviceModel(device); const lastInform = device["_lastInform"] as number | undefined; const deviceId = device["DeviceID.ID"] as string; // WAN IP – try TR-098 then TR-181 const wanIp = getParam( device, "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress", "Device.IP.Interface.1.IPv4Address.1.IPAddress", "Device.PPP.Interface.1.IPCPLocalIPAddress", ) ?? null; // WiFi SSID – try TR-098 then TR-181 const ssid = getParam( device, "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", "InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.SSID", "Device.WiFi.SSID.1.SSID", "Device.WiFi.SSID.2.SSID", ) ?? null; // Firmware const firmware = getParam( device, "InternetGatewayDevice.DeviceInfo.SoftwareVersion", "Device.DeviceInfo.SoftwareVersion", ) ?? null; // Tags const tags = device["_tags"] as Record | undefined; const activeTags = tags ? Object.entries(tags) .filter(([, v]) => v) .map(([k]) => k) : []; function row(label: string, value: string | null): Children { return m("div.unifi-device-card__row", [ m("span.unifi-device-card__row-label", label), m( `span.unifi-device-card__row-value${value == null ? ".empty" : ""}`, value ?? "—", ), ]); } return m( "div.unifi-device-card", { onclick: vnode.attrs.onclick }, [ // Header m("div.unifi-device-card__header", [ m(statusDot, { status }), m("div", { style: "flex:1;overflow:hidden;" }, [ m("p.unifi-device-card__name", { title: name }, name), m("p.unifi-device-card__model", model), ]), m(statusBadge, { status }), ]), // Body m("div.unifi-device-card__body", [ wanIp !== null && row("WAN", wanIp), ssid !== null && row("WiFi", ssid), firmware !== null && row("FW", firmware), row( "ID", typeof deviceId === "string" ? deviceId.slice(0, 30) : null, ), ]), // Footer m("div.unifi-device-card__footer", [ m( "span", lastInform ? `Last seen ${timeAgo(lastInform)}` : "Never connected", ), activeTags.length > 0 && m( "div.unifi-device-card__tags", activeTags.slice(0, 3).map((t) => m("span.unifi-tag", t)), ), ]), ], ); }, }); // ─── PageSizeSelector ────────────────────────────────────────────────────── interface PageSizeSelectorAttrs { value: number; options: number[]; onChange: (n: number) => void; } export const pageSizeSelector: ClosureComponent = () => ({ view: (vnode) => m("div.unifi-page-size-selector", [ m("span", "Per page:"), m( "select", { value: vnode.attrs.value, onchange: (e: Event) => vnode.attrs.onChange( Number((e.target as HTMLSelectElement).value), ), }, vnode.attrs.options.map((n) => m("option", { value: n, selected: n === vnode.attrs.value }, String(n)), ), ), ]), }); // --- END UNIFI THEME ---