- simulate/page.tsx: remove launch form — simulations are triggered via Airflow DAG, not the admin UI. Page now shows run history + links to Airflow and MLflow only (#109) - docs.ts: use DOCS_ROOT env var (fallback: ../../docs for local dev) so the path works in Docker standalone where CWD is /app (#110) - Dockerfile.admin: copy docs/ into the runner image at /app/docs and set DOCS_ROOT=/app/docs so listAllDocs() finds the files at runtime (#110) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AdminShell } from '@/components/AdminShell';
|
||||
import {
|
||||
startSimulation,
|
||||
getSimulationRuns,
|
||||
getSimulationRun,
|
||||
SimRun,
|
||||
} from '@/lib/api';
|
||||
import { getSimulationRuns, SimRun } from '@/lib/api';
|
||||
|
||||
const POLICIES = ['linucb-v1', 'egreedy-v1', 'egreedy-v2'];
|
||||
const mlflowBase = process.env.NEXT_PUBLIC_MLFLOW_URL ?? '/mlflow';
|
||||
const airflowBase = process.env.NEXT_PUBLIC_AIRFLOW_URL ?? '/airflow';
|
||||
|
||||
@@ -83,15 +77,7 @@ function SummaryRow({ run }: { run: SimRun }) {
|
||||
export default function SimulatePage() {
|
||||
const [runs, setRuns] = useState<SimRun[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [launching, setLaunching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const [nUsers, setNUsers] = useState(5);
|
||||
const [nRounds, setNRounds] = useState(20);
|
||||
const [tasksPerRound, setTasksPerRound] = useState(8);
|
||||
const [judgeMode, setJudgeMode] = useState<'rule' | 'llm'>('rule');
|
||||
const [selectedPolicies, setSelectedPolicies] = useState<string[]>(['linucb-v1', 'egreedy-v1']);
|
||||
|
||||
const refresh = () =>
|
||||
getSimulationRuns()
|
||||
@@ -105,112 +91,30 @@ export default function SimulatePage() {
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const togglePolicy = (p: string) =>
|
||||
setSelectedPolicies((prev) =>
|
||||
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p],
|
||||
);
|
||||
|
||||
const handleLaunch = async () => {
|
||||
if (selectedPolicies.length < 2) { setError('Select at least 2 policies.'); return; }
|
||||
setLaunching(true); setError(''); setMsg('');
|
||||
try {
|
||||
const r = await startSimulation({ nUsers, nRounds, tasksPerRound, judgeMode, policies: selectedPolicies });
|
||||
setMsg(r.airflow_dag_run_id
|
||||
? `Launched via Airflow — dag_run_id: ${r.airflow_dag_run_id}`
|
||||
: `Launched locally — run id: ${r.id}`);
|
||||
await refresh();
|
||||
} catch (e: unknown) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLaunching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="space-y-8 max-w-4xl">
|
||||
<h1 className="text-xl font-semibold">Simulations</h1>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
{msg && <p className="text-green-400 text-sm">{msg}</p>}
|
||||
|
||||
{/* Launch form */}
|
||||
<section className="bg-gray-900 border border-gray-800 rounded p-5 space-y-4">
|
||||
<h2 className="text-base font-medium text-gray-300">New simulation</h2>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Users</span>
|
||||
<input type="number" min={1} max={50} value={nUsers}
|
||||
onChange={(e) => setNUsers(Number(e.target.value))}
|
||||
className="w-full bg-gray-950 border border-gray-700 rounded px-2 py-1 text-gray-300" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Rounds</span>
|
||||
<input type="number" min={1} max={200} value={nRounds}
|
||||
onChange={(e) => setNRounds(Number(e.target.value))}
|
||||
className="w-full bg-gray-950 border border-gray-700 rounded px-2 py-1 text-gray-300" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Tasks/round</span>
|
||||
<input type="number" min={1} max={20} value={tasksPerRound}
|
||||
onChange={(e) => setTasksPerRound(Number(e.target.value))}
|
||||
className="w-full bg-gray-950 border border-gray-700 rounded px-2 py-1 text-gray-300" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
<span className="text-gray-500">Policies (select ≥ 2)</span>
|
||||
<div className="flex gap-2 flex-wrap pt-1">
|
||||
{POLICIES.map((p) => (
|
||||
<button key={p} onClick={() => togglePolicy(p)}
|
||||
className={`px-3 py-1 rounded border text-xs font-mono ${
|
||||
selectedPolicies.includes(p)
|
||||
? 'bg-indigo-900 border-indigo-700 text-indigo-200'
|
||||
: 'border-gray-700 text-gray-500 hover:border-gray-500'
|
||||
}`}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
<span className="text-gray-500">Judge</span>
|
||||
<div className="flex gap-2 pt-1">
|
||||
{(['rule', 'llm'] as const).map((m) => (
|
||||
<button key={m} onClick={() => setJudgeMode(m)}
|
||||
className={`px-3 py-1 rounded border text-xs ${
|
||||
judgeMode === m
|
||||
? 'bg-gray-700 border-gray-500 text-white'
|
||||
: 'border-gray-700 text-gray-500 hover:border-gray-500'
|
||||
}`}>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{judgeMode === 'llm' && (
|
||||
<p className="text-xs text-yellow-600 mt-1">LLM judge requires ANTHROPIC_API_KEY in ml/serving env.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={handleLaunch} disabled={launching}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded px-4 py-2 text-sm">
|
||||
{launching ? 'Launching…' : 'Launch simulation'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-600">
|
||||
Runs via <a href={airflowBase} target="_blank" rel="noreferrer" className="text-indigo-500 hover:underline">Airflow</a> (mlops profile) when available; falls back to local subprocess.
|
||||
Results logged to <a href={mlflowBase} target="_blank" rel="noreferrer" className="text-indigo-500 hover:underline">MLflow</a>.
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Simulations</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Offline policy comparisons — run via the{' '}
|
||||
<a href={airflowBase} target="_blank" rel="noreferrer" className="text-indigo-400 hover:underline">
|
||||
Airflow <code className="text-xs">bench_collect</code> DAG
|
||||
</a>
|
||||
{' '}(mlops profile). Results are logged to{' '}
|
||||
<a href={mlflowBase} target="_blank" rel="noreferrer" className="text-indigo-400 hover:underline">MLflow ↗</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
{/* Run history */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-medium text-gray-300">
|
||||
<h2 className="text-xs text-gray-500 uppercase tracking-widest font-medium">
|
||||
Run history
|
||||
{loading && <span className="text-xs text-gray-600 ml-2">loading…</span>}
|
||||
{loading && <span className="text-gray-600 ml-2 normal-case">loading…</span>}
|
||||
</h2>
|
||||
{runs.length === 0 && !loading && (
|
||||
<p className="text-gray-600 text-sm">No simulations yet.</p>
|
||||
<p className="text-gray-600 text-sm">No simulation runs yet. Trigger a run from Airflow.</p>
|
||||
)}
|
||||
{runs.map((r) => <SummaryRow key={r.id} run={r} />)}
|
||||
</section>
|
||||
|
||||
@@ -13,8 +13,11 @@ import { readdir, readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { marked } from 'marked';
|
||||
|
||||
// apps/admin sits two levels below the monorepo root.
|
||||
const DOCS_ROOT = path.resolve(process.cwd(), '../../docs');
|
||||
// In development: process.cwd() = apps/admin/, so ../../docs = monorepo root docs/.
|
||||
// In Docker standalone: CWD = /app, so ../../docs is wrong. Set DOCS_ROOT in the
|
||||
// container to the absolute path where docs/ is copied (e.g. /app/docs).
|
||||
const DOCS_ROOT =
|
||||
process.env.DOCS_ROOT ?? path.resolve(process.cwd(), '../../docs');
|
||||
|
||||
export type DocCategory = 'adr' | 'architecture';
|
||||
|
||||
|
||||
@@ -26,8 +26,9 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
RUN pnpm --filter @oo/admin build
|
||||
|
||||
FROM node:22-slim AS runner
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3080
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3080 DOCS_ROOT=/app/docs
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/apps/admin/.next/standalone ./
|
||||
COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static
|
||||
COPY --from=builder /app/docs ./docs
|
||||
CMD ["node", "apps/admin/server.js"]
|
||||
|
||||
Reference in New Issue
Block a user