feat(admin): add cohort and student management portal
New /admin route (frontend/src/admin/), gated by a separate admin session (scrypt + timingSafeEqual against ADMIN_PASSWORD, no plaintext storage, no new dependency). Covers cohort CRUD + roster + curriculum assignment, and student CRUD + cohort assignment + environment/progress/ attempt views, all under /api/admin/* on the Platform API. main.jsx now does a three-way path split (lab / admin / platform) so the existing, unmodified Lab UI, the student dashboard, and the new admin portal all ship in one Vite bundle without a router dependency.
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
/* Admin portal — visually distinct from the student shell (a muted accent instead of
|
||||||
|
--green) so instructors and students never mistake which mode they're in, while still
|
||||||
|
reusing the same design tokens for everything else. */
|
||||||
|
|
||||||
|
.wrap { min-height: 100vh; background: var(--bg); color: var(--text); font-family: var(--sans); }
|
||||||
|
|
||||||
|
.loginWrap { height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.loginCard { width: 360px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 2rem; }
|
||||||
|
|
||||||
|
.brand { font-family: var(--mono); font-size: 1.2rem; font-weight: 600; color: var(--blue); display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.subtitle { color: var(--text-2); font-size: 0.85rem; margin: 0.4rem 0 1.25rem; }
|
||||||
|
|
||||||
|
.input, .select {
|
||||||
|
width: 100%; padding: 0.55rem 0.6rem; margin-top: 0.3rem;
|
||||||
|
background: var(--surface2); border: 1px solid var(--border2); border-radius: var(--radius);
|
||||||
|
color: var(--text); font-family: var(--sans); font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin-top: 0.9rem; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.55rem 0.9rem; background: var(--blue); color: #04141c; border: none;
|
||||||
|
border-radius: var(--radius); font-weight: 600; font-size: 0.85rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btnSecondary { padding: 0.5rem 0.8rem; background: transparent; color: var(--text); border: 1px solid var(--border2); border-radius: var(--radius); font-size: 0.85rem; cursor: pointer; }
|
||||||
|
.btnDanger { padding: 0.5rem 0.8rem; background: transparent; color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); font-size: 0.85rem; cursor: pointer; }
|
||||||
|
|
||||||
|
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 2rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.nav { display: flex; gap: 1.25rem; }
|
||||||
|
.navLink { color: var(--text-2); cursor: pointer; font-size: 0.9rem; background: none; border: none; padding: 0.3rem 0; }
|
||||||
|
.navLinkActive { color: var(--blue); border-bottom: 2px solid var(--blue); }
|
||||||
|
|
||||||
|
.content { max-width: 1000px; margin: 1.5rem auto; padding: 0 1.5rem; }
|
||||||
|
|
||||||
|
.statGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||||
|
.statCard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
|
||||||
|
.statValue { font-size: 1.6rem; font-weight: 700; }
|
||||||
|
.statLabel { color: var(--text-2); font-size: 0.78rem; margin-top: 0.2rem; }
|
||||||
|
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.25rem; margin-bottom: 1.25rem; }
|
||||||
|
.card h2, .card h3 { margin-bottom: 0.75rem; font-size: 1rem; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
th { text-align: left; color: var(--text-2); font-weight: 600; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border2); font-size: 0.75rem; text-transform: uppercase; }
|
||||||
|
td { padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
|
||||||
|
tr:hover td { background: var(--surface2); }
|
||||||
|
|
||||||
|
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 0.75rem; font-family: var(--mono); }
|
||||||
|
.pillOk { background: var(--green-dim); color: var(--green); }
|
||||||
|
.pillWarn { background: var(--amber-dim); color: var(--amber); }
|
||||||
|
.pillErr { background: var(--red-dim); color: var(--red); }
|
||||||
|
.pillMuted { background: var(--surface3); color: var(--text-2); }
|
||||||
|
|
||||||
|
.formRow { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||||
|
.formRow > div { flex: 1; min-width: 140px; }
|
||||||
|
|
||||||
|
.linkBtn { background: none; border: none; color: var(--blue); cursor: pointer; font-size: 0.85rem; padding: 0; text-decoration: underline; }
|
||||||
|
.errorBox { background: var(--red-dim); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); padding: 0.6rem 0.8rem; font-size: 0.85rem; margin-bottom: 1rem; }
|
||||||
|
.muted { color: var(--text-2); font-size: 0.85rem; }
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from './api'
|
||||||
|
import styles from './Admin.module.css'
|
||||||
|
|
||||||
|
// Operational overview — active cohorts, students, Lab states, recent activity. Deliberately
|
||||||
|
// lean (no Prometheus/Grafana here, see docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §9) — this is
|
||||||
|
// "is class ready / is anything on fire," not a metrics platform.
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.dashboard().then(setData).catch((e) => setError(e.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||||
|
if (!data) return <p className={styles.muted}>Loading…</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.statGrid}>
|
||||||
|
<Stat label="Active cohorts" value={data.active_cohorts} />
|
||||||
|
<Stat label="Total students" value={data.total_students} />
|
||||||
|
<Stat label="Labs running" value={data.labs.running} />
|
||||||
|
<Stat label="Labs provisioning" value={data.labs.provisioning} />
|
||||||
|
<Stat label="Labs in error" value={data.labs.error} />
|
||||||
|
<Stat label="Labs idle/destroyed" value={data.labs.idle_or_destroyed} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3>Recent completions</h3>
|
||||||
|
{data.recent_completions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Student</th><th>Scenario</th><th>Completed</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{data.recent_completions.map((c, i) => (
|
||||||
|
<tr key={i}><td>{c.display_name}</td><td>{c.scenario_id}</td><td>{new Date(c.completed_at).toLocaleString()}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3>Recent environment failures</h3>
|
||||||
|
{data.recent_failures.length === 0 ? <p className={styles.muted}>None.</p> : (
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Student</th><th>Reason</th><th>When</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{data.recent_failures.map((f, i) => (
|
||||||
|
<tr key={i}><td>{f.student_id}</td><td>{f.error_reason || '—'}</td><td>{f.started_at ? new Date(f.started_at).toLocaleString() : '—'}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<div className={styles.statValue}>{value}</div>
|
||||||
|
<div className={styles.statLabel}>{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from './api'
|
||||||
|
import AdminDashboard from './AdminDashboard'
|
||||||
|
import CohortsView from './CohortsView'
|
||||||
|
import StudentsView from './StudentsView'
|
||||||
|
import styles from './Admin.module.css'
|
||||||
|
|
||||||
|
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||||
|
// separate rn_admin session cookie, separate login). A student session cannot reach any of
|
||||||
|
// these views or their underlying /api/admin/* routes — see
|
||||||
|
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §10.
|
||||||
|
export default function AdminPortal() {
|
||||||
|
const [authed, setAuthed] = useState(null) // null = checking, false = need login, true = in
|
||||||
|
const [view, setView] = useState('dashboard')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.whoami().then(() => setAuthed(true)).catch(() => setAuthed(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (authed === null) return null
|
||||||
|
if (!authed) return <AdminLogin onLogin={() => setAuthed(true)} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.brand}>⚙ RootNode Admin</div>
|
||||||
|
<nav className={styles.nav}>
|
||||||
|
{['dashboard', 'cohorts', 'students'].map((v) => (
|
||||||
|
<button key={v} className={`${styles.navLink} ${view === v ? styles.navLinkActive : ''}`} onClick={() => setView(v)}>
|
||||||
|
{v[0].toUpperCase() + v.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setAuthed(false))}>Sign out</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{view === 'dashboard' && <AdminDashboard />}
|
||||||
|
{view === 'cohorts' && <CohortsView />}
|
||||||
|
{view === 'students' && <StudentsView />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminLogin({ onLogin }) {
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true); setError(null)
|
||||||
|
try { await adminApi.login(password); onLogin() }
|
||||||
|
catch (e) { setError('Incorrect password.') }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.loginWrap}>
|
||||||
|
<form className={styles.loginCard} onSubmit={submit}>
|
||||||
|
<div className={styles.brand}>⚙ RootNode Admin</div>
|
||||||
|
<p className={styles.subtitle}>Instructor sign-in. Not connected to student accounts.</p>
|
||||||
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
<label className={styles.label}>Password</label>
|
||||||
|
<input className={styles.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus />
|
||||||
|
<button className={styles.btn} style={{ marginTop: '1.25rem', width: '100%' }} disabled={busy || !password}>
|
||||||
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { adminApi } from './api'
|
||||||
|
import styles from './Admin.module.css'
|
||||||
|
|
||||||
|
export default function CohortsView() {
|
||||||
|
const [cohorts, setCohorts] = useState(null)
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
adminApi.cohorts().then(setCohorts).catch((e) => setError(e.message))
|
||||||
|
}, [])
|
||||||
|
useEffect(reload, [reload])
|
||||||
|
|
||||||
|
if (selected) return <CohortDetail cohortId={selected} onBack={() => { setSelected(null); reload() }} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2>Create cohort</h2>
|
||||||
|
<CreateCohortForm onCreated={reload} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2>Cohorts</h2>
|
||||||
|
{!cohorts ? <p className={styles.muted}>Loading…</p> : cohorts.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Tier</th><th>Students</th><th>Status</th><th /></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{cohorts.map((c) => (
|
||||||
|
<tr key={c.cohort_id}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>{c.tier || '—'}</td>
|
||||||
|
<td>{c.student_count}</td>
|
||||||
|
<td><span className={`${styles.pill} ${c.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{c.status}</span></td>
|
||||||
|
<td><button className={styles.linkBtn} onClick={() => setSelected(c.cohort_id)}>Open</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateCohortForm({ onCreated }) {
|
||||||
|
const [form, setForm] = useState({ cohort_id: '', name: '', tier: '', start_date: '', end_date: '' })
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true); setError(null)
|
||||||
|
try {
|
||||||
|
await adminApi.createCohort(form)
|
||||||
|
setForm({ cohort_id: '', name: '', tier: '', start_date: '', end_date: '' })
|
||||||
|
onCreated()
|
||||||
|
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div><label className={styles.label}>Cohort ID (slug)</label>
|
||||||
|
<input className={styles.input} placeholder="root-fall-2026" value={form.cohort_id} onChange={(e) => setForm({ ...form, cohort_id: e.target.value })} required /></div>
|
||||||
|
<div><label className={styles.label}>Name</label>
|
||||||
|
<input className={styles.input} placeholder="Root Fall 2026" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
|
||||||
|
<div><label className={styles.label}>Tier</label>
|
||||||
|
<input className={styles.input} placeholder="root" value={form.tier} onChange={(e) => setForm({ ...form, tier: e.target.value })} /></div>
|
||||||
|
<div><label className={styles.label}>Start date</label>
|
||||||
|
<input className={styles.input} type="date" value={form.start_date} onChange={(e) => setForm({ ...form, start_date: e.target.value })} /></div>
|
||||||
|
<div><label className={styles.label}>End date</label>
|
||||||
|
<input className={styles.input} type="date" value={form.end_date} onChange={(e) => setForm({ ...form, end_date: e.target.value })} /></div>
|
||||||
|
<div><button className={styles.btn} disabled={busy}>{busy ? 'Creating…' : 'Create cohort'}</button></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CohortDetail({ cohortId, onBack }) {
|
||||||
|
const [students, setStudents] = useState(null)
|
||||||
|
const [progress, setProgress] = useState(null)
|
||||||
|
const [prepResults, setPrepResults] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [newStudentName, setNewStudentName] = useState('')
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
adminApi.cohortStudents(cohortId).then(setStudents).catch((e) => setError(e.message))
|
||||||
|
adminApi.cohortProgress(cohortId).then(setProgress).catch(() => {})
|
||||||
|
}, [cohortId])
|
||||||
|
useEffect(reload, [reload])
|
||||||
|
|
||||||
|
const addStudent = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newStudentName.trim()) return
|
||||||
|
setBusy(true); setError(null)
|
||||||
|
try {
|
||||||
|
await adminApi.createStudent(newStudentName.trim(), cohortId)
|
||||||
|
setNewStudentName('')
|
||||||
|
reload()
|
||||||
|
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareLabs = async () => {
|
||||||
|
setBusy(true); setError(null); setPrepResults(null)
|
||||||
|
try {
|
||||||
|
const { results } = await adminApi.prepareLabs(cohortId)
|
||||||
|
setPrepResults(results)
|
||||||
|
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const destroyLabs = async () => {
|
||||||
|
if (!window.confirm(`Destroy all Labs for this cohort? Academic progress is not affected.`)) return
|
||||||
|
setBusy(true); setError(null)
|
||||||
|
try {
|
||||||
|
const { results } = await adminApi.destroyLabs(cohortId)
|
||||||
|
setPrepResults(results)
|
||||||
|
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignCurriculum = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try { await adminApi.assignCohortCurriculum(cohortId, 'rootnode-path') } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyCount = prepResults?.filter((r) => r.status === 'running').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className={styles.linkBtn} onClick={onBack}>← Back to cohorts</button>
|
||||||
|
<h2 style={{ margin: '1rem 0' }}>{cohortId}</h2>
|
||||||
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3>Roster ({students?.length ?? '…'})</h3>
|
||||||
|
<form onSubmit={addStudent} className={styles.formRow}>
|
||||||
|
<div><input className={styles.input} placeholder="New student name" value={newStudentName} onChange={(e) => setNewStudentName(e.target.value)} /></div>
|
||||||
|
<div><button className={styles.btn} disabled={busy}>Add student</button></div>
|
||||||
|
</form>
|
||||||
|
{students && (
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Status</th><th>Last active</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{students.map((s) => (
|
||||||
|
<tr key={s.student_id}>
|
||||||
|
<td>{s.display_name}</td>
|
||||||
|
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
||||||
|
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3>Curriculum</h3>
|
||||||
|
<p className={styles.muted}>RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)</p>
|
||||||
|
<button className={styles.btnSecondary} onClick={assignCurriculum} disabled={busy}>Assign RootNode Path to cohort</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{progress && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3>Cohort progress ({progress.student_count} students)</h3>
|
||||||
|
{progress.stages.map((s) => (
|
||||||
|
<div key={s.id} style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem' }}>
|
||||||
|
<span>{s.name}</span><span className={styles.muted}>{s.pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--surface3)', borderRadius: 4, height: 6 }}>
|
||||||
|
<div style={{ background: 'var(--green)', borderRadius: 4, height: 6, width: `${s.pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3>Classroom preparation</h3>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<button className={styles.btn} onClick={prepareLabs} disabled={busy}>{busy ? 'Working…' : 'Prepare Labs'}</button>
|
||||||
|
<button className={styles.btnDanger} onClick={destroyLabs} disabled={busy}>Destroy Cohort Labs</button>
|
||||||
|
</div>
|
||||||
|
{prepResults && (
|
||||||
|
<>
|
||||||
|
{readyCount !== undefined && <p className={styles.muted}>{readyCount}/{prepResults.length} READY</p>}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Student</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{prepResults.map((r) => (
|
||||||
|
<tr key={r.student_id}>
|
||||||
|
<td>{r.display_name || r.student_id}</td>
|
||||||
|
<td><StatusPill status={r.status} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPill({ status }) {
|
||||||
|
const cls = status === 'running' ? styles.pillOk
|
||||||
|
: status === 'error' ? styles.pillErr
|
||||||
|
: ['provisioning', 'requested', 'resetting'].includes(status) ? styles.pillWarn
|
||||||
|
: styles.pillMuted
|
||||||
|
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { adminApi } from './api'
|
||||||
|
import { StatusPill } from './CohortsView'
|
||||||
|
import styles from './Admin.module.css'
|
||||||
|
|
||||||
|
export default function StudentsView() {
|
||||||
|
const [students, setStudents] = useState(null)
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
adminApi.students().then(setStudents).catch((e) => setError(e.message))
|
||||||
|
}, [])
|
||||||
|
useEffect(reload, [reload])
|
||||||
|
|
||||||
|
if (selected) return <StudentDetail studentId={selected} onBack={() => { setSelected(null); reload() }} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2>Students ({students?.length ?? '…'})</h2>
|
||||||
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
{students && (
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>ID</th><th>Status</th><th>Last active</th><th /></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{students.map((s) => (
|
||||||
|
<tr key={s.student_id}>
|
||||||
|
<td>{s.display_name}</td>
|
||||||
|
<td className={styles.muted}>{s.student_id}</td>
|
||||||
|
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
||||||
|
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
|
||||||
|
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudentDetail({ studentId, onBack }) {
|
||||||
|
const [progress, setProgress] = useState(null)
|
||||||
|
const [attempts, setAttempts] = useState(null)
|
||||||
|
const [env, setEnv] = useState(null)
|
||||||
|
const [events, setEvents] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
|
||||||
|
adminApi.studentAttempts(studentId).then(setAttempts).catch(() => {})
|
||||||
|
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
|
||||||
|
}, [studentId])
|
||||||
|
useEffect(reload, [reload])
|
||||||
|
|
||||||
|
const withBusy = (fn) => async () => {
|
||||||
|
setBusy(true); setError(null)
|
||||||
|
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
if (!env) return
|
||||||
|
try { setEvents(await adminApi.environmentEvents(env.environment_id)) } catch (e) { setError(e.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className={styles.linkBtn} onClick={onBack}>← Back to students</button>
|
||||||
|
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
|
||||||
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3>Environment</h3>
|
||||||
|
{!env ? <p className={styles.muted}>No Lab provisioned.</p> : (
|
||||||
|
<>
|
||||||
|
<p>Status: <StatusPill status={env.status} /> — Lab host: {env.lab_host_id} — created {new Date(env.created_at).toLocaleString()}</p>
|
||||||
|
{env.last_active_at && <p className={styles.muted}>Last active: {new Date(env.last_active_at).toLocaleString()}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className={styles.formRow} style={{ marginTop: '0.75rem' }}>
|
||||||
|
{!env && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.provisionStudentLab(studentId))}>Provision Lab</button>}
|
||||||
|
{env && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.resetEnvironment(env.environment_id))}>Reset/Recreate</button>}
|
||||||
|
{env && (
|
||||||
|
<button className={styles.btnDanger} disabled={busy} onClick={withBusy(async () => {
|
||||||
|
if (window.confirm('Destroy this Lab? Progress is not affected.')) await adminApi.destroyEnvironment(env.environment_id)
|
||||||
|
})}>Destroy Lab</button>
|
||||||
|
)}
|
||||||
|
{env && <button className={styles.linkBtn} onClick={loadEvents}>View event history</button>}
|
||||||
|
</div>
|
||||||
|
{events && (
|
||||||
|
<table style={{ marginTop: '0.75rem' }}>
|
||||||
|
<thead><tr><th>Event</th><th>Actor</th><th>When</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map((e) => <tr key={e.id}><td>{e.event_type}</td><td>{e.actor}</td><td>{new Date(e.created_at).toLocaleString()}</td></tr>)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3>Curriculum progress</h3>
|
||||||
|
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
|
||||||
|
<div key={s.id} style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<strong>{s.name}</strong>
|
||||||
|
<div className={styles.muted}>
|
||||||
|
{s.scenarios.filter((sc) => sc.status === 'completed').length}/{s.scenarios.length} complete
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3>Recent attempts</h3>
|
||||||
|
{!attempts ? <p className={styles.muted}>Loading…</p> : attempts.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Scenario</th><th>Result</th><th>Checks</th><th>When</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{attempts.map((a) => (
|
||||||
|
<tr key={a.attempt_id}>
|
||||||
|
<td>{a.scenario_id}</td>
|
||||||
|
<td><span className={`${styles.pill} ${a.result === 'passed' ? styles.pillOk : styles.pillWarn}`}>{a.result}</span></td>
|
||||||
|
<td className={styles.muted}>{a.validation_count ?? '—'}</td>
|
||||||
|
<td>{new Date(a.created_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Admin API client. Uses the rn_admin session cookie exclusively (same-origin, sent
|
||||||
|
// automatically) — completely separate credential/cookie from student sessions (see
|
||||||
|
// platform/api/src/sessions.js). No admin data is ever cached client-side beyond the current
|
||||||
|
// React state; nothing here reads or writes localStorage.
|
||||||
|
|
||||||
|
async function json(res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(body.message || body.error || `Request failed (${res.status})`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(path, body) {
|
||||||
|
return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(json)
|
||||||
|
}
|
||||||
|
function patch(path, body) {
|
||||||
|
return fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(json)
|
||||||
|
}
|
||||||
|
function del(path) {
|
||||||
|
return fetch(path, { method: 'DELETE' }).then(json)
|
||||||
|
}
|
||||||
|
function get(path) {
|
||||||
|
return fetch(path).then(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
login: (password) => post('/api/admin/login', { password }),
|
||||||
|
logout: () => post('/api/admin/logout'),
|
||||||
|
whoami: () => get('/api/admin/whoami'),
|
||||||
|
|
||||||
|
dashboard: () => get('/api/admin/dashboard'),
|
||||||
|
|
||||||
|
students: () => get('/api/admin/students'),
|
||||||
|
createStudent: (display_name, cohort_id) => post('/api/admin/students', { display_name, cohort_id }),
|
||||||
|
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
|
||||||
|
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
|
||||||
|
studentAttempts: (id) => get(`/api/admin/students/${id}/attempts`),
|
||||||
|
studentEnvironment: (id) => get(`/api/admin/students/${id}/environment`),
|
||||||
|
provisionStudentLab: (id) => post(`/api/admin/students/${id}/environment/provision`),
|
||||||
|
assignStudentCohort: (id, cohort_id) => post(`/api/admin/students/${id}/cohort`, { cohort_id }),
|
||||||
|
removeStudentCohort: (id, cohortId) => del(`/api/admin/students/${id}/cohort/${cohortId}`),
|
||||||
|
|
||||||
|
cohorts: () => get('/api/admin/cohorts'),
|
||||||
|
createCohort: (fields) => post('/api/admin/cohorts', fields),
|
||||||
|
updateCohort: (id, fields) => patch(`/api/admin/cohorts/${id}`, fields),
|
||||||
|
cohortStudents: (id) => get(`/api/admin/cohorts/${id}/students`),
|
||||||
|
cohortProgress: (id) => get(`/api/admin/cohorts/${id}/progress`),
|
||||||
|
assignCohortCurriculum: (id, curriculum_path) => post(`/api/admin/cohorts/${id}/curriculum`, { curriculum_path }),
|
||||||
|
prepareLabs: (id) => post(`/api/admin/cohorts/${id}/prepare-labs`),
|
||||||
|
destroyLabs: (id) => post(`/api/admin/cohorts/${id}/destroy-labs`),
|
||||||
|
|
||||||
|
resetEnvironment: (id) => post(`/api/admin/environments/${id}/reset`),
|
||||||
|
destroyEnvironment: (id) => del(`/api/admin/environments/${id}`),
|
||||||
|
environmentEvents: (id) => get(`/api/admin/environments/${id}/events`),
|
||||||
|
}
|
||||||
@@ -2,22 +2,24 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import PlatformShell from './platform/PlatformShell.jsx'
|
import PlatformShell from './platform/PlatformShell.jsx'
|
||||||
|
import AdminPortal from './admin/AdminPortal.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
// Apply saved theme before first paint to avoid flash
|
// Apply saved theme before first paint to avoid flash
|
||||||
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
|
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
|
||||||
document.documentElement.setAttribute('data-theme', savedTheme)
|
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||||
|
|
||||||
// This bundle is served both by the persistent identity/platform instance (root paths) and by
|
// This bundle is served both by the lightweight Platform API (root paths + /admin) and by
|
||||||
// every ephemeral per-student Lab container (reached via the Gateway at /lab/<token>/) — same
|
// every ephemeral per-student Lab container (reached via the Gateway at /lab/<token>/) — same
|
||||||
// build, same code, split purely by the URL path at runtime. See
|
// build, same code, split purely by the URL path at runtime. See
|
||||||
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student flow" for why: it lets the existing Lab UI
|
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Student/admin flow" for why: it lets the existing
|
||||||
// (<App/>) stay completely unmodified while the new Platform Shell (entry/dashboard/launch)
|
// Lab UI (<App/>) stay completely unmodified while the Platform Shell and Admin Portal live
|
||||||
// lives alongside it in the same bundle.
|
// alongside it in the same bundle.
|
||||||
const isLabSession = window.location.pathname.startsWith('/lab/')
|
const path = window.location.pathname
|
||||||
|
const view = path.startsWith('/lab/') ? 'lab' : path.startsWith('/admin') ? 'admin' : 'platform'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
{isLabSession ? <App /> : <PlatformShell />}
|
{view === 'lab' ? <App /> : view === 'admin' ? <AdminPortal /> : <PlatformShell />}
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user