feat(web): canary tokens page (under AUTOMATION)
New /canary-tokens route, lazy-loaded and gated behind the existing auth flow. Wired into the AUTOMATION NavGroup beside Orchestrator and Persona Generation, using the Target icon. Two components: - CanaryTokens.tsx: list + filter (text + state), stats summary, Tokens / Blobs tab switcher, inline CreateModal + UploadModal. Alt+C opens the create modal (per feedback_linux_meta_key). Drag- drop blob upload, server-sniffed MIME drives the instrumenter. - CanaryTokenDrawer.tsx: per-token detail panel matching the MailDrawer.tsx visual format (right-side drawer, --bg/--border/ --dim/--text CSS vars, X close, focus trap + escape key, monospace metadata table, paginated callback history). Backdrop close uses target===currentTarget instead of stopPropagation on the panel (per feedback_react_stop_propagation_native_delegation). Preview button downloads the deterministically re-derived instrumented bytes; revoke button hits DELETE with a confirm prompt. Type-checks clean (npx tsc --noEmit).
This commit is contained in:
@@ -25,6 +25,10 @@ const Campaigns = lazy(() => import('./components/Campaigns'));
|
||||
const CampaignDetail = lazy(() => import('./components/CampaignDetail'));
|
||||
const Orchestrator = lazy(() => import('./components/Orchestrator'));
|
||||
const PersonaGeneration = lazy(() => import('./components/PersonaGeneration'));
|
||||
const CanaryTokens = lazy(() => import('./components/CanaryTokens'));
|
||||
const TopologyPersonaGeneration = lazy(() =>
|
||||
import('./components/PersonaGeneration').then((m) => ({ default: m.TopologyPersonaGeneration })),
|
||||
);
|
||||
const Config = lazy(() => import('./components/Config'));
|
||||
const Bounty = lazy(() => import('./components/Bounty'));
|
||||
const Credentials = lazy(() => import('./components/Credentials'));
|
||||
@@ -125,6 +129,8 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
|
||||
<Route path="/campaigns/:id" element={<CampaignDetail />} />
|
||||
<Route path="/orchestrator" element={<Orchestrator />} />
|
||||
<Route path="/persona-generation" element={<PersonaGeneration />} />
|
||||
<Route path="/canary-tokens" element={<CanaryTokens />} />
|
||||
<Route path="/topologies/:id/personas" element={<TopologyPersonaGeneration />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/swarm-updates" element={<RemoteUpdates />} />
|
||||
<Route path="/swarm/hosts" element={<SwarmHosts />} />
|
||||
|
||||
315
decnet_web/src/components/CanaryTokenDrawer.tsx
Normal file
315
decnet_web/src/components/CanaryTokenDrawer.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { X, Download, AlertTriangle, Trash2, Eye } from '../icons';
|
||||
import api from '../utils/api';
|
||||
import { useEscapeKey } from '../hooks/useEscapeKey';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||
|
||||
export interface CanaryTokenRow {
|
||||
uuid: string;
|
||||
kind: 'http' | 'dns' | 'aws_passive';
|
||||
decky_name: string;
|
||||
blob_uuid: string | null;
|
||||
instrumenter: string | null;
|
||||
generator: string | null;
|
||||
placement_path: string;
|
||||
callback_token: string;
|
||||
placed_at: string;
|
||||
last_triggered_at: string | null;
|
||||
trigger_count: number;
|
||||
created_by: string;
|
||||
state: 'planted' | 'revoked' | 'failed';
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
interface CanaryTrigger {
|
||||
uuid: string;
|
||||
token_uuid: string;
|
||||
occurred_at: string;
|
||||
src_ip: string;
|
||||
user_agent: string | null;
|
||||
request_path: string | null;
|
||||
dns_qname: string | null;
|
||||
headers: Record<string, string>;
|
||||
attacker_id: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
token: CanaryTokenRow;
|
||||
onClose: () => void;
|
||||
onRevoked: (uuid: string) => void;
|
||||
}
|
||||
|
||||
const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
||||
<div style={{ display: 'flex', gap: '12px', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<div style={{ minWidth: '140px', color: 'var(--dim-color)', fontSize: '0.75rem', textTransform: 'uppercase' }}>{label}</div>
|
||||
<div style={{ flex: 1, fontSize: '0.85rem', wordBreak: 'break-all' }}>{value ?? <span style={{ opacity: 0.4 }}>—</span>}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function fmt(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
const STATE_COLOR: Record<CanaryTokenRow['state'], string> = {
|
||||
planted: '#00ff88',
|
||||
revoked: 'var(--dim-color)',
|
||||
failed: '#ff5555',
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<CanaryTokenRow['kind'], string> = {
|
||||
http: 'HTTP CALLBACK',
|
||||
dns: 'DNS CALLBACK',
|
||||
aws_passive: 'AWS PASSIVE',
|
||||
};
|
||||
|
||||
const CanaryTokenDrawer: React.FC<Props> = ({ token, onClose, onRevoked }) => {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscapeKey(onClose, true);
|
||||
useFocusTrap(panelRef, true);
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
const [triggers, setTriggers] = useState<CanaryTrigger[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get(`/canary/tokens/${encodeURIComponent(token.uuid)}/triggers?limit=200`)
|
||||
.then((res) => { if (!cancelled) setTriggers(res.data.triggers || []); })
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
const status = err?.response?.status;
|
||||
setError(
|
||||
status === 403 ? 'Viewer role required.' :
|
||||
status === 404 ? 'Token has been deleted.' :
|
||||
'Failed to load triggers.'
|
||||
);
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [token.uuid]);
|
||||
|
||||
const handleDownloadPreview = async () => {
|
||||
setDownloading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/canary/tokens/${encodeURIComponent(token.uuid)}/preview`,
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
const blobUrl = URL.createObjectURL(res.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = token.placement_path.split('/').pop() || `canary-${token.callback_token}.bin`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
setError(
|
||||
status === 403 ? 'Admin role required to preview.' :
|
||||
status === 409 ? 'Token has no preview-able bytes (passive aws_creds, or blob deleted).' :
|
||||
'Preview failed.'
|
||||
);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!window.confirm(`Revoke canary token on ${token.decky_name}? This unlinks the file and stops the slug from resolving.`)) return;
|
||||
setRevoking(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/canary/tokens/${encodeURIComponent(token.uuid)}`);
|
||||
onRevoked(token.uuid);
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
setError(
|
||||
status === 403 ? 'Admin role required to revoke.' :
|
||||
status === 404 ? 'Token already gone.' :
|
||||
'Revoke failed.'
|
||||
);
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewable = token.kind !== 'aws_passive';
|
||||
const callbackUrl = token.kind === 'http'
|
||||
? `<canary-host>/c/${token.callback_token}`
|
||||
: token.kind === 'dns'
|
||||
? `${token.callback_token}.<dns-zone>`
|
||||
: '— (passive bait, no callback)';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex', justifyContent: 'flex-end',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
width: 'min(640px, 100%)', height: '100%',
|
||||
backgroundColor: 'var(--bg-color, #0d1117)',
|
||||
borderLeft: '1px solid var(--border-color, #30363d)',
|
||||
padding: '24px', overflowY: 'auto',
|
||||
color: 'var(--text-color)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em' }}>
|
||||
CANARY TOKEN · {token.decky_name}
|
||||
</div>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 'bold', marginTop: '4px', wordBreak: 'break-all' }}>
|
||||
{token.placement_path}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 12px', marginBottom: '16px',
|
||||
border: `1px solid ${STATE_COLOR[token.state]}33`,
|
||||
backgroundColor: `${STATE_COLOR[token.state]}11`,
|
||||
fontSize: '0.75rem', color: STATE_COLOR[token.state],
|
||||
}}>
|
||||
<AlertTriangle size={14} />
|
||||
{token.state.toUpperCase()} · {KIND_LABEL[token.kind]} · {token.trigger_count} {token.trigger_count === 1 ? 'hit' : 'hits'}
|
||||
{token.state === 'failed' && token.last_error && <span style={{ color: '#ff5555' }}>· {token.last_error}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px', flexWrap: 'wrap' }}>
|
||||
{previewable && (
|
||||
<button
|
||||
onClick={handleDownloadPreview}
|
||||
disabled={downloading}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 14px',
|
||||
border: '1px solid var(--text-color)',
|
||||
background: 'transparent', color: 'var(--text-color)',
|
||||
cursor: downloading ? 'wait' : 'pointer',
|
||||
opacity: downloading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Download size={14} /> {downloading ? 'DOWNLOADING…' : 'PREVIEW BYTES'}
|
||||
</button>
|
||||
)}
|
||||
{token.state === 'planted' && (
|
||||
<button
|
||||
onClick={handleRevoke}
|
||||
disabled={revoking}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 14px',
|
||||
border: '1px solid #ff5555',
|
||||
background: 'transparent', color: '#ff5555',
|
||||
cursor: revoking ? 'wait' : 'pointer',
|
||||
opacity: revoking ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> {revoking ? 'REVOKING…' : 'REVOKE'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '16px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<section style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
|
||||
METADATA
|
||||
</h3>
|
||||
<Row label="UUID" value={<code>{token.uuid}</code>} />
|
||||
<Row label="Decky" value={token.decky_name} />
|
||||
<Row label="Kind" value={KIND_LABEL[token.kind]} />
|
||||
<Row label="Source" value={token.generator ? `generator: ${token.generator}` : token.instrumenter ? `instrumenter: ${token.instrumenter}` : '—'} />
|
||||
<Row label="Slug" value={<code>{token.callback_token}</code>} />
|
||||
<Row label="Callback" value={<code>{callbackUrl}</code>} />
|
||||
<Row label="Placed at" value={fmt(token.placed_at)} />
|
||||
<Row label="Last hit" value={fmt(token.last_triggered_at)} />
|
||||
<Row label="Trigger count" value={token.trigger_count} />
|
||||
<Row label="Created by" value={token.created_by} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
|
||||
<Eye size={14} style={{ verticalAlign: 'middle', marginRight: '6px' }} />
|
||||
CALLBACK HISTORY ({triggers.length}{triggers.length === 200 ? '+' : ''})
|
||||
</h3>
|
||||
{loading && <div style={{ fontSize: '0.8rem', opacity: 0.6 }}>loading…</div>}
|
||||
{!loading && triggers.length === 0 && (
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
||||
No callbacks yet. The slug will start firing if the artifact gets exfiltrated and opened.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{triggers.map((t) => (
|
||||
<div
|
||||
key={t.uuid}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', fontFamily: 'monospace' }}>
|
||||
<span>{t.src_ip}</span>
|
||||
<span style={{ color: 'var(--dim-color)' }}>{fmt(t.occurred_at)}</span>
|
||||
</div>
|
||||
{t.user_agent && (
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
UA · {t.user_agent}
|
||||
</div>
|
||||
)}
|
||||
{t.request_path && (
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
HTTP · {t.request_path}
|
||||
</div>
|
||||
)}
|
||||
{t.dns_qname && (
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
DNS · {t.dns_qname}
|
||||
</div>
|
||||
)}
|
||||
{t.attacker_id && (
|
||||
<div style={{ fontSize: '0.7rem', color: '#00ff88', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
attacker · {t.attacker_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanaryTokenDrawer;
|
||||
710
decnet_web/src/components/CanaryTokens.tsx
Normal file
710
decnet_web/src/components/CanaryTokens.tsx
Normal file
@@ -0,0 +1,710 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Target, Plus, Upload, X, AlertTriangle, Search,
|
||||
} from '../icons';
|
||||
import api from '../utils/api';
|
||||
import { useEscapeKey } from '../hooks/useEscapeKey';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||
import CanaryTokenDrawer, { CanaryTokenRow } from './CanaryTokenDrawer';
|
||||
|
||||
interface BlobRow {
|
||||
uuid: string;
|
||||
sha256: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
uploaded_by: string;
|
||||
uploaded_at: string;
|
||||
token_count: number;
|
||||
}
|
||||
|
||||
const KNOWN_GENERATORS = [
|
||||
'git_config', 'env_file', 'ssh_key', 'aws_creds', 'honeydoc',
|
||||
] as const;
|
||||
type GeneratorName = typeof KNOWN_GENERATORS[number];
|
||||
|
||||
const KIND_OPTIONS: Array<{ value: 'http' | 'dns' | 'aws_passive'; label: string }> = [
|
||||
{ value: 'http', label: 'HTTP callback' },
|
||||
{ value: 'dns', label: 'DNS callback' },
|
||||
{ value: 'aws_passive', label: 'AWS passive (no callback)' },
|
||||
];
|
||||
|
||||
function extractError(err: unknown, fallback: string): string {
|
||||
const e = err as { response?: { status?: number; data?: { detail?: string } } };
|
||||
if (e?.response?.data?.detail) return e.response.data.detail;
|
||||
if (e?.response?.status === 403) return 'Insufficient permissions (admin only).';
|
||||
if (e?.response?.status === 401) return 'Session expired — please log in again.';
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function fmt(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
const STATE_COLOR = {
|
||||
planted: '#00ff88',
|
||||
revoked: 'var(--dim-color)',
|
||||
failed: '#ff5555',
|
||||
};
|
||||
|
||||
// ─── CREATE MODAL ──────────────────────────────────────────────────────────
|
||||
|
||||
interface CreateModalProps {
|
||||
blobs: BlobRow[];
|
||||
onClose: () => void;
|
||||
onCreated: (token: CanaryTokenRow) => void;
|
||||
}
|
||||
|
||||
const CreateModal: React.FC<CreateModalProps> = ({ blobs, onClose, onCreated }) => {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscapeKey(onClose, true);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const [decky, setDecky] = useState('');
|
||||
const [kind, setKind] = useState<'http' | 'dns' | 'aws_passive'>('http');
|
||||
const [path, setPath] = useState('/home/admin/.aws/credentials');
|
||||
const [source, setSource] = useState<'generator' | 'blob'>('generator');
|
||||
const [generator, setGenerator] = useState<GeneratorName>('aws_creds');
|
||||
const [blobUuid, setBlobUuid] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
if (!decky.trim()) return setError('decky_name required.');
|
||||
if (!path.trim().startsWith('/')) return setError('placement_path must be absolute.');
|
||||
if (source === 'blob' && !blobUuid) return setError('Pick a blob or switch to Generator.');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
decky_name: decky.trim(),
|
||||
kind,
|
||||
placement_path: path.trim(),
|
||||
};
|
||||
if (source === 'generator') body.generator = generator;
|
||||
else body.blob_uuid = blobUuid;
|
||||
const res = await api.post('/canary/tokens', body);
|
||||
onCreated(res.data);
|
||||
} catch (err) {
|
||||
setError(extractError(err, 'Create failed.'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
width: 'min(560px, 100%)', maxHeight: '90vh', overflowY: 'auto',
|
||||
backgroundColor: 'var(--bg-color, #0d1117)',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
padding: '24px', color: 'var(--text-color)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>NEW CANARY TOKEN</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Field label="Decky name">
|
||||
<input
|
||||
value={decky}
|
||||
onChange={(e) => setDecky(e.target.value)}
|
||||
placeholder="web1"
|
||||
autoFocus
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Kind">
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as typeof kind)}
|
||||
style={INPUT_STYLE}
|
||||
>
|
||||
{KIND_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Placement path (inside the container)">
|
||||
<input
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="/home/admin/.aws/credentials"
|
||||
style={{ ...INPUT_STYLE, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
||||
{(['generator', 'blob'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSource(s)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
background: source === s ? 'var(--accent-color, #00ff88)' : 'transparent',
|
||||
color: source === s ? 'var(--bg-color, #0d1117)' : 'var(--text-color)',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
cursor: 'pointer', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{s === 'generator' ? 'Built-in template' : 'Operator upload'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{source === 'generator' && (
|
||||
<Field label="Generator">
|
||||
<select
|
||||
value={generator}
|
||||
onChange={(e) => setGenerator(e.target.value as GeneratorName)}
|
||||
style={INPUT_STYLE}
|
||||
>
|
||||
{KNOWN_GENERATORS.map((g) => (
|
||||
<option key={g} value={g}>{g}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{source === 'blob' && (
|
||||
<Field label="Uploaded artifact">
|
||||
{blobs.length === 0 ? (
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
|
||||
No blobs uploaded yet. Use "Upload artifact" on the main page first.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={blobUuid}
|
||||
onChange={(e) => setBlobUuid(e.target.value)}
|
||||
style={INPUT_STYLE}
|
||||
>
|
||||
<option value="">— select —</option>
|
||||
{blobs.map((b) => (
|
||||
<option key={b.uuid} value={b.uuid}>
|
||||
{b.filename} ({b.content_type}, {fmtBytes(b.size_bytes)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}>
|
||||
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
style={{ ...BTN_PRIMARY, opacity: submitting ? 0.5 : 1, cursor: submitting ? 'wait' : 'pointer' }}
|
||||
>
|
||||
{submitting ? 'PLANTING…' : 'PLANT TOKEN'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── BLOB UPLOAD MODAL ─────────────────────────────────────────────────────
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void;
|
||||
onUploaded: (blob: BlobRow) => void;
|
||||
}
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({ onClose, onUploaded }) => {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscapeKey(onClose, true);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) return setError('Pick a file first.');
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api.post('/canary/blobs', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
onUploaded(res.data);
|
||||
} catch (err) {
|
||||
setError(extractError(err, 'Upload failed.'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
width: 'min(520px, 100%)',
|
||||
backgroundColor: 'var(--bg-color, #0d1117)',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
padding: '24px', color: 'var(--text-color)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>UPLOAD CANARY ARTIFACT</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) setFile(f);
|
||||
}}
|
||||
style={{
|
||||
border: `2px dashed ${dragOver ? 'var(--accent-color, #00ff88)' : 'var(--border-color, #30363d)'}`,
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
cursor: 'pointer',
|
||||
background: dragOver ? 'rgba(0, 255, 136, 0.05)' : 'transparent',
|
||||
}}
|
||||
onClick={() => document.getElementById('canary-blob-input')?.click()}
|
||||
>
|
||||
<Upload size={32} style={{ opacity: 0.5, marginBottom: '8px' }} />
|
||||
<div style={{ fontSize: '0.85rem' }}>
|
||||
{file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'}
|
||||
</div>
|
||||
{!file && (
|
||||
<div style={{ fontSize: '0.7rem', opacity: 0.6, marginTop: '6px' }}>
|
||||
DOCX · XLSX · PDF · HTML · PNG/JPEG · plain configs
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="canary-blob-input"
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 12px', marginBottom: '16px',
|
||||
border: '1px solid rgba(255, 170, 0, 0.3)',
|
||||
backgroundColor: 'rgba(255, 170, 0, 0.05)',
|
||||
fontSize: '0.75rem', color: '#ffaa00',
|
||||
}}>
|
||||
<AlertTriangle size={14} />
|
||||
DECNET injects the callback server-side; the original bytes stay on the master.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!file || uploading}
|
||||
style={{ ...BTN_PRIMARY, opacity: (!file || uploading) ? 0.5 : 1, cursor: uploading ? 'wait' : 'pointer' }}
|
||||
>
|
||||
{uploading ? 'UPLOADING…' : 'UPLOAD'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── MAIN PAGE ─────────────────────────────────────────────────────────────
|
||||
|
||||
const CanaryTokens: React.FC = () => {
|
||||
const [tokens, setTokens] = useState<CanaryTokenRow[]>([]);
|
||||
const [blobs, setBlobs] = useState<BlobRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<'tokens' | 'blobs'>('tokens');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [stateFilter, setStateFilter] = useState<'all' | 'planted' | 'revoked' | 'failed'>('all');
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [drawerToken, setDrawerToken] = useState<CanaryTokenRow | null>(null);
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [t, b] = await Promise.all([
|
||||
api.get('/canary/tokens'),
|
||||
api.get('/canary/blobs').catch(() => ({ data: { blobs: [] } })), // viewers can't list blobs
|
||||
]);
|
||||
setTokens(t.data.tokens || []);
|
||||
setBlobs(b.data.blobs || []);
|
||||
} catch (err) {
|
||||
setError(extractError(err, 'Failed to load canary tokens.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadAll(); }, []);
|
||||
|
||||
// Alt+C — open the create modal (per feedback_linux_meta_key).
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.altKey && e.key.toLowerCase() === 'c' && !showCreate && !showUpload && !drawerToken) {
|
||||
e.preventDefault();
|
||||
setShowCreate(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [showCreate, showUpload, drawerToken]);
|
||||
|
||||
const visibleTokens = useMemo(() => {
|
||||
return tokens.filter((t) => {
|
||||
if (stateFilter !== 'all' && t.state !== stateFilter) return false;
|
||||
if (!filter) return true;
|
||||
const f = filter.toLowerCase();
|
||||
return (
|
||||
t.decky_name.toLowerCase().includes(f) ||
|
||||
t.placement_path.toLowerCase().includes(f) ||
|
||||
t.callback_token.toLowerCase().includes(f) ||
|
||||
(t.generator || '').toLowerCase().includes(f) ||
|
||||
(t.instrumenter || '').toLowerCase().includes(f)
|
||||
);
|
||||
});
|
||||
}, [tokens, filter, stateFilter]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { planted: 0, revoked: 0, failed: 0, hits: 0 };
|
||||
for (const t of tokens) {
|
||||
c[t.state] += 1;
|
||||
c.hits += t.trigger_count;
|
||||
}
|
||||
return c;
|
||||
}, [tokens]);
|
||||
|
||||
const handleDeleteBlob = async (uuid: string) => {
|
||||
if (!window.confirm('Delete this blob? Refused if any token still references it.')) return;
|
||||
try {
|
||||
await api.delete(`/canary/blobs/${encodeURIComponent(uuid)}`);
|
||||
setBlobs((prev) => prev.filter((b) => b.uuid !== uuid));
|
||||
} catch (err) {
|
||||
alert(extractError(err, 'Delete failed.'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', color: 'var(--text-color)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em' }}>AUTOMATION</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Target size={22} /> CANARY TOKENS
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button onClick={() => setShowUpload(true)} style={BTN_GHOST}>
|
||||
<Upload size={14} style={{ marginRight: '6px', verticalAlign: 'middle' }} />
|
||||
UPLOAD ARTIFACT
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(true)} style={BTN_PRIMARY} title="Alt+C">
|
||||
<Plus size={14} style={{ marginRight: '6px', verticalAlign: 'middle' }} />
|
||||
NEW TOKEN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '24px', flexWrap: 'wrap' }}>
|
||||
<Stat label="PLANTED" value={counts.planted} color={STATE_COLOR.planted} />
|
||||
<Stat label="REVOKED" value={counts.revoked} color={STATE_COLOR.revoked} />
|
||||
<Stat label="FAILED" value={counts.failed} color={STATE_COLOR.failed} />
|
||||
<Stat label="TOTAL HITS" value={counts.hits} color="#00ff88" />
|
||||
<Stat label="UPLOADED BLOBS" value={blobs.length} color="var(--text-color)" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', borderBottom: '1px solid var(--border-color, #30363d)' }}>
|
||||
{(['tokens', 'blobs'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: tab === t ? 'var(--text-color)' : 'var(--dim-color)',
|
||||
padding: '8px 16px', cursor: 'pointer',
|
||||
borderBottom: tab === t ? '2px solid var(--accent-color, #00ff88)' : '2px solid transparent',
|
||||
fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{t === 'tokens' ? `Tokens (${tokens.length})` : `Blobs (${blobs.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'tokens' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 300px' }}>
|
||||
<Search size={14} style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', opacity: 0.5 }} />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter by decky / path / slug / generator…"
|
||||
style={{ ...INPUT_STYLE, paddingLeft: '32px', marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={(e) => setStateFilter(e.target.value as typeof stateFilter)}
|
||||
style={{ ...INPUT_STYLE, marginBottom: 0, width: 'auto' }}
|
||||
>
|
||||
<option value="all">all states</option>
|
||||
<option value="planted">planted</option>
|
||||
<option value="revoked">revoked</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ opacity: 0.6 }}>loading…</div>}
|
||||
{error && <div style={{ color: '#ff5555', marginBottom: '16px' }}>{error}</div>}
|
||||
{!loading && visibleTokens.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
|
||||
{tokens.length === 0
|
||||
? 'No canary tokens yet. Click NEW TOKEN to plant one, or UPLOAD ARTIFACT to start with an operator-supplied document.'
|
||||
: 'No tokens match the current filter.'}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{visibleTokens.map((t) => (
|
||||
<button
|
||||
key={t.uuid}
|
||||
onClick={() => setDrawerToken(t)}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '110px 140px 1fr 100px 110px 80px',
|
||||
alignItems: 'center', gap: '12px',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
color: 'var(--text-color)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
color: STATE_COLOR[t.state], fontFamily: 'monospace',
|
||||
fontSize: '0.7rem', letterSpacing: '0.05em',
|
||||
}}>
|
||||
● {t.state.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{t.decky_name}</span>
|
||||
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.placement_path}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>
|
||||
{t.kind === 'aws_passive' ? 'aws-passive' : t.kind}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>
|
||||
{t.generator || t.instrumenter || '?'}
|
||||
</span>
|
||||
<span style={{ textAlign: 'right', fontFamily: 'monospace', color: t.trigger_count > 0 ? '#00ff88' : 'var(--dim-color)' }}>
|
||||
{t.trigger_count} {t.trigger_count === 1 ? 'hit' : 'hits'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'blobs' && (
|
||||
<>
|
||||
{blobs.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
|
||||
No uploaded artifacts. Click UPLOAD ARTIFACT to add one.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{blobs.map((b) => (
|
||||
<div
|
||||
key={b.uuid}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 220px 90px 100px 80px',
|
||||
alignItems: 'center', gap: '12px',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.filename}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>{b.content_type}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmtBytes(b.size_bytes)}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmt(b.uploaded_at)}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteBlob(b.uuid)}
|
||||
disabled={b.token_count > 0}
|
||||
title={b.token_count > 0 ? `${b.token_count} token(s) still reference this blob` : 'Delete'}
|
||||
style={{
|
||||
background: 'transparent', color: b.token_count > 0 ? 'var(--dim-color)' : '#ff5555',
|
||||
border: `1px solid ${b.token_count > 0 ? 'var(--dim-color)' : '#ff5555'}`,
|
||||
padding: '4px 8px', fontSize: '0.7rem',
|
||||
cursor: b.token_count > 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: b.token_count > 0 ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{b.token_count > 0 ? `${b.token_count} REFS` : 'DELETE'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<CreateModal
|
||||
blobs={blobs}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={(t) => {
|
||||
setTokens((prev) => [t, ...prev]);
|
||||
setShowCreate(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showUpload && (
|
||||
<UploadModal
|
||||
onClose={() => setShowUpload(false)}
|
||||
onUploaded={(b) => {
|
||||
setBlobs((prev) => prev.some((x) => x.uuid === b.uuid) ? prev : [b, ...prev]);
|
||||
setShowUpload(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{drawerToken && (
|
||||
<CanaryTokenDrawer
|
||||
token={drawerToken}
|
||||
onClose={() => setDrawerToken(null)}
|
||||
onRevoked={(uuid) => {
|
||||
setTokens((prev) => prev.map((t) =>
|
||||
t.uuid === uuid ? { ...t, state: 'revoked' } : t,
|
||||
));
|
||||
setDrawerToken(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── small style helpers ───────────────────────────────────────────────────
|
||||
|
||||
const INPUT_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
marginBottom: '12px',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
color: 'var(--text-color)',
|
||||
fontSize: '0.85rem',
|
||||
};
|
||||
|
||||
const BTN_PRIMARY: React.CSSProperties = {
|
||||
padding: '8px 14px',
|
||||
border: '1px solid var(--accent-color, #00ff88)',
|
||||
background: 'var(--accent-color, #00ff88)',
|
||||
color: 'var(--bg-color, #0d1117)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontWeight: 'bold',
|
||||
};
|
||||
|
||||
const BTN_GHOST: React.CSSProperties = {
|
||||
padding: '8px 14px',
|
||||
border: '1px solid var(--text-color)',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-color)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
};
|
||||
|
||||
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em', marginBottom: '4px' }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Stat: React.FC<{ label: string; value: number | string; color: string }> = ({ label, value, color }) => (
|
||||
<div style={{
|
||||
flex: '1 1 120px',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em' }}>{label}</div>
|
||||
<div style={{ fontSize: '1.4rem', fontWeight: 'bold', color, marginTop: '4px' }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CanaryTokens;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut,
|
||||
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
|
||||
ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, Zap, Cpu, Mail,
|
||||
Target,
|
||||
} from '../icons';
|
||||
import { prefetchRoute } from '../routePrefetch';
|
||||
import './Layout.css';
|
||||
@@ -35,6 +36,7 @@ const ROUTE_LABELS: Record<string, string> = {
|
||||
'/campaigns': 'CAMPAIGNS',
|
||||
'/orchestrator': 'ORCHESTRATOR',
|
||||
'/persona-generation': 'PERSONA GENERATION',
|
||||
'/canary-tokens': 'CANARY TOKENS',
|
||||
'/config': 'CONFIG',
|
||||
'/swarm-updates': 'REMOTE UPDATES',
|
||||
'/swarm/hosts': 'SWARM HOSTS',
|
||||
@@ -140,6 +142,7 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
<NavGroup label="AUTOMATION" icon={<Zap size={20} />} open={sidebarOpen}>
|
||||
<NavItem to="/orchestrator" icon={<Cpu size={18} />} label="Orchestrator" open={sidebarOpen} indent />
|
||||
<NavItem to="/persona-generation" icon={<Mail size={18} />} label="Persona Generation" open={sidebarOpen} indent />
|
||||
<NavItem to="/canary-tokens" icon={<Target size={18} />} label="Canary Tokens" open={sidebarOpen} indent />
|
||||
</NavGroup>
|
||||
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
|
||||
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />
|
||||
|
||||
Reference in New Issue
Block a user