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:
rodrigo committed 2026-03-30 09:53:11 -03:00
1 parent d445aa03cf
commit d4e1b297ae
24 files changed
+1982 -47

No files matched your search

+8 -2
View File
@@ -21,6 +21,10 @@ import { PermissionSet, UiConfig } from "../lib/types.ts";
import drawerComponent from "./drawer-component.ts";
import { render as renderOverlay } from "./overlay.ts";
import Expression from "../lib/common/expression.ts";
// --- BEGIN UNIFI THEME ---
import * as unifiDevicesPage from "./unifi-devices-page.ts";
import * as unifiDevicePage from "./unifi-device-detail-page.ts";
// --- END UNIFI THEME ---
declare global {
interface Window {
@@ -105,8 +109,10 @@ function pagify(pageName, page): RouteResolver {
m.route(document.body, "/overview", {
"/wizard": pagify("wizard", wizardPage),
"/overview": pagify("overview", overviewPage),
"/devices": pagify("devices", devicesPage),
"/devices/:id": pagify("devices", devicePage),
// --- BEGIN UNIFI THEME ---
"/devices": pagify("devices", unifiDevicesPage),
"/devices/:id": pagify("devices", unifiDevicePage),
// --- END UNIFI THEME ---
"/faults": pagify("faults", faultsPage),
"/presets": pagify("presets", presetsPage),
"/provisions": pagify("provisions", provisionsPage),
+3
View File
@@ -1,5 +1,8 @@
@import "tailwindcss";
@plugin "@tailwindcss/forms";
/* --- BEGIN UNIFI THEME --- */
@import "./unifi-theme.css";
/* --- END UNIFI THEME --- */
@source inline("h-full bg-stone-100");
@source "../../ui/**/*.{ts,tsx}";
+610
View File
@@ -0,0 +1,610 @@
/* =============================================================
* UniFi UI Theme — GenieACS
* Isolates all UniFi Controller visual overrides so that
* upstream GenieACS merges touch this file at most.
* ============================================================= */
/* ---- Sidebar ---- */
.unifi-sidebar {
background-color: #0d1b2a !important;
border-right: 1px solid #111e2e !important;
}
.unifi-sidebar .unifi-brand {
padding: 20px 16px 12px;
display: flex;
align-items: center;
gap: 10px;
}
.unifi-sidebar .unifi-brand img {
filter: brightness(0) invert(1);
height: 32px;
width: auto;
}
.unifi-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
color: #8b9bb4;
text-decoration: none;
transition: background-color 0.15s, color 0.15s;
margin-bottom: 2px;
}
.unifi-nav-item:hover {
background-color: rgba(255, 255, 255, 0.06);
color: #c8d6e8;
}
.unifi-nav-item.active {
background-color: rgba(0, 111, 255, 0.14);
color: #4d9de0;
}
.unifi-nav-item svg {
width: 18px;
height: 18px;
flex-shrink: 0;
opacity: 0.75;
}
.unifi-nav-item.active svg {
opacity: 1;
}
.unifi-sidebar-footer {
padding: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
color: #8b9bb4;
font-size: 0.8125rem;
}
.unifi-sidebar-footer .unifi-user-info {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
}
.unifi-sidebar-footer .unifi-logout-btn {
background: none;
border: none;
color: #4d9de0;
cursor: pointer;
font-size: 0.8125rem;
padding: 0;
margin-left: auto;
text-decoration: none;
}
.unifi-sidebar-footer .unifi-logout-btn:hover {
color: #72b8f5;
}
.unifi-version {
font-size: 0.6875rem;
font-family: var(--font-mono, monospace);
color: #3d5166;
text-align: right;
padding: 6px 12px;
}
/* ---- Page header ---- */
.unifi-page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 0;
flex-wrap: wrap;
gap: 12px;
}
.unifi-page-title {
font-size: 1.25rem;
font-weight: 700;
color: #1a2537;
margin: 0;
}
/* ---- Stat cards row ---- */
.unifi-stat-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.unifi-stat-card {
background: #ffffff;
border-radius: 8px;
padding: 16px 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07);
border: 1px solid #e8eaf0;
display: flex;
flex-direction: column;
gap: 4px;
}
.unifi-stat-card__label {
font-size: 0.75rem;
color: #7a8699;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.unifi-stat-card__value {
font-size: 1.75rem;
font-weight: 700;
color: #1a2537;
line-height: 1;
}
.unifi-stat-card__value.online { color: #43b02a; }
.unifi-stat-card__value.warning { color: #ffa720; }
.unifi-stat-card__value.offline { color: #e53935; }
/* ---- Toolbar ---- */
.unifi-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.unifi-toolbar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.unifi-toolbar-right { display: flex; align-items: center; gap: 10px; }
/* ---- Page size selector ---- */
.unifi-page-size-selector {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
color: #7a8699;
}
.unifi-page-size-selector select {
background: #fff;
border: 1px solid #d0d6e0;
border-radius: 6px;
padding: 4px 8px;
font-size: 0.8125rem;
color: #1a2537;
cursor: pointer;
appearance: auto;
}
/* ---- Buttons ---- */
.unifi-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: background-color 0.15s, opacity 0.15s;
white-space: nowrap;
text-decoration: none;
}
.unifi-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.unifi-btn--primary {
background-color: #006fff;
color: #fff;
border-color: #006fff;
}
.unifi-btn--primary:hover:not(:disabled) { background-color: #005ee0; border-color: #005ee0; }
.unifi-btn--secondary {
background-color: #fff;
color: #1a2537;
border-color: #d0d6e0;
}
.unifi-btn--secondary:hover:not(:disabled) { background-color: #f4f5f7; }
.unifi-btn--danger {
background-color: #fff;
color: #e53935;
border-color: #e53935;
}
.unifi-btn--danger:hover:not(:disabled) { background-color: #fff0f0; }
.unifi-btn--icon {
padding: 6px;
background: none;
border: 1px solid #d0d6e0;
border-radius: 6px;
color: #7a8699;
cursor: pointer;
display: inline-flex;
align-items: center;
}
.unifi-btn--icon:hover { background: #f4f5f7; color: #1a2537; }
/* ---- Device card grid ---- */
.unifi-device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.unifi-device-card {
background: #ffffff;
border-radius: 8px;
border: 1px solid #e8eaf0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
overflow: hidden;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s, transform 0.1s;
display: flex;
flex-direction: column;
}
.unifi-device-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
border-color: #b8c0d0;
transform: translateY(-1px);
}
.unifi-device-card__header {
padding: 14px 16px 10px;
display: flex;
align-items: flex-start;
gap: 10px;
border-bottom: 1px solid #f0f2f6;
}
.unifi-device-card__name {
font-size: 0.9375rem;
font-weight: 600;
color: #1a2537;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.unifi-device-card__model {
font-size: 0.75rem;
color: #7a8699;
margin: 2px 0 0;
}
.unifi-device-card__body {
padding: 12px 16px;
flex: 1;
display: flex;
flex-direction: column;
gap: 7px;
}
.unifi-device-card__row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
}
.unifi-device-card__row-label {
color: #7a8699;
min-width: 52px;
font-size: 0.75rem;
}
.unifi-device-card__row-value {
color: #1a2537;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
}
.unifi-device-card__row-value.empty {
color: #b0b8c8;
font-family: var(--font-sans, sans-serif);
font-style: italic;
}
.unifi-device-card__footer {
padding: 8px 16px;
background: #f8f9fc;
border-top: 1px solid #f0f2f6;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
color: #9aa5b8;
}
.unifi-device-card__tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
/* ---- Status dot / badge ---- */
.unifi-status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 3px;
}
.unifi-status-dot--online { background-color: #43b02a; box-shadow: 0 0 0 3px rgba(67,176,42,0.18); }
.unifi-status-dot--warning { background-color: #ffa720; box-shadow: 0 0 0 3px rgba(255,167,32,0.18); }
.unifi-status-dot--offline { background-color: #e53935; box-shadow: 0 0 0 3px rgba(229,57,53,0.18); }
.unifi-status-dot--unknown { background-color: #b0b8c8; }
.unifi-status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.unifi-status-badge--online { background: rgba(67,176,42,0.12); color: #2e8020; }
.unifi-status-badge--warning { background: rgba(255,167,32,0.12); color: #b87000; }
.unifi-status-badge--offline { background: rgba(229,57,53,0.12); color: #c62828; }
.unifi-status-badge--unknown { background: rgba(176,184,200,0.18); color: #7a8699; }
/* ---- Tag badge ---- */
.unifi-tag {
display: inline-block;
padding: 1px 7px;
border-radius: 999px;
font-size: 0.6875rem;
background: rgba(0, 111, 255, 0.1);
color: #006fff;
font-weight: 500;
}
/* ---- Pagination ---- */
.unifi-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
font-size: 0.8125rem;
color: #7a8699;
}
.unifi-pagination__info {
margin-right: 8px;
}
/* ---- Device detail page ---- */
.unifi-device-detail-header {
background: #fff;
border: 1px solid #e8eaf0;
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.unifi-device-detail-header__top {
display: flex;
align-items: flex-start;
gap: 14px;
flex-wrap: wrap;
}
.unifi-device-detail-header__icon {
width: 48px;
height: 48px;
border-radius: 8px;
background: #f0f6ff;
display: flex;
align-items: center;
justify-content: center;
color: #006fff;
flex-shrink: 0;
}
.unifi-device-detail-header__info {
flex: 1;
}
.unifi-device-detail-header__name {
font-size: 1.25rem;
font-weight: 700;
color: #1a2537;
margin: 0 0 4px;
}
.unifi-device-detail-header__meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 0.8125rem;
color: #7a8699;
}
.unifi-device-detail-header__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
}
.unifi-device-detail-header__kv {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 24px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f2f6;
}
.unifi-detail-kv__label {
font-size: 0.75rem;
color: #7a8699;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 2px;
}
.unifi-detail-kv__value {
font-size: 0.875rem;
color: #1a2537;
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.unifi-detail-kv__value.empty {
color: #b0b8c8;
font-family: var(--font-sans, sans-serif);
font-style: italic;
}
.unifi-device-content {
background: #ffffff;
border: 1px solid #e8eaf0;
border-radius: 8px;
padding: 20px 24px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
/* ---- Back button ---- */
.unifi-back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #4d9de0;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
margin-bottom: 16px;
}
.unifi-back-link:hover { color: #2880cc; }
/* ---- Login page override ---- */
.unifi-login-wrapper {
min-height: 100vh;
background: linear-gradient(135deg, #0d1b2a 0%, #1a2f45 100%);
display: flex;
align-items: center;
justify-content: center;
}
.unifi-login-card {
background: #fff;
border-radius: 12px;
padding: 40px 36px;
width: 100%;
max-width: 400px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
/* ---- Table overrides (for inner device page content) ---- */
.device-page table {
border-collapse: separate;
border-spacing: 0;
width: 100%;
}
.device-page table th {
background: #f8f9fc;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #7a8699;
padding: 10px 14px;
border-bottom: 1px solid #e8eaf0;
text-align: left;
}
.device-page table td {
padding: 9px 14px;
border-bottom: 1px solid #f0f2f6;
font-size: 0.875rem;
color: #1a2537;
vertical-align: middle;
}
.device-page table tr:last-child td {
border-bottom: none;
}
.device-page table tr:hover td {
background-color: #f8faff;
}
/* ---- Main content background ---- */
.unifi-main-bg {
background: #f4f5f7;
min-height: 100vh;
}
/* ---- Users / permissions pages ---- */
.unifi-admin-section {
background: #fff;
border-radius: 8px;
border: 1px solid #e8eaf0;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
margin-bottom: 20px;
}
.unifi-admin-section__header {
padding: 14px 20px;
border-bottom: 1px solid #e8eaf0;
background: #f8f9fc;
display: flex;
align-items: center;
justify-content: space-between;
}
.unifi-admin-section__title {
font-size: 0.9375rem;
font-weight: 600;
color: #1a2537;
margin: 0;
}
/* ---- Responsive adjustments ---- */
@media (max-width: 640px) {
.unifi-device-grid {
grid-template-columns: 1fr;
}
.unifi-stat-row {
grid-template-columns: 1fr 1fr;
}
.unifi-device-detail-header__kv {
grid-template-columns: 1fr;
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>

After

Width:  |  Height:  |  Size: 224 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M12 2v2M12 20v2M20 12h2M2 12h2M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41"/></svg>

After

Width:  |  Height:  |  Size: 347 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M8 14V9a4 4 0 0 1 8 0v5"/><line x1="12" y1="18" x2="12" y2="18"/></svg>

After

Width:  |  Height:  |  Size: 311 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>

After

Width:  |  Height:  |  Size: 368 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>

After

Width:  |  Height:  |  Size: 275 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>

After

Width:  |  Height:  |  Size: 352 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>

After

Width:  |  Height:  |  Size: 243 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>

After

Width:  |  Height:  |  Size: 526 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>

After

Width:  |  Height:  |  Size: 259 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>

After

Width:  |  Height:  |  Size: 345 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9l6 6"/><path d="M15 9l-6 6"/></svg>

After

Width:  |  Height:  |  Size: 279 B

+46 -45
View File
@@ -102,6 +102,21 @@ const component: ClosureComponent<Attrs> = () => {
return { name, href, active: vnode.attrs["page"] === n };
});
// --- BEGIN UNIFI THEME ---
const NAV_ICONS: Record<string, string> = {
"Overview": "nav-overview",
"Devices": "nav-devices",
"Faults": "nav-faults",
"Presets": "nav-presets",
"Provisions": "nav-provisions",
"Virtual Parameters": "nav-virtualparams",
"Files": "nav-files",
"Config": "nav-config",
"Permissions": "nav-permissions",
"Users": "nav-users",
};
// --- END UNIFI THEME ---
return [
<div>
<TransitionRoot show={!!sidebarOpen} duration={300}>
@@ -128,7 +143,8 @@ const component: ClosureComponent<Attrs> = () => {
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<div class="relative flex-1 flex flex-col max-w-xs w-full bg-white">
{/* --- BEGIN UNIFI THEME --- */}
<div class="relative flex-1 flex flex-col max-w-xs w-full unifi-sidebar">
<TransitionChild
enter="ease-in-out duration-300"
enterFrom="opacity-0"
@@ -160,24 +176,21 @@ const component: ClosureComponent<Attrs> = () => {
<a
key={item.name}
href={item.href}
class={classNames(
item.active
? "bg-stone-100 text-stone-900"
: "text-stone-600 hover:bg-stone-50 hover:text-stone-900",
"group flex items-center px-2 py-2 text-base font-medium rounded-md",
)}
class={`unifi-nav-item${item.active ? " active" : ""}`}
>
<Icon name={NAV_ICONS[item.name] || "nav-overview"} class="h-[18px] w-[18px]" />
{item.name}
</a>
))}
</nav>
</div>
<div class="p-2">
<div class="unifi-sidebar-footer">
{window.username ? (
<div class="flex items-center px-2 text-stone-600 text-base">
<div class="unifi-user-info">
<Icon name="nav-users" class="h-4 w-4" />
{window.username}
<button
class="ml-auto text-base font-medium text-cyan-600 hover:text-cyan-500"
class="unifi-logout-btn"
onclick={(e) => {
e.target.disabled = true;
store
@@ -197,19 +210,13 @@ const component: ClosureComponent<Attrs> = () => {
</button>
</div>
) : (
<div class="px-2">
<a
class="text-base font-medium text-cyan-700 hover:text-cyan-900"
href=""
>
Log in
</a>
<div class="unifi-user-info">
<a class="unifi-logout-btn" href="">Log in</a>
</div>
)}
</div>
<div class="text-sm font-mono text-stone-400 text-right p-2">
v{VERSION}
</div>
<div class="unifi-version">v{VERSION}</div>
{/* --- END UNIFI THEME --- */}
</div>
</TransitionChild>
<div class="flex-shrink-0 w-14">
@@ -219,35 +226,33 @@ const component: ClosureComponent<Attrs> = () => {
</TransitionRoot>
{/* Static sidebar for desktop */}
{/* --- BEGIN UNIFI THEME --- */}
<div class="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
<div class="flex-1 flex flex-col min-h-0 border-r border-stone-200 bg-white">
<div class="flex-1 flex flex-col min-h-0 unifi-sidebar">
<div class="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<div class="flex items-center flex-shrink-0 px-4">
<img class="h-10 w-auto" src={LOGO_SVG} alt="GenieACS" />
<div class="unifi-brand">
<img src={LOGO_SVG} alt="GenieACS" />
</div>
<nav class="mt-5 flex-1 px-2 bg-white flex flex-col gap-1">
<nav class="mt-5 flex-1 px-2 flex flex-col gap-1">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
class={classNames(
item.active
? "bg-stone-100 text-stone-900"
: "text-stone-600 hover:bg-stone-50 hover:text-stone-900",
"group flex items-center px-2 py-2 text-sm font-medium rounded-md",
)}
class={`unifi-nav-item${item.active ? " active" : ""}`}
>
<Icon name={NAV_ICONS[item.name] || "nav-overview"} class="h-[18px] w-[18px]" />
{item.name}
</a>
))}
</nav>
</div>
<div class="p-2">
<div class="unifi-sidebar-footer">
{window.username ? (
<div class="flex items-center px-2 text-stone-600 text-sm">
<div class="unifi-user-info">
<Icon name="nav-users" class="h-4 w-4" />
{window.username}
<button
class="ml-auto text-sm font-medium text-cyan-700 hover:text-cyan-900"
class="unifi-logout-btn"
onclick={(e) => {
e.target.disabled = true;
store
@@ -267,23 +272,18 @@ const component: ClosureComponent<Attrs> = () => {
</button>
</div>
) : (
<div class="px-2">
<a
class="text-sm font-medium text-cyan-700 hover:text-cyan-900"
href=""
>
Log in
</a>
<div class="unifi-user-info">
<a class="unifi-logout-btn" href="">Log in</a>
</div>
)}
</div>
<div class="text-xs font-mono text-stone-400 text-right p-2">
v{VERSION}
</div>
<div class="unifi-version">v{VERSION}</div>
</div>
</div>
<div class="md:pl-64 flex flex-col flex-1">
<div class="sticky top-0 z-10 md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-stone-100">
{/* --- END UNIFI THEME --- */}
<div class="md:pl-64 flex flex-col flex-1 unifi-main-bg">
{/* --- BEGIN UNIFI THEME --- */}
<div class="sticky top-0 z-10 md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3">
<button
type="button"
class="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-stone-500 hover:text-stone-900 focus:outline-hidden focus:ring-2 focus:ring-inset focus:ring-cyan-500"
@@ -297,6 +297,7 @@ const component: ClosureComponent<Attrs> = () => {
<Icon name="menu" class="h-6 w-6" />
</button>
</div>
{/* --- END UNIFI THEME --- */}
<main class="flex-1">
<div class="py-6">
<div class="px-4 sm:px-6 md:px-8">
+286
View File
@@ -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 ---
+279
View File
@@ -0,0 +1,279 @@
// --- BEGIN UNIFI THEME ---
// UniFi-style device detail page.
// This file REPLACES device-page.ts in the router without modifying the
// original, preserving upstream merge-friendliness.
// All TR-069 parameter rendering is delegated to the original device-page
// component; this wrapper adds the UniFi header/action-bar on top.
import { ClosureComponent, Children } from "mithril";
import { m } from "./components.ts";
import { icon } from "./tailwind-utility-components.ts";
import { device as deviceConfig } from "./config.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
import { queueTask } from "./task-queue.ts";
import Expression from "../lib/common/expression.ts";
import Path from "../lib/common/path.ts";
import {
statusBadge,
getDeviceStatus,
getDeviceName,
getDeviceModel,
getParam,
timeAgo,
} from "./unifi-components.ts";
export function init(
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (!window.authorizer.hasAccess("devices", 2)) {
return Promise.reject(
new Error("You are not authorized to view this page"),
);
}
return Promise.resolve({
deviceId: args.id,
deviceFilter: new Expression.Binary(
"=",
new Expression.Parameter(Path.parse("DeviceID.ID")),
new Expression.Literal(args.id as string),
),
});
}
interface Attrs {
deviceId: string;
deviceFilter: Expression;
}
// ─── KV pair helper ────────────────────────────────────────────────────────
function kv(label: string, value: string | null): Children {
return m("div", [
m("div.unifi-detail-kv__label", label),
m(
`div.unifi-detail-kv__value${value == null ? ".empty" : ""}`,
value ?? "—",
),
]);
}
// ─── Action helper ─────────────────────────────────────────────────────────
function actionBtn(
label: string,
className: string,
disabled: boolean,
onClick: () => void,
): Children {
return m(
`button.unifi-btn.${className}`,
{ disabled, onclick: onClick },
label,
);
}
// ─── Component ─────────────────────────────────────────────────────────────
export const component: ClosureComponent<Attrs> = () => {
let actionsPending = false;
return {
view: (vnode) => {
const deviceId = vnode.attrs.deviceId;
document.title = `${deviceId} — Devices — GenieACS`;
const dev = store.fetch("devices", vnode.attrs.deviceFilter);
// While loading
if (!dev.value || !dev.value.length) {
if (!dev.fulfilling) {
return m(
"p",
{ style: "color:#e53935;font-weight:bold;" },
`No such device: ${deviceId}`,
);
}
return m("loading", { queries: [dev] }, m("div", { style: "height:100px" }));
}
const device = dev.value[0];
const status = getDeviceStatus(device);
const name = getDeviceName(device);
const model = getDeviceModel(device);
const lastInform = device["_lastInform"] as number | undefined;
const canWrite = window.authorizer.hasAccess("devices", 3);
// Key TR-069 fields
const serial =
getParam(device, "DeviceID.SerialNumber") ??
getParam(
device,
"InternetGatewayDevice.DeviceInfo.SerialNumber",
"Device.DeviceInfo.SerialNumber",
);
const firmware = getParam(
device,
"InternetGatewayDevice.DeviceInfo.SoftwareVersion",
"Device.DeviceInfo.SoftwareVersion",
);
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",
);
const ssid = getParam(
device,
"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID",
"Device.WiFi.SSID.1.SSID",
);
const hardwareVersion = getParam(
device,
"InternetGatewayDevice.DeviceInfo.HardwareVersion",
"Device.DeviceInfo.HardwareVersion",
);
const uptime = getParam(
device,
"InternetGatewayDevice.DeviceInfo.UpTime",
"Device.DeviceInfo.UpTime",
);
// ── Router icon ────────────────────────────────────────────────────
const routerIcon = m(
"div.unifi-device-detail-header__icon",
m(icon, { name: "nav-devices", class: "h-7 w-7" }),
);
// ── Header bar ─────────────────────────────────────────────────────
const header = m("div.unifi-device-detail-header", [
// Back link
m(
"a.unifi-back-link",
{ href: "#!/devices" },
m(icon, { name: "nav-back", class: "h-4 w-4" }),
" Back to Devices",
),
m("div.unifi-device-detail-header__top", [
routerIcon,
m("div.unifi-device-detail-header__info", [
m("h1.unifi-device-detail-header__name", name),
m("div.unifi-device-detail-header__meta", [
m(statusBadge, { status }),
model && m("span", model),
serial && m("span", `S/N: ${serial}`),
lastInform &&
m(
"span",
{ title: new Date(lastInform).toLocaleString() },
`Last seen: ${timeAgo(lastInform)}`,
),
]),
]),
// Actions
canWrite &&
m("div.unifi-device-detail-header__actions", [
actionBtn(
"Reboot",
"unifi-btn--secondary",
actionsPending,
() => {
actionsPending = true;
queueTask({ name: "reboot", device: deviceId });
notifications.push(
"success",
`Reboot task queued for ${name}`,
);
setTimeout(() => {
actionsPending = false;
m.redraw();
}, 2000);
},
),
actionBtn(
"Factory Reset",
"unifi-btn--danger",
actionsPending,
() => {
if (
!confirm(
`Factory reset ${name}? This will erase all configuration.`,
)
)
return;
actionsPending = true;
queueTask({ name: "factoryReset", device: deviceId });
notifications.push(
"success",
`Factory reset task queued for ${name}`,
);
setTimeout(() => {
actionsPending = false;
m.redraw();
}, 2000);
},
),
actionBtn(
"Refresh",
"unifi-btn--secondary",
actionsPending,
() => {
actionsPending = true;
store.setTimestamp(Date.now());
setTimeout(() => {
actionsPending = false;
m.redraw();
}, 1500);
},
),
]),
]),
// Key-value grid
m("div.unifi-device-detail-header__kv", [
kv("WAN IP", wanIp),
kv("WiFi SSID", ssid),
kv("Firmware", firmware),
kv("Hardware", hardwareVersion),
uptime != null &&
kv(
"Uptime",
(() => {
const s = Number(uptime);
if (isNaN(s)) return uptime;
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m2 = Math.floor((s % 3600) / 60);
return [d && `${d}d`, h && `${h}h`, `${m2}m`]
.filter(Boolean)
.join(" ");
})(),
),
kv("Device ID", deviceId),
]),
]);
// ── Original device-page content (all configured panels) ────────────
const conf = deviceConfig;
const cmps = [];
for (const c of Object.values(conf)) {
cmps.push(
m.context(
{ device: dev.value[0], deviceQuery: dev },
store.evaluateExpression(c["type"], {}).value as string,
c as any,
),
);
}
const content = m("div.unifi-device-content", [
m("div.device-page", cmps),
]);
return m("div", [header, content]);
},
};
};
// --- END UNIFI THEME ---
+389
View File
@@ -0,0 +1,389 @@
// --- 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<string, unknown>): Promise<Attrs> {
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<string, number> = 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<string>): 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<string, number>;
}
const PAGE_SIZE_OPTIONS = [30, 50, 100];
export const component: ClosureComponent<Attrs> = () => {
let selected = new Set<string>();
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<string, string> = {};
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<string, unknown>[] = 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 ---