Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d42aa779b1 | ||
|
|
6c3843cb90 | ||
|
|
ff1c61812d | ||
|
|
f2d535837c | ||
|
|
ec27b01531 | ||
|
|
35dfeb7208 | ||
|
|
09bd1b20da | ||
|
|
7e75c8ae02 | ||
|
|
935ab38f28 | ||
|
|
170b95aa35 |
@@ -18,12 +18,11 @@ KubeKosh runs a real [K3s](https://k3s.io/) Kubernetes cluster inside a single D
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
| | | |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -64,10 +64,21 @@ function getDb() {
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
exam_minutes INTEGER NOT NULL DEFAULT 120,
|
||||
duration_secs INTEGER,
|
||||
snapshot TEXT
|
||||
snapshot TEXT,
|
||||
scenario_ids TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrate: add scenario_ids column if missing
|
||||
try {
|
||||
const cols = _db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name);
|
||||
if (!cols.includes('scenario_ids')) {
|
||||
_db.exec(`ALTER TABLE sessions ADD COLUMN scenario_ids TEXT`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate sessions table:', e.message);
|
||||
}
|
||||
|
||||
// Separate exam-session progress table — tracks completions per session
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS exam_progress (
|
||||
@@ -249,20 +260,35 @@ app.post('/api/progress/reset', (req, res) => {
|
||||
|
||||
// ── Exam sessions ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Fisher-Yates shuffle (returns a new array)
|
||||
function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// POST /api/sessions — start a new exam session
|
||||
app.post('/api/sessions', (req, res) => {
|
||||
const { bundleId, examMinutes } = req.body;
|
||||
const { bundleId, examMinutes, scenarioCount } = req.body;
|
||||
const bundle = loadBundles().find(b => b.id === bundleId);
|
||||
if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
|
||||
const db = getDb();
|
||||
const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120));
|
||||
// Shuffle and optionally slice scenario IDs
|
||||
const allIds = bundle.scenario_ids || [];
|
||||
const count = Math.max(1, Math.min(allIds.length, Number(scenarioCount) || allIds.length));
|
||||
const sessionScenarioIds = shuffle(allIds).slice(0, count);
|
||||
// Abandon any existing active session
|
||||
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
|
||||
WHERE status='active'`).run();
|
||||
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes)
|
||||
VALUES (?, ?, datetime('now'), 'active', ?)`).run(id, bundleId, mins);
|
||||
res.json({ id, bundleId, status: 'active', exam_minutes: mins });
|
||||
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids)
|
||||
VALUES (?, ?, datetime('now'), 'active', ?, ?)`)
|
||||
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds));
|
||||
res.json({ id, bundleId, status: 'active', exam_minutes: mins, scenario_ids: sessionScenarioIds });
|
||||
});
|
||||
|
||||
// GET /api/sessions/active — get the current active session
|
||||
@@ -270,13 +296,18 @@ app.get('/api/sessions/active', (req, res) => {
|
||||
const db = getDb();
|
||||
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
|
||||
if (!session) return res.json(null);
|
||||
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
||||
const scenarioIds = bundle?.scenario_ids || [];
|
||||
// Count completions from exam_progress (exam-specific), not global progress
|
||||
// Use session-specific scenario_ids (shuffled/sliced at start time)
|
||||
let scenarioIds = [];
|
||||
try { scenarioIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {}
|
||||
if (!scenarioIds.length) {
|
||||
// Fallback for sessions created before this feature
|
||||
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
||||
scenarioIds = bundle?.scenario_ids || [];
|
||||
}
|
||||
const completed = db.prepare(
|
||||
`SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'`
|
||||
).get(session.id)?.cnt || 0;
|
||||
res.json({ ...session, scenarioCount: scenarioIds.length, completedCount: completed });
|
||||
res.json({ ...session, scenario_ids: scenarioIds, scenarioCount: scenarioIds.length, completedCount: completed });
|
||||
});
|
||||
|
||||
// GET /api/sessions/:id/exam-progress — return per-scenario progress for an exam session
|
||||
@@ -337,7 +368,11 @@ app.post('/api/sessions/:id/submit', (req, res) => {
|
||||
if (!session) return res.status(404).json({ error: 'Session not found' });
|
||||
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
||||
const scenarios = loadScenarios();
|
||||
const bundleScenarios = scenarios.filter(s => bundle?.scenario_ids?.includes(s.id));
|
||||
// Use session-specific scenario IDs (preserves shuffle order)
|
||||
let sessionIds = [];
|
||||
try { sessionIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {}
|
||||
if (!sessionIds.length) sessionIds = bundle?.scenario_ids || [];
|
||||
const bundleScenarios = sessionIds.map(id => scenarios.find(s => s.id === id)).filter(Boolean);
|
||||
|
||||
// Build snapshot from exam_progress (exam-specific), falling back to 'not_started'
|
||||
const examProgressRows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
|
||||
@@ -449,17 +484,34 @@ app.get('/api/bundles', (req, res) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// GET /api/scenarios — list scenarios; optional ?bundle=<id> filter
|
||||
// GET /api/scenarios — list scenarios; optional ?bundle=<id> and ?session=<id> filter
|
||||
app.get('/api/scenarios', (req, res) => {
|
||||
const scenarios = loadScenarios();
|
||||
const progress = loadProgress();
|
||||
const { bundle } = req.query;
|
||||
const scenarios = loadScenarios()
|
||||
const progress = loadProgress()
|
||||
const { bundle, session: sessionId } = req.query
|
||||
|
||||
let filtered = scenarios;
|
||||
// Session-scoped: return only session scenario_ids in their shuffled order
|
||||
if (sessionId) {
|
||||
const db = getDb()
|
||||
const session = db.prepare(`SELECT scenario_ids FROM sessions WHERE id=?`).get(sessionId)
|
||||
let sessionIds = []
|
||||
try { sessionIds = session?.scenario_ids ? JSON.parse(session.scenario_ids) : [] } catch (_) {}
|
||||
const list = sessionIds
|
||||
.map(id => scenarios.find(s => s.id === id))
|
||||
.filter(Boolean)
|
||||
.map(s => ({
|
||||
id: s.id, title: s.title, category: s.category,
|
||||
difficulty: s.difficulty, type: s.type, weight: s.weight,
|
||||
progress: progress[s.id] || { status: 'not_started', attempts: 0 }
|
||||
}))
|
||||
return res.json(list)
|
||||
}
|
||||
|
||||
let filtered = scenarios
|
||||
if (bundle) {
|
||||
const bundles = loadBundles();
|
||||
const b = bundles.find(x => x.id === bundle);
|
||||
if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id));
|
||||
const bundles = loadBundles()
|
||||
const b = bundles.find(x => x.id === bundle)
|
||||
if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id))
|
||||
}
|
||||
|
||||
const list = filtered.map(s => ({
|
||||
@@ -470,9 +522,9 @@ app.get('/api/scenarios', (req, res) => {
|
||||
type: s.type,
|
||||
weight: s.weight,
|
||||
progress: progress[s.id] || { status: 'not_started', attempts: 0 }
|
||||
}));
|
||||
res.json(list);
|
||||
});
|
||||
}))
|
||||
res.json(list)
|
||||
})
|
||||
|
||||
// GET /api/scenarios/:id — full scenario detail
|
||||
app.get('/api/scenarios/:id', (req, res) => {
|
||||
|
||||
@@ -54,6 +54,36 @@ export default function App() {
|
||||
const tmDragY0 = useRef(0)
|
||||
const tmDragH0 = useRef(0)
|
||||
|
||||
// Focus mode — collapses sidebar, bundles nav, terminal for distraction-free reading
|
||||
const [focusMode, setFocusMode] = useState(false)
|
||||
const focusSavedState = useRef(null)
|
||||
|
||||
const toggleFocusMode = useCallback(() => {
|
||||
if (!focusMode) {
|
||||
// Save current states then collapse everything
|
||||
focusSavedState.current = { sidebarCollapsed, bundlesCollapsed, termCollapsed }
|
||||
setSidebarCollapsed(true)
|
||||
setBundlesCollapsed(true)
|
||||
setTermCollapsed(true)
|
||||
setFocusMode(true)
|
||||
} else {
|
||||
// Restore previous states
|
||||
const saved = focusSavedState.current || {}
|
||||
setSidebarCollapsed(saved.sidebarCollapsed ?? false)
|
||||
setBundlesCollapsed(saved.bundlesCollapsed ?? false)
|
||||
setTermCollapsed(saved.termCollapsed ?? false)
|
||||
setFocusMode(false)
|
||||
}
|
||||
}, [focusMode, sidebarCollapsed, bundlesCollapsed, termCollapsed])
|
||||
|
||||
// If the user manually opens any panel while in focus mode, exit focus mode
|
||||
// so the button icon reverts to "expand" (next click re-collapses everything)
|
||||
useEffect(() => {
|
||||
if (focusMode && (!sidebarCollapsed || !bundlesCollapsed || !termCollapsed)) {
|
||||
setFocusMode(false)
|
||||
}
|
||||
}, [sidebarCollapsed, bundlesCollapsed, termCollapsed, focusMode])
|
||||
|
||||
// Track previous scenario id to teardown on switch
|
||||
const prevActiveIdRef = useRef(null)
|
||||
|
||||
@@ -103,7 +133,10 @@ export default function App() {
|
||||
setLoading(true)
|
||||
setActiveId(null)
|
||||
setScenario(null)
|
||||
const url = `/api/scenarios?bundle=${activeBundleId}`
|
||||
// If an exam session is active, fetch only that session's scenarios (pre-shuffled)
|
||||
const url = examSession?.id
|
||||
? `/api/scenarios?session=${examSession.id}`
|
||||
: `/api/scenarios?bundle=${activeBundleId}`
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -112,7 +145,7 @@ export default function App() {
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [activeBundleId])
|
||||
}, [activeBundleId, examSession?.id])
|
||||
|
||||
// ── Load full scenario when selected — teardown previous, load new ─────────
|
||||
useEffect(() => {
|
||||
@@ -137,9 +170,12 @@ export default function App() {
|
||||
}, [activeId])
|
||||
|
||||
const refreshProgress = useCallback(async () => {
|
||||
const scenarioUrl = examSession?.id
|
||||
? `/api/scenarios?session=${examSession.id}`
|
||||
: `/api/scenarios?bundle=${activeBundleId}`
|
||||
const [bundleData, scenarioData] = await Promise.all([
|
||||
fetch('/api/bundles').then(r => r.json()),
|
||||
fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()),
|
||||
fetch(scenarioUrl).then(r => r.json()),
|
||||
])
|
||||
setBundles(bundleData)
|
||||
setScenarios(scenarioData)
|
||||
@@ -160,13 +196,13 @@ export default function App() {
|
||||
}, [activeBundleId, activeId, examSession])
|
||||
|
||||
// ── Exam actions ──────────────────────────────────────────────────────────
|
||||
const startExam = useCallback(async (bundleId, customMinutes) => {
|
||||
const startExam = useCallback(async (bundleId, customMinutes, customScenarioCount) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bundleId, examMinutes: customMinutes }),
|
||||
body: JSON.stringify({ bundleId, examMinutes: customMinutes, scenarioCount: customScenarioCount }),
|
||||
}).then(r => r.json())
|
||||
// refresh to get scenarioCount
|
||||
// refresh to get scenario_ids, scenarioCount
|
||||
const active = await fetch('/api/sessions/active').then(r => r.json())
|
||||
setExamSession(active)
|
||||
setActiveBundleId(bundleId)
|
||||
@@ -257,6 +293,13 @@ export default function App() {
|
||||
const activeBundle = bundles.find(b => b.id === activeBundleId) || null
|
||||
const isMcq = scenario?.type === 'mcq'
|
||||
|
||||
// In exam mode: scenarios are already pre-filtered and ordered by the session API
|
||||
const examScenarios = scenarios
|
||||
|
||||
// Total weight of the exam's selected scenarios (for % display in ScenarioPanel)
|
||||
const totalExamWeight = (!!examSession ? examScenarios : scenarios)
|
||||
.reduce((sum, s) => sum + (s.weight || 0), 0)
|
||||
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
|
||||
@@ -290,7 +333,7 @@ export default function App() {
|
||||
<div className={styles.body}>
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
scenarios={scenarios}
|
||||
scenarios={!!examSession ? examScenarios : scenarios}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
loading={loading}
|
||||
@@ -301,6 +344,7 @@ export default function App() {
|
||||
onProgressUpdate={refreshProgress}
|
||||
isExamMode={!!examSession}
|
||||
examProgress={examProgress}
|
||||
totalExamWeight={totalExamWeight}
|
||||
/>
|
||||
|
||||
{/* Sidebar resize handle */}
|
||||
@@ -315,6 +359,9 @@ export default function App() {
|
||||
onScenarioStart={handleScenarioStart}
|
||||
isExamMode={!!examSession}
|
||||
examProgress={examProgress}
|
||||
totalExamWeight={totalExamWeight}
|
||||
focusMode={focusMode}
|
||||
onToggleFocus={toggleFocusMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -350,9 +397,9 @@ export default function App() {
|
||||
{examModalBundle && (
|
||||
<ExamStartModal
|
||||
bundle={examModalBundle}
|
||||
onStart={mins => {
|
||||
onStart={(mins, count) => {
|
||||
setExamModalBundle(null)
|
||||
startExam(examModalBundle.id, mins)
|
||||
startExam(examModalBundle.id, mins, count)
|
||||
}}
|
||||
onCancel={() => setExamModalBundle(null)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import styles from './BundleNav.module.css'
|
||||
import { useConfirm } from './useConfirm'
|
||||
|
||||
async function resetProgress(scope, opts) {
|
||||
await fetch('/api/progress/reset', {
|
||||
@@ -17,6 +18,7 @@ export default function BundleNav({
|
||||
const [startX, setStartX] = useState(0)
|
||||
const [scrollLeft, setScrollLeft] = useState(0)
|
||||
const [dragDist, setDragDist] = useState(0)
|
||||
const { confirm, ConfirmUI } = useConfirm()
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (!trackRef.current) return
|
||||
@@ -41,6 +43,7 @@ export default function BundleNav({
|
||||
|
||||
return (
|
||||
<>
|
||||
{ConfirmUI}
|
||||
<nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles">
|
||||
{!collapsed && (
|
||||
<div
|
||||
@@ -117,7 +120,13 @@ export default function BundleNav({
|
||||
title={`Reset all progress in "${b.name}"`}
|
||||
onClick={async e => {
|
||||
e.stopPropagation()
|
||||
if (!window.confirm(`Reset all progress in "${b.name}"?`)) return
|
||||
const ok = await confirm({
|
||||
title: 'Reset Bundle Progress',
|
||||
message: `Reset all progress in "${b.name}"?`,
|
||||
confirmLabel: 'Reset',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
await resetProgress('bundle', { bundleId: b.id })
|
||||
onProgressUpdate?.()
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import styles from './ConfirmModal.module.css'
|
||||
|
||||
/**
|
||||
* ConfirmModal — drop-in replacement for window.confirm.
|
||||
*
|
||||
* Usage via the useConfirm hook (see useConfirm.js).
|
||||
* Props:
|
||||
* open boolean
|
||||
* title string
|
||||
* message string
|
||||
* confirmLabel string (default "Confirm")
|
||||
* cancelLabel string (default "Cancel")
|
||||
* danger boolean (red confirm button)
|
||||
* onConfirm () => void
|
||||
* onCancel () => void
|
||||
*/
|
||||
export default function ConfirmModal({
|
||||
open, title, message,
|
||||
confirmLabel = 'Confirm', cancelLabel = 'Cancel',
|
||||
danger = false,
|
||||
onConfirm, onCancel,
|
||||
}) {
|
||||
const confirmRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) confirmRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = e => {
|
||||
if (e.key === 'Escape') onCancel()
|
||||
if (e.key === 'Enter') onConfirm()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onConfirm, onCancel])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onMouseDown={e => e.target === e.currentTarget && onCancel()}>
|
||||
<div className={styles.modal} role="dialog" aria-modal="true">
|
||||
{title && <div className={styles.header}>{title}</div>}
|
||||
{message && <div className={styles.body}>{message}</div>}
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.cancelBtn} onClick={onCancel}>{cancelLabel}</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`${styles.confirmBtn} ${danger ? styles.danger : ''}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(3px);
|
||||
animation: fadeIn 0.12s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 18px 20px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 20px 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.65;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
background: none;
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 6px;
|
||||
color: var(--text-2);
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.cancelBtn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-3);
|
||||
background: var(--surface3);
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
background: var(--green);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 7px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: var(--sans);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.confirmBtn:hover { opacity: 0.85; }
|
||||
|
||||
.confirmBtn.danger {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(12px) } to { opacity: 1; transform: none } }
|
||||
@@ -2,11 +2,18 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import styles from './ExamStartModal.module.css'
|
||||
|
||||
export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
||||
const totalScenarios = bundle?.scenario_ids?.length || 0
|
||||
const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120)
|
||||
const [scenarioCount, setScenarioCount] = useState(totalScenarios)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// Keep scenarioCount in sync if bundle changes
|
||||
useEffect(() => {
|
||||
// Focus input on open
|
||||
setScenarioCount(bundle?.scenario_ids?.length || 0)
|
||||
}, [bundle])
|
||||
|
||||
useEffect(() => {
|
||||
// Focus duration input on open
|
||||
const t = setTimeout(() => inputRef.current?.select(), 60)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
@@ -14,11 +21,14 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
||||
if (!bundle) return null
|
||||
|
||||
const numMinutes = Number(minutes)
|
||||
const isValid = numMinutes >= 5 && numMinutes <= 300
|
||||
const numScenarios = Number(scenarioCount)
|
||||
const isMinutesValid = numMinutes >= 5 && numMinutes <= 300
|
||||
const isScenariosValid = numScenarios >= 1 && numScenarios <= totalScenarios
|
||||
const isValid = isMinutesValid && isScenariosValid
|
||||
|
||||
const handleStart = () => {
|
||||
if (!isValid) return
|
||||
onStart(numMinutes)
|
||||
onStart(numMinutes, numScenarios)
|
||||
}
|
||||
|
||||
const presets = [
|
||||
@@ -42,9 +52,10 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
||||
<div className={styles.body}>
|
||||
<div className={styles.info}>
|
||||
<span>📋</span>
|
||||
<span>{bundle.scenario_ids?.length || '?'} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span>
|
||||
<span>{totalScenarios} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span>
|
||||
</div>
|
||||
|
||||
{/* Duration field */}
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Exam Duration</label>
|
||||
<div className={styles.presets}>
|
||||
@@ -73,13 +84,46 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
|
||||
<span className={styles.unit}>minutes</span>
|
||||
</div>
|
||||
<div className={styles.hint}>
|
||||
{!isValid ? (
|
||||
{!isMinutesValid ? (
|
||||
<span style={{ color: 'var(--red)' }}>⚠ Duration must be between 5 and 300 minutes</span>
|
||||
) : numMinutes < 60 ? '⚡ Speed run mode' :
|
||||
numMinutes <= 120 ? '🎯 Realistic exam timing' :
|
||||
'🧘 Relaxed practice pace'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenario count field */}
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Number of Scenarios</label>
|
||||
<div className={styles.scenarioRow}>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.slider}
|
||||
value={numScenarios}
|
||||
min={1}
|
||||
max={totalScenarios}
|
||||
step={1}
|
||||
onChange={e => setScenarioCount(Number(e.target.value))}
|
||||
style={{ '--bcolor': bundle.color }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
value={scenarioCount}
|
||||
min={1}
|
||||
max={totalScenarios}
|
||||
onChange={e => setScenarioCount(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.hint}>
|
||||
{!isScenariosValid ? (
|
||||
<span style={{ color: 'var(--red)' }}>⚠ Must be between 1 and {totalScenarios}</span>
|
||||
) : numScenarios === totalScenarios
|
||||
? `📚 Full exam — all ${totalScenarios} scenarios`
|
||||
: `🎯 ${numScenarios} randomly selected scenario${numScenarios > 1 ? 's' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
|
||||
@@ -116,6 +116,24 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scenarioRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border2);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
accent-color: var(--bcolor, var(--blue));
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 90px;
|
||||
padding: 8px 12px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import styles from './ExamTimer.module.css'
|
||||
import { useConfirm } from './useConfirm'
|
||||
|
||||
function formatTime(secs) {
|
||||
if (secs < 0) secs = 0
|
||||
@@ -41,19 +42,33 @@ export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
|
||||
const pct = Math.min(100, (elapsed / durationSecs) * 100)
|
||||
const urgent = remaining < 600 // < 10 min
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!window.confirm(`Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`)) return
|
||||
onSubmit()
|
||||
}, [session, onSubmit])
|
||||
const { confirm, ConfirmUI } = useConfirm()
|
||||
|
||||
const handleAbandon = useCallback(() => {
|
||||
if (!window.confirm('Abandon this exam?\n\nYour progress will be saved but no score report will be generated.')) return
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Submit Exam',
|
||||
message: `Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`,
|
||||
confirmLabel: 'Submit',
|
||||
})
|
||||
if (!ok) return
|
||||
onSubmit()
|
||||
}, [session, onSubmit, confirm])
|
||||
|
||||
const handleAbandon = useCallback(async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Abandon Exam',
|
||||
message: 'Abandon this exam?\n\nYour progress will be saved but no score report will be generated.',
|
||||
confirmLabel: 'Abandon',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
onAbandon()
|
||||
}, [onAbandon])
|
||||
}, [onAbandon, confirm])
|
||||
|
||||
if (!session) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}>
|
||||
<div className={styles.left}>
|
||||
<span className={styles.icon}>⏱</span>
|
||||
@@ -87,5 +102,7 @@ export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ConfirmUI}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
import { useConfirm } from './useConfirm'
|
||||
|
||||
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
|
||||
const inlineComponents = {
|
||||
@@ -24,10 +25,11 @@ async function resetProgress(scope, opts) {
|
||||
})
|
||||
}
|
||||
|
||||
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress }) {
|
||||
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress, totalExamWeight, focusMode, onToggleFocus }) {
|
||||
const [tab, setTab] = useState('problem')
|
||||
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
|
||||
const [validating, setValidating] = useState(false)
|
||||
const { confirm, ConfirmUI } = useConfirm()
|
||||
const [validResult, setValidResult] = useState(null)
|
||||
const [selectedOption, setSelectedOption] = useState(null)
|
||||
const [mcqResult, setMcqResult] = useState(null)
|
||||
@@ -197,18 +199,49 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
{ConfirmUI}
|
||||
{/* Scenario header */}
|
||||
<div className={styles.scenarioHeader}>
|
||||
<div className={styles.scenarioMeta}>
|
||||
<span className={styles.category}>{scenario.category}</span>
|
||||
<span className={`${styles.diff} ${styles[scenario.difficulty?.toLowerCase()]}`}>
|
||||
{scenario.difficulty}
|
||||
</span>
|
||||
{!isExamMode && (
|
||||
<span className={`${styles.diff} ${styles[scenario.difficulty?.toLowerCase()]}`}>
|
||||
{scenario.difficulty}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
|
||||
{!isExamMode && <span className={styles.weight}>{scenario.weight} pts</span>}
|
||||
{isExamMode && totalExamWeight > 0 && (
|
||||
<span className={styles.examWeight}>
|
||||
{Math.round((scenario.weight / totalExamWeight) * 100)}% weight
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.scenarioTitle}>{scenario.title}</div>
|
||||
{onToggleFocus && !isExamMode && (
|
||||
<button
|
||||
className={focusMode ? styles.focusBtnActive : styles.focusBtn}
|
||||
onClick={onToggleFocus}
|
||||
title={focusMode ? 'Exit focus mode' : 'Focus mode — hide sidebar, terminal & nav'}
|
||||
>
|
||||
{focusMode ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 14 10 14 10 20" />
|
||||
<polyline points="20 10 14 10 14 4" />
|
||||
<line x1="10" y1="14" x2="3" y2="21" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isExamMode && scenario.progress?.started_at && (
|
||||
<div className={styles.progressStats}>
|
||||
<div className={styles.statItem}>
|
||||
@@ -231,15 +264,42 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && (
|
||||
{onToggleFocus && isExamMode && (
|
||||
<button
|
||||
className={focusMode ? styles.focusBtnActive : styles.focusBtn}
|
||||
onClick={onToggleFocus}
|
||||
title={focusMode ? 'Exit focus mode' : 'Focus mode — hide sidebar, terminal & nav'}
|
||||
>
|
||||
{focusMode ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 14 10 14 10 20" />
|
||||
<polyline points="20 10 14 10 14 4" />
|
||||
<line x1="10" y1="14" x2="3" y2="21" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!isExamMode && scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && (
|
||||
<button
|
||||
className={styles.resetBtn}
|
||||
title="Reset progress for this scenario"
|
||||
onClick={async () => {
|
||||
const msg = scenario.type === 'task'
|
||||
const title = scenario.type === 'task'
|
||||
? 'Reset Scenario & Environment'
|
||||
: 'Reset Scenario Progress'
|
||||
const message = scenario.type === 'task'
|
||||
? `Reset progress and cluster state for "${scenario.title}"?\n\nThis will run teardown to clean the environment.`
|
||||
: `Reset progress for "${scenario.title}"?`
|
||||
if (!window.confirm(msg)) return
|
||||
const ok = await confirm({ title, message, confirmLabel: 'Reset', danger: true })
|
||||
if (!ok) return
|
||||
await resetProgress('scenario', { scenarioId: scenario.id })
|
||||
setSelectedOption(null)
|
||||
setMcqResult(null)
|
||||
|
||||
@@ -76,6 +76,19 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.examWeight {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--teal);
|
||||
background: var(--teal-dim);
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid color-mix(in srgb, var(--teal) 30%, transparent);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
.scenarioTitle {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
@@ -115,6 +128,34 @@
|
||||
background: var(--red-dim);
|
||||
}
|
||||
|
||||
.focusBtn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 6px;
|
||||
color: var(--text-3);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
transition: color 0.12s, border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.focusBtn:hover {
|
||||
color: var(--green);
|
||||
border-color: color-mix(in srgb, var(--green) 40%, transparent);
|
||||
background: var(--green-dim);
|
||||
}
|
||||
.focusBtnActive {
|
||||
composes: focusBtn;
|
||||
color: var(--green);
|
||||
border-color: color-mix(in srgb, var(--green) 40%, transparent);
|
||||
background: var(--green-dim);
|
||||
}
|
||||
|
||||
|
||||
.completedBanner {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import styles from './Sidebar.module.css'
|
||||
import { useConfirm } from './useConfirm'
|
||||
|
||||
const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' }
|
||||
const TYPE_ICON = { task: '⚙', mcq: '◉' }
|
||||
@@ -16,10 +17,11 @@ export default function Sidebar({
|
||||
scenarios, activeId, onSelect, loading,
|
||||
collapsed, onToggleCollapse, width,
|
||||
activeBundleId, onProgressUpdate,
|
||||
isExamMode, examProgress,
|
||||
isExamMode, examProgress, totalExamWeight,
|
||||
}) {
|
||||
const [filterDiff, setFilterDiff] = useState('All')
|
||||
const [filterType, setFilterType] = useState('All')
|
||||
const { confirm, ConfirmUI } = useConfirm()
|
||||
|
||||
const filteredScenarios = useMemo(() => {
|
||||
return scenarios.filter(s => {
|
||||
@@ -72,23 +74,37 @@ export default function Sidebar({
|
||||
|
||||
const handleCategoryReset = async (e, cat) => {
|
||||
e.stopPropagation()
|
||||
if (!window.confirm(`Reset all progress in "${cat}"?`)) return
|
||||
const ok = await confirm({
|
||||
title: 'Reset Category Progress',
|
||||
message: `Reset all progress in "${cat}"?`,
|
||||
confirmLabel: 'Reset',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
await resetProgress('category', { category: cat })
|
||||
onProgressUpdate?.()
|
||||
}
|
||||
|
||||
const handleScenarioReset = async (e, scenarioId, title) => {
|
||||
e.stopPropagation()
|
||||
if (!window.confirm(`Reset progress for "${title}"?`)) return
|
||||
const ok = await confirm({
|
||||
title: 'Reset Scenario Progress',
|
||||
message: `Reset progress for "${title}"?`,
|
||||
confirmLabel: 'Reset',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
await resetProgress('scenario', { scenarioId })
|
||||
onProgressUpdate?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
|
||||
style={{ width, minWidth: width }}
|
||||
>
|
||||
<>
|
||||
{ConfirmUI}
|
||||
<aside
|
||||
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
|
||||
style={{ width, minWidth: width }}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className={styles.sidebarTop}>
|
||||
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
|
||||
@@ -106,8 +122,8 @@ export default function Sidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{!collapsed && (
|
||||
{/* Filter bar — hidden in exam mode */}
|
||||
{!collapsed && !isExamMode && (
|
||||
<div className={styles.filterBar}>
|
||||
<select
|
||||
value={filterDiff}
|
||||
@@ -142,7 +158,42 @@ export default function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && Object.entries(groups).map(([cat, items]) => {
|
||||
{!loading && isExamMode && (
|
||||
/* ── Exam mode: flat numbered list ─────────────────── */
|
||||
<div className={styles.flatList}>
|
||||
{scenarios.map((s, idx) => {
|
||||
const examDone = examProgress?.[s.id]?.status === 'completed'
|
||||
const active = s.id === activeId
|
||||
const hasAttempts = (examProgress?.[s.id]?.attempts || 0) > 0
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`${styles.item} ${active ? styles.active : ''} ${examDone ? styles.done : ''}`}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<div className={styles.itemTop}>
|
||||
<span className={styles.itemNum}>{idx + 1}</span>
|
||||
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
|
||||
<span className={styles.itemTitle}>{s.title}</span>
|
||||
</div>
|
||||
<div className={styles.itemMeta}>
|
||||
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
|
||||
{totalExamWeight > 0 && (
|
||||
<span className={styles.examWeightBadge}>
|
||||
{Math.round((s.weight / totalExamWeight) * 100)}% wt
|
||||
</span>
|
||||
)}
|
||||
{examDone && (
|
||||
<span className={styles.examCompletedTag}>✓ Completed</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !isExamMode && Object.entries(groups).map(([cat, items]) => {
|
||||
const catDone = items.filter(s => s.progress?.status === 'completed').length
|
||||
const isOpen = open[cat] !== false
|
||||
const hasCatProgress = items.some(s => s.progress?.attempts > 0)
|
||||
@@ -156,9 +207,7 @@ export default function Sidebar({
|
||||
</div>
|
||||
<div className={styles.accordionRight}>
|
||||
<span className={styles.catCount}>
|
||||
{isExamMode
|
||||
? `${items.filter(s => examProgress?.[s.id]?.status === 'completed').length}/${items.length}`
|
||||
: `${catDone}/${items.length}`}
|
||||
{`${catDone}/${items.length}`}
|
||||
</span>
|
||||
{hasCatProgress && (
|
||||
<button
|
||||
@@ -178,24 +227,20 @@ export default function Sidebar({
|
||||
{isOpen && (
|
||||
<div className={styles.itemsBox}>
|
||||
{items.map(s => {
|
||||
const examDone = isExamMode && examProgress?.[s.id]?.status === 'completed'
|
||||
const done = !isExamMode && s.progress?.status === 'completed'
|
||||
const done = s.progress?.status === 'completed'
|
||||
const active = s.id === activeId
|
||||
const hasAttempts = isExamMode
|
||||
? (examProgress?.[s.id]?.attempts || 0) > 0
|
||||
: s.progress?.attempts > 0
|
||||
const hasAttempts = s.progress?.attempts > 0
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`${styles.item} ${active ? styles.active : ''} ${(done || examDone) ? styles.done : ''}`}
|
||||
className={`${styles.item} ${active ? styles.active : ''} ${done ? styles.done : ''}`}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<div className={styles.itemTop}>
|
||||
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
|
||||
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
|
||||
<span className={styles.itemTitle}>{s.title}</span>
|
||||
{(done || examDone) && <span className={styles.checkmark}>✓</span>}
|
||||
{/* Per-scenario reset — shown when item has attempts */}
|
||||
{done && <span className={styles.checkmark}>✓</span>}
|
||||
{hasAttempts && (
|
||||
<button
|
||||
className={styles.itemResetBtn}
|
||||
@@ -214,10 +259,7 @@ export default function Sidebar({
|
||||
{s.difficulty}
|
||||
</span>
|
||||
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
|
||||
{!isExamMode && <span className={styles.weight}>{s.weight}pt</span>}
|
||||
{isExamMode && examDone && (
|
||||
<span className={styles.examCompletedTag}>✓ Completed</span>
|
||||
)}
|
||||
<span className={styles.weight}>{s.weight}pt</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -230,5 +272,6 @@ export default function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,22 @@
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.flatList {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 6px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.flatList .item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
||||
/* Filters */
|
||||
.filterBar {
|
||||
display: flex;
|
||||
@@ -338,6 +354,18 @@
|
||||
|
||||
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
|
||||
|
||||
.examWeightBadge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--teal-dim);
|
||||
color: var(--teal);
|
||||
border: 1px solid color-mix(in srgb, var(--teal) 30%, transparent);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.examCompletedTag {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import ConfirmModal from './ConfirmModal'
|
||||
|
||||
/**
|
||||
* useConfirm — returns { confirm, ConfirmUI }
|
||||
*
|
||||
* Usage:
|
||||
* const { confirm, ConfirmUI } = useConfirm()
|
||||
*
|
||||
* // Inside JSX:
|
||||
* {ConfirmUI}
|
||||
*
|
||||
* // Imperatively (awaitable):
|
||||
* const ok = await confirm({
|
||||
* title: 'Delete item',
|
||||
* message: 'This cannot be undone.',
|
||||
* confirmLabel: 'Delete',
|
||||
* danger: true,
|
||||
* })
|
||||
* if (ok) { ... }
|
||||
*/
|
||||
export function useConfirm() {
|
||||
const [state, setState] = useState({ open: false })
|
||||
const resolveRef = useRef(null)
|
||||
|
||||
const confirm = useCallback((options) => {
|
||||
return new Promise(resolve => {
|
||||
resolveRef.current = resolve
|
||||
setState({ open: true, ...options })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setState(s => ({ ...s, open: false }))
|
||||
resolveRef.current?.(true)
|
||||
}, [])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setState(s => ({ ...s, open: false }))
|
||||
resolveRef.current?.(false)
|
||||
}, [])
|
||||
|
||||
const ConfirmUI = (
|
||||
<ConfirmModal
|
||||
open={state.open}
|
||||
title={state.title}
|
||||
message={state.message}
|
||||
confirmLabel={state.confirmLabel}
|
||||
cancelLabel={state.cancelLabel}
|
||||
danger={state.danger}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
return { confirm, ConfirmUI }
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
--red-dim: rgba(255,107,107,0.10);
|
||||
--purple: #9775fa;
|
||||
--purple-dim: rgba(151,117,250,0.10);
|
||||
--teal: #2dd4bf;
|
||||
--teal-dim: rgba(45,212,191,0.11);
|
||||
|
||||
--text: #e2e8f4;
|
||||
--text-2: #8899b8;
|
||||
@@ -52,6 +54,8 @@
|
||||
--red-dim: rgba(220,38,38,0.10);
|
||||
--purple: #7c3aed;
|
||||
--purple-dim: rgba(124,58,237,0.10);
|
||||
--teal: #0d9488;
|
||||
--teal-dim: rgba(13,148,136,0.11);
|
||||
|
||||
--text: #111827;
|
||||
--text-2: #374151;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "k8s-basics",
|
||||
"id": "1-k8s-basics",
|
||||
"name": "Kubernetes Basics",
|
||||
"icon": "🌱",
|
||||
"tagline": "Core concepts for beginners",
|
||||
@@ -36,6 +36,16 @@
|
||||
"annotate-resource-basics",
|
||||
"init-container-task-basics",
|
||||
"restart-policy-mcq",
|
||||
"kubectl-output-format-mcq"
|
||||
"kubectl-output-format-mcq",
|
||||
"kubectl-explain-basics",
|
||||
"kubectl-patch-basics",
|
||||
"field-selector-basics",
|
||||
"sort-by-basics",
|
||||
"kubectl-diff-basics",
|
||||
"node-cordon-basics",
|
||||
"static-pods-mcq",
|
||||
"deployment-strategy-mcq",
|
||||
"pod-node-assignment-mcq",
|
||||
"pod-env-vars-inline"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "k8s-appdev",
|
||||
"id": "2-k8s-appdev",
|
||||
"name": "Kubernetes Developer",
|
||||
"icon": "🛠️",
|
||||
"tagline": "CKAD exam — application development",
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "k8s-admin",
|
||||
"id": "3-k8s-admin",
|
||||
"name": "Kubernetes Administrator",
|
||||
"icon": "🧑✈️",
|
||||
"tagline": "CKA exam — cluster administration",
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "k8s-security",
|
||||
"id": "4-k8s-security",
|
||||
"name": "Kubernetes Security",
|
||||
"icon": "🛡",
|
||||
"tagline": "CKS exam — hardening and threats",
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "deployment-strategy-mcq",
|
||||
"title": "Deployment Update Strategies",
|
||||
"category": "Workloads",
|
||||
"difficulty": "Medium",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
"description": "## Deployment Update Strategies\n\nA production Deployment is being updated. The team requires that **at least 3 pods are always available** during the rollout, and at most **5 pods can exist at any one time** (the Deployment has 4 replicas).\n\nWhich `strategy` configuration achieves this?\n\n```yaml\nspec:\n replicas: 4\n strategy:\n type: RollingUpdate\n rollingUpdate:\n maxUnavailable: ???\n maxSurge: ???\n```",
|
||||
"options": [
|
||||
{
|
||||
"id": "a",
|
||||
"text": "`maxUnavailable: 1`, `maxSurge: 1` — at most 1 pod unavailable (3 always up), at most 5 pods total"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"text": "`maxUnavailable: 0`, `maxSurge: 0` — ensures zero disruption with no extra pods"
|
||||
},
|
||||
{
|
||||
"id": "c",
|
||||
"text": "`maxUnavailable: 2`, `maxSurge: 2` — allows 2 pods down (2 available) and 6 total pods"
|
||||
},
|
||||
{
|
||||
"id": "d",
|
||||
"text": "`maxUnavailable: 4`, `maxSurge: 1` — replaces all pods at once before adding new ones"
|
||||
}
|
||||
],
|
||||
"correct_option": "a",
|
||||
"explanation": "With 4 replicas, `maxUnavailable: 1` means at most 1 pod can be unavailable → minimum 3 pods always running. `maxSurge: 1` means at most 1 extra pod can be created → maximum 5 pods total (4+1). This satisfies both constraints. Option B (`maxUnavailable: 0, maxSurge: 0`) is invalid — at least one of them must be non-zero. Option C allows only 2 available pods (violates the minimum-3 requirement). Option D would take all existing pods offline before any new ones are ready.",
|
||||
"hints": [
|
||||
{
|
||||
"title": "Understanding maxUnavailable and maxSurge",
|
||||
"body": "maxUnavailable: how many pods can be unavailable during an update (can be a count or %). maxSurge: how many extra pods above the desired count can exist simultaneously.",
|
||||
"command": "kubectl explain deployment.spec.strategy.rollingUpdate"
|
||||
},
|
||||
{
|
||||
"title": "Check the current strategy on a deployment",
|
||||
"body": "Use jsonpath to inspect the rolling update config of any existing deployment.",
|
||||
"command": "kubectl get deployment <name> -o jsonpath='{.spec.strategy.rollingUpdate}'"
|
||||
}
|
||||
],
|
||||
"setup_commands": [],
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": []
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"id": "field-selector-basics",
|
||||
"title": "Filtering Resources with Field Selectors",
|
||||
"category": "Core Concepts",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
"description": "## Filtering Resources with Field Selectors\n\nField selectors let you filter Kubernetes resources by the **value of specific object fields** — similar to how label selectors work but targeting any field in the resource spec or status.\n\nCommon use cases:\n- Find all pods in a specific phase: `kubectl get pods --field-selector=status.phase=Running`\n- Find resources in a specific namespace: `kubectl get pods --field-selector=metadata.namespace=kube-system`\n\n**Your task:**\n\nSeveral pods have been created for you — some Running, some in a Failed state.\n\n1. Use a field selector to list only the **Running** pods and count them. Save the count to `/tmp/running-count.txt`\n2. Use a field selector to list all pods **not** in the `default` namespace (filter by `metadata.namespace!=default`)\n\n```bash\n# Count running pods:\nkubectl get pods --field-selector=status.phase=Running --no-headers | wc -l\n```",
|
||||
"hints": [
|
||||
{
|
||||
"title": "Field selector syntax",
|
||||
"body": "Use `--field-selector=field.path=value` to filter. Multiple conditions are comma-separated. Supported operators: `=`, `==`, `!=`.",
|
||||
"command": "kubectl get pods --field-selector=status.phase=Running"
|
||||
},
|
||||
{
|
||||
"title": "Save running pod count to a file",
|
||||
"body": "Pipe the result through wc -l and redirect to the file.",
|
||||
"command": "kubectl get pods --field-selector=status.phase=Running --no-headers | wc -l | tr -d ' ' > /tmp/running-count.txt"
|
||||
}
|
||||
],
|
||||
"setup_commands": [
|
||||
{
|
||||
"command": "kubectl run alpha --image=nginx:alpine 2>/dev/null || true"
|
||||
},
|
||||
{
|
||||
"command": "kubectl run beta --image=nginx:alpine 2>/dev/null || true"
|
||||
},
|
||||
{
|
||||
"command": "kubectl run crash-pod --image=busybox:1.36 --restart=Never -- /bin/false 2>/dev/null || true"
|
||||
},
|
||||
{
|
||||
"command": "kubectl rollout status deployment/alpha --timeout=30s 2>/dev/null || true"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"commands": [
|
||||
{
|
||||
"description": "File /tmp/running-count.txt exists and has a numeric value",
|
||||
"command": "cat /tmp/running-count.txt",
|
||||
"expected_output": "^[0-9]+$",
|
||||
"match": "regex"
|
||||
},
|
||||
{
|
||||
"description": "The saved count matches the actual number of Running pods",
|
||||
"command": "LIVE=$(kubectl get pods --field-selector=status.phase=Running --no-headers 2>/dev/null | wc -l | tr -d ' '); SAVED=$(cat /tmp/running-count.txt 2>/dev/null | tr -d ' '); [ \"$LIVE\" = \"$SAVED\" ] && echo 'match' || echo 'mismatch'",
|
||||
"expected_output": "match",
|
||||
"match": "exact"
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": [
|
||||
{
|
||||
"command": "kubectl delete pod alpha beta crash-pod --ignore-not-found --grace-period=0 --force"
|
||||
},
|
||||
{
|
||||
"command": "rm -f /tmp/running-count.txt"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"id": "kubectl-diff-basics",
|
||||
"title": "Previewing Changes with kubectl diff",
|
||||
"category": "Core Concepts",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
"description": "## Previewing Changes with `kubectl diff`\n\n`kubectl diff` compares a local manifest file against the **live state** of a resource in the cluster, showing exactly what would change if you applied it — without actually making any changes. This is a safe way to review updates before rolling them out.\n\n**Your task:**\n\nA Deployment named `diffme` is running with 1 replica and image `nginx:1.24`.\n\n1. Write a updated manifest for the same Deployment with **3 replicas** and image `nginx:1.25` to `/tmp/diffme-updated.yaml`\n2. Run `kubectl diff` against it to preview the changes\n3. Then **apply** the updated manifest to make the changes live\n\n```bash\n# Preview changes:\nkubectl diff -f /tmp/diffme-updated.yaml\n\n# Apply changes:\nkubectl apply -f /tmp/diffme-updated.yaml\n```",
|
||||
"hints": [
|
||||
{
|
||||
"title": "Write the updated manifest",
|
||||
"body": "Create a YAML file with the updated replicas and image values.",
|
||||
"command": "cat <<EOF > /tmp/diffme-updated.yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: diffme\nspec:\n replicas: 3\n selector:\n matchLabels:\n app: diffme\n template:\n metadata:\n labels:\n app: diffme\n spec:\n containers:\n - name: app\n image: nginx:1.25\nEOF"
|
||||
},
|
||||
{
|
||||
"title": "Run kubectl diff",
|
||||
"body": "kubectl diff exits with code 1 if there are differences (expected), code 0 if nothing changed.",
|
||||
"command": "kubectl diff -f /tmp/diffme-updated.yaml; echo \"Exit code: $?\""
|
||||
},
|
||||
{
|
||||
"title": "Apply the manifest",
|
||||
"body": "Once you have reviewed the diff output, apply the manifest to update the live cluster state.",
|
||||
"command": "kubectl apply -f /tmp/diffme-updated.yaml"
|
||||
}
|
||||
],
|
||||
"setup_commands": [
|
||||
{
|
||||
"command": "kubectl create deployment diffme --image=nginx:1.24 --replicas=1 2>/dev/null || true"
|
||||
},
|
||||
{
|
||||
"command": "kubectl rollout status deployment/diffme --timeout=60s"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"commands": [
|
||||
{
|
||||
"description": "Deployment 'diffme' has 3 replicas",
|
||||
"command": "kubectl get deployment diffme -o jsonpath='{.spec.replicas}'",
|
||||
"expected_output": "3",
|
||||
"match": "exact"
|
||||
},
|
||||
{
|
||||
"description": "Deployment uses nginx:1.25 image",
|
||||
"command": "kubectl get deployment diffme -o jsonpath='{.spec.template.spec.containers[0].image}'",
|
||||
"expected_output": "nginx:1.25",
|
||||
"match": "exact"
|
||||
},
|
||||
{
|
||||
"description": "Manifest file exists at /tmp/diffme-updated.yaml",
|
||||
"command": "test -f /tmp/diffme-updated.yaml && echo 'exists'",
|
||||
"expected_output": "exists",
|
||||
"match": "exact"
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": [
|
||||
{
|
||||
"command": "kubectl delete deployment diffme --ignore-not-found"
|
||||
},
|
||||
{
|
||||
"command": "rm -f /tmp/diffme-updated.yaml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "kubectl-explain-basics",
|
||||
"title": "Exploring the API with kubectl explain",
|
||||
"category": "Core Concepts",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 3,
|
||||
"description": "## Exploring the API with `kubectl explain`\n\n`kubectl explain` is a built-in reference tool that describes the fields of any Kubernetes resource — directly from the live API server. It is invaluable during exams when you need to recall the exact field name or understand what a field accepts.\n\n**Your task:**\n\nA ConfigMap has been created for you. Use `kubectl explain` to answer the following, then:\n\n1. Create a Pod named `explain-demo` using image `nginx:alpine`\n2. Set the pod's `spec.terminationGracePeriodSeconds` to **5** (use `kubectl explain pod.spec.terminationGracePeriodSeconds` to understand the field)\n\n```bash\n# Explore the pod spec:\nkubectl explain pod.spec\n\n# Inspect a specific field:\nkubectl explain pod.spec.terminationGracePeriodSeconds\n```",
|
||||
"hints": [
|
||||
{
|
||||
"title": "kubectl explain syntax",
|
||||
"body": "Use dot-notation to drill into nested fields. For example: `kubectl explain pod.spec.containers.resources`.",
|
||||
"command": "kubectl explain pod.spec.terminationGracePeriodSeconds"
|
||||
},
|
||||
{
|
||||
"title": "Create the pod with the field set",
|
||||
"body": "Use a heredoc to pipe a YAML manifest with terminationGracePeriodSeconds set to 5 directly to kubectl apply.",
|
||||
"command": "cat <<EOF | kubectl apply -f -\napiVersion: v1\nkind: Pod\nmetadata:\n name: explain-demo\nspec:\n terminationGracePeriodSeconds: 5\n containers:\n - name: app\n image: nginx:alpine\nEOF"
|
||||
}
|
||||
],
|
||||
"setup_commands": [],
|
||||
"validation": {
|
||||
"commands": [
|
||||
{
|
||||
"description": "Pod 'explain-demo' exists",
|
||||
"command": "kubectl get pod explain-demo -o jsonpath='{.metadata.name}'",
|
||||
"expected_output": "explain-demo",
|
||||
"match": "exact"
|
||||
},
|
||||
{
|
||||
"description": "Pod uses nginx:alpine image",
|
||||
"command": "kubectl get pod explain-demo -o jsonpath='{.spec.containers[0].image}'",
|
||||
"expected_output": "nginx:alpine",
|
||||
"match": "exact"
|
||||
},
|
||||
{
|
||||
"description": "terminationGracePeriodSeconds is 5",
|
||||
"command": "kubectl get pod explain-demo -o jsonpath='{.spec.terminationGracePeriodSeconds}'",
|
||||
"expected_output": "5",
|
||||
"match": "exact"
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": [
|
||||
{
|
||||
"command": "kubectl delete pod explain-demo --ignore-not-found --grace-period=0 --force"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "kubectl-patch-basics",
|
||||
"title": "Patching Resources with kubectl patch",
|
||||
"category": "Core Concepts",
|
||||
"difficulty": "Medium",
|
||||
"type": "task",
|
||||
"weight": 5,
|
||||
"description": "## Patching Resources with `kubectl patch`\n\n`kubectl patch` lets you surgically update specific fields of a live resource without editing the full manifest. It supports three patch strategies: `merge`, `json`, and `strategic`.\n\n**Your task:**\n\nA Deployment named `patchme` is running in the `default` namespace with 1 replica.\n\n1. Use `kubectl patch` to update its replica count to **3**\n2. Use `kubectl patch` to add a label `env=staging` to the Deployment's **pod template** (`.spec.template.metadata.labels`)\n\n```bash\n# Verify:\nkubectl get deploy patchme -o jsonpath='{.spec.replicas}'\nkubectl get deploy patchme -o jsonpath='{.spec.template.metadata.labels}' \n```",
|
||||
"hints": [
|
||||
{
|
||||
"title": "Patch replicas using merge patch",
|
||||
"body": "Use `--type=merge` with a JSON snippet targeting the field you want to change.",
|
||||
"command": "kubectl patch deployment patchme --type=merge -p '{\"spec\":{\"replicas\":3}}'"
|
||||
},
|
||||
{
|
||||
"title": "Patch pod template labels",
|
||||
"body": "To add labels to the pod template, target spec.template.metadata.labels in your patch body.",
|
||||
"command": "kubectl patch deployment patchme --type=merge -p '{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"env\":\"staging\"}}}}}'"
|
||||
}
|
||||
],
|
||||
"setup_commands": [
|
||||
{
|
||||
"command": "kubectl create deployment patchme --image=nginx:alpine --replicas=1 2>/dev/null || true"
|
||||
},
|
||||
{
|
||||
"command": "kubectl rollout status deployment/patchme --timeout=60s"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"commands": [
|
||||
{
|
||||
"description": "Deployment 'patchme' has 3 replicas",
|
||||
"command": "kubectl get deployment patchme -o jsonpath='{.spec.replicas}'",
|
||||
"expected_output": "3",
|
||||
"match": "exact"
|
||||
},
|
||||
{
|
||||
"description": "Pod template has label env=staging",
|
||||
"command": "kubectl get deployment patchme -o jsonpath='{.spec.template.metadata.labels.env}'",
|
||||
"expected_output": "staging",
|
||||
"match": "exact"
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": [
|
||||
{
|
||||
"command": "kubectl delete deployment patchme --ignore-not-found"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "node-cordon-basics",
|
||||
"title": "Cordoning and Uncordoning a Node",
|
||||
"category": "Cluster Management",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
"description": "## Cordoning and Uncordoning a Node\n\n`kubectl cordon` marks a node as **unschedulable** — the node continues running existing workloads but the scheduler will not place new pods on it. This is useful before maintenance windows.\n\n`kubectl uncordon` reverses the cordon, making the node schedulable again.\n\n**Your task:**\n\n1. Find the name of the cluster node using `kubectl get nodes`\n2. **Cordon** the node\n3. Verify the node shows `SchedulingDisabled` in its status\n4. **Uncordon** the node to restore it to normal\n\n```bash\n# Check node status:\nkubectl get nodes\n\n# Cordon:\nkubectl cordon <node-name>\n\n# Uncordon:\nkubectl uncordon <node-name>\n```",
|
||||
"hints": [
|
||||
{
|
||||
"title": "Find the node name",
|
||||
"body": "In this single-node cluster, there is exactly one node. Use kubectl get nodes to find its name.",
|
||||
"command": "kubectl get nodes -o jsonpath='{.items[0].metadata.name}'"
|
||||
},
|
||||
{
|
||||
"title": "Cordon and uncordon using a variable",
|
||||
"body": "Store the node name in a variable for convenience, then cordon and uncordon it.",
|
||||
"command": "NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}') && kubectl cordon $NODE && kubectl uncordon $NODE"
|
||||
}
|
||||
],
|
||||
"setup_commands": [],
|
||||
"validation": {
|
||||
"commands": [
|
||||
{
|
||||
"description": "Node is schedulable (not cordoned)",
|
||||
"command": "kubectl get nodes -o jsonpath='{.items[0].spec.unschedulable}' 2>/dev/null || echo 'false'",
|
||||
"expected_output": "false",
|
||||
"match": "contains"
|
||||
},
|
||||
{
|
||||
"description": "Node is in Ready state",
|
||||
"command": "kubectl get nodes -o jsonpath='{.items[0].status.conditions[?(@.type==\"Ready\")].status}'",
|
||||
"expected_output": "True",
|
||||
"match": "exact"
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": [
|
||||
{
|
||||
"command": "kubectl uncordon $(kubectl get nodes -o jsonpath='{.items[0].metadata.name}') 2>/dev/null || true"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "pod-env-vars-inline",
|
||||
"title": "Pod with Inline Environment Variables",
|
||||
"category": "Configuration",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 4,
|
||||
"description": "## Pod with Inline Environment Variables\n\nContainers can receive configuration through environment variables from three sources:\n1. **Inline literals** — hardcoded directly in the pod spec (`env[].value`)\n2. **ConfigMap** — loaded from a ConfigMap key\n3. **Secret** — loaded from a Secret key\n\nThis scenario focuses on source **#1**: setting environment variables directly in the pod spec.\n\n**Your task:**\n\nCreate a Pod named `env-demo` using image `busybox:1.36` with command `sleep 3600` and the following environment variables set **inline** (not from a ConfigMap or Secret):\n\n| Name | Value |\n|---|---|\n| `APP_COLOR` | `blue` |\n| `APP_MODE` | `production` |\n\n```bash\n# Verify the env vars are set inside the container:\nkubectl exec env-demo -- env | grep APP_\n```",
|
||||
"hints": [
|
||||
{
|
||||
"title": "Setting env vars with kubectl run",
|
||||
"body": "The quickest way: use `kubectl run` with one `--env` flag per variable.",
|
||||
"command": "kubectl run env-demo --image=busybox:1.36 --command --env=APP_COLOR=blue --env=APP_MODE=production -- sleep 3600"
|
||||
},
|
||||
{
|
||||
"title": "Setting env vars in a manifest",
|
||||
"body": "In a YAML manifest, use the `env` array under the container spec. Each entry has a `name` and `value` field.",
|
||||
"command": "cat <<EOF | kubectl apply -f -\napiVersion: v1\nkind: Pod\nmetadata:\n name: env-demo\nspec:\n containers:\n - name: app\n image: busybox:1.36\n command: [\"sleep\", \"3600\"]\n env:\n - name: APP_COLOR\n value: \"blue\"\n - name: APP_MODE\n value: \"production\"\nEOF"
|
||||
}
|
||||
],
|
||||
"setup_commands": [],
|
||||
"validation": {
|
||||
"commands": [
|
||||
{
|
||||
"description": "Pod 'env-demo' is Running",
|
||||
"command": "kubectl get pod env-demo -o jsonpath='{.status.phase}'",
|
||||
"expected_output": "Running",
|
||||
"match": "exact"
|
||||
},
|
||||
{
|
||||
"description": "APP_COLOR is set to 'blue'",
|
||||
"command": "kubectl get pod env-demo -o jsonpath='{.spec.containers[0].env[?(@.name==\"APP_COLOR\")].value}'",
|
||||
"expected_output": "blue",
|
||||
"match": "exact"
|
||||
},
|
||||
{
|
||||
"description": "APP_MODE is set to 'production'",
|
||||
"command": "kubectl get pod env-demo -o jsonpath='{.spec.containers[0].env[?(@.name==\"APP_MODE\")].value}'",
|
||||
"expected_output": "production",
|
||||
"match": "exact"
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": [
|
||||
{
|
||||
"command": "kubectl delete pod env-demo --ignore-not-found --grace-period=0 --force"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "pod-node-assignment-mcq",
|
||||
"title": "Assigning Pods to Nodes",
|
||||
"category": "Scheduling",
|
||||
"difficulty": "Medium",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
"description": "## Assigning Pods to Nodes\n\nA data-processing Pod **must** run on a specific node named `worker-gpu` that has GPU resources. You want to ensure the Pod is **always** scheduled on that exact node — regardless of node labels.\n\nWhich field in the Pod spec achieves this most directly?",
|
||||
"options": [
|
||||
{
|
||||
"id": "a",
|
||||
"text": "`spec.nodeSelector: {kubernetes.io/hostname: worker-gpu}` — schedules on nodes matching the label"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"text": "`spec.nodeName: worker-gpu` — directly assigns the Pod to the named node, bypassing the scheduler"
|
||||
},
|
||||
{
|
||||
"id": "c",
|
||||
"text": "`spec.affinity.nodeAffinity` with `requiredDuringSchedulingIgnoredDuringExecution` targeting the node name"
|
||||
},
|
||||
{
|
||||
"id": "d",
|
||||
"text": "`spec.tolerations` with a toleration matching the node's taint"
|
||||
}
|
||||
],
|
||||
"correct_option": "b",
|
||||
"explanation": "`spec.nodeName` is the most direct method — the Pod is **directly bound** to the named node, bypassing the Kubernetes scheduler entirely. The pod will only run on that node and will stay in `Pending` if that node is unavailable. `nodeSelector` requires the node to have a matching label (e.g., `kubernetes.io/hostname` is auto-assigned, so option A would also work, but is less direct). `nodeAffinity` is more flexible and preferred for production. `tolerations` allow pods to be scheduled on tainted nodes but do not restrict them to a specific node.",
|
||||
"hints": [
|
||||
{
|
||||
"title": "nodeName vs nodeSelector",
|
||||
"body": "nodeName bypasses the scheduler and pins the pod to a named node. nodeSelector uses labels for a more flexible approach. For production, prefer nodeAffinity.",
|
||||
"command": "kubectl explain pod.spec.nodeName"
|
||||
},
|
||||
{
|
||||
"title": "Get the node's auto-assigned hostname label",
|
||||
"body": "The kubernetes.io/hostname label is automatically set on every node and matches the node name.",
|
||||
"command": "kubectl get nodes --show-labels"
|
||||
}
|
||||
],
|
||||
"setup_commands": [],
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": []
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"id": "sort-by-basics",
|
||||
"title": "Sorting kubectl Output",
|
||||
"category": "Core Concepts",
|
||||
"difficulty": "Easy",
|
||||
"type": "task",
|
||||
"weight": 3,
|
||||
"description": "## Sorting `kubectl` Output\n\nThe `--sort-by` flag on `kubectl get` allows you to sort output by any JSONPath expression — extremely useful for finding the newest pod, the heaviest resource consumer, or the oldest event.\n\n**Your task:**\n\nSeveral pods have been created for you. Complete the following:\n\n1. List all pods in the `default` namespace **sorted by their creation timestamp** (oldest first) and save the output to `/tmp/pods-sorted.txt`\n2. List all pods sorted by name and save to `/tmp/pods-by-name.txt`\n\n```bash\n# Sort by creation time:\nkubectl get pods --sort-by=.metadata.creationTimestamp\n\n# Sort by name:\nkubectl get pods --sort-by=.metadata.name\n```",
|
||||
"hints": [
|
||||
{
|
||||
"title": "--sort-by flag syntax",
|
||||
"body": "Pass any JSONPath expression to --sort-by. The expression must point to a comparable field (string, number, or timestamp).",
|
||||
"command": "kubectl get pods --sort-by=.metadata.creationTimestamp"
|
||||
},
|
||||
{
|
||||
"title": "Save output to a file",
|
||||
"body": "Redirect kubectl output using the > operator.",
|
||||
"command": "kubectl get pods --sort-by=.metadata.creationTimestamp > /tmp/pods-sorted.txt && kubectl get pods --sort-by=.metadata.name > /tmp/pods-by-name.txt"
|
||||
}
|
||||
],
|
||||
"setup_commands": [
|
||||
{
|
||||
"command": "kubectl run sort-pod-c --image=nginx:alpine 2>/dev/null || true && sleep 1"
|
||||
},
|
||||
{
|
||||
"command": "kubectl run sort-pod-b --image=nginx:alpine 2>/dev/null || true && sleep 0.5"
|
||||
},
|
||||
{
|
||||
"command": "kubectl run sort-pod-a --image=nginx:alpine 2>/dev/null || true && sleep 0.5"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"commands": [
|
||||
{
|
||||
"description": "File /tmp/pods-sorted.txt exists and contains pod output",
|
||||
"command": "cat /tmp/pods-sorted.txt",
|
||||
"expected_output": "sort-pod",
|
||||
"match": "contains"
|
||||
},
|
||||
{
|
||||
"description": "File /tmp/pods-by-name.txt exists and contains pod output",
|
||||
"command": "cat /tmp/pods-by-name.txt",
|
||||
"expected_output": "sort-pod",
|
||||
"match": "contains"
|
||||
},
|
||||
{
|
||||
"description": "pods-by-name.txt is sorted alphabetically (sort-pod-a appears before sort-pod-c)",
|
||||
"command": "grep -n 'sort-pod-a' /tmp/pods-by-name.txt | cut -d: -f1",
|
||||
"expected_output": "^[0-9]+$",
|
||||
"match": "regex"
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": [
|
||||
{
|
||||
"command": "kubectl delete pod sort-pod-a sort-pod-b sort-pod-c --ignore-not-found --grace-period=0 --force"
|
||||
},
|
||||
{
|
||||
"command": "rm -f /tmp/pods-sorted.txt /tmp/pods-by-name.txt"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "static-pods-mcq",
|
||||
"title": "Static Pods in Kubernetes",
|
||||
"category": "Core Concepts",
|
||||
"difficulty": "Medium",
|
||||
"type": "mcq",
|
||||
"weight": 3,
|
||||
"description": "## Static Pods in Kubernetes\n\nA cluster administrator wants to run a monitoring agent on every node without relying on the Kubernetes scheduler or API server. The agent must start automatically even if the API server is unavailable.\n\nWhich approach should they use, and where should the manifest be placed?",
|
||||
"options": [
|
||||
{
|
||||
"id": "a",
|
||||
"text": "Create a DaemonSet — the scheduler places one pod per node automatically"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"text": "Create a Static Pod by placing a manifest in the kubelet's static pod directory (typically `/etc/kubernetes/manifests/`)"
|
||||
},
|
||||
{
|
||||
"id": "c",
|
||||
"text": "Use a CronJob with `--concurrencyPolicy=Forbid` to schedule one pod per node"
|
||||
},
|
||||
{
|
||||
"id": "d",
|
||||
"text": "Annotate a Deployment with `node-placement: static` to pin it to every node"
|
||||
}
|
||||
],
|
||||
"correct_option": "b",
|
||||
"explanation": "Static Pods are managed directly by the kubelet daemon on a specific node, **without the API server scheduling them**. The kubelet watches a directory (e.g., `/etc/kubernetes/manifests/`) and automatically creates any pods defined there — even if the API server is down. They are ideal for bootstrapping control-plane components (etcd, kube-apiserver, etc.) and for workloads that must survive API server failures. DaemonSets are managed by the scheduler/API server and won't work if the API server is unavailable. There is no `node-placement: static` annotation.",
|
||||
"hints": [
|
||||
{
|
||||
"title": "What makes a pod 'static'?",
|
||||
"body": "Static pods are defined as YAML files on the node's filesystem. The kubelet monitors the staticPodPath directory and reconciles the pods itself — no scheduler, no API server required.",
|
||||
"command": "ls /etc/kubernetes/manifests/ 2>/dev/null || echo 'Static pod dir may differ per distro'"
|
||||
},
|
||||
{
|
||||
"title": "Identifying static pods in the cluster",
|
||||
"body": "Static pods always have the node name appended to their pod name (e.g., `kube-apiserver-controlplane`). You can also look at the pod's ownerReferences — static pods have no ownerReference.",
|
||||
"command": "kubectl get pods -n kube-system"
|
||||
}
|
||||
],
|
||||
"setup_commands": [],
|
||||
"default_namespace": "default",
|
||||
"teardown_commands": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 514 KiB After Width: | Height: | Size: 477 KiB |
|
Before Width: | Height: | Size: 593 KiB After Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 526 KiB After Width: | Height: | Size: 517 KiB |
|
Before Width: | Height: | Size: 550 KiB After Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 956 KiB After Width: | Height: | Size: 988 KiB |
|
Before Width: | Height: | Size: 595 KiB After Width: | Height: | Size: 565 KiB |
|
Before Width: | Height: | Size: 599 KiB After Width: | Height: | Size: 630 KiB |
|
Before Width: | Height: | Size: 640 KiB After Width: | Height: | Size: 894 KiB |
|
After Width: | Height: | Size: 769 KiB |
@@ -92,7 +92,23 @@ done
|
||||
kubectl wait --for=condition=Ready nodes --all --timeout=120s
|
||||
OK "Cluster node is Ready"
|
||||
|
||||
# Phase 3: wait for flannel CNI to write its subnet config.
|
||||
# Phase 3: on resource-constrained hosts k3s can briefly flap right after
|
||||
# node Ready. Poll for 10s to confirm the API stays up.
|
||||
LOG "Verifying API server stability..."
|
||||
for i in $(seq 1 5); do
|
||||
sleep 2
|
||||
if ! kubectl get nodes &>/dev/null; then
|
||||
LOG "API server not yet stable (attempt $i/5), waiting..."
|
||||
fi
|
||||
done
|
||||
if ! kubectl get nodes &>/dev/null; then
|
||||
ERR "API server became unavailable after node Ready. Aborting."
|
||||
tail -20 /var/log/k3s.log >&2
|
||||
exit 1
|
||||
fi
|
||||
OK "API server is stable"
|
||||
|
||||
# Phase 4: wait for flannel CNI to write its subnet config.
|
||||
# Pods scheduled before flannel is ready get FailedCreatePodSandBox warnings
|
||||
# (missing /run/flannel/subnet.env). Waiting here avoids that noise.
|
||||
for i in $(seq 1 30); do
|
||||
@@ -137,6 +153,9 @@ alias klogs='kubectl logs'
|
||||
kns() { kubectl config set-context --current --namespace="$1"; }
|
||||
kctx() { kubectl config use-context "$1"; }
|
||||
|
||||
# Load bash-completion framework
|
||||
[ -f /usr/share/bash-completion/bash_completion ] && source /usr/share/bash-completion/bash_completion
|
||||
|
||||
source <(kubectl completion bash) 2>/dev/null || true
|
||||
complete -F __start_kubectl k 2>/dev/null || true
|
||||
|
||||
@@ -153,17 +172,50 @@ BASHRC
|
||||
|
||||
OK "Shell configured"
|
||||
|
||||
# ── 5. Start Node.js API server ──────────────────────────────────────────────
|
||||
# ── 5. k3s watchdog — restart k3s if it crashes ─────────────────────────────
|
||||
# This loop detects if k3s has crashed and restarts it, keeping the cluster available.
|
||||
watchdog_k3s() {
|
||||
while true; do
|
||||
sleep 15
|
||||
if ! kill -0 "$K3S_PID" 2>/dev/null; then
|
||||
LOG "⚠ k3s process died (PID $K3S_PID), restarting..."
|
||||
k3s server \
|
||||
--disable=traefik \
|
||||
--disable=servicelb \
|
||||
--write-kubeconfig-mode=644 \
|
||||
--node-name=k8s-lab \
|
||||
--snapshotter=native \
|
||||
--kubelet-arg=cgroups-per-qos=false \
|
||||
--kubelet-arg=enforce-node-allocatable="" \
|
||||
&>>/var/log/k3s.log &
|
||||
K3S_PID=$!
|
||||
LOG "k3s restarted (new PID $K3S_PID), waiting for API..."
|
||||
for i in $(seq 1 30); do
|
||||
sleep 2
|
||||
if kubectl get nodes &>/dev/null; then
|
||||
# Re-sync the kubeconfig in case it was regenerated
|
||||
cp /etc/rancher/k3s/k3s.yaml /root/.kube/config 2>/dev/null || true
|
||||
OK "k3s recovered successfully"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
}
|
||||
watchdog_k3s &
|
||||
WATCHDOG_PID=$!
|
||||
|
||||
# ── 6. Start Node.js API server ──────────────────────────────────────────────
|
||||
LOG "Starting API server..."
|
||||
cd /app/backend && node server.js &>/var/log/api.log &
|
||||
OK "API server started (port 4000)"
|
||||
|
||||
# ── 6. Browser terminal ──────────────────────────────────────────────────────
|
||||
# ── 7. Browser terminal ──────────────────────────────────────────────────────
|
||||
# Terminal is served via WebSocket at /shell-ws by the Node.js API server
|
||||
# using node-pty — no external ttyd binary needed.
|
||||
|
||||
|
||||
# ── 7. Start nginx reverse proxy ────────────────────────────────────────────
|
||||
# ── 8. Start nginx reverse proxy ────────────────────────────────────────────
|
||||
LOG "Starting nginx proxy..."
|
||||
nginx -g 'daemon off;' &>/var/log/nginx.log &
|
||||
OK "nginx started (port 80)"
|
||||
@@ -171,6 +223,7 @@ OK "nginx started (port 80)"
|
||||
# ── 8. Keep Alive & Graceful Shutdown ────────────────────────────────────────
|
||||
cleanup() {
|
||||
LOG "Caught signal, shutting down KubeKosh..."
|
||||
kill -TERM "$WATCHDOG_PID" 2>/dev/null || true
|
||||
kill -TERM "$K3S_PID" 2>/dev/null || true
|
||||
kill $(jobs -p) 2>/dev/null || true
|
||||
exit 0
|
||||
|
||||