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:
co-authored by
Claude Opus 4.8
parent
efb430d934
commit
9dfb8a5333
@@ -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 />} />
|
||||
|
||||
@@ -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 & 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'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 & 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'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 "{r.ssid}" — 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 @@
|
||||
export const APP_VERSION = 'v0.16.2 Beta';
|
||||
export const APP_VERSION = 'v0.16.3 Beta';
|
||||
|
||||
Reference in New Issue
Block a user