feat(student): add curriculum dashboard and lab launch/reset/recreate flow

StudentDashboard.jsx — "Up Next" curriculum view + Launch Lab flow, in one
component since they share state (environment status affects what curriculum
actions make sense).

- Renders GET /api/curriculum/path as-is: the existing RootNode path
  (Foundation -> Workloads -> Networking -> Configuration -> Troubleshooting
  -> Real Ops -> Capstone), no new structure invented. Capstone stays the
  locked placeholder from the earlier slice.
- Launch Lab -> POST /api/environment/launch, polls status every 2.5s while
  provisioning/resetting, transitions through none -> provisioning -> running
  -> error with a distinct UI state for each (EnvironmentPanel). On running,
  navigates to launch_path with the student's own id as ?sid= — a
  defense-in-depth ownership check the Gateway verifies on that specific
  navigation (see the gateway commit).
- One "Recreate Lab" control, calling POST /api/environment/reset — no raw
  destroy-without-recreate exposed to students, matching "don't expose raw
  destroy/admin controls beyond what is safe for a student." A visible note
  states progress is saved separately and survives.

Platform.module.css reuses the existing design tokens from index.css
(--green, --surface, --text, etc.) so this reads as the same product as the
Lab UI, not a bolted-on separate app.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-18 11:16:35 -05:00
co-authored by Claude Sonnet 5
parent 5e4f5518fd
commit fb2945069c
2 changed files with 320 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
/* Platform Shell styling — reuses the existing design tokens from index.css (--bg, --surface,
--green, --text, etc.) so the entry/dashboard screens feel like the same product as the Lab
UI, not a bolted-on separate app. */
.entryWrap {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
font-family: var(--sans);
color: var(--text);
}
.entryCard {
width: 380px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2rem;
}
.brand {
font-family: var(--mono);
font-size: 1.3rem;
font-weight: 600;
color: var(--green);
display: flex;
align-items: center;
gap: 0.5rem;
}
.brandIcon { font-size: 1.4rem; }
.tagline { color: var(--text-2); margin: 0.5rem 0 0.25rem; font-size: 0.9rem; }
.pilotNote { color: var(--text-3); font-size: 0.75rem; margin-bottom: 1.25rem; }
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 0.9rem 0 0.3rem; }
.select {
width: 100%;
padding: 0.55rem 0.6rem;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: var(--sans);
font-size: 0.9rem;
}
.primaryBtn {
margin-top: 1.25rem;
width: 100%;
padding: 0.65rem 1rem;
background: var(--green);
color: #05130a;
border: none;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
text-align: center;
text-decoration: none;
display: inline-block;
}
.primaryBtn:disabled { opacity: 0.5; cursor: not-allowed; }
.secondaryBtn {
padding: 0.65rem 1rem;
background: transparent;
color: var(--text);
border: 1px solid var(--border2);
border-radius: var(--radius);
font-size: 0.9rem;
cursor: pointer;
}
.secondaryBtn:disabled { opacity: 0.5; cursor: not-allowed; }
.linkBtn {
background: none;
border: none;
color: var(--text-2);
font-size: 0.85rem;
cursor: pointer;
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;
}
/* ── Dashboard ─────────────────────────────────────────────────────────── */
.dashWrap {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
padding: 0 0 3rem;
}
.dashHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 2rem;
border-bottom: 1px solid var(--border);
}
.headerRight { display: flex; align-items: center; gap: 1rem; }
.studentName { font-weight: 600; }
.card {
max-width: 760px;
margin: 1.5rem auto 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.card h2 { font-size: 1rem; margin-bottom: 1rem; color: var(--text); }
.muted { color: var(--text-2); font-size: 0.9rem; }
.stageRow { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
.stagePill {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.4rem 0.7rem;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 999px;
font-size: 0.8rem;
color: var(--text-2);
}
.stagePillActive { border-color: var(--green); color: var(--text); }
.stagePillLocked { opacity: 0.5; }
.stageCount { color: var(--text-3); font-family: var(--mono); font-size: 0.75rem; }
.nextUp { font-size: 0.9rem; color: var(--text); }
.statusLine { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; margin-bottom: 0.9rem; }
.btnRow { display: flex; gap: 0.75rem; }
.hint { color: var(--text-3); font-size: 0.78rem; margin-top: 0.75rem; }
.dotOk, .dotErr { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dotOk { background: var(--green); }
.dotErr { background: var(--red); }
.spinner {
width: 12px; height: 12px;
border: 2px solid var(--border2);
border-top-color: var(--green);
border-radius: 50%;
display: inline-block;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
+159
View File
@@ -0,0 +1,159 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api, clearStudent } from './api'
import styles from './Platform.module.css'
const POLL_MS = 2500
// C/D/F. Student home ("Up Next") + Launch Lab / Reset flow.
// Uses the existing GET /api/curriculum/path — the RootNode path (Foundation -> Workloads ->
// Networking -> Configuration -> Troubleshooting -> Real Ops -> Capstone) is not reinvented
// here, just rendered.
export default function StudentDashboard({ student, onLogout }) {
const [curriculum, setCurriculum] = useState(null)
const [envStatus, setEnvStatus] = useState(null) // { status, launch_path?, error_reason? }
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const pollRef = useRef(null)
const loadCurriculum = useCallback(() => {
api.curriculumPath().then(setCurriculum).catch((e) => setError(e.message))
}, [])
const loadEnvStatus = useCallback(() => {
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
}, [])
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
// Poll while an environment is in-flight (provisioning/resetting) so the UI transitions to
// ready/error on its own — matches requirement D.4: provisioning -> starting -> ready -> error.
useEffect(() => {
const inFlight = envStatus && ['requested', 'provisioning', 'resetting'].includes(envStatus.status)
if (inFlight && !pollRef.current) {
pollRef.current = setInterval(loadEnvStatus, POLL_MS)
} else if (!inFlight && pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
}, [envStatus, loadEnvStatus])
const handleLaunch = async () => {
setBusy(true); setError(null)
try {
const result = await api.launchEnvironment()
setEnvStatus(result)
if (result.status === 'running' && result.launch_path) {
// sid is a defense-in-depth ownership check the Gateway verifies on this specific
// navigation — see docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Routing design."
window.location.href = `${result.launch_path}?sid=${encodeURIComponent(student.student_id)}`
}
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const handleReset = async () => {
setBusy(true); setError(null)
try {
const result = await api.resetEnvironment()
setEnvStatus(result)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const handleLogout = () => { clearStudent(); onLogout() }
const nextStage = curriculum?.stages?.find((s) => s.id === curriculum.next_stage)
const nextScenario = nextStage?.scenarios?.find((sc) => sc.status !== 'completed')
return (
<div className={styles.dashWrap}>
<header className={styles.dashHeader}>
<div className={styles.brand}><span className={styles.brandIcon}>⎈</span> RootNode Academy</div>
<div className={styles.headerRight}>
<span className={styles.studentName}>{student.display_name}</span>
<button className={styles.linkBtn} onClick={handleLogout}>Sign out</button>
</div>
</header>
{error && <div className={styles.errorBox}>{error}</div>}
<section className={styles.card}>
<h2>Up Next</h2>
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
<>
<div className={styles.stageRow}>
{curriculum.stages.map((s) => (
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.next_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
<span>{s.icon}</span> {s.name}
<span className={styles.stageCount}>{s.stats.completed}/{s.stats.total}</span>
</div>
))}
</div>
{nextStage && (
<p className={styles.nextUp}>
Next: <strong>{nextStage.name}</strong>
{nextScenario && <> — {nextScenario.title}</>}
</p>
)}
{!nextStage && <p className={styles.nextUp}>All unlocked stages complete — nice work.</p>}
</>
)}
</section>
<section className={styles.card}>
<h2>Your Lab</h2>
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
</section>
</div>
)
}
function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
const s = status?.status || 'none'
if (s === 'none' || s === 'destroyed') {
return (
<>
<p className={styles.muted}>No lab running yet.</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Launch Lab'}
</button>
</>
)
}
if (['requested', 'provisioning', 'resetting'].includes(s)) {
return <p className={styles.statusLine}><span className={styles.spinner} /> {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}</p>
}
if (s === 'running') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotOk} /> Lab ready.</p>
<div className={styles.btnRow}>
<a className={styles.primaryBtn} href={`${status.launch_path}`}>Continue Lab</a>
<button className={styles.secondaryBtn} disabled={busy} onClick={onReset}>
{busy ? 'Working…' : 'Recreate Lab'}
</button>
</div>
<p className={styles.hint}>Recreating gives you a clean environment. Your progress is saved separately and is never lost.</p>
</>
)
}
if (s === 'error') {
return (
<>
<p className={styles.statusLine}><span className={styles.dotErr} /> {status.error_reason || 'Your lab hit a problem.'}</p>
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
{busy ? 'Launching…' : 'Try Again'}
</button>
</>
)
}
return <p className={styles.muted}>Unknown state ({s}).</p>
}