Files
genieacs/ui/unifi-components.ts
rodrigo d4e1b297ae implementação de mudança de interface
Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
2026-03-30 09:53:11 -03:00

287 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// --- 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 ---