347 lines
13 KiB
React
347 lines
13 KiB
React
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import Sidebar from './components/Sidebar'
|
|
import ScenarioPanel from './components/ScenarioPanel'
|
|
import Terminal from './components/Terminal'
|
|
import Header from './components/Header'
|
|
import BundleNav from './components/BundleNav'
|
|
import ExamTimer from './components/ExamTimer'
|
|
import ExamReport from './components/ExamReport'
|
|
import ExamStartModal from './components/ExamStartModal'
|
|
import styles from './App.module.css'
|
|
|
|
const MIN_SIDEBAR_W = 180
|
|
const MAX_SIDEBAR_W = 560
|
|
const DEFAULT_SIDEBAR_W = 280
|
|
const SIDEBAR_COLLAPSE_PX = 100
|
|
const SIDEBAR_COLLAPSED_W = 40
|
|
|
|
const MIN_TERM_H = 36
|
|
const MAX_TERM_H = 600
|
|
const DEFAULT_TERM_H = 280
|
|
const TERM_COLLAPSE_PX = 60
|
|
|
|
export default function App() {
|
|
const [bundles, setBundles] = useState([])
|
|
const [activeBundleId, setActiveBundleId] = useState(null)
|
|
|
|
const [scenarios, setScenarios] = useState([])
|
|
const [activeId, setActiveId] = useState(null)
|
|
const [scenario, setScenario] = useState(null)
|
|
const [progress, setProgress] = useState({})
|
|
const [clusterReady, setClusterReady] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
// ── Exam mode ─────────────────────────────────────────────────────────────
|
|
const [examSession, setExamSession] = useState(null) // active session object
|
|
const [examReport, setExamReport] = useState(null) // submitted report
|
|
const [examModalBundle, setExamModalBundle] = useState(null) // bundle for start/retry modal
|
|
|
|
// Sidebar resize / collapse
|
|
const [sidebarW, setSidebarW] = useState(DEFAULT_SIDEBAR_W)
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
|
const sbDragging = useRef(false)
|
|
const sbDragX0 = useRef(0)
|
|
const sbDragW0 = useRef(0)
|
|
|
|
// Terminal resize / collapse
|
|
const [termH, setTermH] = useState(300)
|
|
const [termCollapsed, setTermCollapsed] = useState(false)
|
|
const [bundlesCollapsed, setBundlesCollapsed] = useState(false)
|
|
const tmDragging = useRef(false)
|
|
const tmDragY0 = useRef(0)
|
|
const tmDragH0 = useRef(0)
|
|
|
|
// Track previous scenario id to teardown on switch
|
|
const prevActiveIdRef = useRef(null)
|
|
|
|
// ── Cluster health ────────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
async function check() {
|
|
try {
|
|
const d = await fetch('/api/health').then(r => r.json())
|
|
setClusterReady(d.cluster === 'ready')
|
|
} catch { setClusterReady(false) }
|
|
}
|
|
check()
|
|
const t = setInterval(check, 8000)
|
|
return () => clearInterval(t)
|
|
}, [])
|
|
|
|
// ── Load bundles (once) ───────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
fetch('/api/bundles')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
setBundles(data)
|
|
if (data.length > 0) setActiveBundleId(data[0].id)
|
|
})
|
|
.catch(console.error)
|
|
}, [])
|
|
|
|
// ── Restore active exam session on load ───────────────────────────────────
|
|
useEffect(() => {
|
|
fetch('/api/sessions/active')
|
|
.then(r => r.json())
|
|
.then(s => { if (s) setExamSession(s) })
|
|
.catch(() => { })
|
|
}, [])
|
|
|
|
// ── Load scenarios for active bundle ─────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!activeBundleId) return
|
|
setLoading(true)
|
|
setActiveId(null)
|
|
setScenario(null)
|
|
const url = `/api/scenarios?bundle=${activeBundleId}`
|
|
fetch(url)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
setScenarios(data)
|
|
setProgress(Object.fromEntries(data.map(s => [s.id, s.progress])))
|
|
})
|
|
.catch(console.error)
|
|
.finally(() => setLoading(false))
|
|
}, [activeBundleId])
|
|
|
|
// ── Load full scenario when selected — teardown previous, load new ─────────
|
|
useEffect(() => {
|
|
if (!activeId) return
|
|
|
|
// Teardown the previously active scenario's cluster state on switch
|
|
const prevId = prevActiveIdRef.current
|
|
if (prevId && prevId !== activeId) {
|
|
fetch(`/api/scenarios/${prevId}/teardown`, { method: 'POST' }).catch(() => { })
|
|
}
|
|
prevActiveIdRef.current = activeId
|
|
|
|
setScenario(null)
|
|
fetch(`/api/scenarios/${activeId}`)
|
|
.then(r => r.json())
|
|
.then(s => {
|
|
setScenario(s)
|
|
// Feature 3: sync terminal context when scenario selected
|
|
fetch(`/api/scenarios/${activeId}/context`, { method: 'POST' }).catch(() => { })
|
|
})
|
|
.catch(console.error)
|
|
}, [activeId])
|
|
|
|
const refreshProgress = useCallback(async () => {
|
|
const [bundleData, scenarioData] = await Promise.all([
|
|
fetch('/api/bundles').then(r => r.json()),
|
|
fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()),
|
|
])
|
|
setBundles(bundleData)
|
|
setScenarios(scenarioData)
|
|
setProgress(Object.fromEntries(scenarioData.map(s => [s.id, s.progress])))
|
|
if (activeId) {
|
|
const d2 = await fetch(`/api/scenarios/${activeId}`).then(r => r.json())
|
|
setScenario(d2)
|
|
}
|
|
// Refresh exam session completion count
|
|
if (examSession) {
|
|
const updated = await fetch('/api/sessions/active').then(r => r.json()).catch(() => null)
|
|
if (updated) setExamSession(updated)
|
|
}
|
|
}, [activeBundleId, activeId, examSession])
|
|
|
|
// ── Exam actions ──────────────────────────────────────────────────────────
|
|
const startExam = useCallback(async (bundleId, customMinutes) => {
|
|
const res = await fetch('/api/sessions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ bundleId, examMinutes: customMinutes }),
|
|
}).then(r => r.json())
|
|
// refresh to get scenarioCount
|
|
const active = await fetch('/api/sessions/active').then(r => r.json())
|
|
setExamSession(active)
|
|
setActiveBundleId(bundleId)
|
|
setActiveId(null)
|
|
setScenario(null)
|
|
}, [])
|
|
|
|
const submitExam = useCallback(async () => {
|
|
if (!examSession) return
|
|
const result = await fetch(`/api/sessions/${examSession.id}/submit`, { method: 'POST' })
|
|
.then(r => r.json())
|
|
const bundle = bundles.find(b => b.id === examSession.bundle_id)
|
|
setExamReport({ ...result, bundle })
|
|
setExamSession(null)
|
|
}, [examSession, bundles])
|
|
|
|
const abandonExam = useCallback(async () => {
|
|
if (!examSession) return
|
|
await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { })
|
|
setExamSession(null)
|
|
}, [examSession])
|
|
|
|
// ── Teardown when restarting a scenario (Feature 2) ───────────────────────
|
|
const handleScenarioStart = useCallback(async (scenarioId) => {
|
|
// Run teardown first to clean cluster state
|
|
await fetch(`/api/scenarios/${scenarioId}/teardown`, { method: 'POST' }).catch(() => { })
|
|
// Then setup will be called by ScenarioPanel as before
|
|
}, [])
|
|
|
|
// ── Sidebar drag ─────────────────────────────────────────────────────────
|
|
const onSidebarDragDown = useCallback((e) => {
|
|
e.preventDefault()
|
|
sbDragging.current = true
|
|
sbDragX0.current = e.clientX
|
|
sbDragW0.current = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW
|
|
|
|
function onMove(ev) {
|
|
if (!sbDragging.current) return
|
|
const newW = sbDragW0.current + (ev.clientX - sbDragX0.current)
|
|
if (newW < SIDEBAR_COLLAPSE_PX) {
|
|
setSidebarCollapsed(true)
|
|
} else {
|
|
setSidebarCollapsed(false)
|
|
setSidebarW(Math.min(MAX_SIDEBAR_W, Math.max(MIN_SIDEBAR_W, newW)))
|
|
}
|
|
}
|
|
function onUp() {
|
|
sbDragging.current = false
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', onUp)
|
|
}
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', onUp)
|
|
}, [sidebarCollapsed, sidebarW])
|
|
|
|
// ── Terminal drag ────────────────────────────────────────────────────────
|
|
const onTermDragDown = useCallback((e) => {
|
|
e.preventDefault()
|
|
tmDragging.current = true
|
|
tmDragY0.current = e.clientY
|
|
tmDragH0.current = termCollapsed ? MIN_TERM_H : termH
|
|
|
|
function onMove(ev) {
|
|
if (!tmDragging.current) return
|
|
const newH = tmDragH0.current + (tmDragY0.current - ev.clientY)
|
|
if (newH < TERM_COLLAPSE_PX) {
|
|
setTermCollapsed(true)
|
|
} else {
|
|
setTermCollapsed(false)
|
|
setTermH(Math.min(MAX_TERM_H, Math.max(MIN_TERM_H + 40, newH)))
|
|
}
|
|
}
|
|
function onUp() {
|
|
tmDragging.current = false
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', onUp)
|
|
}
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', onUp)
|
|
}, [termCollapsed, termH])
|
|
|
|
const currentSidebarW = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW
|
|
const currentTermH = termCollapsed ? MIN_TERM_H : termH
|
|
const activeBundle = bundles.find(b => b.id === activeBundleId) || null
|
|
const isMcq = scenario?.type === 'mcq'
|
|
|
|
return (
|
|
<div className={styles.app}>
|
|
<Header clusterReady={clusterReady} />
|
|
|
|
{/* Bundle navigation bar */}
|
|
<BundleNav
|
|
bundles={bundles}
|
|
activeBundleId={activeBundleId}
|
|
examSession={examSession}
|
|
onSelect={id => {
|
|
// In exam mode, only allow switching within the exam bundle
|
|
if (examSession && id !== examSession.bundle_id) return
|
|
setActiveBundleId(id); setActiveId(null); setScenario(null)
|
|
}}
|
|
onProgressUpdate={refreshProgress}
|
|
onStartExam={setExamModalBundle}
|
|
collapsed={bundlesCollapsed}
|
|
onToggleCollapse={() => setBundlesCollapsed(c => !c)}
|
|
/>
|
|
|
|
{/* Exam timer bar */}
|
|
{examSession && (
|
|
<ExamTimer
|
|
session={examSession}
|
|
bundle={activeBundle}
|
|
onSubmit={submitExam}
|
|
onAbandon={abandonExam}
|
|
/>
|
|
)}
|
|
|
|
<div className={styles.body}>
|
|
{/* Sidebar */}
|
|
<Sidebar
|
|
scenarios={scenarios}
|
|
activeId={activeId}
|
|
onSelect={setActiveId}
|
|
loading={loading}
|
|
collapsed={sidebarCollapsed}
|
|
onToggleCollapse={() => setSidebarCollapsed(c => !c)}
|
|
width={currentSidebarW}
|
|
activeBundleId={activeBundleId}
|
|
onProgressUpdate={refreshProgress}
|
|
/>
|
|
|
|
{/* Sidebar resize handle */}
|
|
<div className={styles.sidebarHandle} onMouseDown={onSidebarDragDown} />
|
|
|
|
{/* Main area */}
|
|
<div className={styles.main}>
|
|
<div className={styles.scenarioWrap}>
|
|
<ScenarioPanel
|
|
scenario={scenario}
|
|
onProgressUpdate={refreshProgress}
|
|
onScenarioStart={handleScenarioStart}
|
|
isExamMode={!!examSession}
|
|
/>
|
|
</div>
|
|
|
|
{!isMcq && (
|
|
<div className={styles.termHandle} onMouseDown={onTermDragDown} />
|
|
)}
|
|
|
|
{!isMcq && (
|
|
<div className={styles.terminalWrap} style={{ height: currentTermH }}>
|
|
<Terminal
|
|
collapsed={termCollapsed}
|
|
onToggleCollapse={() => setTermCollapsed(c => !c)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Exam report modal */}
|
|
{examReport && (
|
|
<ExamReport
|
|
report={examReport}
|
|
bundle={examReport.bundle}
|
|
onClose={() => setExamReport(null)}
|
|
onRetry={() => {
|
|
setExamReport(null)
|
|
setExamModalBundle(examReport.bundle)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Exam start modal */}
|
|
{examModalBundle && (
|
|
<ExamStartModal
|
|
bundle={examModalBundle}
|
|
onStart={mins => {
|
|
setExamModalBundle(null)
|
|
startExam(examModalBundle.id, mins)
|
|
}}
|
|
onCancel={() => setExamModalBundle(null)}
|
|
/>
|
|
)}
|
|
<footer className={styles.footer}>
|
|
<div>© {new Date().getFullYear()} The KubeKosh Project • All rights reserved</div>
|
|
<div>
|
|
Made with <span className={styles.heart}>❤️</span> by <a href="https://github.com/zeborg" target="_blank" rel="noopener noreferrer" className={styles.footerLink}>zeborg</a>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
)
|
|
}
|