diff --git a/frontend/src/platform/Platform.module.css b/frontend/src/platform/Platform.module.css new file mode 100644 index 0000000..b7cc8d3 --- /dev/null +++ b/frontend/src/platform/Platform.module.css @@ -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); } } diff --git a/frontend/src/platform/StudentDashboard.jsx b/frontend/src/platform/StudentDashboard.jsx new file mode 100644 index 0000000..b929bb0 --- /dev/null +++ b/frontend/src/platform/StudentDashboard.jsx @@ -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 ( +
+
+
⎈ RootNode Academy
+
+ {student.display_name} + +
+
+ + {error &&
{error}
} + +
+

Up Next

+ {!curriculum ?

Loading curriculum…

: ( + <> +
+ {curriculum.stages.map((s) => ( +
+ {s.icon} {s.name} + {s.stats.completed}/{s.stats.total} +
+ ))} +
+ {nextStage && ( +

+ Next: {nextStage.name} + {nextScenario && <> — {nextScenario.title}} +

+ )} + {!nextStage &&

All unlocked stages complete — nice work.

} + + )} +
+ +
+

Your Lab

+ +
+
+ ) +} + +function EnvironmentPanel({ status, busy, onLaunch, onReset }) { + const s = status?.status || 'none' + + if (s === 'none' || s === 'destroyed') { + return ( + <> +

No lab running yet.

+ + + ) + } + if (['requested', 'provisioning', 'resetting'].includes(s)) { + return

{s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}

+ } + if (s === 'running') { + return ( + <> +

Lab ready.

+
+ Continue Lab + +
+

Recreating gives you a clean environment. Your progress is saved separately and is never lost.

+ + ) + } + if (s === 'error') { + return ( + <> +

{status.error_reason || 'Your lab hit a problem.'}

+ + + ) + } + return

Unknown state ({s}).

+}