feat(frontend): v2 rewrite — Vite + React + TS readonly SPA
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.
This commit is contained in:
132
src/pages/AgentActivity.tsx
Normal file
132
src/pages/AgentActivity.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user