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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-07-02 21:58:02 -07:00
co-authored by Claude Opus 4.8
parent efb430d934
commit 9dfb8a5333
38 changed files with 3517 additions and 20 deletions
@@ -0,0 +1,267 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
KeyRound, Webhook as WebhookIcon, Mail, Plus, Trash2, Copy, Check, Send,
RefreshCw, AlertTriangle, Power, X,
} from 'lucide-react';
import clsx from 'clsx';
import { automationApi } from '../../services/api';
import { formatDistanceToNow } from 'date-fns';
const EVENT_LABEL: Record<string, string> = {
device_offline: 'Device offline', device_online: 'Device online',
log_error: 'Log error', log_warning: 'Log warning',
high_cpu: 'High CPU', high_memory: 'High memory', cert_expiry: 'Cert expiry',
device_discovered: 'Device discovered', firmware_update_available: 'Firmware update',
config_drift: 'Config drift', rollout_completed: 'Rollout completed', rollout_failed: 'Rollout failed',
};
// ─── API tokens ────────────────────────────────────────────────────────────────
function TokensSection({ isAdmin }: { isAdmin: boolean }) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [scope, setScope] = useState<'read' | 'write'>('read');
const [expires, setExpires] = useState('');
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { data: tokens = [] } = useQuery({
queryKey: ['api-tokens'],
queryFn: () => automationApi.listTokens().then(r => r.data),
enabled: isAdmin,
});
const create = useMutation({
mutationFn: () => automationApi.createToken({ name: name.trim(), scope, expires_days: expires ? parseInt(expires, 10) : undefined }),
onSuccess: (r) => { setNewToken(r.data.token); setName(''); setExpires(''); qc.invalidateQueries({ queryKey: ['api-tokens'] }); },
});
const del = useMutation({ mutationFn: (id: number) => automationApi.deleteToken(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['api-tokens'] }) });
if (!isAdmin) {
return <div className="card p-6 text-sm text-gray-400 dark:text-slate-500">API tokens can only be managed by administrators.</div>;
}
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<KeyRound className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">API Tokens</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">for scripting &amp; IaC — send as <span className="font-mono">Authorization: Bearer mtm_…</span></span>
</div>
<div className="p-5 space-y-4">
{/* Newly created token (shown once) */}
{newToken && (
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-3">
<div className="flex items-center gap-2 mb-2 text-sm font-medium text-green-700 dark:text-green-400">
<Check className="w-4 h-4" />Token created — copy it now, it won&apos;t be shown again
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-2 py-1.5 rounded bg-white dark:bg-slate-800 font-mono text-xs break-all text-gray-800 dark:text-slate-200">{newToken}</code>
<button className="btn-secondary flex items-center gap-1.5 text-xs" onClick={() => { navigator.clipboard.writeText(newToken); setCopied(true); setTimeout(() => setCopied(false), 2000); }}>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}{copied ? 'Copied' : 'Copy'}
</button>
<button className="p-1.5 text-gray-400 hover:text-gray-600" onClick={() => setNewToken(null)}><X className="w-4 h-4" /></button>
</div>
</div>
)}
{/* Create form */}
<div className="flex flex-wrap items-end gap-2">
<div><label className="label">Name</label><input className="input w-48" value={name} onChange={e => setName(e.target.value)} placeholder="ci-pipeline" /></div>
<div><label className="label">Scope</label>
<select className="input w-32" value={scope} onChange={e => setScope(e.target.value as 'read' | 'write')}>
<option value="read">Read only</option>
<option value="write">Read + write</option>
</select>
</div>
<div><label className="label">Expires (days)</label><input type="number" min={1} className="input w-28" value={expires} onChange={e => setExpires(e.target.value)} placeholder="never" /></div>
<button className="btn-primary flex items-center gap-1.5" disabled={!name.trim() || create.isPending} onClick={() => create.mutate()}>
<Plus className="w-4 h-4" />Create
</button>
</div>
{/* List */}
{tokens.length > 0 && (
<div className="divide-y divide-gray-100 dark:divide-slate-700/50 -mx-1">
{tokens.map(t => (
<div key={t.id} className="flex items-center gap-3 py-2.5 px-1">
<code className="font-mono text-xs text-gray-500 dark:text-slate-400">{t.prefix}…</code>
<span className="text-sm font-medium text-gray-900 dark:text-white">{t.name}</span>
<span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-bold uppercase', t.scope === 'write' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>{t.scope}</span>
<span className="text-xs text-gray-400 dark:text-slate-500">
{t.last_used_at ? `used ${formatDistanceToNow(new Date(t.last_used_at))} ago` : 'never used'}
{t.expires_at ? ` · expires ${formatDistanceToNow(new Date(t.expires_at), { addSuffix: true })}` : ''}
</span>
<button className="ml-auto p-1.5 text-gray-400 hover:text-red-600" onClick={() => del.mutate(t.id)} title="Revoke"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 dark:text-slate-500">Write tokens map to Operator privileges; no token can perform admin actions or manage other tokens.</p>
</div>
</div>
);
}
// ─── Webhooks ──────────────────────────────────────────────────────────────────
function WebhooksSection() {
const qc = useQueryClient();
const [form, setForm] = useState({ name: '', url: '', secret: '', events: new Set<string>() });
const [testResult, setTestResult] = useState<Record<number, string>>({});
const { data } = useQuery({ queryKey: ['webhooks'], queryFn: () => automationApi.listWebhooks().then(r => r.data) });
const webhooks = data?.webhooks ?? [];
const events = data?.availableEvents ?? [];
const create = useMutation({
mutationFn: () => automationApi.createWebhook({ name: form.name.trim(), url: form.url.trim(), secret: form.secret.trim() || undefined, events: [...form.events] }),
onSuccess: () => { setForm({ name: '', url: '', secret: '', events: new Set() }); qc.invalidateQueries({ queryKey: ['webhooks'] }); },
});
const toggle = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => automationApi.updateWebhook(id, { enabled }), onSuccess: () => qc.invalidateQueries({ queryKey: ['webhooks'] }) });
const del = useMutation({ mutationFn: (id: number) => automationApi.deleteWebhook(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['webhooks'] }) });
const test = useMutation({
mutationFn: (id: number) => automationApi.testWebhook(id),
onSuccess: (r, id) => { setTestResult(p => ({ ...p, [id]: r.data.ok ? `OK (${r.data.status})` : `HTTP ${r.data.status}` })); setTimeout(() => setTestResult(p => { const n = { ...p }; delete n[id]; return n; }), 5000); qc.invalidateQueries({ queryKey: ['webhooks'] }); },
onError: (e: unknown, id) => setTestResult(p => ({ ...p, [id]: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'failed' })),
});
const toggleEvent = (e: string) => setForm(f => {
const s = new Set(f.events);
if (s.has(e)) s.delete(e); else s.add(e);
return { ...f, events: s };
});
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<WebhookIcon className="w-4 h-4 text-violet-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Outbound Webhooks</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">POST JSON on events · HMAC-signed with <span className="font-mono">X-MTM-Signature</span></span>
</div>
<div className="p-5 space-y-4">
{/* existing */}
{webhooks.map(h => (
<div key={h.id} className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div className="flex items-center gap-2 mb-1.5">
<span className={clsx('w-2 h-2 rounded-full flex-shrink-0', h.enabled ? 'bg-green-500' : 'bg-gray-400')} />
<span className="text-sm font-medium text-gray-900 dark:text-white">{h.name}</span>
{h.has_secret && <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">signed</span>}
{h.last_status != null && <span className={clsx('text-[10px] px-1.5 py-0.5 rounded', h.last_status >= 200 && h.last_status < 300 ? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400' : 'bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400')}>last: {h.last_status || 'err'}</span>}
<div className="ml-auto flex items-center gap-1">
{testResult[h.id] && <span className="text-xs text-gray-500 dark:text-slate-400 mr-1">{testResult[h.id]}</span>}
<button className="p-1.5 text-gray-400 hover:text-blue-600" title="Send test" onClick={() => test.mutate(h.id)}><Send className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-amber-600" title={h.enabled ? 'Disable' : 'Enable'} onClick={() => toggle.mutate({ id: h.id, enabled: !h.enabled })}><Power className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-red-600" title="Delete" onClick={() => del.mutate(h.id)}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
<div className="font-mono text-xs text-gray-500 dark:text-slate-400 truncate">{h.url}</div>
<div className="flex flex-wrap gap-1 mt-1.5">
{h.events.map(e => <span key={e} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">{EVENT_LABEL[e] || e}</span>)}
</div>
</div>
))}
{/* create */}
<div className="rounded-lg border border-dashed border-gray-300 dark:border-slate-600 p-3 space-y-2.5">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. Slack, PagerDuty)" />
<input className="input sm:col-span-2 font-mono text-sm" value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} placeholder="https://hooks.example.com/…" />
</div>
<input className="input font-mono text-sm" value={form.secret} onChange={e => setForm(f => ({ ...f, secret: e.target.value }))} placeholder="Signing secret (optional, recommended)" />
<div className="flex flex-wrap gap-1.5">
{events.map(e => (
<button key={e} onClick={() => toggleEvent(e)}
className={clsx('text-xs px-2 py-1 rounded-md border transition-colors',
form.events.has(e) ? 'bg-violet-100 dark:bg-violet-900/30 border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300' : 'border-gray-200 dark:border-slate-600 text-gray-500 dark:text-slate-400 hover:bg-gray-50 dark:hover:bg-slate-700')}>
{EVENT_LABEL[e] || e}
</button>
))}
</div>
<div className="flex justify-end">
<button className="btn-primary flex items-center gap-1.5" disabled={!form.name.trim() || !form.url.trim() || form.events.size === 0 || create.isPending} onClick={() => create.mutate()}>
<Plus className="w-4 h-4" />Add webhook
</button>
</div>
{create.isError && <p className="text-xs text-red-500 flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" />{(create.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed'}</p>}
</div>
</div>
</div>
);
}
// ─── Scheduled reports ───────────────────────────────────────────────────────────
function ReportsSection() {
const qc = useQueryClient();
const [form, setForm] = useState({ name: '', frequency: 'weekly', recipients: '' });
const [sendMsg, setSendMsg] = useState<Record<number, string>>({});
const { data: reports = [] } = useQuery({ queryKey: ['report-schedules'], queryFn: () => automationApi.listReports().then(r => r.data) });
const create = useMutation({
mutationFn: () => automationApi.createReport(form),
onSuccess: () => { setForm({ name: '', frequency: 'weekly', recipients: '' }); qc.invalidateQueries({ queryKey: ['report-schedules'] }); },
});
const toggle = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => automationApi.updateReport(id, { enabled }), onSuccess: () => qc.invalidateQueries({ queryKey: ['report-schedules'] }) });
const del = useMutation({ mutationFn: (id: number) => automationApi.deleteReport(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['report-schedules'] }) });
const sendNow = useMutation({
mutationFn: (id: number) => automationApi.sendReportNow(id),
onSuccess: (_r, id) => { setSendMsg(p => ({ ...p, [id]: 'Sent!' })); setTimeout(() => setSendMsg(p => { const n = { ...p }; delete n[id]; return n; }), 4000); qc.invalidateQueries({ queryKey: ['report-schedules'] }); },
onError: (e: unknown, id) => setSendMsg(p => ({ ...p, [id]: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Send failed' })),
});
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Mail className="w-4 h-4 text-emerald-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Scheduled Reports</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">emailed fleet summaries — uses your Alerting SMTP settings</span>
</div>
<div className="p-5 space-y-4">
{reports.map(r => (
<div key={r.id} className="flex items-center gap-3 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<span className={clsx('w-2 h-2 rounded-full flex-shrink-0', r.enabled ? 'bg-green-500' : 'bg-gray-400')} />
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">{r.name} <span className="text-xs font-normal text-gray-400 capitalize">· {r.frequency}</span></div>
<div className="text-xs text-gray-400 dark:text-slate-500 truncate">{r.recipients}</div>
</div>
<span className="text-xs text-gray-400 dark:text-slate-500 ml-2">
{r.last_sent_at ? `last ${formatDistanceToNow(new Date(r.last_sent_at))} ago` : `next ${formatDistanceToNow(new Date(r.next_run_at), { addSuffix: true })}`}
</span>
<div className="ml-auto flex items-center gap-1">
{sendMsg[r.id] && <span className="text-xs text-gray-500 dark:text-slate-400 mr-1">{sendMsg[r.id]}</span>}
<button className="p-1.5 text-gray-400 hover:text-blue-600" title="Send now" onClick={() => sendNow.mutate(r.id)} disabled={sendNow.isPending}><Send className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-amber-600" title={r.enabled ? 'Disable' : 'Enable'} onClick={() => toggle.mutate({ id: r.id, enabled: !r.enabled })}><Power className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-red-600" title="Delete" onClick={() => del.mutate(r.id)}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
))}
<div className="rounded-lg border border-dashed border-gray-300 dark:border-slate-600 p-3 flex flex-wrap items-end gap-2">
<div><label className="label">Name</label><input className="input w-44" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Weekly summary" /></div>
<div><label className="label">Frequency</label>
<select className="input w-32" value={form.frequency} onChange={e => setForm(f => ({ ...f, frequency: e.target.value }))}>
<option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option>
</select>
</div>
<div className="flex-1 min-w-[200px]"><label className="label">Recipients</label><input className="input" value={form.recipients} onChange={e => setForm(f => ({ ...f, recipients: e.target.value }))} placeholder="ops@company.com, noc@company.com" /></div>
<button className="btn-primary flex items-center gap-1.5" disabled={!form.name.trim() || !form.recipients.trim() || create.isPending} onClick={() => create.mutate()}><Plus className="w-4 h-4" />Add</button>
{create.isError && <p className="text-xs text-red-500 w-full flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" />{(create.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed'}</p>}
</div>
</div>
</div>
);
}
export default function AutomationSettings({ isAdmin }: { isAdmin: boolean }) {
return (
<div className="space-y-4">
<TokensSection isAdmin={isAdmin} />
<WebhooksSection />
<ReportsSection />
</div>
);
}