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
@@ -0,0 +1,28 @@
|
|||||||
|
# Dependências e artefatos de build
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Arquivos de ambiente (contêm segredos)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Logs e debug
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Artefatos de teste
|
||||||
|
test/*.js
|
||||||
|
|
||||||
|
# Docs geradas
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# Dados de desenvolvimento
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# ============================================================
|
||||||
|
# GenieACS — variáveis de ambiente
|
||||||
|
# Copie este arquivo para .env e ajuste os valores
|
||||||
|
# NUNCA faça commit do .env com dados reais em repositórios
|
||||||
|
# públicos.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ── MongoDB ─────────────────────────────────────────────────
|
||||||
|
# Quando usando docker-compose o host é o nome do serviço "mongo"
|
||||||
|
GENIEACS_MONGODB_CONNECTION_URL=mongodb://mongo:27017/genieacs
|
||||||
|
|
||||||
|
# ── Extensões ───────────────────────────────────────────────
|
||||||
|
GENIEACS_EXT_DIR=/opt/genieacs/config/ext
|
||||||
|
GENIEACS_EXT_TIMEOUT=3000
|
||||||
|
|
||||||
|
# ── Logs ────────────────────────────────────────────────────
|
||||||
|
# json | simple
|
||||||
|
GENIEACS_LOG_FORMAT=json
|
||||||
|
GENIEACS_ACCESS_LOG_FORMAT=json
|
||||||
|
|
||||||
|
# Para gravar logs em arquivo (opcional, senão vai para stdout/stderr)
|
||||||
|
# GENIEACS_CWMP_LOG_FILE=/opt/genieacs/logs/cwmp.log
|
||||||
|
# GENIEACS_CWMP_ACCESS_LOG_FILE=/opt/genieacs/logs/cwmp-access.log
|
||||||
|
# GENIEACS_NBI_LOG_FILE=/opt/genieacs/logs/nbi.log
|
||||||
|
# GENIEACS_NBI_ACCESS_LOG_FILE=/opt/genieacs/logs/nbi-access.log
|
||||||
|
# GENIEACS_FS_LOG_FILE=/opt/genieacs/logs/fs.log
|
||||||
|
# GENIEACS_FS_ACCESS_LOG_FILE=/opt/genieacs/logs/fs-access.log
|
||||||
|
# GENIEACS_UI_LOG_FILE=/opt/genieacs/logs/ui.log
|
||||||
|
# GENIEACS_UI_ACCESS_LOG_FILE=/opt/genieacs/logs/ui-access.log
|
||||||
|
|
||||||
|
# ── Debug (opcional) ────────────────────────────────────────
|
||||||
|
# GENIEACS_DEBUG_FILE=/opt/genieacs/logs/debug.yaml
|
||||||
|
# GENIEACS_DEBUG_FORMAT=yaml
|
||||||
|
|
||||||
|
# ── Worker processes (0 = 1 por CPU core) ───────────────────
|
||||||
|
GENIEACS_CWMP_WORKER_PROCESSES=1
|
||||||
|
GENIEACS_NBI_WORKER_PROCESSES=1
|
||||||
|
GENIEACS_FS_WORKER_PROCESSES=1
|
||||||
|
GENIEACS_UI_WORKER_PROCESSES=1
|
||||||
|
|
||||||
|
# ── Portas ──────────────────────────────────────────────────
|
||||||
|
GENIEACS_CWMP_PORT=7547
|
||||||
|
GENIEACS_NBI_PORT=7557
|
||||||
|
GENIEACS_FS_PORT=7567
|
||||||
|
GENIEACS_UI_PORT=3000
|
||||||
|
|
||||||
|
# ── Interface de bind (:: = todas as interfaces) ────────────
|
||||||
|
GENIEACS_CWMP_INTERFACE=::
|
||||||
|
GENIEACS_NBI_INTERFACE=::
|
||||||
|
GENIEACS_FS_INTERFACE=::
|
||||||
|
GENIEACS_UI_INTERFACE=::
|
||||||
|
|
||||||
|
# ── UI — JWT Secret (OBRIGATÓRIO trocar em produção!) ───────
|
||||||
|
# Mínimo 32 chars, máximo 64 chars
|
||||||
|
GENIEACS_UI_JWT_SECRET=mude-isto-em-producao-use-uma-string-aleatoria-longa
|
||||||
|
|
||||||
|
# ── FS — URL prefix (configure se CWMP e FS estiverem em ────
|
||||||
|
# hosts/IPs diferentes ou atrás de proxy)
|
||||||
|
# GENIEACS_FS_URL_PREFIX=http://seu-ip-publico:7567/
|
||||||
|
|
||||||
|
# ── SSL (opcional — passe o caminho para cert/key PEM) ──────
|
||||||
|
# GENIEACS_CWMP_SSL_CERT=/opt/genieacs/certs/cwmp.crt
|
||||||
|
# GENIEACS_CWMP_SSL_KEY=/opt/genieacs/certs/cwmp.key
|
||||||
|
# GENIEACS_NBI_SSL_CERT=/opt/genieacs/certs/nbi.crt
|
||||||
|
# GENIEACS_NBI_SSL_KEY=/opt/genieacs/certs/nbi.key
|
||||||
|
# GENIEACS_FS_SSL_CERT=/opt/genieacs/certs/fs.crt
|
||||||
|
# GENIEACS_FS_SSL_KEY=/opt/genieacs/certs/fs.key
|
||||||
|
# GENIEACS_UI_SSL_CERT=/opt/genieacs/certs/ui.crt
|
||||||
|
# GENIEACS_UI_SSL_KEY=/opt/genieacs/certs/ui.key
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
GitHub Copilot Instructions: GenieACS -> UniFi UI Theme
|
||||||
|
You are an expert senior software engineer and frontend architect focused on UI/UX modernization, maintainability, and clean architecture. Your primary goal is to help modify the GenieACS user interface to closely resemble the UniFi Controller, while strictly ensuring the codebase remains easy to synchronize (via merge or rebase) with the original GenieACS upstream repository for bug fixes and core updates.
|
||||||
|
|
||||||
|
1. Core Principles (Non-Negotiable)
|
||||||
|
Sync-Friendly Architecture (Crucial): Write code defensively to prevent future merge conflicts with the original GenieACS project. Prefer extending, overriding, or wrapping components rather than heavily modifying deeply ingrained original core files.
|
||||||
|
|
||||||
|
Visual Fidelity (UniFi): Implement the clean, dashboard-centric, modern aesthetic of the UniFi Controller. Use comparable color palettes, typography, spacing, and network topology layouts.
|
||||||
|
|
||||||
|
Safety First: Never modify the underlying TR-069 communication logic, backend models, or database schemas unless explicitly requested. The focus is strictly on the presentation layer (View).
|
||||||
|
|
||||||
|
Minimal Footprint: When generating files, aim to isolate custom UI assets. Keep custom CSS/SCSS and new UI components in distinct directories or files whenever possible.
|
||||||
|
|
||||||
|
2. GenieACS Customization & Upstream Integration
|
||||||
|
CRITICAL: Whenever the user requests a modification to a page or component, you MUST prioritize the following strategies to keep the fork maintainable:
|
||||||
|
|
||||||
|
Style Overrides: Prefer creating custom stylesheets (e.g., unifi-theme.scss) to override existing GenieACS classes instead of changing inline styles or the original CSS files directly.
|
||||||
|
|
||||||
|
Component Wrapping: If an existing UI component needs significant structural changes, create a new "UniFi-styled" wrapper component that consumes the original GenieACS state/props, rather than gutting the original component.
|
||||||
|
|
||||||
|
Clear Demarcation: When modifying an original GenieACS file is unavoidable, clearly mark the beginning and end of the custom code with comments (e.g., // --- BEGIN UNIFI THEME --- and // --- END UNIFI THEME ---). This makes resolving Git merge conflicts significantly easier.
|
||||||
|
|
||||||
|
3. Code Generation Rules
|
||||||
|
A. Refactoring & Modification
|
||||||
|
Context Awareness: Read the surrounding code to understand the existing state management and API calls. Do not break the data flow between the GenieACS backend and the frontend.
|
||||||
|
|
||||||
|
Preserve Logic: When changing how data is displayed (e.g., changing a simple table to a UniFi-style card grid), ensure all original data points remain accessible or are intentionally hidden via CSS, rather than deleting the data-fetching logic.
|
||||||
|
|
||||||
|
B. New Features (UI Components)
|
||||||
|
Modularity: Build reusable UI components (Buttons, Cards, Navbars) that match the UniFi design system, so they can be applied globally across the GenieACS interface.
|
||||||
|
|
||||||
|
Responsiveness: Ensure all new UI elements are fully responsive, as network management dashboards are often viewed on various screen sizes.
|
||||||
|
|
||||||
|
C. Testing
|
||||||
|
Prioritize visual regression checks and ensure that frontend unit tests (if applicable) are updated to reflect structural DOM changes. Ensure the connection to the GenieACS API remains mocked and functional in tests.
|
||||||
|
|
||||||
|
4. Response Format
|
||||||
|
Plan First: Before generating code, briefly outline your strategy. Specifically state which files will be modified and how you plan to implement the change while minimizing upstream merge conflicts.
|
||||||
|
|
||||||
|
Complete Code: Output the full, functional block of code needed for the file. Do not use placeholders like // ... rest of code unless the file is massive and the change is highly localized.
|
||||||
|
|
||||||
|
Git Context: If a change requires creating a new file to override an old one, explain how to integrate it into the project's build system.
|
||||||
|
|
||||||
|
5. "Do No Harm" Checklist
|
||||||
|
Before outputting the final response, ask yourself:
|
||||||
|
|
||||||
|
Does this modification break the core GenieACS device management logic?
|
||||||
|
|
||||||
|
Will this change cause a massive merge conflict if the upstream repository updates this specific component? (If yes, rethink the approach).
|
||||||
|
|
||||||
|
Does the resulting UI genuinely reflect the UniFi Controller design language?
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
docs/_build
|
docs/_build
|
||||||
|
.env
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# =========================================================
|
||||||
|
# GenieACS — UniFi Theme Fork
|
||||||
|
# Multi-stage build:
|
||||||
|
# Stage 1 (builder): instala devDeps + compila o projeto
|
||||||
|
# Stage 2 (runtime): imagem mínima só com dist/ + prodDeps
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
# ── Stage 1: builder ─────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
# Dependências de sistema necessárias para o build
|
||||||
|
# (git é exigido pelo build.ts para calcular o metadata de versão)
|
||||||
|
RUN apk add --no-cache git python3 make g++
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copiar manifests primeiro para cache de camadas do npm install
|
||||||
|
COPY package.json npm-shrinkwrap.json ./
|
||||||
|
|
||||||
|
# Instalar TODAS as dependências (incluindo devDependencies)
|
||||||
|
RUN npm ci --fund=false --audit=false
|
||||||
|
|
||||||
|
# Copiar o restante do fonte
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Compilar o projeto (gera dist/)
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2: runtime ─────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
|
||||||
|
# Timezone e utilitários mínimos
|
||||||
|
RUN apk add --no-cache tzdata dumb-init
|
||||||
|
|
||||||
|
WORKDIR /opt/genieacs
|
||||||
|
|
||||||
|
# Copiar artefatos compilados
|
||||||
|
COPY --from=builder /build/dist ./
|
||||||
|
|
||||||
|
# Instalar apenas dependências de produção a partir do dist/
|
||||||
|
RUN npm ci --omit=dev --fund=false --audit=false
|
||||||
|
|
||||||
|
# Criar diretórios de configuração e extensões
|
||||||
|
RUN mkdir -p \
|
||||||
|
/opt/genieacs/config/ext \
|
||||||
|
/opt/genieacs/logs \
|
||||||
|
&& addgroup -S genieacs \
|
||||||
|
&& adduser -S -G genieacs genieacs \
|
||||||
|
&& chown -R genieacs:genieacs /opt/genieacs
|
||||||
|
|
||||||
|
USER genieacs
|
||||||
|
|
||||||
|
# Variáveis de ambiente padrão (sobrescreva no compose ou .env)
|
||||||
|
ENV GENIEACS_MONGODB_CONNECTION_URL=mongodb://mongo:27017/genieacs \
|
||||||
|
GENIEACS_EXT_DIR=/opt/genieacs/config/ext \
|
||||||
|
GENIEACS_LOG_FORMAT=json \
|
||||||
|
GENIEACS_ACCESS_LOG_FORMAT=json \
|
||||||
|
GENIEACS_UI_JWT_SECRET=change-me-in-production \
|
||||||
|
GENIEACS_CWMP_WORKER_PROCESSES=1 \
|
||||||
|
GENIEACS_NBI_WORKER_PROCESSES=1 \
|
||||||
|
GENIEACS_FS_WORKER_PROCESSES=1 \
|
||||||
|
GENIEACS_UI_WORKER_PROCESSES=1
|
||||||
|
|
||||||
|
# Expor todas as portas dos serviços
|
||||||
|
EXPOSE 3000 7547 7557 7567
|
||||||
|
|
||||||
|
# dumb-init garante propagação correta de sinais
|
||||||
|
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
# ── MongoDB ──────────────────────────────────────────────────────────────
|
||||||
|
mongo:
|
||||||
|
image: mongo:6
|
||||||
|
container_name: genieacs-mongo
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
- mongo_config:/data/configdb
|
||||||
|
networks:
|
||||||
|
- genieacs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
# ── CWMP — TR-069 protocol handler (porta 7547) ──────────────────────────
|
||||||
|
cwmp:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: runtime
|
||||||
|
image: genieacs-unifi:latest
|
||||||
|
container_name: genieacs-cwmp
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["node", "bin/genieacs-cwmp"]
|
||||||
|
ports:
|
||||||
|
- "7547:7547"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
GENIEACS_MONGODB_CONNECTION_URL: mongodb://mongo:27017/genieacs
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ext_dir:/opt/genieacs/config/ext
|
||||||
|
- logs:/opt/genieacs/logs
|
||||||
|
networks:
|
||||||
|
- genieacs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:7547/ || true"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# ── NBI — Northbound REST API (porta 7557) ───────────────────────────────
|
||||||
|
nbi:
|
||||||
|
image: genieacs-unifi:latest
|
||||||
|
container_name: genieacs-nbi
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["node", "bin/genieacs-nbi"]
|
||||||
|
ports:
|
||||||
|
- "7557:7557"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
GENIEACS_MONGODB_CONNECTION_URL: mongodb://mongo:27017/genieacs
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
cwmp:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- ext_dir:/opt/genieacs/config/ext
|
||||||
|
- logs:/opt/genieacs/logs
|
||||||
|
networks:
|
||||||
|
- genieacs
|
||||||
|
|
||||||
|
# ── FS — File Server / GridFS (porta 7567) ───────────────────────────────
|
||||||
|
fs:
|
||||||
|
image: genieacs-unifi:latest
|
||||||
|
container_name: genieacs-fs
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["node", "bin/genieacs-fs"]
|
||||||
|
ports:
|
||||||
|
- "7567:7567"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
GENIEACS_MONGODB_CONNECTION_URL: mongodb://mongo:27017/genieacs
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
cwmp:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- ext_dir:/opt/genieacs/config/ext
|
||||||
|
- logs:/opt/genieacs/logs
|
||||||
|
networks:
|
||||||
|
- genieacs
|
||||||
|
|
||||||
|
# ── UI — Web Interface (porta 3000) ──────────────────────────────────────
|
||||||
|
ui:
|
||||||
|
image: genieacs-unifi:latest
|
||||||
|
container_name: genieacs-ui
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["node", "bin/genieacs-ui"]
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
GENIEACS_MONGODB_CONNECTION_URL: mongodb://mongo:27017/genieacs
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
nbi:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- ext_dir:/opt/genieacs/config/ext
|
||||||
|
- logs:/opt/genieacs/logs
|
||||||
|
networks:
|
||||||
|
- genieacs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo_data:
|
||||||
|
driver: local
|
||||||
|
mongo_config:
|
||||||
|
driver: local
|
||||||
|
ext_dir:
|
||||||
|
driver: local
|
||||||
|
logs:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
genieacs:
|
||||||
|
driver: bridge
|
||||||
@@ -21,6 +21,10 @@ import { PermissionSet, UiConfig } from "../lib/types.ts";
|
|||||||
import drawerComponent from "./drawer-component.ts";
|
import drawerComponent from "./drawer-component.ts";
|
||||||
import { render as renderOverlay } from "./overlay.ts";
|
import { render as renderOverlay } from "./overlay.ts";
|
||||||
import Expression from "../lib/common/expression.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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -105,8 +109,10 @@ function pagify(pageName, page): RouteResolver {
|
|||||||
m.route(document.body, "/overview", {
|
m.route(document.body, "/overview", {
|
||||||
"/wizard": pagify("wizard", wizardPage),
|
"/wizard": pagify("wizard", wizardPage),
|
||||||
"/overview": pagify("overview", overviewPage),
|
"/overview": pagify("overview", overviewPage),
|
||||||
"/devices": pagify("devices", devicesPage),
|
// --- BEGIN UNIFI THEME ---
|
||||||
"/devices/:id": pagify("devices", devicePage),
|
"/devices": pagify("devices", unifiDevicesPage),
|
||||||
|
"/devices/:id": pagify("devices", unifiDevicePage),
|
||||||
|
// --- END UNIFI THEME ---
|
||||||
"/faults": pagify("faults", faultsPage),
|
"/faults": pagify("faults", faultsPage),
|
||||||
"/presets": pagify("presets", presetsPage),
|
"/presets": pagify("presets", presetsPage),
|
||||||
"/provisions": pagify("provisions", provisionsPage),
|
"/provisions": pagify("provisions", provisionsPage),
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "@tailwindcss/forms";
|
@plugin "@tailwindcss/forms";
|
||||||
|
/* --- BEGIN UNIFI THEME --- */
|
||||||
|
@import "./unifi-theme.css";
|
||||||
|
/* --- END UNIFI THEME --- */
|
||||||
|
|
||||||
@source inline("h-full bg-stone-100");
|
@source inline("h-full bg-stone-100");
|
||||||
@source "../../ui/**/*.{ts,tsx}";
|
@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 };
|
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 [
|
return [
|
||||||
<div>
|
<div>
|
||||||
<TransitionRoot show={!!sidebarOpen} duration={300}>
|
<TransitionRoot show={!!sidebarOpen} duration={300}>
|
||||||
@@ -128,7 +143,8 @@ const component: ClosureComponent<Attrs> = () => {
|
|||||||
leaveFrom="translate-x-0"
|
leaveFrom="translate-x-0"
|
||||||
leaveTo="-translate-x-full"
|
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
|
<TransitionChild
|
||||||
enter="ease-in-out duration-300"
|
enter="ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
@@ -160,24 +176,21 @@ const component: ClosureComponent<Attrs> = () => {
|
|||||||
<a
|
<a
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={classNames(
|
class={`unifi-nav-item${item.active ? " active" : ""}`}
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
|
<Icon name={NAV_ICONS[item.name] || "nav-overview"} class="h-[18px] w-[18px]" />
|
||||||
{item.name}
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2">
|
<div class="unifi-sidebar-footer">
|
||||||
{window.username ? (
|
{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}
|
{window.username}
|
||||||
<button
|
<button
|
||||||
class="ml-auto text-base font-medium text-cyan-600 hover:text-cyan-500"
|
class="unifi-logout-btn"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.target.disabled = true;
|
e.target.disabled = true;
|
||||||
store
|
store
|
||||||
@@ -197,19 +210,13 @@ const component: ClosureComponent<Attrs> = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="px-2">
|
<div class="unifi-user-info">
|
||||||
<a
|
<a class="unifi-logout-btn" href="">Log in</a>
|
||||||
class="text-base font-medium text-cyan-700 hover:text-cyan-900"
|
|
||||||
href=""
|
|
||||||
>
|
|
||||||
Log in
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-mono text-stone-400 text-right p-2">
|
<div class="unifi-version">v{VERSION}</div>
|
||||||
v{VERSION}
|
{/* --- END UNIFI THEME --- */}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
<div class="flex-shrink-0 w-14">
|
<div class="flex-shrink-0 w-14">
|
||||||
@@ -219,35 +226,33 @@ const component: ClosureComponent<Attrs> = () => {
|
|||||||
</TransitionRoot>
|
</TransitionRoot>
|
||||||
|
|
||||||
{/* Static sidebar for desktop */}
|
{/* 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="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-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||||
<div class="flex items-center flex-shrink-0 px-4">
|
<div class="unifi-brand">
|
||||||
<img class="h-10 w-auto" src={LOGO_SVG} alt="GenieACS" />
|
<img src={LOGO_SVG} alt="GenieACS" />
|
||||||
</div>
|
</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) => (
|
{navigation.map((item) => (
|
||||||
<a
|
<a
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={classNames(
|
class={`unifi-nav-item${item.active ? " active" : ""}`}
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
|
<Icon name={NAV_ICONS[item.name] || "nav-overview"} class="h-[18px] w-[18px]" />
|
||||||
{item.name}
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2">
|
<div class="unifi-sidebar-footer">
|
||||||
{window.username ? (
|
{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}
|
{window.username}
|
||||||
<button
|
<button
|
||||||
class="ml-auto text-sm font-medium text-cyan-700 hover:text-cyan-900"
|
class="unifi-logout-btn"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.target.disabled = true;
|
e.target.disabled = true;
|
||||||
store
|
store
|
||||||
@@ -267,23 +272,18 @@ const component: ClosureComponent<Attrs> = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="px-2">
|
<div class="unifi-user-info">
|
||||||
<a
|
<a class="unifi-logout-btn" href="">Log in</a>
|
||||||
class="text-sm font-medium text-cyan-700 hover:text-cyan-900"
|
|
||||||
href=""
|
|
||||||
>
|
|
||||||
Log in
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-mono text-stone-400 text-right p-2">
|
<div class="unifi-version">v{VERSION}</div>
|
||||||
v{VERSION}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:pl-64 flex flex-col flex-1">
|
{/* --- END UNIFI THEME --- */}
|
||||||
<div class="sticky top-0 z-10 md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-stone-100">
|
<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
|
<button
|
||||||
type="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"
|
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" />
|
<Icon name="menu" class="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* --- END UNIFI THEME --- */}
|
||||||
<main class="flex-1">
|
<main class="flex-1">
|
||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<div class="px-4 sm:px-6 md:px-8">
|
<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 ---
|
||||||