implementação de mudança de interface
Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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">
|
||||
|
||||
@@ -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 ---
|
||||
@@ -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 ---
|
||||
@@ -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 ---
|
||||