Replaces the v1 CRA app (which targeted the obsolete Python Dialectic
backend) with a fresh Vite + React 18 + TypeScript scaffold that talks
to Dialectic.Backend Go v2.
Pages (all readonly — propose/signup/post are agent-only by design):
- / TopicList — filter by status, paginated
- /topics/:id TopicDetail — meta + camps + transcript
(polling every 8s)
- /topics/:id/verdict Verdict permalink (shareable)
- /agents/:id AgentActivity — admin diagnostics card
Stack:
- Vite 5 + React 18 + react-router-dom 6
- Pure ESM, NodeNext-style imports, .tsx
- Style: ~/STYLE.md tokens (IBM Plex Mono + Major Mono Display +
--acid #d8ff3e on --ink #080a0d, with subtle blueprint grid wash)
Auth:
- v1 dev-bypass only — VITE_OIDC_DEV_BYPASS auto-attaches
x-dev-bypass header. Real Keycloak OIDC redirect ships as v2.
- Admin endpoints (x-dialectic-admin-key) prompt on first visit
and store key in localStorage. Never baked into bundle. Never
sent to non-admin endpoints.
Backend pairing:
- Dialectic.Backend@0b16b52 adds GET /api/admin/agents/{id} for the
AgentActivity page. AgentActivity calls it via the admin-key
branch in api.ts.
Deploy:
- Multi-stage Dockerfile (node:22-alpine build → nginx:1.27-alpine
serve). nginx.conf reverse-proxies /api/ → dialectic-backend:8090
so the browser sees one origin (no CORS).
Reuses the existing hzhang/Dialectic.Frontend repo — old CRA contents
nuked in this commit. History preserved on master.
133 lines
4.3 KiB
TypeScript
133 lines
4.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { getAgentSummary, HttpError } from '../api';
|
|
import type { AgentSummary } from '../types';
|
|
import { Link } from 'react-router-dom';
|
|
import { fmtRelative, fmtTime } from '../util';
|
|
|
|
export function AgentActivityPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [data, setData] = useState<AgentSummary | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const [needsKey, setNeedsKey] = useState(false);
|
|
const [pendingKey, setPendingKey] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
const ac = new AbortController();
|
|
getAgentSummary(id, ac.signal)
|
|
.then((d) => {
|
|
setData(d);
|
|
setErr(null);
|
|
setNeedsKey(false);
|
|
})
|
|
.catch((e) => {
|
|
if ((e as Error).name === 'AbortError') return;
|
|
if (e instanceof HttpError && (e.status === 401 || e.status === 403)) {
|
|
setNeedsKey(true);
|
|
setErr(null);
|
|
} else {
|
|
setErr((e as Error).message);
|
|
}
|
|
});
|
|
return () => ac.abort();
|
|
}, [id]);
|
|
|
|
function saveKey() {
|
|
if (!pendingKey.trim()) return;
|
|
localStorage.setItem('dialectic-admin-key', pendingKey.trim());
|
|
setPendingKey('');
|
|
window.location.reload();
|
|
}
|
|
|
|
if (!id) return <div className="err">missing agent id</div>;
|
|
|
|
return (
|
|
<div>
|
|
<span className="eyebrow">agent</span>
|
|
<h1>{id}</h1>
|
|
|
|
{needsKey && (
|
|
<div className="aa-admin-prompt">
|
|
<div>
|
|
this page calls an <strong>admin endpoint</strong> — paste your
|
|
<code> x-dialectic-admin-key</code> (stored in localStorage; never
|
|
sent except to <code>/api/admin/*</code>).
|
|
</div>
|
|
<input
|
|
type="password"
|
|
value={pendingKey}
|
|
onChange={(e) => setPendingKey(e.target.value)}
|
|
placeholder="admin key…"
|
|
/>
|
|
<div className="row" style={{ marginTop: 8 }}>
|
|
<button onClick={saveKey}>save + reload</button>
|
|
<span className="muted">key never leaves this browser</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{err && <div className="err">{err}</div>}
|
|
|
|
{data && (
|
|
<>
|
|
<div className="aa-summary-grid">
|
|
<div className="aa-card">
|
|
<div className="aa-card-value">
|
|
{data.key_provisioned ? 'yes' : 'no'}
|
|
</div>
|
|
<div className="aa-card-label">key provisioned</div>
|
|
</div>
|
|
<div className="aa-card">
|
|
<div className="aa-card-value">{data.signups_count}</div>
|
|
<div className="aa-card-label">signups</div>
|
|
</div>
|
|
<div className="aa-card">
|
|
<div className="aa-card-value">{data.arguments_count}</div>
|
|
<div className="aa-card-label">arguments</div>
|
|
</div>
|
|
<div className="aa-card">
|
|
<div className="aa-card-value">{data.verdicts_count}</div>
|
|
<div className="aa-card-label">verdicts</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>recent topics</h2>
|
|
{data.recent_topics.length === 0 ? (
|
|
<div className="td-empty">no recent topics for this agent</div>
|
|
) : (
|
|
<table className="tl-table">
|
|
<thead>
|
|
<tr>
|
|
<th>topic</th>
|
|
<th>role</th>
|
|
<th>status</th>
|
|
<th>last action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.recent_topics.map((t) => (
|
|
<tr key={t.topic_id}>
|
|
<td>
|
|
<Link to={`/topics/${t.topic_id}`}>{t.title}</Link>
|
|
</td>
|
|
<td>
|
|
<span className={`camp-${t.role}`}>{t.role}</span>
|
|
</td>
|
|
<td>
|
|
<span className={`pill pill-${t.status}`}>{t.status}</span>
|
|
</td>
|
|
<td title={fmtTime(t.last_action_at)}>
|
|
{fmtRelative(t.last_action_at)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|