Compare commits

..
10 Commits
Author SHA1 Message Date
Abhinav Sinha d42aa779b1 feat(scenarios): add 10 new Kubernetes Basics scenarios
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-20 23:54:52 +05:30
Abhinav Sinha 6c3843cb90 fix(cluster): add API stability verification and k3s watchdog
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-19 22:11:43 +05:30
Abhinav Sinha ff1c61812d docs: update README.md with new screenshots
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 12:18:37 +05:30
Abhinav Sinha f2d535837c fix: remove reset button from scenario panel in exam mode
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 02:00:21 +05:30
Abhinav Sinha ec27b01531 refactor: replace window alerts with modals
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 01:49:10 +05:30
Abhinav Sinha 35dfeb7208 feat: add focus mode to maximize scenario panel
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 01:30:39 +05:30
Abhinav Sinha 09bd1b20da refactor(exam): flat sidebar list, weight badges, UI cleanup
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 01:11:19 +05:30
Abhinav Sinha 7e75c8ae02 fix: hide difficulty from scenario ui in exam mode
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-17 16:33:14 +05:30
Abhinav Sinha 935ab38f28 fix: ordered bundles sequence by id
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-17 16:23:44 +05:30
Abhinav Sinha 170b95aa35 fix: kubectl bash completion
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-17 15:09:39 +05:30
39 changed files with 1234 additions and 90 deletions
+5 -6
View File
@@ -18,12 +18,11 @@ KubeKosh runs a real [K3s](https://k3s.io/) Kubernetes cluster inside a single D
## Screenshots ## Screenshots
| | | | | | |
|---|---| |---|---|---|
| ![Scenario browser with live terminal](screenshots/1.png) | ![Task scenario with problem statement](screenshots/2.png) | | ![Scenario browser with live terminal](screenshots/1.png) | ![Task scenario with problem statement](screenshots/2.png) | ![Contextual hints with copy-ready commands](screenshots/3.png) |
| ![Contextual hints with copy-ready commands](screenshots/3.png) | ![Automated validation — all checks passed](screenshots/4.png) | | ![Automated validation — all checks passed](screenshots/4.png) | ![Exam mode — start with custom duration and scenario count](screenshots/5.png) | ![Exam mode — live exam with timer](screenshots/6.png) |
| ![Exam mode — start with custom duration](screenshots/5.png) | ![Exam mode - live exam with timer](screenshots/6.png) | | ![Exam MCQ — selecting an answer](screenshots/7.png) | ![Exam MCQ — correct answer with explanation](screenshots/8.png) | ![Exam history with per-scenario score report](screenshots/9.png) |
| ![Multiple-choice question view](screenshots/7.png) | ![MCQ with correct answer and explanation](screenshots/8.png) |
--- ---
+73 -21
View File
@@ -64,10 +64,21 @@ function getDb() {
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
exam_minutes INTEGER NOT NULL DEFAULT 120, exam_minutes INTEGER NOT NULL DEFAULT 120,
duration_secs INTEGER, 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 // Separate exam-session progress table — tracks completions per session
_db.exec(` _db.exec(`
CREATE TABLE IF NOT EXISTS exam_progress ( CREATE TABLE IF NOT EXISTS exam_progress (
@@ -249,20 +260,35 @@ app.post('/api/progress/reset', (req, res) => {
// ── Exam sessions ───────────────────────────────────────────────────────────── // ── 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 // POST /api/sessions — start a new exam session
app.post('/api/sessions', (req, res) => { 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); const bundle = loadBundles().find(b => b.id === bundleId);
if (!bundle) return res.status(404).json({ error: 'Bundle not found' }); if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
const db = getDb(); const db = getDb();
const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120)); 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 // Abandon any existing active session
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now') db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
WHERE status='active'`).run(); WHERE status='active'`).run();
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; 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) db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids)
VALUES (?, ?, datetime('now'), 'active', ?)`).run(id, bundleId, mins); VALUES (?, ?, datetime('now'), 'active', ?, ?)`)
res.json({ id, bundleId, status: 'active', exam_minutes: mins }); .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 // GET /api/sessions/active — get the current active session
@@ -270,13 +296,18 @@ app.get('/api/sessions/active', (req, res) => {
const db = getDb(); const db = getDb();
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get(); const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
if (!session) return res.json(null); if (!session) return res.json(null);
const bundle = loadBundles().find(b => b.id === session.bundle_id); // Use session-specific scenario_ids (shuffled/sliced at start time)
const scenarioIds = bundle?.scenario_ids || []; let scenarioIds = [];
// Count completions from exam_progress (exam-specific), not global progress 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( const completed = db.prepare(
`SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'` `SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'`
).get(session.id)?.cnt || 0; ).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 // 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' }); if (!session) return res.status(404).json({ error: 'Session not found' });
const bundle = loadBundles().find(b => b.id === session.bundle_id); const bundle = loadBundles().find(b => b.id === session.bundle_id);
const scenarios = loadScenarios(); 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' // 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); 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); 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) => { app.get('/api/scenarios', (req, res) => {
const scenarios = loadScenarios(); const scenarios = loadScenarios()
const progress = loadProgress(); const progress = loadProgress()
const { bundle } = req.query; 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) { if (bundle) {
const bundles = loadBundles(); const bundles = loadBundles()
const b = bundles.find(x => x.id === bundle); const b = bundles.find(x => x.id === bundle)
if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id)); if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id))
} }
const list = filtered.map(s => ({ const list = filtered.map(s => ({
@@ -470,9 +522,9 @@ app.get('/api/scenarios', (req, res) => {
type: s.type, type: s.type,
weight: s.weight, weight: s.weight,
progress: progress[s.id] || { status: 'not_started', attempts: 0 } progress: progress[s.id] || { status: 'not_started', attempts: 0 }
})); }))
res.json(list); res.json(list)
}); })
// GET /api/scenarios/:id — full scenario detail // GET /api/scenarios/:id — full scenario detail
app.get('/api/scenarios/:id', (req, res) => { app.get('/api/scenarios/:id', (req, res) => {
+56 -9
View File
@@ -54,6 +54,36 @@ export default function App() {
const tmDragY0 = useRef(0) const tmDragY0 = useRef(0)
const tmDragH0 = 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 // Track previous scenario id to teardown on switch
const prevActiveIdRef = useRef(null) const prevActiveIdRef = useRef(null)
@@ -103,7 +133,10 @@ export default function App() {
setLoading(true) setLoading(true)
setActiveId(null) setActiveId(null)
setScenario(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) fetch(url)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -112,7 +145,7 @@ export default function App() {
}) })
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [activeBundleId]) }, [activeBundleId, examSession?.id])
// ── Load full scenario when selected — teardown previous, load new ───────── // ── Load full scenario when selected — teardown previous, load new ─────────
useEffect(() => { useEffect(() => {
@@ -137,9 +170,12 @@ export default function App() {
}, [activeId]) }, [activeId])
const refreshProgress = useCallback(async () => { const refreshProgress = useCallback(async () => {
const scenarioUrl = examSession?.id
? `/api/scenarios?session=${examSession.id}`
: `/api/scenarios?bundle=${activeBundleId}`
const [bundleData, scenarioData] = await Promise.all([ const [bundleData, scenarioData] = await Promise.all([
fetch('/api/bundles').then(r => r.json()), fetch('/api/bundles').then(r => r.json()),
fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()), fetch(scenarioUrl).then(r => r.json()),
]) ])
setBundles(bundleData) setBundles(bundleData)
setScenarios(scenarioData) setScenarios(scenarioData)
@@ -160,13 +196,13 @@ export default function App() {
}, [activeBundleId, activeId, examSession]) }, [activeBundleId, activeId, examSession])
// ── Exam actions ────────────────────────────────────────────────────────── // ── Exam actions ──────────────────────────────────────────────────────────
const startExam = useCallback(async (bundleId, customMinutes) => { const startExam = useCallback(async (bundleId, customMinutes, customScenarioCount) => {
const res = await fetch('/api/sessions', { const res = await fetch('/api/sessions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bundleId, examMinutes: customMinutes }), body: JSON.stringify({ bundleId, examMinutes: customMinutes, scenarioCount: customScenarioCount }),
}).then(r => r.json()) }).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()) const active = await fetch('/api/sessions/active').then(r => r.json())
setExamSession(active) setExamSession(active)
setActiveBundleId(bundleId) setActiveBundleId(bundleId)
@@ -257,6 +293,13 @@ export default function App() {
const activeBundle = bundles.find(b => b.id === activeBundleId) || null const activeBundle = bundles.find(b => b.id === activeBundleId) || null
const isMcq = scenario?.type === 'mcq' 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 ( return (
<div className={styles.app}> <div className={styles.app}>
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} /> <Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
@@ -290,7 +333,7 @@ export default function App() {
<div className={styles.body}> <div className={styles.body}>
{/* Sidebar */} {/* Sidebar */}
<Sidebar <Sidebar
scenarios={scenarios} scenarios={!!examSession ? examScenarios : scenarios}
activeId={activeId} activeId={activeId}
onSelect={setActiveId} onSelect={setActiveId}
loading={loading} loading={loading}
@@ -301,6 +344,7 @@ export default function App() {
onProgressUpdate={refreshProgress} onProgressUpdate={refreshProgress}
isExamMode={!!examSession} isExamMode={!!examSession}
examProgress={examProgress} examProgress={examProgress}
totalExamWeight={totalExamWeight}
/> />
{/* Sidebar resize handle */} {/* Sidebar resize handle */}
@@ -315,6 +359,9 @@ export default function App() {
onScenarioStart={handleScenarioStart} onScenarioStart={handleScenarioStart}
isExamMode={!!examSession} isExamMode={!!examSession}
examProgress={examProgress} examProgress={examProgress}
totalExamWeight={totalExamWeight}
focusMode={focusMode}
onToggleFocus={toggleFocusMode}
/> />
</div> </div>
@@ -350,9 +397,9 @@ export default function App() {
{examModalBundle && ( {examModalBundle && (
<ExamStartModal <ExamStartModal
bundle={examModalBundle} bundle={examModalBundle}
onStart={mins => { onStart={(mins, count) => {
setExamModalBundle(null) setExamModalBundle(null)
startExam(examModalBundle.id, mins) startExam(examModalBundle.id, mins, count)
}} }}
onCancel={() => setExamModalBundle(null)} onCancel={() => setExamModalBundle(null)}
/> />
+10 -1
View File
@@ -1,5 +1,6 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import styles from './BundleNav.module.css' import styles from './BundleNav.module.css'
import { useConfirm } from './useConfirm'
async function resetProgress(scope, opts) { async function resetProgress(scope, opts) {
await fetch('/api/progress/reset', { await fetch('/api/progress/reset', {
@@ -17,6 +18,7 @@ export default function BundleNav({
const [startX, setStartX] = useState(0) const [startX, setStartX] = useState(0)
const [scrollLeft, setScrollLeft] = useState(0) const [scrollLeft, setScrollLeft] = useState(0)
const [dragDist, setDragDist] = useState(0) const [dragDist, setDragDist] = useState(0)
const { confirm, ConfirmUI } = useConfirm()
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
if (!trackRef.current) return if (!trackRef.current) return
@@ -41,6 +43,7 @@ export default function BundleNav({
return ( return (
<> <>
{ConfirmUI}
<nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles"> <nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles">
{!collapsed && ( {!collapsed && (
<div <div
@@ -117,7 +120,13 @@ export default function BundleNav({
title={`Reset all progress in "${b.name}"`} title={`Reset all progress in "${b.name}"`}
onClick={async e => { onClick={async e => {
e.stopPropagation() 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 }) await resetProgress('bundle', { bundleId: b.id })
onProgressUpdate?.() onProgressUpdate?.()
}} }}
+60
View File
@@ -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 } }
+49 -5
View File
@@ -2,11 +2,18 @@ import { useState, useEffect, useRef } from 'react'
import styles from './ExamStartModal.module.css' import styles from './ExamStartModal.module.css'
export default function ExamStartModal({ bundle, onStart, onCancel }) { export default function ExamStartModal({ bundle, onStart, onCancel }) {
const totalScenarios = bundle?.scenario_ids?.length || 0
const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120) const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120)
const [scenarioCount, setScenarioCount] = useState(totalScenarios)
const inputRef = useRef(null) const inputRef = useRef(null)
// Keep scenarioCount in sync if bundle changes
useEffect(() => { 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) const t = setTimeout(() => inputRef.current?.select(), 60)
return () => clearTimeout(t) return () => clearTimeout(t)
}, []) }, [])
@@ -14,11 +21,14 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
if (!bundle) return null if (!bundle) return null
const numMinutes = Number(minutes) 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 = () => { const handleStart = () => {
if (!isValid) return if (!isValid) return
onStart(numMinutes) onStart(numMinutes, numScenarios)
} }
const presets = [ const presets = [
@@ -42,9 +52,10 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
<div className={styles.body}> <div className={styles.body}>
<div className={styles.info}> <div className={styles.info}>
<span>📋</span> <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> </div>
{/* Duration field */}
<div className={styles.field}> <div className={styles.field}>
<label className={styles.label}>Exam Duration</label> <label className={styles.label}>Exam Duration</label>
<div className={styles.presets}> <div className={styles.presets}>
@@ -73,13 +84,46 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
<span className={styles.unit}>minutes</span> <span className={styles.unit}>minutes</span>
</div> </div>
<div className={styles.hint}> <div className={styles.hint}>
{!isValid ? ( {!isMinutesValid ? (
<span style={{ color: 'var(--red)' }}> Duration must be between 5 and 300 minutes</span> <span style={{ color: 'var(--red)' }}> Duration must be between 5 and 300 minutes</span>
) : numMinutes < 60 ? '⚡ Speed run mode' : ) : numMinutes < 60 ? '⚡ Speed run mode' :
numMinutes <= 120 ? '🎯 Realistic exam timing' : numMinutes <= 120 ? '🎯 Realistic exam timing' :
'🧘 Relaxed practice pace'} '🧘 Relaxed practice pace'}
</div> </div>
</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>
<div className={styles.actions}> <div className={styles.actions}>
@@ -116,6 +116,24 @@
gap: 8px; 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 { .input {
width: 90px; width: 90px;
padding: 8px 12px; padding: 8px 12px;
+24 -7
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import styles from './ExamTimer.module.css' import styles from './ExamTimer.module.css'
import { useConfirm } from './useConfirm'
function formatTime(secs) { function formatTime(secs) {
if (secs < 0) secs = 0 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 pct = Math.min(100, (elapsed / durationSecs) * 100)
const urgent = remaining < 600 // < 10 min const urgent = remaining < 600 // < 10 min
const handleSubmit = useCallback(async () => { const { confirm, ConfirmUI } = useConfirm()
if (!window.confirm(`Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`)) return
onSubmit()
}, [session, onSubmit])
const handleAbandon = useCallback(() => { const handleSubmit = useCallback(async () => {
if (!window.confirm('Abandon this exam?\n\nYour progress will be saved but no score report will be generated.')) return 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]) }, [onAbandon, confirm])
if (!session) return null if (!session) return null
return ( return (
<>
<div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}> <div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}>
<div className={styles.left}> <div className={styles.left}>
<span className={styles.icon}></span> <span className={styles.icon}></span>
@@ -87,5 +102,7 @@ export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
</button> </button>
</div> </div>
</div> </div>
{ConfirmUI}
</>
) )
} }
+67 -7
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css' import styles from './ScenarioPanel.module.css'
import { useConfirm } from './useConfirm'
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans // Inline markdown: renders without a wrapping <p> — safe for buttons/spans
const inlineComponents = { 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 [tab, setTab] = useState('problem')
const [setupState, setSetupState] = useState('idle') // idle | running | done | error const [setupState, setSetupState] = useState('idle') // idle | running | done | error
const [validating, setValidating] = useState(false) const [validating, setValidating] = useState(false)
const { confirm, ConfirmUI } = useConfirm()
const [validResult, setValidResult] = useState(null) const [validResult, setValidResult] = useState(null)
const [selectedOption, setSelectedOption] = useState(null) const [selectedOption, setSelectedOption] = useState(null)
const [mcqResult, setMcqResult] = useState(null) const [mcqResult, setMcqResult] = useState(null)
@@ -197,18 +199,49 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
return ( return (
<div className={styles.panel}> <div className={styles.panel}>
{ConfirmUI}
{/* Scenario header */} {/* Scenario header */}
<div className={styles.scenarioHeader}> <div className={styles.scenarioHeader}>
<div className={styles.scenarioMeta}> <div className={styles.scenarioMeta}>
<span className={styles.category}>{scenario.category}</span> <span className={styles.category}>{scenario.category}</span>
<span className={`${styles.diff} ${styles[scenario.difficulty?.toLowerCase()]}`}> {!isExamMode && (
{scenario.difficulty} <span className={`${styles.diff} ${styles[scenario.difficulty?.toLowerCase()]}`}>
</span> {scenario.difficulty}
</span>
)}
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span> <span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
{!isExamMode && <span className={styles.weight}>{scenario.weight} pts</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>
<div className={styles.titleRow}> <div className={styles.titleRow}>
<div className={styles.scenarioTitle}>{scenario.title}</div> <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 && ( {isExamMode && scenario.progress?.started_at && (
<div className={styles.progressStats}> <div className={styles.progressStats}>
<div className={styles.statItem}> <div className={styles.statItem}>
@@ -231,15 +264,42 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
</div> </div>
</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 <button
className={styles.resetBtn} className={styles.resetBtn}
title="Reset progress for this scenario" title="Reset progress for this scenario"
onClick={async () => { 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 and cluster state for "${scenario.title}"?\n\nThis will run teardown to clean the environment.`
: `Reset progress for "${scenario.title}"?` : `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 }) await resetProgress('scenario', { scenarioId: scenario.id })
setSelectedOption(null) setSelectedOption(null)
setMcqResult(null) setMcqResult(null)
@@ -76,6 +76,19 @@
margin-left: auto; 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 { .scenarioTitle {
font-size: 19px; font-size: 19px;
font-weight: 800; font-weight: 800;
@@ -115,6 +128,34 @@
background: var(--red-dim); 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 { .completedBanner {
margin-top: 8px; margin-top: 8px;
font-size: 12px; font-size: 12px;
+68 -25
View File
@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
import { useConfirm } from './useConfirm'
const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' } const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' }
const TYPE_ICON = { task: '⚙', mcq: '◉' } const TYPE_ICON = { task: '⚙', mcq: '◉' }
@@ -16,10 +17,11 @@ export default function Sidebar({
scenarios, activeId, onSelect, loading, scenarios, activeId, onSelect, loading,
collapsed, onToggleCollapse, width, collapsed, onToggleCollapse, width,
activeBundleId, onProgressUpdate, activeBundleId, onProgressUpdate,
isExamMode, examProgress, isExamMode, examProgress, totalExamWeight,
}) { }) {
const [filterDiff, setFilterDiff] = useState('All') const [filterDiff, setFilterDiff] = useState('All')
const [filterType, setFilterType] = useState('All') const [filterType, setFilterType] = useState('All')
const { confirm, ConfirmUI } = useConfirm()
const filteredScenarios = useMemo(() => { const filteredScenarios = useMemo(() => {
return scenarios.filter(s => { return scenarios.filter(s => {
@@ -72,23 +74,37 @@ export default function Sidebar({
const handleCategoryReset = async (e, cat) => { const handleCategoryReset = async (e, cat) => {
e.stopPropagation() 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 }) await resetProgress('category', { category: cat })
onProgressUpdate?.() onProgressUpdate?.()
} }
const handleScenarioReset = async (e, scenarioId, title) => { const handleScenarioReset = async (e, scenarioId, title) => {
e.stopPropagation() 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 }) await resetProgress('scenario', { scenarioId })
onProgressUpdate?.() onProgressUpdate?.()
} }
return ( return (
<aside <>
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`} {ConfirmUI}
style={{ width, minWidth: width }} <aside
> className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
style={{ width, minWidth: width }}
>
{/* Top bar */} {/* Top bar */}
<div className={styles.sidebarTop}> <div className={styles.sidebarTop}>
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>} {!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
@@ -106,8 +122,8 @@ export default function Sidebar({
</button> </button>
</div> </div>
{/* Filter bar */} {/* Filter bar — hidden in exam mode */}
{!collapsed && ( {!collapsed && !isExamMode && (
<div className={styles.filterBar}> <div className={styles.filterBar}>
<select <select
value={filterDiff} value={filterDiff}
@@ -142,7 +158,42 @@ export default function Sidebar({
</div> </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 catDone = items.filter(s => s.progress?.status === 'completed').length
const isOpen = open[cat] !== false const isOpen = open[cat] !== false
const hasCatProgress = items.some(s => s.progress?.attempts > 0) const hasCatProgress = items.some(s => s.progress?.attempts > 0)
@@ -156,9 +207,7 @@ export default function Sidebar({
</div> </div>
<div className={styles.accordionRight}> <div className={styles.accordionRight}>
<span className={styles.catCount}> <span className={styles.catCount}>
{isExamMode {`${catDone}/${items.length}`}
? `${items.filter(s => examProgress?.[s.id]?.status === 'completed').length}/${items.length}`
: `${catDone}/${items.length}`}
</span> </span>
{hasCatProgress && ( {hasCatProgress && (
<button <button
@@ -178,24 +227,20 @@ export default function Sidebar({
{isOpen && ( {isOpen && (
<div className={styles.itemsBox}> <div className={styles.itemsBox}>
{items.map(s => { {items.map(s => {
const examDone = isExamMode && examProgress?.[s.id]?.status === 'completed' const done = s.progress?.status === 'completed'
const done = !isExamMode && s.progress?.status === 'completed'
const active = s.id === activeId const active = s.id === activeId
const hasAttempts = isExamMode const hasAttempts = s.progress?.attempts > 0
? (examProgress?.[s.id]?.attempts || 0) > 0
: s.progress?.attempts > 0
return ( return (
<button <button
key={s.id} 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)} onClick={() => onSelect(s.id)}
> >
<div className={styles.itemTop}> <div className={styles.itemTop}>
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span> <span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span> <span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
<span className={styles.itemTitle}>{s.title}</span> <span className={styles.itemTitle}>{s.title}</span>
{(done || examDone) && <span className={styles.checkmark}></span>} {done && <span className={styles.checkmark}></span>}
{/* Per-scenario reset — shown when item has attempts */}
{hasAttempts && ( {hasAttempts && (
<button <button
className={styles.itemResetBtn} className={styles.itemResetBtn}
@@ -214,10 +259,7 @@ export default function Sidebar({
{s.difficulty} {s.difficulty}
</span> </span>
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span> <span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
{!isExamMode && <span className={styles.weight}>{s.weight}pt</span>} <span className={styles.weight}>{s.weight}pt</span>
{isExamMode && examDone && (
<span className={styles.examCompletedTag}> Completed</span>
)}
</div> </div>
</button> </button>
) )
@@ -230,5 +272,6 @@ export default function Sidebar({
</div> </div>
)} )}
</aside> </aside>
</>
) )
} }
@@ -70,6 +70,22 @@
padding: 8px 0; 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 */ /* Filters */
.filterBar { .filterBar {
display: flex; display: flex;
@@ -338,6 +354,18 @@
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} } @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 { .examCompletedTag {
font-family: var(--mono); font-family: var(--mono);
font-size: 10px; font-size: 10px;
+57
View File
@@ -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 }
}
+4
View File
@@ -19,6 +19,8 @@
--red-dim: rgba(255,107,107,0.10); --red-dim: rgba(255,107,107,0.10);
--purple: #9775fa; --purple: #9775fa;
--purple-dim: rgba(151,117,250,0.10); --purple-dim: rgba(151,117,250,0.10);
--teal: #2dd4bf;
--teal-dim: rgba(45,212,191,0.11);
--text: #e2e8f4; --text: #e2e8f4;
--text-2: #8899b8; --text-2: #8899b8;
@@ -52,6 +54,8 @@
--red-dim: rgba(220,38,38,0.10); --red-dim: rgba(220,38,38,0.10);
--purple: #7c3aed; --purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.10); --purple-dim: rgba(124,58,237,0.10);
--teal: #0d9488;
--teal-dim: rgba(13,148,136,0.11);
--text: #111827; --text: #111827;
--text-2: #374151; --text-2: #374151;
@@ -1,5 +1,5 @@
{ {
"id": "k8s-basics", "id": "1-k8s-basics",
"name": "Kubernetes Basics", "name": "Kubernetes Basics",
"icon": "🌱", "icon": "🌱",
"tagline": "Core concepts for beginners", "tagline": "Core concepts for beginners",
@@ -36,6 +36,16 @@
"annotate-resource-basics", "annotate-resource-basics",
"init-container-task-basics", "init-container-task-basics",
"restart-policy-mcq", "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", "name": "Kubernetes Developer",
"icon": "🛠️", "icon": "🛠️",
"tagline": "CKAD exam — application development", "tagline": "CKAD exam — application development",
@@ -1,5 +1,5 @@
{ {
"id": "k8s-admin", "id": "3-k8s-admin",
"name": "Kubernetes Administrator", "name": "Kubernetes Administrator",
"icon": "🧑‍✈️", "icon": "🧑‍✈️",
"tagline": "CKA exam — cluster administration", "tagline": "CKA exam — cluster administration",
@@ -1,5 +1,5 @@
{ {
"id": "k8s-security", "id": "4-k8s-security",
"name": "Kubernetes Security", "name": "Kubernetes Security",
"icon": "🛡", "icon": "🛡",
"tagline": "CKS exam — hardening and threats", "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": []
}
+60
View File
@@ -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"
}
]
}
+65
View File
@@ -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"
}
]
}
+51
View File
@@ -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"
}
]
}
+44
View File
@@ -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"
}
]
}
+50
View File
@@ -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": []
}
+63
View File
@@ -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"
}
]
}
+44
View File
@@ -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": []
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

After

Width:  |  Height:  |  Size: 477 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 KiB

After

Width:  |  Height:  |  Size: 578 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 517 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

After

Width:  |  Height:  |  Size: 564 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 KiB

After

Width:  |  Height:  |  Size: 988 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 KiB

After

Width:  |  Height:  |  Size: 565 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

After

Width:  |  Height:  |  Size: 630 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

After

Width:  |  Height:  |  Size: 894 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

+57 -4
View File
@@ -92,7 +92,23 @@ done
kubectl wait --for=condition=Ready nodes --all --timeout=120s kubectl wait --for=condition=Ready nodes --all --timeout=120s
OK "Cluster node is Ready" 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 # Pods scheduled before flannel is ready get FailedCreatePodSandBox warnings
# (missing /run/flannel/subnet.env). Waiting here avoids that noise. # (missing /run/flannel/subnet.env). Waiting here avoids that noise.
for i in $(seq 1 30); do for i in $(seq 1 30); do
@@ -137,6 +153,9 @@ alias klogs='kubectl logs'
kns() { kubectl config set-context --current --namespace="$1"; } kns() { kubectl config set-context --current --namespace="$1"; }
kctx() { kubectl config use-context "$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 source <(kubectl completion bash) 2>/dev/null || true
complete -F __start_kubectl k 2>/dev/null || true complete -F __start_kubectl k 2>/dev/null || true
@@ -153,17 +172,50 @@ BASHRC
OK "Shell configured" 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..." LOG "Starting API server..."
cd /app/backend && node server.js &>/var/log/api.log & cd /app/backend && node server.js &>/var/log/api.log &
OK "API server started (port 4000)" 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 # Terminal is served via WebSocket at /shell-ws by the Node.js API server
# using node-pty — no external ttyd binary needed. # using node-pty — no external ttyd binary needed.
# ── 7. Start nginx reverse proxy ──────────────────────────────────────────── # ── 8. Start nginx reverse proxy ────────────────────────────────────────────
LOG "Starting nginx proxy..." LOG "Starting nginx proxy..."
nginx -g 'daemon off;' &>/var/log/nginx.log & nginx -g 'daemon off;' &>/var/log/nginx.log &
OK "nginx started (port 80)" OK "nginx started (port 80)"
@@ -171,6 +223,7 @@ OK "nginx started (port 80)"
# ── 8. Keep Alive & Graceful Shutdown ──────────────────────────────────────── # ── 8. Keep Alive & Graceful Shutdown ────────────────────────────────────────
cleanup() { cleanup() {
LOG "Caught signal, shutting down KubeKosh..." LOG "Caught signal, shutting down KubeKosh..."
kill -TERM "$WATCHDOG_PID" 2>/dev/null || true
kill -TERM "$K3S_PID" 2>/dev/null || true kill -TERM "$K3S_PID" 2>/dev/null || true
kill $(jobs -p) 2>/dev/null || true kill $(jobs -p) 2>/dev/null || true
exit 0 exit 0