implementação de mudança de interface
Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
1 parent
d445aa03cf
commit
d4e1b297ae
24 files changed
+1982
-47
No files matched your search
@@ -0,0 +1,286 @@
|
||||
// --- 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 ---
|
||||
Reference in new issue
Block a user