feat: Guest WiFi, network intelligence, firmware orchestration, platform automation, api-ssl (v0.16.3-beta)

Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest
SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool,
DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits +
printable sheets; live guest sessions; walled garden.

Network intelligence: per-client device fingerprinting (vendor/hostname →
category) with a persisted per-client override on the detail page; rogue/neighbor
AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly
insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the
Operations engine.

Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with
pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and
scheduling; live per-device progress + cancel.

Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest,
Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired
from the alert pipeline + firmware orchestrator; scheduled email reports.

api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's
default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices
connect without manual cert trust. Fixes #57.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-07-02 21:58:02 -07:00
co-authored by Claude Opus 4.8
parent efb430d934
commit 9dfb8a5333
38 changed files with 3517 additions and 20 deletions
+4
View File
@@ -9,6 +9,7 @@ import ClientsPage from './pages/ClientsPage';
import EventsPage from './pages/EventsPage';
import TopologyPage from './pages/TopologyPage';
import BackupsPage from './pages/BackupsPage';
import FirmwarePage from './pages/FirmwarePage';
import SettingsPage from './pages/SettingsPage';
import SwitchesOverviewPage from './pages/SwitchesOverviewPage';
import SwitchesSettingsPage from './pages/SwitchesSettingsPage';
@@ -17,6 +18,7 @@ import RouterSettingsPage from './pages/RouterSettingsPage';
import WirelessPage from './pages/WirelessPage';
import WirelessSettingsPage from './pages/WirelessSettingsPage';
import WirelessClientsPage from './pages/WirelessClientsPage';
import GuestWifiPage from './pages/GuestWifiPage';
import ClientDetailPage from './pages/ClientDetailPage';
import NetworkServicesOverviewPage from './pages/NetworkServicesOverviewPage';
import NetworkServicesDHCPPage from './pages/NetworkServicesDHCPPage';
@@ -56,6 +58,7 @@ export default function App() {
<Route path="events" element={<EventsPage />} />
<Route path="topology" element={<TopologyPage />} />
<Route path="backups" element={<BackupsPage />} />
<Route path="firmware" element={<FirmwarePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="switches" element={<SwitchesOverviewPage />} />
<Route path="switches/settings" element={<SwitchesSettingsPage />} />
@@ -64,6 +67,7 @@ export default function App() {
<Route path="wireless" element={<WirelessPage />} />
<Route path="wireless/settings" element={<WirelessSettingsPage />} />
<Route path="wireless/clients" element={<WirelessClientsPage />} />
<Route path="wireless/guest" element={<GuestWifiPage />} />
<Route path="network-services" element={<NetworkServicesOverviewPage />} />
<Route path="network-services/dhcp" element={<NetworkServicesDHCPPage />} />
<Route path="network-services/dns" element={<NetworkServicesDNSPage />} />
+3 -1
View File
@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import {
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
Server, Globe, Clock, Shield, FileText, Activity, BarChart3,
Server, Globe, Clock, Shield, FileText, Activity, BarChart3, Ticket, ArrowUpCircle,
} from 'lucide-react';
import clsx from 'clsx';
import { APP_VERSION } from '../../version';
@@ -19,6 +19,7 @@ const navItems = [
{ to: '/events', icon: Bell, label: 'Events' },
{ to: '/topology', icon: GitBranch, label: 'Topology' },
{ to: '/backups', icon: HardDrive, label: 'Backups' },
{ to: '/firmware', icon: ArrowUpCircle, label: 'Firmware' },
];
const switchSubItems = [
@@ -34,6 +35,7 @@ const routerSubItems = [
const wirelessSubItems = [
{ to: '/wireless', icon: Wifi, label: 'Overview' },
{ to: '/wireless/clients', icon: Users, label: 'Clients' },
{ to: '/wireless/guest', icon: Ticket, label: 'Guest WiFi' },
{ to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' },
];
@@ -0,0 +1,267 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
KeyRound, Webhook as WebhookIcon, Mail, Plus, Trash2, Copy, Check, Send,
RefreshCw, AlertTriangle, Power, X,
} from 'lucide-react';
import clsx from 'clsx';
import { automationApi } from '../../services/api';
import { formatDistanceToNow } from 'date-fns';
const EVENT_LABEL: Record<string, string> = {
device_offline: 'Device offline', device_online: 'Device online',
log_error: 'Log error', log_warning: 'Log warning',
high_cpu: 'High CPU', high_memory: 'High memory', cert_expiry: 'Cert expiry',
device_discovered: 'Device discovered', firmware_update_available: 'Firmware update',
config_drift: 'Config drift', rollout_completed: 'Rollout completed', rollout_failed: 'Rollout failed',
};
// ─── API tokens ────────────────────────────────────────────────────────────────
function TokensSection({ isAdmin }: { isAdmin: boolean }) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [scope, setScope] = useState<'read' | 'write'>('read');
const [expires, setExpires] = useState('');
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { data: tokens = [] } = useQuery({
queryKey: ['api-tokens'],
queryFn: () => automationApi.listTokens().then(r => r.data),
enabled: isAdmin,
});
const create = useMutation({
mutationFn: () => automationApi.createToken({ name: name.trim(), scope, expires_days: expires ? parseInt(expires, 10) : undefined }),
onSuccess: (r) => { setNewToken(r.data.token); setName(''); setExpires(''); qc.invalidateQueries({ queryKey: ['api-tokens'] }); },
});
const del = useMutation({ mutationFn: (id: number) => automationApi.deleteToken(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['api-tokens'] }) });
if (!isAdmin) {
return <div className="card p-6 text-sm text-gray-400 dark:text-slate-500">API tokens can only be managed by administrators.</div>;
}
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<KeyRound className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">API Tokens</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">for scripting &amp; IaC — send as <span className="font-mono">Authorization: Bearer mtm_…</span></span>
</div>
<div className="p-5 space-y-4">
{/* Newly created token (shown once) */}
{newToken && (
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-3">
<div className="flex items-center gap-2 mb-2 text-sm font-medium text-green-700 dark:text-green-400">
<Check className="w-4 h-4" />Token created — copy it now, it won&apos;t be shown again
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-2 py-1.5 rounded bg-white dark:bg-slate-800 font-mono text-xs break-all text-gray-800 dark:text-slate-200">{newToken}</code>
<button className="btn-secondary flex items-center gap-1.5 text-xs" onClick={() => { navigator.clipboard.writeText(newToken); setCopied(true); setTimeout(() => setCopied(false), 2000); }}>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}{copied ? 'Copied' : 'Copy'}
</button>
<button className="p-1.5 text-gray-400 hover:text-gray-600" onClick={() => setNewToken(null)}><X className="w-4 h-4" /></button>
</div>
</div>
)}
{/* Create form */}
<div className="flex flex-wrap items-end gap-2">
<div><label className="label">Name</label><input className="input w-48" value={name} onChange={e => setName(e.target.value)} placeholder="ci-pipeline" /></div>
<div><label className="label">Scope</label>
<select className="input w-32" value={scope} onChange={e => setScope(e.target.value as 'read' | 'write')}>
<option value="read">Read only</option>
<option value="write">Read + write</option>
</select>
</div>
<div><label className="label">Expires (days)</label><input type="number" min={1} className="input w-28" value={expires} onChange={e => setExpires(e.target.value)} placeholder="never" /></div>
<button className="btn-primary flex items-center gap-1.5" disabled={!name.trim() || create.isPending} onClick={() => create.mutate()}>
<Plus className="w-4 h-4" />Create
</button>
</div>
{/* List */}
{tokens.length > 0 && (
<div className="divide-y divide-gray-100 dark:divide-slate-700/50 -mx-1">
{tokens.map(t => (
<div key={t.id} className="flex items-center gap-3 py-2.5 px-1">
<code className="font-mono text-xs text-gray-500 dark:text-slate-400">{t.prefix}…</code>
<span className="text-sm font-medium text-gray-900 dark:text-white">{t.name}</span>
<span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-bold uppercase', t.scope === 'write' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>{t.scope}</span>
<span className="text-xs text-gray-400 dark:text-slate-500">
{t.last_used_at ? `used ${formatDistanceToNow(new Date(t.last_used_at))} ago` : 'never used'}
{t.expires_at ? ` · expires ${formatDistanceToNow(new Date(t.expires_at), { addSuffix: true })}` : ''}
</span>
<button className="ml-auto p-1.5 text-gray-400 hover:text-red-600" onClick={() => del.mutate(t.id)} title="Revoke"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 dark:text-slate-500">Write tokens map to Operator privileges; no token can perform admin actions or manage other tokens.</p>
</div>
</div>
);
}
// ─── Webhooks ──────────────────────────────────────────────────────────────────
function WebhooksSection() {
const qc = useQueryClient();
const [form, setForm] = useState({ name: '', url: '', secret: '', events: new Set<string>() });
const [testResult, setTestResult] = useState<Record<number, string>>({});
const { data } = useQuery({ queryKey: ['webhooks'], queryFn: () => automationApi.listWebhooks().then(r => r.data) });
const webhooks = data?.webhooks ?? [];
const events = data?.availableEvents ?? [];
const create = useMutation({
mutationFn: () => automationApi.createWebhook({ name: form.name.trim(), url: form.url.trim(), secret: form.secret.trim() || undefined, events: [...form.events] }),
onSuccess: () => { setForm({ name: '', url: '', secret: '', events: new Set() }); qc.invalidateQueries({ queryKey: ['webhooks'] }); },
});
const toggle = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => automationApi.updateWebhook(id, { enabled }), onSuccess: () => qc.invalidateQueries({ queryKey: ['webhooks'] }) });
const del = useMutation({ mutationFn: (id: number) => automationApi.deleteWebhook(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['webhooks'] }) });
const test = useMutation({
mutationFn: (id: number) => automationApi.testWebhook(id),
onSuccess: (r, id) => { setTestResult(p => ({ ...p, [id]: r.data.ok ? `OK (${r.data.status})` : `HTTP ${r.data.status}` })); setTimeout(() => setTestResult(p => { const n = { ...p }; delete n[id]; return n; }), 5000); qc.invalidateQueries({ queryKey: ['webhooks'] }); },
onError: (e: unknown, id) => setTestResult(p => ({ ...p, [id]: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'failed' })),
});
const toggleEvent = (e: string) => setForm(f => {
const s = new Set(f.events);
if (s.has(e)) s.delete(e); else s.add(e);
return { ...f, events: s };
});
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<WebhookIcon className="w-4 h-4 text-violet-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Outbound Webhooks</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">POST JSON on events · HMAC-signed with <span className="font-mono">X-MTM-Signature</span></span>
</div>
<div className="p-5 space-y-4">
{/* existing */}
{webhooks.map(h => (
<div key={h.id} className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div className="flex items-center gap-2 mb-1.5">
<span className={clsx('w-2 h-2 rounded-full flex-shrink-0', h.enabled ? 'bg-green-500' : 'bg-gray-400')} />
<span className="text-sm font-medium text-gray-900 dark:text-white">{h.name}</span>
{h.has_secret && <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">signed</span>}
{h.last_status != null && <span className={clsx('text-[10px] px-1.5 py-0.5 rounded', h.last_status >= 200 && h.last_status < 300 ? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400' : 'bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400')}>last: {h.last_status || 'err'}</span>}
<div className="ml-auto flex items-center gap-1">
{testResult[h.id] && <span className="text-xs text-gray-500 dark:text-slate-400 mr-1">{testResult[h.id]}</span>}
<button className="p-1.5 text-gray-400 hover:text-blue-600" title="Send test" onClick={() => test.mutate(h.id)}><Send className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-amber-600" title={h.enabled ? 'Disable' : 'Enable'} onClick={() => toggle.mutate({ id: h.id, enabled: !h.enabled })}><Power className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-red-600" title="Delete" onClick={() => del.mutate(h.id)}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
<div className="font-mono text-xs text-gray-500 dark:text-slate-400 truncate">{h.url}</div>
<div className="flex flex-wrap gap-1 mt-1.5">
{h.events.map(e => <span key={e} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">{EVENT_LABEL[e] || e}</span>)}
</div>
</div>
))}
{/* create */}
<div className="rounded-lg border border-dashed border-gray-300 dark:border-slate-600 p-3 space-y-2.5">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. Slack, PagerDuty)" />
<input className="input sm:col-span-2 font-mono text-sm" value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} placeholder="https://hooks.example.com/…" />
</div>
<input className="input font-mono text-sm" value={form.secret} onChange={e => setForm(f => ({ ...f, secret: e.target.value }))} placeholder="Signing secret (optional, recommended)" />
<div className="flex flex-wrap gap-1.5">
{events.map(e => (
<button key={e} onClick={() => toggleEvent(e)}
className={clsx('text-xs px-2 py-1 rounded-md border transition-colors',
form.events.has(e) ? 'bg-violet-100 dark:bg-violet-900/30 border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300' : 'border-gray-200 dark:border-slate-600 text-gray-500 dark:text-slate-400 hover:bg-gray-50 dark:hover:bg-slate-700')}>
{EVENT_LABEL[e] || e}
</button>
))}
</div>
<div className="flex justify-end">
<button className="btn-primary flex items-center gap-1.5" disabled={!form.name.trim() || !form.url.trim() || form.events.size === 0 || create.isPending} onClick={() => create.mutate()}>
<Plus className="w-4 h-4" />Add webhook
</button>
</div>
{create.isError && <p className="text-xs text-red-500 flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" />{(create.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed'}</p>}
</div>
</div>
</div>
);
}
// ─── Scheduled reports ───────────────────────────────────────────────────────────
function ReportsSection() {
const qc = useQueryClient();
const [form, setForm] = useState({ name: '', frequency: 'weekly', recipients: '' });
const [sendMsg, setSendMsg] = useState<Record<number, string>>({});
const { data: reports = [] } = useQuery({ queryKey: ['report-schedules'], queryFn: () => automationApi.listReports().then(r => r.data) });
const create = useMutation({
mutationFn: () => automationApi.createReport(form),
onSuccess: () => { setForm({ name: '', frequency: 'weekly', recipients: '' }); qc.invalidateQueries({ queryKey: ['report-schedules'] }); },
});
const toggle = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => automationApi.updateReport(id, { enabled }), onSuccess: () => qc.invalidateQueries({ queryKey: ['report-schedules'] }) });
const del = useMutation({ mutationFn: (id: number) => automationApi.deleteReport(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['report-schedules'] }) });
const sendNow = useMutation({
mutationFn: (id: number) => automationApi.sendReportNow(id),
onSuccess: (_r, id) => { setSendMsg(p => ({ ...p, [id]: 'Sent!' })); setTimeout(() => setSendMsg(p => { const n = { ...p }; delete n[id]; return n; }), 4000); qc.invalidateQueries({ queryKey: ['report-schedules'] }); },
onError: (e: unknown, id) => setSendMsg(p => ({ ...p, [id]: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Send failed' })),
});
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Mail className="w-4 h-4 text-emerald-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Scheduled Reports</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">emailed fleet summaries — uses your Alerting SMTP settings</span>
</div>
<div className="p-5 space-y-4">
{reports.map(r => (
<div key={r.id} className="flex items-center gap-3 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<span className={clsx('w-2 h-2 rounded-full flex-shrink-0', r.enabled ? 'bg-green-500' : 'bg-gray-400')} />
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">{r.name} <span className="text-xs font-normal text-gray-400 capitalize">· {r.frequency}</span></div>
<div className="text-xs text-gray-400 dark:text-slate-500 truncate">{r.recipients}</div>
</div>
<span className="text-xs text-gray-400 dark:text-slate-500 ml-2">
{r.last_sent_at ? `last ${formatDistanceToNow(new Date(r.last_sent_at))} ago` : `next ${formatDistanceToNow(new Date(r.next_run_at), { addSuffix: true })}`}
</span>
<div className="ml-auto flex items-center gap-1">
{sendMsg[r.id] && <span className="text-xs text-gray-500 dark:text-slate-400 mr-1">{sendMsg[r.id]}</span>}
<button className="p-1.5 text-gray-400 hover:text-blue-600" title="Send now" onClick={() => sendNow.mutate(r.id)} disabled={sendNow.isPending}><Send className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-amber-600" title={r.enabled ? 'Disable' : 'Enable'} onClick={() => toggle.mutate({ id: r.id, enabled: !r.enabled })}><Power className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-red-600" title="Delete" onClick={() => del.mutate(r.id)}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
))}
<div className="rounded-lg border border-dashed border-gray-300 dark:border-slate-600 p-3 flex flex-wrap items-end gap-2">
<div><label className="label">Name</label><input className="input w-44" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Weekly summary" /></div>
<div><label className="label">Frequency</label>
<select className="input w-32" value={form.frequency} onChange={e => setForm(f => ({ ...f, frequency: e.target.value }))}>
<option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option>
</select>
</div>
<div className="flex-1 min-w-[200px]"><label className="label">Recipients</label><input className="input" value={form.recipients} onChange={e => setForm(f => ({ ...f, recipients: e.target.value }))} placeholder="ops@company.com, noc@company.com" /></div>
<button className="btn-primary flex items-center gap-1.5" disabled={!form.name.trim() || !form.recipients.trim() || create.isPending} onClick={() => create.mutate()}><Plus className="w-4 h-4" />Add</button>
{create.isError && <p className="text-xs text-red-500 w-full flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" />{(create.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed'}</p>}
</div>
</div>
</div>
);
}
export default function AutomationSettings({ isAdmin }: { isAdmin: boolean }) {
return (
<div className="space-y-4">
<TokensSection isAdmin={isAdmin} />
<WebhooksSection />
<ReportsSection />
</div>
);
}
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ShieldAlert, Radio, ChevronDown, ChevronRight } from 'lucide-react';
import clsx from 'clsx';
import { wirelessApi } from '../../services/api';
import { formatDistanceToNow } from 'date-fns';
function signalCls(dbm: number): string {
if (dbm >= -55) return 'text-green-600 dark:text-green-400';
if (dbm >= -70) return 'text-yellow-600 dark:text-yellow-400';
return 'text-gray-400 dark:text-slate-500';
}
export default function RogueApsCard() {
const [showNeighbors, setShowNeighbors] = useState(false);
const { data } = useQuery({
queryKey: ['rogue-aps'],
queryFn: () => wirelessApi.getRogueAps().then(r => r.data),
refetchInterval: 5 * 60_000,
});
const rogues = data?.rogues ?? [];
const neighbors = data?.neighbors ?? [];
const hasScans = (data?.scannedDevices ?? 0) > 0;
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<ShieldAlert className={clsx('w-4 h-4', rogues.length > 0 ? 'text-red-500' : 'text-emerald-500')} />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Rogue &amp; Neighbor APs</h2>
<span className="ml-auto text-[11px] text-gray-400 dark:text-slate-500">
{data?.lastScanAt
? `last scan ${formatDistanceToNow(new Date(data.lastScanAt))} ago · ${data.scannedDevices} AP${data.scannedDevices !== 1 ? 's' : ''}`
: 'no scans yet'}
</span>
</div>
{!hasScans ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">
No AP scan data yet. Run a <strong>Nearby Access Points</strong> scan from a device&apos;s Radios tab
(or enable scheduled scans in Wireless Settings) to detect evil twins and map neighboring networks.
</div>
) : (
<div>
{/* Rogues */}
{rogues.length === 0 ? (
<div className="px-5 py-3 flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
No rogue APs — nothing is spoofing your SSIDs.
</div>
) : (
<div className="divide-y divide-red-100 dark:divide-red-900/30">
{rogues.map(r => (
<div key={r.bssid} className="px-5 py-3 bg-red-50/60 dark:bg-red-900/10 flex items-start gap-3">
<ShieldAlert className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-red-700 dark:text-red-400">
Foreign AP broadcasting &quot;{r.ssid}&quot; — possible evil twin
</div>
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 font-mono">
{r.bssid}{r.vendor ? ` (${r.vendor})` : ''} · {r.band} · seen by {r.seenBy}
</div>
</div>
<span className={clsx('font-mono text-xs flex-shrink-0', signalCls(r.signal))}>{r.signal} dBm</span>
</div>
))}
</div>
)}
{/* Neighbors (collapsed) */}
<button onClick={() => setShowNeighbors(v => !v)}
className="w-full px-5 py-2.5 flex items-center gap-2 text-xs text-gray-500 dark:text-slate-400 hover:bg-gray-50 dark:hover:bg-slate-700/40 transition-colors border-t border-gray-100 dark:border-slate-700/60">
{showNeighbors ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
<Radio className="w-3.5 h-3.5" />
{neighbors.length} neighboring network{neighbors.length !== 1 ? 's' : ''} detected
</button>
{showNeighbors && neighbors.length > 0 && (
<div className="overflow-x-auto border-t border-gray-100 dark:border-slate-700/60">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">SSID</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">BSSID / Vendor</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Band</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Security</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Signal</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{neighbors.slice(0, 25).map((n, i) => (
<tr key={n.bssid} className={clsx(i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-2 text-gray-800 dark:text-slate-200">{n.ssid}</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{n.bssid}{n.vendor ? ` (${n.vendor})` : ''}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-slate-400">{n.band}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-slate-400">{n.security || '—'}</td>
<td className={clsx('px-4 py-2 text-right font-mono text-xs', signalCls(n.signal))}>{n.signal} dBm</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
+40
View File
@@ -12,6 +12,7 @@ import {
} from 'recharts';
import { format, formatDistanceToNow, parseISO } from 'date-fns';
import { clientsApi, trafficApi, devicesApi } from '../services/api';
import { CATEGORY_META, SELECTABLE_CATEGORIES } from '../utils/clientCategories';
import type { ClientDetail } from '../services/api';
import type { SignalPoint } from '../services/api';
import clsx from 'clsx';
@@ -329,6 +330,15 @@ function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrit
},
});
const categoryMutation = useMutation({
mutationFn: (category: string | null) => clientsApi.updateCategory(client.mac_address, category),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['client-detail', client.mac_address] });
qc.invalidateQueries({ queryKey: ['clients'] });
qc.invalidateQueries({ queryKey: ['wireless-clients-page'] });
},
});
const handleWol = async () => {
setWolStatus('sending');
try {
@@ -431,6 +441,36 @@ function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrit
<DetailRow label="Vendor">{client.vendor}</DetailRow>
)}
{/* Device category — auto-fingerprinted, user-overridable */}
<DetailRow label="Device type">
<div className="flex items-center gap-2 min-w-0">
{canWrite ? (
<select
className="input py-1 text-sm w-auto max-w-[220px]"
value={client.custom_category ?? ''}
onChange={(e) => categoryMutation.mutate(e.target.value === '' ? null : e.target.value)}
disabled={categoryMutation.isPending}
>
<option value="">
Automatic — {CATEGORY_META[client.auto_category ?? 'unknown']?.label ?? 'Unknown'}
</option>
{SELECTABLE_CATEGORIES.filter(c => c !== 'unknown').map(c => (
<option key={c} value={c}>{CATEGORY_META[c].label}</option>
))}
</select>
) : (
<span>{CATEGORY_META[client.device_category ?? 'unknown']?.label ?? 'Unknown'}</span>
)}
{client.custom_category && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex-shrink-0"
title={`Auto-detected as: ${CATEGORY_META[client.auto_category ?? 'unknown']?.label ?? 'Unknown'}`}>
manual
</span>
)}
{categoryMutation.isPending && <span className="text-xs text-gray-400">saving…</span>}
</div>
</DetailRow>
<DetailRow label="Type">
<span className={clsx(
'capitalize',
+18 -2
View File
@@ -1,7 +1,11 @@
import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import {
Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown,
ChevronLeft, ChevronRight, RefreshCw,
} from 'lucide-react';
import { CATEGORY_META } from '../utils/clientCategories';
import { clientsApi } from '../services/api';
import type { Client } from '../types';
import { useCanWrite } from '../hooks/useCanWrite';
@@ -46,6 +50,17 @@ function getPageList(current0: number, totalPages: number): (number | 'ellipsis'
return out;
}
function CategoryIcon({ category }: { category?: string }) {
const meta = category && category !== 'unknown' ? CATEGORY_META[category] : undefined;
if (!meta) return null;
const Icon = meta.icon;
return (
<span title={meta.label} className="flex-shrink-0 text-gray-400 dark:text-slate-500">
<Icon className="w-3.5 h-3.5" />
</span>
);
}
function formatDataBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -365,7 +380,8 @@ export default function ClientsPage() {
className="hover:bg-gray-50 dark:hover:bg-slate-700/30"
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<CategoryIcon category={client.device_category} />
<button
onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)}
className="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left truncate"
+382
View File
@@ -0,0 +1,382 @@
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ArrowUpCircle, RefreshCw, CheckCircle, XCircle, AlertTriangle, Clock,
HardDrive, ShieldAlert, Rocket, Ban, ChevronRight,
} from 'lucide-react';
import clsx from 'clsx';
import { firmwareApi } from '../services/api';
import type { FirmwareRolloutDevice } from '../services/api';
import { useCanWrite } from '../hooks/useCanWrite';
import { formatDistanceToNow } from 'date-fns';
const ITEM_STATUS: Record<string, { label: string; cls: string; spin?: boolean }> = {
pending: { label: 'Pending', cls: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400' },
backing_up: { label: 'Backing up', cls: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400', spin: true },
upgrading: { label: 'Upgrading', cls: 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-400', spin: true },
rebooting: { label: 'Rebooting', cls: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400', spin: true },
verifying: { label: 'Verifying', cls: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400', spin: true },
success: { label: 'Success', cls: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' },
failed: { label: 'Failed', cls: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
skipped: { label: 'Skipped', cls: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400' },
};
const ROLLOUT_STATUS: Record<string, string> = {
pending: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300',
running: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
completed: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
failed: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400',
cancelled: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400',
};
function TypePill({ type }: { type: string }) {
const label = type === 'wireless_ap' ? 'AP' : type === 'switch' ? 'SW' : type === 'router' ? 'RTR' : '—';
return <span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">{label}</span>;
}
// ─── Rollout progress panel ────────────────────────────────────────────────────
function RolloutPanel({ rolloutId, canWrite }: { rolloutId: number; canWrite: boolean }) {
const qc = useQueryClient();
const { data: rollout } = useQuery({
queryKey: ['fw-rollout', rolloutId],
queryFn: () => firmwareApi.getRollout(rolloutId).then(r => r.data),
refetchInterval: (q) => {
const s = q.state.data?.status;
return s === 'running' || s === 'pending' ? 4_000 : false;
},
});
const cancelMutation = useMutation({
mutationFn: () => firmwareApi.cancelRollout(rolloutId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['fw-rollout', rolloutId] }),
});
if (!rollout) return null;
const waves = [...new Set((rollout.devices ?? []).map(d => d.wave))].sort((a, b) => a - b);
const active = rollout.status === 'running' || rollout.status === 'pending';
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2 flex-wrap">
<Rocket className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{rollout.name}</h3>
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium capitalize', ROLLOUT_STATUS[rollout.status])}>
{rollout.status}
</span>
{rollout.scheduled_at && rollout.status === 'pending' && (
<span className="text-xs text-gray-400 dark:text-slate-500 flex items-center gap-1">
<Clock className="w-3 h-3" />starts {formatDistanceToNow(new Date(rollout.scheduled_at), { addSuffix: true })}
</span>
)}
<span className="ml-auto flex items-center gap-3 text-xs text-gray-400 dark:text-slate-500">
{rollout.pre_backup && <span className="flex items-center gap-1"><HardDrive className="w-3 h-3" />pre-backup</span>}
{rollout.halt_on_failure && <span className="flex items-center gap-1"><ShieldAlert className="w-3 h-3" />halt on failure</span>}
{canWrite && active && (
<button onClick={() => cancelMutation.mutate()} disabled={cancelMutation.isPending}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<Ban className="w-3 h-3" />Cancel
</button>
)}
</span>
</div>
{waves.map(w => (
<div key={w}>
<div className="px-5 py-1.5 bg-gray-50 dark:bg-slate-800/50 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-slate-500">
Wave {w}{w === 1 ? ' — canary' : ''}
</div>
{(rollout.devices ?? []).filter(d => d.wave === w).map((d: FirmwareRolloutDevice) => {
const meta = ITEM_STATUS[d.status] ?? ITEM_STATUS.pending;
return (
<div key={d.id} className="px-5 py-2.5 flex items-center gap-3 border-t border-gray-100 dark:border-slate-700/60">
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 flex-shrink-0', meta.cls)}>
{meta.spin && <RefreshCw className="w-3 h-3 animate-spin" />}
{d.status === 'success' && <CheckCircle className="w-3 h-3" />}
{d.status === 'failed' && <XCircle className="w-3 h-3" />}
{meta.label}
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{d.device_name}</span>
<span className="font-mono text-xs text-gray-400 dark:text-slate-500 flex items-center gap-1 flex-shrink-0">
{d.from_version || '—'}
{(d.to_version || d.status === 'success') && <><ChevronRight className="w-3 h-3" />{d.to_version || '?'}</>}
</span>
{d.error && <span className="text-xs text-red-500 truncate" title={d.error}>{d.error}</span>}
<span className="ml-auto text-[11px] text-gray-400 dark:text-slate-500 flex-shrink-0">
{d.finished_at ? formatDistanceToNow(new Date(d.finished_at), { addSuffix: true }) : ''}
</span>
</div>
);
})}
</div>
))}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function FirmwarePage() {
const qc = useQueryClient();
const canWrite = useCanWrite();
const [selected, setSelected] = useState<Map<number, number>>(new Map()); // deviceId → wave
const [rolloutName, setRolloutName] = useState('');
const [preBackup, setPreBackup] = useState(true);
const [haltOnFailure, setHaltOnFailure] = useState(true);
const [scheduleAt, setScheduleAt] = useState('');
const [viewRolloutId, setViewRolloutId] = useState<number | null>(null);
const [checkResult, setCheckResult] = useState<string | null>(null);
const { data: overview, isLoading } = useQuery({
queryKey: ['fw-overview'],
queryFn: () => firmwareApi.overview().then(r => r.data),
refetchInterval: 30_000,
});
const { data: rollouts = [] } = useQuery({
queryKey: ['fw-rollouts'],
queryFn: () => firmwareApi.listRollouts().then(r => r.data),
refetchInterval: 15_000,
});
const devices = overview?.devices ?? [];
const updatable = devices.filter(d => d.firmware_update_available && d.status === 'online');
const upToDate = devices.filter(d => !d.firmware_update_available && d.ros_version).length;
const activeRolloutId = viewRolloutId ?? overview?.runningRolloutId ?? overview?.latestRolloutId ?? null;
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['fw-overview'] });
qc.invalidateQueries({ queryKey: ['fw-rollouts'] });
};
const checkAll = useMutation({
mutationFn: () => firmwareApi.checkAll(),
onSuccess: (r) => {
const avail = r.data.results.filter(x => x.ok && x.available).length;
const failed = r.data.results.filter(x => !x.ok).length;
setCheckResult(`Checked ${r.data.results.length} device(s): ${avail} update(s) available${failed ? `, ${failed} unreachable` : ''}`);
setTimeout(() => setCheckResult(null), 6000);
invalidate();
},
});
const createRollout = useMutation({
mutationFn: () => firmwareApi.createRollout({
name: rolloutName.trim() || `RouterOS upgrade ${new Date().toISOString().slice(0, 10)}`,
halt_on_failure: haltOnFailure,
pre_backup: preBackup,
scheduled_at: scheduleAt ? new Date(scheduleAt).toISOString() : null,
start: !scheduleAt,
devices: [...selected.entries()].map(([device_id, wave]) => ({ device_id, wave })),
}),
onSuccess: (r) => {
setSelected(new Map());
setViewRolloutId(r.data.id);
invalidate();
},
});
const toggleDevice = (id: number) => {
setSelected(prev => {
const next = new Map(prev);
if (next.has(id)) next.delete(id);
else next.set(id, next.size === 0 ? 1 : 2); // first pick becomes the canary
return next;
});
};
const setWave = (id: number, wave: number) => setSelected(prev => new Map(prev).set(id, wave));
const selectedCount = selected.size;
const waveSummary = useMemo(() => {
const byWave = new Map<number, number>();
for (const w of selected.values()) byWave.set(w, (byWave.get(w) ?? 0) + 1);
return [...byWave.entries()].sort((a, b) => a[0] - b[0]).map(([w, n]) => `wave ${w}: ${n}`).join(' · ');
}, [selected]);
return (
<div className="space-y-5">
{/* Header */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Firmware</h1>
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
Staged RouterOS upgrades — canary first, wave by wave, with pre-backups and health verification
</p>
</div>
<div className="flex items-center gap-2">
{checkResult && <span className="text-xs text-green-600 dark:text-green-400">{checkResult}</span>}
{canWrite && (
<button className="btn-secondary flex items-center gap-2" onClick={() => checkAll.mutate()} disabled={checkAll.isPending}>
<RefreshCw className={clsx('w-4 h-4', checkAll.isPending && 'animate-spin')} />
{checkAll.isPending ? 'Checking fleet…' : 'Check all for updates'}
</button>
)}
</div>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-50 dark:bg-green-900/20"><CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" /></div>
<div><div className="text-2xl font-bold text-gray-900 dark:text-white">{upToDate}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Up to date</div></div>
</div>
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20"><ArrowUpCircle className="w-5 h-5 text-amber-600 dark:text-amber-400" /></div>
<div><div className="text-2xl font-bold text-gray-900 dark:text-white">{devices.filter(d => d.firmware_update_available).length}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Updates available</div></div>
</div>
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20"><Rocket className="w-5 h-5 text-blue-600 dark:text-blue-400" /></div>
<div><div className="text-2xl font-bold text-gray-900 dark:text-white">{overview?.runningRolloutId ? 'Running' : '—'}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Active rollout</div></div>
</div>
</div>
{/* Fleet table */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Fleet versions</h3>
{canWrite && updatable.length > 0 && (
<button className="ml-auto text-xs text-blue-600 dark:text-blue-400 hover:underline"
onClick={() => setSelected(new Map(updatable.map((d, i) => [d.id, i === 0 ? 1 : 2])))}>
Select all updatable ({updatable.length})
</button>
)}
</div>
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
{canWrite && <th className="px-4 py-2.5 w-8" />}
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Device</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Model</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">RouterOS</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Latest</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">RouterBOOT</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Status</th>
{canWrite && <th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Wave</th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{devices.map((d, i) => {
const isSel = selected.has(d.id);
const selectable = canWrite && d.firmware_update_available && d.status === 'online';
return (
<tr key={d.id} className={clsx('transition-colors', isSel ? 'bg-blue-50/60 dark:bg-blue-900/10' : i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
{canWrite && (
<td className="px-4 py-2.5">
<input type="checkbox" className="w-4 h-4 rounded" checked={isSel} disabled={!selectable}
onChange={() => toggleDevice(d.id)} />
</td>
)}
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<TypePill type={d.device_type} />
<span className="font-medium text-gray-900 dark:text-white">{d.name}</span>
{d.status !== 'online' && <span className="text-[10px] text-red-400">offline</span>}
</div>
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">{d.model || '—'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 dark:text-slate-300">{d.ros_version || '—'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{d.latest_ros_version || '—'}</td>
<td className="px-4 py-2.5 text-xs">
{d.routerboard_upgrade_available
? <span className="px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-mono">{d.firmware_version} → {d.upgrade_firmware_version}</span>
: <span className="font-mono text-gray-400 dark:text-slate-500">{d.firmware_version || '—'}</span>}
</td>
<td className="px-4 py-2.5">
{d.firmware_update_available
? <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">Update available</span>
: <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">Up to date</span>}
</td>
{canWrite && (
<td className="px-4 py-2.5">
{isSel && (
<select className="input py-0.5 text-xs w-auto" value={selected.get(d.id)}
onChange={e => setWave(d.id, parseInt(e.target.value, 10))}>
{[1, 2, 3].map(w => <option key={w} value={w}>{w === 1 ? '1 — canary' : String(w)}</option>)}
</select>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Rollout builder bar */}
{canWrite && selectedCount > 0 && (
<div className="px-5 py-4 border-t border-gray-200 dark:border-slate-700 bg-blue-50/40 dark:bg-blue-900/10 space-y-3">
<div className="flex flex-wrap items-center gap-3">
<input className="input w-64" placeholder={`RouterOS upgrade ${new Date().toISOString().slice(0, 10)}`}
value={rolloutName} onChange={e => setRolloutName(e.target.value)} />
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300 cursor-pointer">
<input type="checkbox" className="w-4 h-4 rounded" checked={preBackup} onChange={e => setPreBackup(e.target.checked)} />
Pre-upgrade backup
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300 cursor-pointer">
<input type="checkbox" className="w-4 h-4 rounded" checked={haltOnFailure} onChange={e => setHaltOnFailure(e.target.checked)} />
Halt on failure
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300">
<Clock className="w-4 h-4 text-gray-400" />
<input type="datetime-local" className="input py-1 text-xs w-auto" value={scheduleAt} onChange={e => setScheduleAt(e.target.value)} />
</label>
</div>
<div className="flex items-center gap-3">
<button className="btn-primary flex items-center gap-2" disabled={createRollout.isPending}
onClick={() => createRollout.mutate()}>
{createRollout.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Rocket className="w-4 h-4" />}
{scheduleAt ? 'Schedule rollout' : 'Start rollout now'} ({selectedCount} device{selectedCount !== 1 ? 's' : ''})
</button>
<span className="text-xs text-gray-500 dark:text-slate-400">{waveSummary}</span>
{createRollout.isError && (
<span className="text-xs text-red-500 flex items-center gap-1">
<AlertTriangle className="w-3.5 h-3.5" />
{(createRollout.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed to create rollout'}
</span>
)}
{scheduleAt && (
<span className="text-xs text-gray-400 dark:text-slate-500">
Tip: schedule inside a maintenance window to avoid disrupting users.
</span>
)}
</div>
</div>
)}
</div>
{/* Latest / running rollout */}
{activeRolloutId && <RolloutPanel rolloutId={activeRolloutId} canWrite={canWrite} />}
{/* Past rollouts */}
{rollouts.length > 1 && (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">History</h3>
</div>
<div className="divide-y divide-gray-100 dark:divide-slate-700/50">
{rollouts.map(r => (
<button key={r.id} onClick={() => setViewRolloutId(r.id)}
className="w-full px-5 py-2.5 flex items-center gap-3 text-left hover:bg-gray-50 dark:hover:bg-slate-700/40 transition-colors">
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium capitalize flex-shrink-0', ROLLOUT_STATUS[r.status])}>{r.status}</span>
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{r.name}</span>
<span className="text-xs text-gray-400 dark:text-slate-500">
{r.success_count}/{r.device_count} succeeded{(r.failed_count ?? 0) > 0 ? ` · ${r.failed_count} failed` : ''}
</span>
<span className="ml-auto text-[11px] text-gray-400 dark:text-slate-500 flex-shrink-0">
{formatDistanceToNow(new Date(r.created_at), { addSuffix: true })}
</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
+607
View File
@@ -0,0 +1,607 @@
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Ticket, Wifi, Users, Globe, Power, Trash2, RefreshCw, Plus, Printer,
CheckCircle, AlertTriangle, X, Clock, Database,
} from 'lucide-react';
import clsx from 'clsx';
import { guestWifiApi, devicesApi } from '../services/api';
import { useCanWrite } from '../hooks/useCanWrite';
import type { Device } from '../types';
type HS = Record<string, string>;
function formatBytes(raw: string | number | undefined): string {
const b = Number(raw ?? 0);
if (!b) return '0 B';
if (b < 1024) return `${b} B`;
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} KB`;
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(1)} MB`;
return `${(b / 1024 ** 3).toFixed(2)} GB`;
}
// ─── Voucher print sheet ─────────────────────────────────────────────────────
function printVouchers(codes: string[], opts: { network: string; durationHours?: number; dataCapMB?: number }) {
const cards = codes.map(code => `
<div class="card">
<div class="net">📶 ${opts.network}</div>
<div class="code">${code}</div>
<div class="meta">
${opts.durationHours ? `Valid ${opts.durationHours}h` : 'No time limit'}
${opts.dataCapMB ? ` · ${opts.dataCapMB >= 1024 ? (opts.dataCapMB / 1024).toFixed(0) + ' GB' : opts.dataCapMB + ' MB'}` : ''}
</div>
<div class="hint">Connect to WiFi, enter this code as username.<br/>Leave password blank.</div>
</div>`).join('');
const html = `<!doctype html><html><head><title>Guest WiFi Vouchers</title><style>
body { font-family: -apple-system, system-ui, sans-serif; margin: 24px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.card { border: 1.5px dashed #94a3b8; border-radius: 10px; padding: 14px; text-align: center; break-inside: avoid; }
.net { font-size: 12px; color: #475569; margin-bottom: 6px; }
.code { font-size: 20px; font-weight: 700; letter-spacing: 1px; font-family: ui-monospace, monospace; }
.meta { font-size: 11px; color: #64748b; margin-top: 6px; }
.hint { font-size: 9.5px; color: #94a3b8; margin-top: 8px; line-height: 1.4; }
@media print { body { margin: 8px; } }
</style></head><body><div class="grid">${cards}</div><script>window.print()</script></body></html>`;
const w = window.open('', '_blank', 'width=900,height=700');
if (w) { w.document.write(html); w.document.close(); }
}
// ─── Setup wizard ─────────────────────────────────────────────────────────────
function SetupWizard({ deviceId, interfaces, isAP, onDone }: {
deviceId: number;
interfaces: { name: string; type: string }[];
isAP: boolean;
onDone: () => void;
}) {
const [mode, setMode] = useState<'ssid' | 'existing'>(isAP ? 'ssid' : 'existing');
const [form, setForm] = useState({
name: 'guest',
ssid: 'Guest WiFi',
passphrase: '',
vlanId: '',
interfaceName: interfaces[0]?.name ?? '',
gatewayCidr: '10.5.50.1/24',
poolRange: '10.5.50.10-10.5.50.254',
dnsName: '',
rateLimit: '10M/10M',
masquerade: true,
});
const set = <K extends keyof typeof form>(k: K) => (v: typeof form[K]) => setForm(f => ({ ...f, [k]: v }));
const [warnings, setWarnings] = useState<string[]>([]);
const mutation = useMutation({
mutationFn: () => guestWifiApi.setup(deviceId, {
name: form.name,
gatewayCidr: form.gatewayCidr,
poolRange: form.poolRange,
dnsName: form.dnsName || undefined,
rateLimit: form.rateLimit || undefined,
masquerade: form.masquerade,
...(mode === 'ssid'
? {
ssid: { ssid: form.ssid, passphrase: form.passphrase || undefined },
vlanId: form.vlanId ? parseInt(form.vlanId, 10) : undefined,
}
: { interfaceName: form.interfaceName }),
}),
onSuccess: (r) => {
setWarnings(r.data.warnings ?? []);
if ((r.data.warnings ?? []).length === 0) onDone();
},
});
// Setup succeeded but produced warnings — show them with a continue button
if (warnings.length > 0) {
return (
<div className="card p-6 max-w-2xl">
<div className="flex items-center gap-2 mb-3">
<CheckCircle className="w-5 h-5 text-green-500" />
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Guest network created — review these</h2>
</div>
<div className="space-y-2 mb-4">
{warnings.map((w, i) => (
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />{w}
</div>
))}
</div>
<div className="flex justify-end">
<button className="btn-primary" onClick={onDone}>Continue to Guest WiFi</button>
</div>
</div>
);
}
return (
<div className="card p-6 max-w-2xl">
<div className="flex items-center gap-2 mb-1">
<Ticket className="w-5 h-5 text-blue-500" />
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Set up Guest WiFi</h2>
</div>
<p className="text-sm text-gray-500 dark:text-slate-400 mb-4">
Builds the whole guest network in one pass: broadcasts a new guest SSID, isolates the traffic
(VLAN or dedicated bridge), and stands up the captive portal — pool, DHCP, portal server, rate-limited guest profile, NAT.
</p>
{/* Mode toggle */}
<div className="flex rounded-lg border border-gray-200 dark:border-slate-700 p-1 gap-1 w-fit mb-5">
{isAP && (
<button onClick={() => setMode('ssid')}
className={clsx('px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
mode === 'ssid' ? 'bg-blue-600 text-white' : 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700')}>
Create new guest SSID
</button>
)}
<button onClick={() => setMode('existing')}
className={clsx('px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
mode === 'existing' ? 'bg-blue-600 text-white' : 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700')}>
Use existing interface
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{mode === 'ssid' ? (
<>
<div>
<label className="label">Guest SSID</label>
<input className="input" value={form.ssid} onChange={e => set('ssid')(e.target.value)} placeholder="Guest WiFi" maxLength={32} />
<p className="text-xs text-gray-400 mt-1">A new virtual AP is created on every radio (2.4 + 5 GHz).</p>
</div>
<div>
<label className="label">WiFi password (optional)</label>
<input className="input font-mono" value={form.passphrase} onChange={e => set('passphrase')(e.target.value)} placeholder="empty = open network" />
<p className="text-xs text-gray-400 mt-1">Open is typical for captive portals — the voucher is the gate.</p>
</div>
<div>
<label className="label">Guest VLAN ID (recommended)</label>
<input type="number" min={1} max={4094} className="input font-mono" value={form.vlanId} onChange={e => set('vlanId')(e.target.value)} placeholder="e.g. 50" />
<p className="text-xs text-gray-400 mt-1">Tags guest traffic for firewall/isolation. Empty = dedicated guest bridge instead.</p>
</div>
</>
) : (
<div>
<label className="label">Interface</label>
<select className="input" value={form.interfaceName} onChange={e => set('interfaceName')(e.target.value)}>
{interfaces.map(i => <option key={i.name} value={i.name}>{i.name}{i.type ? ` (${i.type})` : ''}</option>)}
</select>
<p className="text-xs text-gray-400 mt-1">An existing guest bridge/VLAN/SSID interface.</p>
</div>
)}
<div>
<label className="label">Network name</label>
<input className="input" value={form.name} onChange={e => set('name')(e.target.value)} placeholder="guest" />
<p className="text-xs text-gray-400 mt-1">Names the hotspot, pool &amp; profiles (a–z, 0–9, dash).</p>
</div>
<div>
<label className="label">Gateway address (CIDR)</label>
<input className="input font-mono" value={form.gatewayCidr} onChange={e => set('gatewayCidr')(e.target.value)} placeholder="10.5.50.1/24" />
</div>
<div>
<label className="label">DHCP pool range</label>
<input className="input font-mono" value={form.poolRange} onChange={e => set('poolRange')(e.target.value)} placeholder="10.5.50.10-10.5.50.254" />
</div>
<div>
<label className="label">Guest speed limit (rx/tx)</label>
<input className="input font-mono" value={form.rateLimit} onChange={e => set('rateLimit')(e.target.value)} placeholder="10M/10M" />
<p className="text-xs text-gray-400 mt-1">Per-guest cap. Leave empty for unlimited.</p>
</div>
<div>
<label className="label">Portal DNS name (optional)</label>
<input className="input font-mono" value={form.dnsName} onChange={e => set('dnsName')(e.target.value)} placeholder="wifi.guest" />
</div>
</div>
<label className="mt-4 flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-slate-300">
<input type="checkbox" className="w-4 h-4 rounded" checked={form.masquerade} onChange={e => set('masquerade')(e.target.checked)} />
NAT guest traffic to the internet (adds a masquerade rule for the guest subnet)
</label>
{mode === 'existing' && (
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>Pick a dedicated guest interface. Enabling a hotspot on your main LAN interface will put a captive portal in front of <em>every</em> client on it.</span>
</div>
)}
{mutation.isError && (
<p className="mt-3 text-sm text-red-500">
{(mutation.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Setup failed'}
</p>
)}
<div className="mt-5 flex justify-end">
<button className="btn-primary flex items-center gap-2"
disabled={mutation.isPending || (mode === 'ssid' ? !form.ssid.trim() : !form.interfaceName)}
onClick={() => mutation.mutate()}>
{mutation.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
{mutation.isPending ? 'Setting up…' : 'Create Guest Network'}
</button>
</div>
</div>
);
}
// ─── Voucher generator ───────────────────────────────────────────────────────
function VoucherGenerator({ deviceId, userProfiles, networkName, onCreated }: {
deviceId: number;
userProfiles: HS[];
networkName: string;
onCreated: () => void;
}) {
const [count, setCount] = useState(10);
const [durationHours, setDurationHours] = useState(24);
const [dataCapMB, setDataCapMB] = useState(0);
const [profile, setProfile] = useState(userProfiles.find(p => (p['name'] || '').includes('guest'))?.['name'] ?? userProfiles[0]?.['name'] ?? '');
const [result, setResult] = useState<string[] | null>(null);
const mutation = useMutation({
mutationFn: () => guestWifiApi.createVouchers(deviceId, {
count,
durationHours: durationHours > 0 ? durationHours : undefined,
dataCapMB: dataCapMB > 0 ? dataCapMB : undefined,
userProfile: profile || undefined,
}),
onSuccess: (r) => { setResult(r.data.codes); onCreated(); },
});
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Plus className="w-4 h-4 text-green-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Generate vouchers</h3>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div>
<label className="label">Count</label>
<input type="number" min={1} max={100} className="input" value={count} onChange={e => setCount(parseInt(e.target.value) || 1)} />
</div>
<div>
<label className="label">Valid for (hours)</label>
<input type="number" min={0} className="input" value={durationHours} onChange={e => setDurationHours(parseInt(e.target.value) || 0)} />
<p className="text-[10px] text-gray-400 mt-0.5">0 = unlimited</p>
</div>
<div>
<label className="label">Data cap (MB)</label>
<input type="number" min={0} className="input" value={dataCapMB} onChange={e => setDataCapMB(parseInt(e.target.value) || 0)} />
<p className="text-[10px] text-gray-400 mt-0.5">0 = unlimited</p>
</div>
<div>
<label className="label">Speed profile</label>
<select className="input" value={profile} onChange={e => setProfile(e.target.value)}>
{userProfiles.map(p => (
<option key={p['.id'] ?? p['name']} value={p['name']}>
{p['name']}{p['rate-limit'] ? ` (${p['rate-limit']})` : ''}
</option>
))}
</select>
</div>
</div>
{mutation.isError && (
<p className="mt-3 text-sm text-red-500">
{(mutation.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed to create vouchers'}
</p>
)}
<div className="mt-4 flex justify-end">
<button className="btn-primary flex items-center gap-2" disabled={mutation.isPending} onClick={() => mutation.mutate()}>
{mutation.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Ticket className="w-4 h-4" />}
{mutation.isPending ? 'Generating…' : `Generate ${count}`}
</button>
</div>
{/* Result modal */}
{result && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setResult(null)}>
<div className="card w-full max-w-lg p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-2 mb-3">
<CheckCircle className="w-5 h-5 text-green-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{result.length} voucher{result.length !== 1 ? 's' : ''} created</h3>
<button className="ml-auto text-gray-400 hover:text-gray-600" onClick={() => setResult(null)}><X className="w-4 h-4" /></button>
</div>
<div className="grid grid-cols-3 gap-2 max-h-64 overflow-y-auto mb-4">
{result.map(c => (
<div key={c} className="px-2 py-1.5 rounded bg-gray-100 dark:bg-slate-700 text-center font-mono text-sm text-gray-800 dark:text-slate-200">{c}</div>
))}
</div>
<div className="flex justify-end gap-2">
<button className="btn-secondary" onClick={() => setResult(null)}>Close</button>
<button className="btn-primary flex items-center gap-2"
onClick={() => printVouchers(result, { network: networkName, durationHours: durationHours > 0 ? durationHours : undefined, dataCapMB: dataCapMB > 0 ? dataCapMB : undefined })}>
<Printer className="w-4 h-4" />Print sheet
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default function GuestWifiPage() {
const qc = useQueryClient();
const canWrite = useCanWrite();
const [deviceId, setDeviceId] = useState<number | ''>('');
const [wgHost, setWgHost] = useState('');
const { data: devices = [] } = useQuery({
queryKey: ['devices'],
queryFn: () => devicesApi.list().then(r => r.data),
staleTime: 60_000,
});
// Hotspots belong on the device that serves the guest network: an AP or the
// gateway router. Switches (and 'other') aren't sensible portal hosts.
const online = (devices as Device[]).filter(
d => d.status === 'online' && (d.device_type === 'wireless_ap' || d.device_type === 'router')
);
const devId = typeof deviceId === 'number' ? deviceId : (online[0]?.id ?? 0);
const { data: overview, isLoading, refetch, isFetching } = useQuery({
queryKey: ['guest-wifi-overview', devId],
queryFn: () => guestWifiApi.overview(devId).then(r => r.data),
enabled: devId > 0,
});
const hasServer = (overview?.servers?.length ?? 0) > 0;
const { data: users = [] } = useQuery({
queryKey: ['guest-wifi-users', devId],
queryFn: () => guestWifiApi.users(devId).then(r => r.data),
enabled: devId > 0 && hasServer,
});
const { data: active = [] } = useQuery({
queryKey: ['guest-wifi-active', devId],
queryFn: () => guestWifiApi.active(devId).then(r => r.data),
enabled: devId > 0 && hasServer,
refetchInterval: 30_000,
});
const { data: walledGarden = [] } = useQuery({
queryKey: ['guest-wifi-wg', devId],
queryFn: () => guestWifiApi.walledGarden(devId).then(r => r.data),
enabled: devId > 0 && hasServer,
});
const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ['guest-wifi-overview', devId] });
qc.invalidateQueries({ queryKey: ['guest-wifi-users', devId] });
qc.invalidateQueries({ queryKey: ['guest-wifi-active', devId] });
qc.invalidateQueries({ queryKey: ['guest-wifi-wg', devId] });
};
const toggleServer = useMutation({
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => guestWifiApi.setServerDisabled(devId, id, disabled),
onSuccess: invalidateAll,
});
const deleteUser = useMutation({ mutationFn: (id: string) => guestWifiApi.deleteUser(devId, id), onSuccess: invalidateAll });
const kickGuest = useMutation({ mutationFn: (id: string) => guestWifiApi.disconnect(devId, id), onSuccess: invalidateAll });
const addWg = useMutation({ mutationFn: (host: string) => guestWifiApi.addWalledGarden(devId, host), onSuccess: () => { setWgHost(''); invalidateAll(); } });
const removeWg = useMutation({ mutationFn: (id: string) => guestWifiApi.removeWalledGarden(devId, id), onSuccess: invalidateAll });
const vouchers = useMemo(() => users.filter(u => (u['comment'] || '').startsWith('voucher')), [users]);
const networkName = overview?.servers?.[0]?.['name'] ?? 'Guest WiFi';
return (
<div className="space-y-5">
{/* Header */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Guest WiFi</h1>
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
Captive portal, vouchers, and guest session management (RouterOS Hotspot)
</p>
</div>
<div className="flex items-center gap-2">
<select className="input w-auto" value={devId} onChange={e => setDeviceId(parseInt(e.target.value))}>
{online.map(d => (
<option key={d.id} value={d.id}>
{d.name} ({d.device_type === 'wireless_ap' ? 'AP' : 'Router'})
</option>
))}
</select>
<button className="btn-secondary flex items-center gap-2" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={clsx('w-4 h-4', isFetching && 'animate-spin')} />Refresh
</button>
</div>
</div>
{online.length === 0 ? (
<div className="card p-10 text-center text-sm text-gray-400 dark:text-slate-500">No online devices.</div>
) : isLoading ? (
<div className="card p-10 text-center text-sm text-gray-400 dark:text-slate-500">
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading hotspot state…
</div>
) : !hasServer ? (
canWrite
? <SetupWizard deviceId={devId} interfaces={overview?.interfaces ?? []}
isAP={online.find(d => d.id === devId)?.device_type === 'wireless_ap'} onDone={invalidateAll} />
: <div className="card p-10 text-center text-sm text-gray-400 dark:text-slate-500">No guest network configured on this device.</div>
) : (
<>
{/* Server status + KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{overview!.servers.map(s => {
const disabled = s['disabled'] === 'true';
return (
<div key={s['.id']} className="card p-4 flex items-center gap-3">
<div className={clsx('p-2 rounded-lg', disabled ? 'bg-gray-100 dark:bg-slate-700' : 'bg-green-50 dark:bg-green-900/20')}>
<Wifi className={clsx('w-5 h-5', disabled ? 'text-gray-400' : 'text-green-600 dark:text-green-400')} />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">{s['name']}</div>
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono truncate">{s['interface']} · {disabled ? 'disabled' : 'running'}</div>
</div>
{canWrite && (
<button onClick={() => toggleServer.mutate({ id: s['.id'], disabled: !disabled })}
className={clsx('ml-auto p-1.5 rounded-lg transition-colors', disabled ? 'text-gray-400 hover:text-green-600' : 'text-green-500 hover:text-red-500')}
title={disabled ? 'Enable portal' : 'Disable portal'}>
<Power className="w-4 h-4" />
</button>
)}
</div>
);
})}
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20"><Users className="w-5 h-5 text-blue-600 dark:text-blue-400" /></div>
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">{active.length}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Guests online</div>
</div>
</div>
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-violet-50 dark:bg-violet-900/20"><Ticket className="w-5 h-5 text-violet-600 dark:text-violet-400" /></div>
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">{vouchers.length}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Vouchers issued</div>
</div>
</div>
</div>
{/* Voucher generator */}
{canWrite && (
<VoucherGenerator deviceId={devId} userProfiles={overview!.userProfiles} networkName={networkName} onCreated={invalidateAll} />
)}
{/* Vouchers table */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Ticket className="w-4 h-4 text-violet-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Vouchers ({vouchers.length})</h3>
{vouchers.length > 0 && (
<button className="ml-auto text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
onClick={() => printVouchers(vouchers.map(v => v['name']), { network: networkName })}>
<Printer className="w-3.5 h-3.5" />Print all
</button>
)}
</div>
{vouchers.length === 0 ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">No vouchers yet — generate a batch above.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Code</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Profile</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Time limit</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Used</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Data</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Batch</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{vouchers.map((v, i) => {
const used = (v['uptime'] && v['uptime'] !== '0s');
return (
<tr key={v['.id'] ?? i} className={clsx('hover:bg-blue-50/40 dark:hover:bg-slate-700/30', i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-2.5 font-mono font-semibold text-gray-900 dark:text-white">{v['name']}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">{v['profile'] || 'default'}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400"><Clock className="w-3 h-3 inline mr-1" />{v['limit-uptime'] || '∞'}</td>
<td className="px-4 py-2.5 text-xs">
<span className={clsx('px-1.5 py-0.5 rounded-full font-medium', used ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400')}>
{used ? `used ${v['uptime']}` : 'unused'}
</span>
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400"><Database className="w-3 h-3 inline mr-1" />{v['limit-bytes-total'] ? formatBytes(v['limit-bytes-total']) : '∞'}</td>
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">{(v['comment'] || '').replace('voucher ', '')}</td>
<td className="px-4 py-2.5 text-right">
{canWrite && (
<button onClick={() => deleteUser.mutate(v['.id'])} className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-slate-700" title="Delete voucher">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Active guests */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Users className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Guests online ({active.length})</h3>
</div>
{active.length === 0 ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">No guests connected right now.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">User / Code</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">IP</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">MAC</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Session</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Down / Up</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{active.map((a, i) => (
<tr key={a['.id'] ?? i} className={clsx('hover:bg-blue-50/40 dark:hover:bg-slate-700/30', i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-2.5 font-mono font-medium text-gray-900 dark:text-white">{a['user'] || '—'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 dark:text-slate-400">{a['address'] || '—'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 dark:text-slate-400">{a['mac-address'] || '—'}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">{a['uptime'] || '—'}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">{formatBytes(a['bytes-out'])} / {formatBytes(a['bytes-in'])}</td>
<td className="px-4 py-2.5 text-right">
{canWrite && (
<button onClick={() => kickGuest.mutate(a['.id'])} className="text-xs text-red-500 hover:text-red-600 hover:underline" title="Disconnect guest">
Disconnect
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Walled garden */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Globe className="w-4 h-4 text-emerald-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Walled garden</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">sites reachable before login</span>
</div>
<div className="p-4 space-y-3">
{canWrite && (
<div className="flex gap-2">
<input className="input flex-1 font-mono" value={wgHost} onChange={e => setWgHost(e.target.value)}
placeholder="*.example.com" onKeyDown={e => { if (e.key === 'Enter' && wgHost.trim()) addWg.mutate(wgHost.trim()); }} />
<button className="btn-secondary flex items-center gap-1.5" disabled={!wgHost.trim() || addWg.isPending} onClick={() => addWg.mutate(wgHost.trim())}>
<Plus className="w-4 h-4" />Allow
</button>
</div>
)}
{walledGarden.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-slate-500">No walled-garden entries.</p>
) : (
<div className="flex flex-wrap gap-2">
{walledGarden.map((w, i) => (
<span key={w['.id'] ?? i} className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 text-xs font-mono">
{w['dst-host'] || w['dst-address'] || '—'}
{canWrite && (
<button onClick={() => removeWg.mutate(w['.id'])} className="hover:text-red-500"><X className="w-3 h-3" /></button>
)}
</span>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
);
}
+8 -2
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText,
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap,
} from 'lucide-react';
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api';
import type { MaintenanceWindow, ConfigTemplate } from '../services/api';
@@ -16,6 +16,7 @@ import {
} from '../utils/backupSchedule';
import clsx from 'clsx';
import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings';
import AutomationSettings from '../components/settings/AutomationSettings';
const ROLE_META: Record<UserRole, { label: string; color: string; desc: string }> = {
admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' },
@@ -47,7 +48,7 @@ export default function SettingsPage() {
const { theme, setTheme } = useThemeStore();
const isAdmin = user?.role === 'admin';
const canWrite = user?.role !== 'viewer';
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general');
const [auditSearch, setAuditSearch] = useState('');
const [auditPage, setAuditPage] = useState(1);
const [newTagName, setNewTagName] = useState('');
@@ -470,6 +471,7 @@ export default function SettingsPage() {
{ key: 'security' as const, label: 'My Password', icon: Key },
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
{ key: 'alerting' as const, label: 'Alerting', icon: Bell },
{ key: 'automation' as const, label: 'Automation', icon: Zap },
...(isAdmin ? [
{ key: 'templates' as const, label: 'Config Templates', icon: FileText },
{ key: 'tags' as const, label: 'Tags', icon: ShieldCheck },
@@ -1774,6 +1776,10 @@ export default function SettingsPage() {
</div>
</div>
)}
{/* ── Automation (API tokens, webhooks, scheduled reports) ── */}
{activeTab === 'automation' && (
<AutomationSettings isAdmin={isAdmin} />
)}
{/* ── Config Templates ── */}
{activeTab === 'templates' && (
<div className="space-y-4">
+4
View File
@@ -6,6 +6,7 @@ import {
import { wirelessApi } from '../services/api';
import type { WirelessAP } from '../types';
import RfHealth from '../components/wireless/RfHealth';
import RogueApsCard from '../components/wireless/RogueApsCard';
import clsx from 'clsx';
function timeAgo(ts: string | undefined): string {
@@ -101,6 +102,9 @@ export default function WirelessPage() {
{/* RF Health — channel map, density, TX retries, connectivity */}
{aps.length > 0 && <RfHealth />}
{/* Rogue & neighbor AP detection (from stored AP scans) */}
{aps.length > 0 && <RogueApsCard />}
{/* AP table */}
<div className="card overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700">
+124
View File
@@ -368,6 +368,8 @@ export const clientsApi = {
api.put<Client>(`/clients/${mac}/notes`, { notes }),
updateHostname: (mac: string, hostname: string) =>
api.put<Client>(`/clients/${mac}/hostname`, { hostname }),
updateCategory: (mac: string, category: string | null) =>
api.put<Client>(`/clients/${encodeURIComponent(mac)}/category`, { category }),
purgeStale: () => api.post<{ message: string; count: number }>('/clients/purge'),
};
@@ -413,6 +415,124 @@ export const eventsApi = {
};
// ─── Backups ──────────────────────────────────────────────────────────────────
// ─── Guest WiFi (Hotspot) ───────────────────────────────────────────────────────
type HS = Record<string, string>;
export interface GuestWifiOverview {
servers: HS[];
profiles: HS[];
userProfiles: HS[];
userCount: number;
activeCount: number;
interfaces: { name: string; type: string }[];
}
export const guestWifiApi = {
overview: (deviceId: number) =>
api.get<GuestWifiOverview>('/guest-wifi/overview', { params: { deviceId }, timeout: 60_000 }),
setup: (deviceId: number, data: {
name: string; gatewayCidr: string; poolRange: string; dnsName?: string; rateLimit?: string;
interfaceName?: string; ssid?: { ssid: string; passphrase?: string }; vlanId?: number; masquerade?: boolean;
}) =>
api.post<{
server: string; profile: string; userProfile: string; pool: string;
targetInterface: string; ssidInterfaces: string[]; vlanInterface?: string; warnings: string[];
}>('/guest-wifi/setup', { deviceId, ...data }, { timeout: 120_000 }),
users: (deviceId: number) => api.get<HS[]>('/guest-wifi/users', { params: { deviceId } }),
createVouchers: (deviceId: number, data: { count: number; durationHours?: number; dataCapMB?: number; userProfile?: string }) =>
api.post<{ created: number; codes: string[] }>('/guest-wifi/vouchers', { deviceId, ...data }, { timeout: 120_000 }),
deleteUser: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/users/${encodeURIComponent(id)}`, { params: { deviceId } }),
active: (deviceId: number) => api.get<HS[]>('/guest-wifi/active', { params: { deviceId } }),
disconnect: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/active/${encodeURIComponent(id)}`, { params: { deviceId } }),
walledGarden: (deviceId: number) => api.get<HS[]>('/guest-wifi/walled-garden', { params: { deviceId } }),
addWalledGarden: (deviceId: number, dstHost: string, comment?: string) =>
api.post('/guest-wifi/walled-garden', { deviceId, dstHost, comment }),
removeWalledGarden: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/walled-garden/${encodeURIComponent(id)}`, { params: { deviceId } }),
setServerDisabled: (deviceId: number, id: string, disabled: boolean) =>
api.put(`/guest-wifi/servers/${encodeURIComponent(id)}`, { deviceId, disabled }),
removeServer: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/servers/${encodeURIComponent(id)}`, { params: { deviceId } }),
};
// ─── Platform & automation ──────────────────────────────────────────────────────
export interface ApiToken {
id: number; name: string; prefix: string; scope: 'read' | 'write';
created_by: string | null; last_used_at: string | null; expires_at: string | null; created_at: string;
}
export interface Webhook {
id: number; name: string; url: string; has_secret: boolean;
events: string[]; enabled: boolean; last_status: number | null; last_fired_at: string | null; created_at: string;
}
export interface ReportSchedule {
id: number; name: string; frequency: 'daily' | 'weekly' | 'monthly';
recipients: string; enabled: boolean; last_sent_at: string | null; next_run_at: string; created_at: string;
}
export const automationApi = {
// tokens (admin only)
listTokens: () => api.get<ApiToken[]>('/automation/tokens'),
createToken: (data: { name: string; scope: 'read' | 'write'; expires_days?: number }) =>
api.post<{ id: number; token: string }>('/automation/tokens', data),
deleteToken: (id: number) => api.delete(`/automation/tokens/${id}`),
// webhooks
listWebhooks: () => api.get<{ webhooks: Webhook[]; availableEvents: string[] }>('/automation/webhooks'),
createWebhook: (data: { name: string; url: string; secret?: string; events: string[] }) =>
api.post<{ id: number }>('/automation/webhooks', data),
updateWebhook: (id: number, data: Partial<{ name: string; url: string; secret: string; events: string[]; enabled: boolean }>) =>
api.put(`/automation/webhooks/${id}`, data),
deleteWebhook: (id: number) => api.delete(`/automation/webhooks/${id}`),
testWebhook: (id: number) => api.post<{ ok: boolean; status: number }>(`/automation/webhooks/${id}/test`),
// reports
listReports: () => api.get<ReportSchedule[]>('/automation/reports'),
createReport: (data: { name: string; frequency: string; recipients: string }) =>
api.post<{ id: number }>('/automation/reports', data),
updateReport: (id: number, data: { enabled?: boolean }) => api.put(`/automation/reports/${id}`, data),
deleteReport: (id: number) => api.delete(`/automation/reports/${id}`),
sendReportNow: (id: number) => api.post(`/automation/reports/${id}/send-now`, undefined, { timeout: 60_000 }),
};
// ─── Firmware orchestration ─────────────────────────────────────────────────────
export interface FirmwareDeviceRow {
id: number; name: string; device_type: string; status: string; model: string | null;
ros_version: string | null; latest_ros_version: string | null;
firmware_update_available: boolean;
firmware_version: string | null; upgrade_firmware_version: string | null;
routerboard_upgrade_available: boolean;
}
export interface FirmwareRollout {
id: number; name: string; status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
halt_on_failure: boolean; pre_backup: boolean;
scheduled_at: string | null; started_at: string | null; finished_at: string | null; created_at: string;
device_count?: number; success_count?: number; failed_count?: number;
}
export interface FirmwareRolloutDevice {
id: number; rollout_id: number; device_id: number; wave: number;
status: 'pending' | 'backing_up' | 'upgrading' | 'rebooting' | 'verifying' | 'success' | 'failed' | 'skipped';
from_version: string | null; to_version: string | null; error: string | null;
started_at: string | null; finished_at: string | null;
device_name: string; device_type: string; model: string | null;
}
export const firmwareApi = {
overview: () =>
api.get<{ devices: FirmwareDeviceRow[]; latestRolloutId: number | null; runningRolloutId: number | null }>('/firmware/overview'),
checkAll: () =>
api.post<{ results: { name: string; ok: boolean; installed?: string; latest?: string; available?: boolean; error?: string }[] }>(
'/firmware/check-all', undefined, { timeout: 180_000 }),
createRollout: (data: {
name: string; halt_on_failure: boolean; pre_backup: boolean;
scheduled_at?: string | null; start?: boolean;
devices: { device_id: number; wave: number }[];
}) => api.post<{ id: number }>('/firmware/rollouts', data),
listRollouts: () => api.get<FirmwareRollout[]>('/firmware/rollouts'),
getRollout: (id: number) =>
api.get<FirmwareRollout & { devices: FirmwareRolloutDevice[] }>(`/firmware/rollouts/${id}`),
startRollout: (id: number) => api.post(`/firmware/rollouts/${id}/start`),
cancelRollout: (id: number) => api.post(`/firmware/rollouts/${id}/cancel`),
};
// ─── Operations ────────────────────────────────────────────────────────────────
export interface OpsAttentionItem {
sev: 'error' | 'warn' | 'info';
@@ -754,6 +874,10 @@ export const wirelessApi = {
getAPScanHistory: (apId: number, limit = 5) =>
api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }),
// Rogue / neighbor AP detection (classified from stored AP scans)
getRogueAps: () =>
api.get<import('../types').RogueApReport>('/wireless/rf/rogue'),
// RF Health (fleet-wide, or scoped to one AP via deviceId)
getChannelUsage: (deviceId?: number) =>
api.get<import('../types').RfChannelRow[]>('/wireless/rf/channels', { params: { deviceId } }),
+20
View File
@@ -190,6 +190,12 @@ export interface Client {
device_name?: string;
mac_address: string;
custom_name?: string;
/** Effective device category: user override if set, else the fingerprint. */
device_category?: string;
/** What the fingerprint engine detected (before any user override). */
auto_category?: string;
/** User's manual category override, if any. */
custom_category?: string | null;
hostname?: string;
vendor?: string;
ip_address?: string;
@@ -364,6 +370,20 @@ export interface WirelessMetricPoint {
noise_floor?: number;
}
// ─── Rogue AP detection ─────────────────────────────────────────────────────
export interface RogueAp {
ssid: string; bssid: string; vendor: string; signal: number; band: string;
seenBy: string; scannedAt: string;
}
export interface NeighborAp extends RogueAp { security: string }
export interface RogueApReport {
rogues: RogueAp[];
neighbors: NeighborAp[];
lastScanAt: string | null;
scannedDevices: number;
}
// ─── RF Health ──────────────────────────────────────────────────────────────
export interface RfChannelRow {
+24
View File
@@ -0,0 +1,24 @@
import {
Network, Server, Monitor, Smartphone, Tv, Speaker, Camera, Printer,
Gamepad2, Mic, Home, Cpu, HelpCircle, type LucideIcon,
} from 'lucide-react';
// Device categories (must match the backend's DEVICE_CATEGORIES)
export const CATEGORY_META: Record<string, { icon: LucideIcon; label: string }> = {
network: { icon: Network, label: 'Network gear' },
server: { icon: Server, label: 'Server / NAS' },
computer: { icon: Monitor, label: 'Computer' },
phone: { icon: Smartphone, label: 'Phone / tablet' },
tv: { icon: Tv, label: 'TV / streaming' },
media: { icon: Speaker, label: 'AV / speaker' },
camera: { icon: Camera, label: 'Camera' },
printer: { icon: Printer, label: 'Printer' },
'game-console': { icon: Gamepad2, label: 'Game console' },
'voice-assistant': { icon: Mic, label: 'Voice assistant' },
'smart-home': { icon: Home, label: 'Smart home' },
iot: { icon: Cpu, label: 'IoT device' },
unknown: { icon: HelpCircle, label: 'Unknown' },
};
/** Categories a user can pick when overriding (same order as the meta map). */
export const SELECTABLE_CATEGORIES = Object.keys(CATEGORY_META);
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.16.2 Beta';
export const APP_VERSION = 'v0.16.3 Beta';