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.

: ( + + + + {data.recent_completions.map((c, i) => ( + + ))} + +
StudentScenarioCompleted
{c.display_name}{c.scenario_id}{new Date(c.completed_at).toLocaleString()}
+ )} +
+ +
+

Recent environment failures

+ {data.recent_failures.length === 0 ?

None.

: ( + + + + {data.recent_failures.map((f, i) => ( + + ))} + +
StudentReasonWhen
{f.student_id}{f.error_reason || '—'}{f.started_at ? new Date(f.started_at).toLocaleString() : '—'}
+ )} +
+ + ) +} + +function Stat({ label, value }) { + return ( +
+
{value}
+
{label}
+
+ ) +} 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 ( +
+
+
⚙ RootNode Admin
+

Instructor sign-in. Not connected to student accounts.

+ {error &&
{error}
} + + setPassword(e.target.value)} autoFocus /> + +
+
+ ) +} 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.

: ( + + + + {cohorts.map((c) => ( + + + + + + + + ))} + +
NameTierStudentsStatus
{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 ( +
+ {error &&
{error}
} +
+
+ setForm({ ...form, cohort_id: e.target.value })} required />
+
+ setForm({ ...form, name: e.target.value })} required />
+
+ setForm({ ...form, tier: e.target.value })} />
+
+ setForm({ ...form, start_date: e.target.value })} />
+
+ setForm({ ...form, end_date: e.target.value })} />
+
+
+
+ ) +} + +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 ?? '…'})

+
+
setNewStudentName(e.target.value)} />
+
+
+ {students && ( + + + + {students.map((s) => ( + + + + + + ))} + +
NameStatusLast active
{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

} + + + + {prepResults.map((r) => ( + + + + + ))} + +
StudentStatus
{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 && ( + + + + {students.map((s) => ( + + + + + + + + ))} + +
NameIDStatusLast active
{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 && ( + + + + {events.map((e) => )} + +
EventActorWhen
{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.

: ( + + + + {attempts.map((a) => ( + + + + + + + ))} + +
ScenarioResultChecksWhen
{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' ? : } )