Add runtime comrade account, null-safe spread funnel API/UI, server-started cloudflared connector with Calibrate token field and builtin fallback, and simplify LAUNCH to delegate tunnel startup to AetherForge.
83 lines
3.5 KiB
TypeScript
83 lines
3.5 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { api } from '../../api/client';
|
|
import type { AuditEntry } from '../../types';
|
|
import NeonCard from '../NeonCard/NeonCard';
|
|
|
|
export function AuditLogStrip({ limit = 8 }: { limit?: number }) {
|
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
|
|
|
useEffect(() => {
|
|
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => setEntries([]));
|
|
const t = setInterval(() => {
|
|
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => {});
|
|
}, 60000);
|
|
return () => clearInterval(t);
|
|
}, [limit]);
|
|
|
|
return (
|
|
<NeonCard accent="purple" tilt3d={false}>
|
|
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>OPERATOR AUDIT</h3>
|
|
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
|
|
{entries.length === 0 ? (
|
|
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No audit entries yet.</p>
|
|
) : (
|
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.75rem', fontFamily: 'monospace' }}>
|
|
{entries.map((e) => (
|
|
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid #1a1a1a' }}>
|
|
<span style={{ color: 'var(--clr-dim)' }}>{new Date(e.timestamp).toLocaleString()}</span>
|
|
{' '}
|
|
<span style={{ color: 'var(--neon-cyan, #0ff)' }}>{e.username || '—'}</span>
|
|
{' · '}
|
|
<strong>{e.action}</strong>
|
|
{e.agent_id && <span style={{ color: 'var(--clr-dim)' }}> @{e.agent_id.slice(0, 8)}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</NeonCard>
|
|
);
|
|
}
|
|
|
|
export function SpreadFunnelWidget() {
|
|
const [stats, setStats] = useState<Awaited<ReturnType<typeof api.getSpreadFunnel>> | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.getSpreadFunnel().then(setStats).catch(() => setStats(null));
|
|
}, []);
|
|
|
|
if (!stats) return null;
|
|
const byBuild = stats.by_build ?? [];
|
|
|
|
return (
|
|
<NeonCard accent="cyan" tilt3d={false}>
|
|
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>INSTALL FUNNEL</h3>
|
|
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
|
|
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '0.75rem', fontSize: '0.85rem' }}>
|
|
<span>New today: <strong>{stats.new_connects_today}</strong></span>
|
|
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
|
|
</div>
|
|
{byBuild.length === 0 ? (
|
|
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
|
|
) : (
|
|
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ textAlign: 'left', color: 'var(--clr-dim)' }}>
|
|
<th>Build</th><th>Worker</th><th>Count</th><th>USB</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{byBuild.slice(0, 10).map((r, i) => (
|
|
<tr key={i} style={{ borderTop: '1px solid #222' }}>
|
|
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
|
|
<td>{r.worker_name}</td>
|
|
<td>{r.count}</td>
|
|
<td>{r.usb_spread_count > 0 ? r.usb_spread_count : '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</NeonCard>
|
|
);
|
|
}
|