feat(student): add learning tabs and Lab hint/assessment UX
StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice, Assessments (available/active-with-live-countdown/history), Capstone (locked checklist -> unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard). Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has stripped hints (detected via scenario.hints being undefined but hint_count present - standalone dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner shows a live countdown and completion count whenever a Platform assessment is running, rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
This commit is contained in:
@@ -8,6 +8,7 @@ import ExamTimer from './components/ExamTimer'
|
||||
import ExamReport from './components/ExamReport'
|
||||
import ExamStartModal from './components/ExamStartModal'
|
||||
import ExamHistory from './components/ExamHistory'
|
||||
import AssessmentBanner from './components/AssessmentBanner'
|
||||
import styles from './App.module.css'
|
||||
|
||||
const MIN_SIDEBAR_W = 180
|
||||
@@ -303,6 +304,7 @@ export default function App() {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
|
||||
<AssessmentBanner />
|
||||
|
||||
{/* Bundle navigation bar */}
|
||||
<BundleNav
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Phase 3B — shows when the student has an active Platform-level assessment (exam or capstone)
|
||||
// running, distinct from the pre-existing Lab-local exam mode (ExamTimer/ExamSession above,
|
||||
// which predates this and stays untouched). Polls /api/assessments/active, which 404s/errors
|
||||
// harmlessly in standalone/single-container dev mode (no Platform API there at all) — so this
|
||||
// component renders nothing in that mode, by design.
|
||||
export default function AssessmentBanner() {
|
||||
const [active, setActive] = useState(null)
|
||||
const [remaining, setRemaining] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
function poll() {
|
||||
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
|
||||
if (!cancelled) setActive(d.active ? d : null)
|
||||
}).catch(() => { if (!cancelled) setActive(null) })
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, 15000)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const tick = () => setRemaining(Math.max(0, new Date(active.expires_at) - Date.now()))
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [active])
|
||||
|
||||
if (!active) return null
|
||||
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
|
||||
const completedCount = active.scenarios.filter((sc) => sc.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 1rem',
|
||||
background: 'var(--amber-dim)', color: 'var(--amber)', fontSize: '0.85rem', fontFamily: 'var(--mono)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<span>🎯 Assessment Mode: {active.title}</span>
|
||||
<span>{completedCount}/{active.scenarios.length} scenarios complete</span>
|
||||
<span>⏱ {m}m {s}s remaining</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
|
||||
// Phase 3B progressive hints — used when the Gateway has stripped the full `hints` array from
|
||||
// the scenario payload (the multi-student, Platform-mediated deployment; see
|
||||
// platform/gateway/src/server.js's GET /lab/:token/api/scenarios/:scenarioId). Hints are
|
||||
// fetched one at a time from the Platform API, server-enforced sequential (can't skip to hint
|
||||
// 3), and every reveal is recorded against the student's attempt — none of that is possible
|
||||
// with the old fully-preloaded array this replaces for this deployment mode.
|
||||
//
|
||||
// In standalone/single-container dev mode there's no Gateway rewrite, so `scenario.hints` is
|
||||
// still the full array and ScenarioPanel never renders this component at all — see the
|
||||
// `hintCount != null` check at the call site.
|
||||
export default function ProgressiveHints({ scenarioId, totalHints }) {
|
||||
const [revealed, setRevealed] = useState([]) // [{level, title, body, command}]
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||
const [assessmentHintPolicy, setAssessmentHintPolicy] = useState(null) // null = not in an assessment; 'free'|'disabled'|'flagged'
|
||||
|
||||
// A scenario/environment change resets local reveal state — the server is still the source
|
||||
// of truth (GET /attempt would show the real highest_hint_level), this is just what's
|
||||
// rendered locally this session. Also checks whether this scenario is part of an active
|
||||
// Platform assessment, purely to show the policy indicator pre-emptively (the server enforces
|
||||
// the policy regardless, on every reveal call — this is UX, not the security boundary).
|
||||
useEffect(() => {
|
||||
setRevealed([]); setError(null); setAssessmentHintPolicy(null)
|
||||
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
|
||||
if (d.active && d.scenarios?.some((s) => s.scenario_id === scenarioId)) setAssessmentHintPolicy(d.hint_policy)
|
||||
}).catch(() => {})
|
||||
}, [scenarioId])
|
||||
|
||||
const revealNext = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const r = await fetch(`api/scenarios/${scenarioId}/hint`, { method: 'POST' })
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setError(d.message || d.error || 'Could not reveal a hint.'); return }
|
||||
setRevealed((h) => [...h, { level: d.level, ...d.hint }])
|
||||
} catch {
|
||||
setError('Could not reach the platform to reveal a hint.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [scenarioId])
|
||||
|
||||
function copyCmd(cmd, idx) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(cmd).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1800) })
|
||||
}
|
||||
}
|
||||
|
||||
if (totalHints === 0) return <div className={styles.noHints}>No hints available for this scenario.</div>
|
||||
|
||||
if (assessmentHintPolicy === 'disabled' && revealed.length === 0) {
|
||||
return <div className={styles.noHints}>Hints Disabled — this assessment does not allow hints.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{assessmentHintPolicy === 'flagged' && <div className={styles.setupNote} style={{ marginBottom: '0.75rem' }}>Using a hint will be recorded.</div>}
|
||||
{revealed.map((hint, i) => (
|
||||
<div key={i} className={styles.hintCard}>
|
||||
<div className={styles.hintHeader}>
|
||||
<div className={styles.hintLeft}>
|
||||
<span className={styles.hintNum}>Hint {hint.level}</span>
|
||||
<span className={styles.hintTitle}>{hint.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hintBody}>
|
||||
<p className={styles.hintText}><ReactMarkdown remarkPlugins={[remarkGfm]}>{hint.body}</ReactMarkdown></p>
|
||||
{hint.command && (
|
||||
<div className={styles.cmdBlock}>
|
||||
<pre className={styles.cmdPre}>{hint.command}</pre>
|
||||
<button className={styles.copyBtn} onClick={() => copyCmd(hint.command, i)}>{copiedIdx === i ? '✓ Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && <div className={styles.validateError}>⚠ {error}</div>}
|
||||
{revealed.length < totalHints && (
|
||||
<button className={styles.setupBtn} disabled={loading} onClick={revealNext}>
|
||||
{loading ? 'Revealing…' : `Reveal Hint ${revealed.length + 1} of ${totalHints}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
import { useConfirm } from './useConfirm'
|
||||
import ProgressiveHints from './ProgressiveHints'
|
||||
|
||||
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
|
||||
const inlineComponents = {
|
||||
@@ -48,6 +49,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
setHintsRevealed([])
|
||||
}, [scenario?.id])
|
||||
|
||||
// Phase 3B attempt telemetry — fire-and-forget "scenario started" so the Platform can record
|
||||
// a real started_at for this working session. Only meaningful under the Platform-multiplied
|
||||
// deployment (routed through the Gateway); 404s harmlessly in standalone/single-container dev
|
||||
// mode, where there's no Platform API to reach at all.
|
||||
useEffect(() => {
|
||||
if (!scenario?.id) return
|
||||
fetch(`api/scenarios/${scenario.id}/start`, { method: 'POST' }).catch(() => {})
|
||||
}, [scenario?.id])
|
||||
|
||||
const timeSpentRef = useRef(0)
|
||||
const lastScenarioIdRef = useRef(null)
|
||||
|
||||
@@ -341,7 +351,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === 'problem' ? '📄 Problem'
|
||||
: t === 'hints' ? `💡 Hints (${scenario.hints?.length || 0})`
|
||||
: t === 'hints' ? `💡 Hints (${scenario.hints?.length ?? scenario.hint_count ?? 0})`
|
||||
: '✓ Validate'}
|
||||
</button>
|
||||
))}
|
||||
@@ -448,6 +458,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
{/* HINTS TAB */}
|
||||
{tab === 'hints' && (
|
||||
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
|
||||
{/* Phase 3B: the Gateway strips `hints` from the payload under the Platform-
|
||||
multiplied deployment (see platform/gateway/src/server.js) — hint_count survives
|
||||
as the signal to switch to server-gated progressive reveal instead of the old
|
||||
fully-preloaded array. Standalone/dev mode still has the full array (no Gateway
|
||||
rewrite happens there) and keeps the original behavior untouched below. */}
|
||||
{scenario.hints === undefined && typeof scenario.hint_count === 'number' ? (
|
||||
<ProgressiveHints scenarioId={scenario.id} totalHints={scenario.hint_count} />
|
||||
) : (
|
||||
<>
|
||||
{scenario.hints?.length === 0 && (
|
||||
<div className={styles.noHints}>No hints available for this scenario.</div>
|
||||
)}
|
||||
@@ -483,6 +502,8 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const LABELS = {
|
||||
stage_completed: 'Stage completed', curriculum_completed: 'Curriculum completed',
|
||||
assessment_completed: 'Assessment completed', capstone_completed: 'Capstone completed',
|
||||
}
|
||||
const ICONS = {
|
||||
stage_completed: '✅', curriculum_completed: '🎓', assessment_completed: '📝', capstone_completed: '🏁',
|
||||
}
|
||||
|
||||
// Not a public certificate — a durable internal record. Phase 4/5 can render a certificate from
|
||||
// these later (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §16); nothing here is shareable yet.
|
||||
export default function AchievementsTab() {
|
||||
const [achievements, setAchievements] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.achievements().then(setAchievements).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className={styles.card}>
|
||||
<h2>Achievements & History</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{!achievements ? <p className={styles.muted}>Loading…</p> : achievements.length === 0 ? <p className={styles.muted}>Nothing yet — complete a stage to see it here.</p> : (
|
||||
achievements.map((a) => (
|
||||
<div key={a.achievement_id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', padding: '0.5rem 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>{ICONS[a.achievement_type] || '🏅'}</span>
|
||||
<div>
|
||||
<div>{LABELS[a.achievement_type] || a.achievement_type}{a.stage_id ? `: ${a.stage_id}` : ''}{a.metadata?.title ? `: ${a.metadata.title}` : ''}</div>
|
||||
<div className={styles.muted} style={{ fontSize: '0.78rem' }}>{new Date(a.achieved_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Available exams (assigned to the student's cohort), an active session if one is running (with
|
||||
// a live countdown), and completed history. Starting an exam draws its scenario set
|
||||
// server-side — the student works through it inside their existing Lab, the same one My
|
||||
// Learning launches (see docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §9 for why a separate Lab
|
||||
// per assessment isn't needed).
|
||||
export default function AssessmentsTab() {
|
||||
const [assessments, setAssessments] = useState(null)
|
||||
const [active, setActive] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api.assessments().then(setAssessments).catch((e) => setError(e.message))
|
||||
api.activeAssessment().then(setActive).catch(() => {})
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const handleStart = async (definitionId) => {
|
||||
setBusy(true); setError(null)
|
||||
try { await api.startAssessment(definitionId); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
{active?.active && (
|
||||
<section className={styles.card}>
|
||||
<h2>Active Assessment</h2>
|
||||
<p><strong>{active.title}</strong></p>
|
||||
<CountdownLine expiresAt={active.expires_at} />
|
||||
<table className={styles.dataTable} style={{ marginTop: '0.75rem' }}>
|
||||
<thead><tr><th>#</th><th>Scenario</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{active.scenarios.map((s) => (
|
||||
<tr key={s.scenario_id}>
|
||||
<td>{s.position + 1}</td><td>{s.scenario_id}</td>
|
||||
<td>
|
||||
{s.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
|
||||
: <span className={`${styles.pill} ${styles.pillMuted}`}>{s.status}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className={styles.hint} style={{ marginTop: '0.75rem' }}>Launch your Lab from My Learning and work through these scenarios there — this list stays in sync automatically.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Available Assessments</h2>
|
||||
{!assessments ? <p className={styles.muted}>Loading…</p> : assessments.length === 0 ? <p className={styles.muted}>None assigned to your cohort yet.</p> : (
|
||||
assessments.map((a) => {
|
||||
const completedSessions = a.sessions.filter((s) => s.status === 'completed')
|
||||
return (
|
||||
<div key={a.assessment_definition_id} style={{ padding: '0.75rem 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<p><strong>{a.title}</strong> <span className={styles.muted}>· {a.duration_minutes} min</span></p>
|
||||
{a.description && <p className={styles.muted} style={{ fontSize: '0.85rem' }}>{a.description}</p>}
|
||||
{completedSessions.length > 0 && (
|
||||
<p className={styles.hint}>Completed {completedSessions.length}× — best result: {completedSessions.some((s) => s.result?.passed) ? 'passed' : 'not yet passed'}</p>
|
||||
)}
|
||||
{!active?.active && (
|
||||
<button className={styles.secondaryBtn} disabled={busy} onClick={() => handleStart(a.assessment_definition_id)}>
|
||||
{busy ? 'Starting…' : 'Start'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>History</h2>
|
||||
{!assessments ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Assessment</th><th>Started</th><th>Status</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{assessments.flatMap((a) => a.sessions).filter((s) => s.status !== 'not_started').sort((a, b) => new Date(b.started_at) - new Date(a.started_at)).map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{assessments.find((a) => a.sessions.includes(s))?.title}</td>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
|
||||
<td><StatusPill status={s.status} /></td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPill({ status }) {
|
||||
const cls = status === 'completed' ? styles.pillOk : status === 'expired' || status === 'cancelled' ? styles.pillErr : styles.pillWarn
|
||||
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
function CountdownLine({ expiresAt }) {
|
||||
const [remaining, setRemaining] = useState(() => Math.max(0, new Date(expiresAt) - Date.now()))
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setRemaining(Math.max(0, new Date(expiresAt) - Date.now())), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [expiresAt])
|
||||
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
|
||||
return <p className={styles.statusLine}><span className={styles.spinner} /> Time remaining: {m}m {s}s</p>
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const STAGE_NAMES = {
|
||||
foundation: 'Foundation', workloads: 'Workloads', networking: 'Networking',
|
||||
configuration: 'Configuration', troubleshooting: 'Troubleshooting', 'real-ops': 'RootNode: Real Ops',
|
||||
}
|
||||
|
||||
export default function CapstoneTab() {
|
||||
const [capstone, setCapstone] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api.capstone().then(setCapstone).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const handleStart = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await api.startAssessment(capstone.assessment_definition_id); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!capstone) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
if (capstone.status === 'locked') {
|
||||
return (
|
||||
<section className={styles.card}>
|
||||
<h2>Capstone</h2>
|
||||
<div className={styles.lockedBox}>
|
||||
<p style={{ fontSize: '2rem' }}>🔒</p>
|
||||
<p>Complete the rest of the curriculum to unlock the RootNode Capstone.</p>
|
||||
</div>
|
||||
<h3 style={{ marginTop: '1rem', fontSize: '0.9rem' }}>Prerequisites</h3>
|
||||
{capstone.blocking_stage_ids.map((id) => (
|
||||
<div key={id} className={styles.checklistItem}><span>⬜</span> {STAGE_NAMES[id] || id}</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const activeSession = capstone.sessions.find((s) => s.status === 'in_progress');
|
||||
const completedSession = capstone.sessions.find((s) => s.status === 'completed' && s.result?.passed);
|
||||
|
||||
return (
|
||||
<section className={styles.card}>
|
||||
<h2>Capstone {capstone.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>completed</span> : <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>unlocked</span>}</h2>
|
||||
{capstone.definition && (
|
||||
<>
|
||||
<p><strong>{capstone.definition.title}</strong> <span className={styles.muted}>· {capstone.definition.duration_minutes} min</span></p>
|
||||
<p className={styles.muted} style={{ fontSize: '0.85rem' }}>{capstone.definition.description}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{completedSession ? (
|
||||
<p className={styles.hint} style={{ marginTop: '1rem' }}>
|
||||
Completed {new Date(completedSession.submitted_at).toLocaleString()} — {completedSession.result.scenarios_passed}/{completedSession.result.scenarios_total} scenarios passed.
|
||||
</p>
|
||||
) : activeSession ? (
|
||||
<p className={styles.hint} style={{ marginTop: '1rem' }}>Your Capstone is in progress — launch your Lab from My Learning to continue.</p>
|
||||
) : (
|
||||
<button className={styles.primaryBtn} style={{ marginTop: '1rem' }} disabled={busy} onClick={handleStart}>
|
||||
{busy ? 'Starting…' : 'Start Capstone'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{capstone.sessions.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>Attempt History</h3>
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Started</th><th>Status</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{capstone.sessions.map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
|
||||
<td>{s.status}</td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const POLL_MS = 2500
|
||||
|
||||
// The original Phase 2A/2B "Up Next" + Launch Lab / Reset flow, now extended with overall
|
||||
// percent-complete (Phase 3B's rule-driven curriculum state, GET /api/curriculum/state, instead
|
||||
// of the older /api/curriculum/path stats-only shape — same stage list, richer per-stage data).
|
||||
export default function MyLearningTab() {
|
||||
const [curriculum, setCurriculum] = useState(null)
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const loadCurriculum = useCallback(() => {
|
||||
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
const loadEnvStatus = useCallback(() => {
|
||||
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
|
||||
|
||||
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) window.location.href = result.launch_path
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
const handleReset = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { setEnvStatus(await api.resetEnvironment()) } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const currentStage = curriculum?.stages?.find((s) => s.id === curriculum.current_stage)
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Current Stage</h2>
|
||||
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
|
||||
<>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
|
||||
<span>Overall progress</span><span className={styles.muted}>{curriculum.percent_complete}%</span>
|
||||
</div>
|
||||
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${curriculum.percent_complete}%` }} /></div>
|
||||
</div>
|
||||
<div className={styles.stageRow}>
|
||||
{curriculum.stages.map((s) => (
|
||||
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.current_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
|
||||
<span>{s.icon}</span> {s.name}
|
||||
<span className={styles.stageCount}>{s.completed}/{s.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{currentStage ? (
|
||||
<p className={styles.nextUp}>
|
||||
Up next: <strong>{currentStage.name}</strong>
|
||||
{currentStage.next_scenario && <> — {currentStage.next_scenario}</>}
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.nextUp}>All curriculum stages complete — your Capstone is waiting.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Your Lab</h2>
|
||||
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -159,3 +159,25 @@
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Learning tabs (Phase 3B) ─────────────────────────────────────────── */
|
||||
|
||||
.tabNav { display: flex; gap: 1.25rem; padding: 0 2rem; border-bottom: 1px solid var(--border); }
|
||||
.tab { color: var(--text-2); cursor: pointer; font-size: 0.9rem; background: none; border: none; padding: 0.75rem 0; }
|
||||
.tabActive { color: var(--green); border-bottom: 2px solid var(--green); }
|
||||
|
||||
.progressBarTrack { background: var(--surface3); border-radius: 4px; height: 8px; overflow: hidden; }
|
||||
.progressBarFill { background: var(--green); height: 8px; }
|
||||
|
||||
table.dataTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.dataTable th { text-align: left; color: var(--text-2); font-weight: 600; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border2); font-size: 0.72rem; text-transform: uppercase; }
|
||||
.dataTable td { padding: 0.5rem 0.5rem; border-bottom: 1px solid var(--border); }
|
||||
|
||||
.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); }
|
||||
|
||||
.lockedBox { text-align: center; padding: 2rem 1rem; color: var(--text-2); }
|
||||
.checklistItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0; font-size: 0.88rem; }
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Available/completed scenarios (from curriculum state) + recent attempt history
|
||||
// (GET /api/attempts/recent) — purely informational, no controls here; the actual work happens
|
||||
// inside the Lab itself once launched from My Learning.
|
||||
export default function PracticeTab() {
|
||||
const [curriculum, setCurriculum] = useState(null)
|
||||
const [attempts, setAttempts] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
|
||||
api.recentAttempts().then(setAttempts).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
const allScenarios = curriculum?.stages.flatMap((s) => Array(s.total).fill(null).map((_, i) => i)) || []
|
||||
const completedCount = curriculum?.stages.reduce((n, s) => n + s.completed, 0) || 0
|
||||
const totalCount = allScenarios.length
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<section className={styles.card}>
|
||||
<h2>Scenarios</h2>
|
||||
{!curriculum ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Stage</th><th>Completed</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{curriculum.stages.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.icon} {s.name}</td>
|
||||
<td>{s.completed}/{s.total}</td>
|
||||
<td>
|
||||
{s.locked ? <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>
|
||||
: s.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>complete</span>
|
||||
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{curriculum && <p className={styles.hint} style={{ marginTop: '0.75rem' }}>{completedCount}/{totalCount} scenarios completed overall.</p>}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Recent Attempts</h2>
|
||||
{!attempts ? <p className={styles.muted}>Loading…</p> : attempts.length === 0 ? <p className={styles.muted}>No attempts yet — launch your Lab and pick a scenario.</p> : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{attempts.map((a, i) => (
|
||||
<tr key={i}>
|
||||
<td>{a.scenario_id}</td>
|
||||
<td>
|
||||
{a.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
|
||||
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
|
||||
</td>
|
||||
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
|
||||
<td>{a.hint_count || 0}</td>
|
||||
<td>{a.duration_seconds ? formatDuration(a.duration_seconds) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const m = Math.floor(seconds / 60), s = seconds % 60
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
@@ -1,78 +1,27 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
import MyLearningTab from './MyLearningTab'
|
||||
import PracticeTab from './PracticeTab'
|
||||
import AssessmentsTab from './AssessmentsTab'
|
||||
import CapstoneTab from './CapstoneTab'
|
||||
import AchievementsTab from './AchievementsTab'
|
||||
|
||||
const POLL_MS = 2500
|
||||
const TABS = [
|
||||
{ key: 'learning', label: 'My Learning' },
|
||||
{ key: 'practice', label: 'Practice' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'capstone', label: 'Capstone' },
|
||||
{ key: 'achievements', label: 'Achievements' },
|
||||
]
|
||||
|
||||
// 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.
|
||||
// Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My
|
||||
// Learning (the original Phase 2A/2B "Up Next" + Lab launch flow, unchanged in behavior, just
|
||||
// extracted into its own component), Practice, Assessments, Capstone, Achievements/History.
|
||||
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) {
|
||||
// No ?sid= needed anymore (Phase 2B) — the Gateway verifies ownership from the
|
||||
// rn_session cookie itself, which travels with this navigation automatically. See
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
|
||||
window.location.href = result.launch_path
|
||||
}
|
||||
} 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 [tab, setTab] = useState('learning')
|
||||
const handleLogout = () => { api.logout().then(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}>
|
||||
@@ -83,78 +32,19 @@ export default function StudentDashboard({ student, onLogout }) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<nav className={styles.tabNav}>
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} className={`${styles.tab} ${tab === t.key ? styles.tabActive : ''}`} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
{tab === 'learning' && <MyLearningTab />}
|
||||
{tab === 'practice' && <PracticeTab />}
|
||||
{tab === 'assessments' && <AssessmentsTab />}
|
||||
{tab === 'capstone' && <CapstoneTab />}
|
||||
{tab === 'achievements' && <AchievementsTab />}
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -66,8 +66,21 @@ export const api = {
|
||||
whoami: () => fetch('/api/session/whoami').then(json),
|
||||
|
||||
curriculumPath: () => fetch('/api/curriculum/path').then(json),
|
||||
curriculumState: () => fetch('/api/curriculum/state').then(json),
|
||||
|
||||
environmentStatus: () => fetch('/api/environment/status').then(json),
|
||||
launchEnvironment: () => mutate('/api/environment/launch'),
|
||||
resetEnvironment: () => mutate('/api/environment/reset'),
|
||||
|
||||
recentAttempts: () => fetch('/api/attempts/recent').then(json),
|
||||
|
||||
assessments: () => fetch('/api/assessments').then(json),
|
||||
activeAssessment: () => fetch('/api/assessments/active').then(json),
|
||||
assessmentDetail: (sessionId) => fetch(`/api/assessments/${sessionId}`).then(json),
|
||||
startAssessment: (definitionId) => mutate(`/api/assessments/${definitionId}/start`),
|
||||
submitAssessment: (sessionId) => mutate(`/api/assessments/${sessionId}/submit`),
|
||||
|
||||
capstone: () => fetch('/api/capstone').then(json),
|
||||
|
||||
achievements: () => fetch('/api/achievements').then(json),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user