diff --git a/frontend/src/admin/Admin.module.css b/frontend/src/admin/Admin.module.css
new file mode 100644
index 0000000..d20e261
--- /dev/null
+++ b/frontend/src/admin/Admin.module.css
@@ -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; }
diff --git a/frontend/src/admin/AdminDashboard.jsx b/frontend/src/admin/AdminDashboard.jsx
new file mode 100644
index 0000000..d51e82f
--- /dev/null
+++ b/frontend/src/admin/AdminDashboard.jsx
@@ -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
{error}
+ if (!data) return Loading…
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
Recent completions
+ {data.recent_completions.length === 0 ?
None yet.
: (
+
+ | Student | Scenario | Completed |
+
+ {data.recent_completions.map((c, i) => (
+ | {c.display_name} | {c.scenario_id} | {new Date(c.completed_at).toLocaleString()} |
+ ))}
+
+
+ )}
+
+
+
+
Recent environment failures
+ {data.recent_failures.length === 0 ?
None.
: (
+
+ | Student | Reason | When |
+
+ {data.recent_failures.map((f, i) => (
+ | {f.student_id} | {f.error_reason || '—'} | {f.started_at ? new Date(f.started_at).toLocaleString() : '—'} |
+ ))}
+
+
+ )}
+
+ >
+ )
+}
+
+function Stat({ label, value }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/admin/AdminPortal.jsx b/frontend/src/admin/AdminPortal.jsx
new file mode 100644
index 0000000..30c5263
--- /dev/null
+++ b/frontend/src/admin/AdminPortal.jsx
@@ -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 setAuthed(true)} />
+
+ return (
+
+
+ ⚙ RootNode Admin
+
+
+
+ {view === 'dashboard' &&
}
+ {view === 'cohorts' &&
}
+ {view === 'students' &&
}
+
+
+ )
+}
+
+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 (
+
+ )
+}
diff --git a/frontend/src/admin/CohortsView.jsx b/frontend/src/admin/CohortsView.jsx
new file mode 100644
index 0000000..3c13252
--- /dev/null
+++ b/frontend/src/admin/CohortsView.jsx
@@ -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 { setSelected(null); reload() }} />
+
+ return (
+ <>
+ {error && {error}
}
+
+
Create cohort
+
+
+
+
Cohorts
+ {!cohorts ?
Loading…
: cohorts.length === 0 ?
None yet.
: (
+
+ | Name | Tier | Students | Status | |
+
+ {cohorts.map((c) => (
+
+ | {c.name} |
+ {c.tier || '—'} |
+ {c.student_count} |
+ {c.status} |
+ |
+
+ ))}
+
+
+ )}
+
+ >
+ )
+}
+
+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 (
+
+ )
+}
+
+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 (
+ <>
+
+ {cohortId}
+ {error && {error}
}
+
+
+
Roster ({students?.length ?? '…'})
+
+ {students && (
+
+ | Name | Status | Last active |
+
+ {students.map((s) => (
+
+ | {s.display_name} |
+ {s.status} |
+ {s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'} |
+
+ ))}
+
+
+ )}
+
+
+
+
Curriculum
+
RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)
+
+
+
+ {progress && (
+
+
Cohort progress ({progress.student_count} students)
+ {progress.stages.map((s) => (
+
+
+ {s.name}{s.pct}%
+
+
+
+ ))}
+
+ )}
+
+
+
Classroom preparation
+
+
+
+
+ {prepResults && (
+ <>
+ {readyCount !== undefined &&
{readyCount}/{prepResults.length} READY
}
+
+ | Student | Status |
+
+ {prepResults.map((r) => (
+
+ | {r.display_name || r.student_id} |
+ |
+
+ ))}
+
+
+ >
+ )}
+
+ >
+ )
+}
+
+export function StatusPill({ status }) {
+ const cls = status === 'running' ? styles.pillOk
+ : status === 'error' ? styles.pillErr
+ : ['provisioning', 'requested', 'resetting'].includes(status) ? styles.pillWarn
+ : styles.pillMuted
+ return {status}
+}
diff --git a/frontend/src/admin/StudentsView.jsx b/frontend/src/admin/StudentsView.jsx
new file mode 100644
index 0000000..836343a
--- /dev/null
+++ b/frontend/src/admin/StudentsView.jsx
@@ -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 { setSelected(null); reload() }} />
+
+ return (
+
+
Students ({students?.length ?? '…'})
+ {error &&
{error}
}
+ {students && (
+
+ | Name | ID | Status | Last active | |
+
+ {students.map((s) => (
+
+ | {s.display_name} |
+ {s.student_id} |
+ {s.status} |
+ {s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'} |
+ |
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+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 (
+ <>
+
+ {studentId}
+ {error && {error}
}
+
+
+
Environment
+ {!env ?
No Lab provisioned.
: (
+ <>
+
Status: — Lab host: {env.lab_host_id} — created {new Date(env.created_at).toLocaleString()}
+ {env.last_active_at &&
Last active: {new Date(env.last_active_at).toLocaleString()}
}
+ >
+ )}
+
+ {!env && }
+ {env && }
+ {env && (
+
+ )}
+ {env && }
+
+ {events && (
+
+ | Event | Actor | When |
+
+ {events.map((e) => | {e.event_type} | {e.actor} | {new Date(e.created_at).toLocaleString()} |
)}
+
+
+ )}
+
+
+
+
Curriculum progress
+ {!progress ?
Loading…
: progress.stages.map((s) => (
+
+
{s.name}
+
+ {s.scenarios.filter((sc) => sc.status === 'completed').length}/{s.scenarios.length} complete
+
+
+ ))}
+
+
+
+
Recent attempts
+ {!attempts ?
Loading…
: attempts.length === 0 ?
None yet.
: (
+
+ | Scenario | Result | Checks | When |
+
+ {attempts.map((a) => (
+
+ | {a.scenario_id} |
+ {a.result} |
+ {a.validation_count ?? '—'} |
+ {new Date(a.created_at).toLocaleString()} |
+
+ ))}
+
+
+ )}
+
+ >
+ )
+}
diff --git a/frontend/src/admin/api.js b/frontend/src/admin/api.js
new file mode 100644
index 0000000..72e3f71
--- /dev/null
+++ b/frontend/src/admin/api.js
@@ -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`),
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 389ed5d..5520ad8 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -2,22 +2,24 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import PlatformShell from './platform/PlatformShell.jsx'
+import AdminPortal from './admin/AdminPortal.jsx'
import './index.css'
// Apply saved theme before first paint to avoid flash
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
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//) — same
// 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
-// () stay completely unmodified while the new Platform Shell (entry/dashboard/launch)
-// lives alongside it in the same bundle.
-const isLabSession = window.location.pathname.startsWith('/lab/')
+// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Student/admin flow" for why: it lets the existing
+// Lab UI () stay completely unmodified while the Platform Shell and Admin Portal live
+// alongside it in the same bundle.
+const path = window.location.pathname
+const view = path.startsWith('/lab/') ? 'lab' : path.startsWith('/admin') ? 'admin' : 'platform'
ReactDOM.createRoot(document.getElementById('root')).render(
- {isLabSession ? : }
+ {view === 'lab' ? : view === 'admin' ? : }
)