// --- BEGIN UNIFI THEME --- // UniFi-style card grid for the Devices listing. // This file REPLACES devices-page.ts in the router but DOES NOT modify // the original file, keeping upstream merges clean. // Data-fetching uses the same store.fetch/count API as the original. import { ClosureComponent, Children } from "mithril"; import { m } from "./components.ts"; import { pageSize as DEFAULT_PAGE_SIZE, index as indexConfig } from "./config.ts"; import filterComponent from "./filter-component.ts"; import * as store from "./store.ts"; import { queueTask, stageDownload } from "./task-queue.ts"; import * as notifications from "./notifications.ts"; import Expression, { extractPaths } from "../lib/common/expression.ts"; import memoize from "../lib/common/memoize.ts"; import * as smartQuery from "./smart-query.ts"; import { deviceCard, statCard, pageSizeSelector, getDeviceStatus, getDeviceName, } from "./unifi-components.ts"; const unpackSmartQuery = memoize((query: Expression) => { return query.evaluate((e) => { if (e instanceof Expression.FunctionCall) { if (e.name === "Q") { if ( e.args[0] instanceof Expression.Literal && e.args[1] instanceof Expression.Literal ) { return smartQuery.unpack( "devices", e.args[0].value as string, e.args[1].value as string, ); } } } return e; }); }); // Filters for counting online / warning / offline devices const ONLINE_FILTER = Expression.parse( "IS NOT NULL(_lastInform) AND _lastInform > NOW() - 300000", ); const WARNING_FILTER = Expression.parse( "IS NOT NULL(_lastInform) AND _lastInform > NOW() - 900000 AND _lastInform <= NOW() - 300000", ); const OFFLINE_FILTER = Expression.parse( "IS NOT NULL(_lastInform) AND _lastInform <= NOW() - 900000", ); export function init(args: Record): Promise { return new Promise((resolve, reject) => { if (!window.authorizer.hasAccess("devices", 2)) return void reject( new Error("You are not authorized to view this page"), ); let filter: Expression = null; let sort: Record = null; if (args.hasOwnProperty("filter")) filter = Expression.parse(args["filter"] as string); if (args.hasOwnProperty("sort")) sort = JSON.parse(args["sort"] as string); const indexParameters = indexConfig; if (!indexParameters.length) { indexParameters.push({ label: "ID", parameter: Expression.parse("DeviceID.ID"), unsortable: false, raw: {}, }); } resolve({ filter, indexParameters, sort }); }); } function renderBulkActions(selected: Set): Children { const disabled = !selected.size; const s = selected.size; function btn( label: string, title: string, cls: string, onClick: () => void, ): Children { return m( `button.unifi-btn.${cls}`, { title, disabled, onclick: onClick }, label, ); } return [ btn("Reboot", `Reboot ${s} device(s)`, "unifi-btn--secondary", () => { queueTask(...[...selected].map((id) => ({ name: "reboot", device: id }))); }), btn("Reset", `Factory-reset ${s} device(s)`, "unifi-btn--secondary", () => { queueTask( ...[...selected].map((id) => ({ name: "factoryReset", device: id })), ); }), btn("Push File", `Push file to ${s} device(s)`, "unifi-btn--secondary", () => { stageDownload({ name: "download", devices: [...selected] }); }), btn("Delete", `Delete ${s} device(s)`, "unifi-btn--danger", () => { if (!confirm(`Delete ${s} device(s)?`)) return; let counter = 1; for (const id of selected) { ++counter; store .deleteResource("devices", id) .then(() => { notifications.push("success", `${id}: Deleted`); if (--counter === 0) store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", `${id}: ${err.message}`); if (--counter === 0) store.setTimestamp(Date.now()); }); } if (--counter === 0) store.setTimestamp(Date.now()); }), btn("Tag", `Tag ${s} device(s)`, "unifi-btn--secondary", () => { const tag = prompt(`Tag to assign to ${s} device(s):`); if (!tag) return; let counter = 1; for (const id of selected) { ++counter; store .updateTags(id, { [tag]: true }) .then(() => { notifications.push("success", `${id}: Tagged`); if (--counter === 0) store.setTimestamp(Date.now()); }) .catch((err) => { notifications.push("error", `${id}: ${err.message}`); if (--counter === 0) store.setTimestamp(Date.now()); }); } if (--counter === 0) store.setTimestamp(Date.now()); }), ]; } interface Attrs { indexParameters: typeof indexConfig; filter?: Expression; sort?: Record; } const PAGE_SIZE_OPTIONS = [30, 50, 100]; export const component: ClosureComponent = () => { let selected = new Set(); let pageSize = DEFAULT_PAGE_SIZE; // Clamp pageSize to our supported options on first render const validOptions = PAGE_SIZE_OPTIONS; if (!validOptions.includes(pageSize)) pageSize = 30; return { view: (vnode) => { document.title = "Devices — GenieACS"; function onFilterChanged(filter: Expression): void { const ops: Record = {}; if (!(filter instanceof Expression.Literal && filter.value)) ops["filter"] = filter.toString(); m.route.set("/devices", ops); selected = new Set(); } const filter = unpackSmartQuery( vnode.attrs["filter"] ?? new Expression.Literal(true), ); const devs = store.fetch("devices", filter, { limit: vnode.state["showCount"] || pageSize, sort: vnode.attrs.sort || {}, }); const totalCount = store.count("devices", filter); const onlineCount = store.count("devices", ONLINE_FILTER); const warningCount = store.count("devices", WARNING_FILTER); const offlineCount = store.count("devices", OFFLINE_FILTER); function showMore(): void { vnode.state["showCount"] = (vnode.state["showCount"] || pageSize) + pageSize; m.redraw(); } const devices: Record[] = devs.value ?? []; const total: number = totalCount.value ?? 0; const loaded = devices.length; const hasMore = total > loaded; const selectAll = loaded > 0 && selected.size === loaded; // ── Stats row ───────────────────────────────────────────────────────── const statsRow = m( "div.unifi-stat-row", m(statCard, { label: "Total Devices", value: total ?? "…" }), m(statCard, { label: "Online", value: onlineCount.value ?? "…", colorClass: "online", }), m(statCard, { label: "Recent", value: warningCount.value ?? "…", colorClass: "warning", }), m(statCard, { label: "Offline", value: offlineCount.value ?? "…", colorClass: "offline", }), ); // ── Toolbar ─────────────────────────────────────────────────────────── const actionsEnabled = window.authorizer.hasAccess("devices", 3); const toolbar = m("div.unifi-toolbar", [ // Left: bulk actions (visible when devices selected) m( "div.unifi-toolbar-left", actionsEnabled && selected.size > 0 ? [ m( "span", { style: "font-size:0.8125rem;color:#7a8699;" }, `${selected.size} selected`, ), ...renderBulkActions(selected), ] : [ // Select-all checkbox actionsEnabled && m( "label", { style: "display:flex;align-items:center;gap:6px;font-size:0.8125rem;color:#7a8699;cursor:pointer;", }, m("input", { type: "checkbox", checked: selectAll, disabled: loaded === 0, onchange: (e: Event) => { if ((e.target as HTMLInputElement).checked) { for (const d of devices) selected.add(d["DeviceID.ID"] as string); } else { selected.clear(); } }, }), "Select all", ), ], ), // Right: page size selector m("div.unifi-toolbar-right", [ total > 0 && m( "span", { style: "font-size:0.8125rem;color:#9aa5b8;" }, `${loaded} / ${total}`, ), m(pageSizeSelector, { value: vnode.state["pageSizeValue"] || pageSize, options: PAGE_SIZE_OPTIONS, onChange: (n) => { vnode.state["pageSizeValue"] = n; vnode.state["showCount"] = n; selected = new Set(); m.redraw(); }, }), ]), ]); // ── Filter bar ──────────────────────────────────────────────────────── const filterBar = m(filterComponent, { resource: "devices", filter: vnode.attrs["filter"], onChange: onFilterChanged, }); // ── Device grid ─────────────────────────────────────────────────────── const grid = devices.length === 0 ? m( "div", { style: "text-align:center;padding:60px 20px;color:#9aa5b8;font-size:0.9375rem;", }, devs.fulfilling ? "Loading devices…" : "No devices found.", ) : m( "div.unifi-device-grid", devices.map((device) => { const id = device["DeviceID.ID"] as string; const isSelected = selected.has(id); return m(deviceCard, { key: id, device, onclick: (e: MouseEvent) => { // Ctrl/Cmd + click = toggle selection if (e.ctrlKey || e.metaKey) { if (isSelected) selected.delete(id); else selected.add(id); m.redraw(); return; } // Normal click = navigate to device detail m.route.set( `/devices/${encodeURIComponent(id)}`, ); }, }); }), ); // ── Load-more / pagination ────────────────────────────────────────── const loadMoreBar = hasMore ? m( "div.unifi-pagination", m( "span.unifi-pagination__info", `Showing ${loaded} of ${total} devices`, ), m( "button.unifi-btn.unifi-btn--secondary", { onclick: showMore }, `Load ${Math.min(pageSize, total - loaded)} more`, ), ) : total > 0 ? m( "div.unifi-pagination", m( "span.unifi-pagination__info", `All ${total} devices loaded`, ), ) : null; return [ m("div.unifi-page-header", [ m("h1.unifi-page-title", "Devices"), // CSV download link total > 0 && window.authorizer.hasAccess("devices", 2) && m( "a.unifi-btn.unifi-btn--secondary", { href: `api/devices.csv?filter=${encodeURIComponent(vnode.attrs["filter"]?.toString() ?? "")}`, download: "devices.csv", }, "Export CSV", ), ]), statsRow, filterBar, toolbar, m( "loading", { queries: [devs, totalCount, onlineCount, warningCount, offlineCount], }, grid, ), loadMoreBar, ]; }, }; }; // --- END UNIFI THEME ---