287 lines
9.0 KiB
TypeScript
287 lines
9.0 KiB
TypeScript
// --- 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<string, unknown>,
|
||
): 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<DeviceStatus, string> = {
|
||
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<string, unknown>,
|
||
...paths: string[]
|
||
): string | null {
|
||
for (const path of paths) {
|
||
const entry = device[path];
|
||
if (entry == null) continue;
|
||
if (
|
||
typeof entry === "object" &&
|
||
(entry as Record<string, unknown>)["value"] != null
|
||
) {
|
||
const v = (entry as Record<string, unknown[]>)["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, unknown>): 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, unknown>): 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<StatusDotAttrs> = () => ({
|
||
view: (vnode) =>
|
||
m(`span.unifi-status-dot.unifi-status-dot--${vnode.attrs.status}`),
|
||
});
|
||
|
||
// ─── StatusBadge ───────────────────────────────────────────────────────────
|
||
|
||
interface StatusBadgeAttrs {
|
||
status: DeviceStatus;
|
||
}
|
||
|
||
export const statusBadge: ClosureComponent<StatusBadgeAttrs> = () => ({
|
||
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<StatCardAttrs> = () => ({
|
||
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<string, unknown>;
|
||
onclick?: (e: MouseEvent) => void;
|
||
}
|
||
|
||
export const deviceCard: ClosureComponent<DeviceCardAttrs> = () => ({
|
||
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<string, boolean> | 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<PageSizeSelectorAttrs> = () => ({
|
||
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 ---
|