2026-03-07 05:43:51 +00:00
<!DOCTYPE html>
2026-03-18 18:12:04 +08:00
< html lang = "en" >
2026-03-07 05:43:51 +00:00
< head >
2026-03-10 07:30:42 +08:00
< meta charset = "UTF-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" / >
2026-03-18 18:12:04 +08:00
< title > Linux Learning Lab< / title >
2026-03-10 07:30:42 +08:00
< style >
:root {
2026-03-18 18:12:04 +08:00
--bg: #f3f7fb;
--panel: rgba(255, 255, 255, 0.94);
--line: #d8e2ee;
--text: #102033;
--muted: #5d7187;
--brand: #0f6db6;
--accent: #1d9b6c;
--soft: #e9f3ff;
--terminal: #0e1724;
--shadow: 0 18px 40px rgba(16, 32, 51, 0.12);
}
[data-theme="dark"] {
--bg: #08111d;
--panel: rgba(12, 24, 38, 0.94);
--line: #1d3245;
--text: #edf4fb;
--muted: #9bb0c3;
--brand: #67baff;
--accent: #62d6ab;
--soft: rgba(103, 186, 255, 0.1);
--terminal: #06101c;
--shadow: 0 20px 44px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
2026-03-10 07:30:42 +08:00
body {
2026-03-18 18:12:04 +08:00
margin: 0;
2026-03-10 07:30:42 +08:00
min-height: 100vh;
2026-03-18 18:12:04 +08:00
color: var(--text);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top right, rgba(15, 109, 182, 0.15), transparent 25%),
radial-gradient(circle at bottom left, rgba(29, 155, 108, 0.12), transparent 20%),
var(--bg);
}
button, input { font: inherit; }
.shell { max-width: 1480px; margin: 0 auto; padding: 20px; }
.topbar, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.topbar {
2026-03-10 07:30:42 +08:00
display: flex;
justify-content: space-between;
2026-03-18 18:12:04 +08:00
gap: 16px;
padding: 20px 24px;
margin-bottom: 16px;
2026-03-10 07:41:38 +08:00
align-items: center;
2026-03-10 07:30:42 +08:00
}
2026-03-18 18:12:04 +08:00
.title h1 { margin: 0 0 8px; font-size: 30px; }
.title p, .muted { color: var(--muted); line-height: 1.8; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.btn, .btn-soft, .btn-ghost {
border: 0;
border-radius: 999px;
padding: 11px 16px;
cursor: pointer;
font-weight: 700;
}
.btn { color: #fff; background: linear-gradient(135deg, var(--brand), #37a1ff); }
.btn-soft { color: var(--brand); background: var(--soft); }
.btn-ghost { color: var(--text); background: transparent; border: 1px solid var(--line); }
2026-03-10 07:30:42 +08:00
.layout {
2026-03-18 18:12:04 +08:00
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.sidebar { padding: 20px; position: sticky; top: 20px; }
.section { margin-bottom: 18px; }
.eyebrow { font-size: 12px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--brand); }
h2 { margin: 8px 0 10px; font-size: 18px; }
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 12px;
}
.stat {
padding: 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.3);
}
.stat span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.stat strong { font-size: 22px; }
2026-03-19 13:53:49 +08:00
.mini-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.mini-stat {
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.2);
}
.mini-stat span {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.mini-stat strong { font-size: 20px; }
2026-03-18 18:12:04 +08:00
.search {
display: flex;
gap: 8px;
margin-top: 12px;
}
.search input {
flex: 1;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: transparent;
color: var(--text);
outline: none;
}
.chip-list, .cmd-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.chip {
display: inline-flex;
align-items: center;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: var(--soft);
color: var(--brand);
}
.results, .modules { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; max-height: 34vh; overflow: auto; }
.result, .lesson-btn, .module-card {
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.18);
}
.result, .lesson-btn {
width: 100%;
text-align: left;
padding: 12px 14px;
color: var(--text);
cursor: pointer;
}
.result strong, .lesson-btn strong { display: block; margin-bottom: 4px; font-size: 14px; }
.module-card { padding: 14px; }
.module-card h3 { margin: 0 0 8px; font-size: 16px; }
.module-card p { margin: 0 0 10px; color: var(--muted); line-height: 1.7; }
.lesson-list { display: flex; flex-direction: column; gap: 8px; }
.lesson-btn.active { background: rgba(15, 109, 182, 0.12); border-color: rgba(15, 109, 182, 0.28); }
.main { display: flex; flex-direction: column; gap: 16px; }
.hero, .detail, .practice { padding: 22px; }
.hero-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 14px;
}
.hero-head h2 { margin: 8px 0 10px; font-size: 34px; }
.hero-grid, .detail-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 14px;
2026-03-10 07:41:38 +08:00
}
.panel {
2026-03-18 18:12:04 +08:00
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
background: rgba(255,255,255,0.12);
}
.panel h3 { margin: 0 0 10px; font-size: 17px; }
.panel p, .panel li { color: var(--muted); line-height: 1.9; }
.panel ul { margin: 0; padding-left: 18px; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.code {
margin: 0;
padding: 13px 14px;
border-radius: 14px;
background: var(--terminal);
color: #d8e8ff;
font-family: Consolas, "Courier New", monospace;
font-size: 14px;
overflow: auto;
}
.examples { display: flex; flex-direction: column; gap: 10px; }
.exercise-list { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
.exercise {
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
background: rgba(255,255,255,0.12);
}
.badge {
display: inline-flex;
margin-bottom: 10px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(29, 155, 108, 0.12);
color: var(--accent);
font-size: 12px;
font-weight: 800;
}
.exercise h4 { margin: 0 0 10px; font-size: 20px; }
.terminal {
margin-top: 12px;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(151,177,201,0.18);
background: linear-gradient(180deg, var(--terminal), #172435);
}
.terminal-head { padding: 11px 14px; color: #b8d4ef; border-bottom: 1px solid rgba(151,177,201,0.16); }
.terminal-input { display: flex; align-items: center; gap: 8px; padding: 12px 14px; }
.prompt { color: #62d6ab; font-family: Consolas, "Courier New", monospace; font-weight: 800; }
.terminal-input input {
flex: 1;
border: 0;
outline: none;
background: transparent;
color: #f4fbff;
font-family: Consolas, "Courier New", monospace;
}
.terminal-input button {
border: 0;
border-radius: 12px;
padding: 9px 13px;
background: linear-gradient(135deg, var(--brand), #37a1ff);
color: #fff;
cursor: pointer;
font-weight: 800;
}
.output {
margin: 0;
min-height: 100px;
padding: 14px;
white-space: pre-wrap;
color: #d8e8ff;
font-family: Consolas, "Courier New", monospace;
}
.feedback { display: none; margin-top: 10px; padding: 11px 13px; border-radius: 14px; line-height: 1.7; font-weight: 700; }
.feedback.show { display: block; }
.feedback.success { background: rgba(29,155,108,0.1); border: 1px solid rgba(29,155,108,0.22); color: #187653; }
.feedback.warn { background: rgba(240,180,41,0.1); border: 1px solid rgba(240,180,41,0.24); color: #a26b04; }
2026-03-19 13:53:49 +08:00
.mastery-note {
margin-top: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.18);
color: var(--muted);
line-height: 1.8;
}
2026-03-19 14:58:56 +08:00
.diagnostic {
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.18);
color: var(--muted);
line-height: 1.8;
}
.module-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.module-summary-card {
padding: 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.18);
}
.module-summary-card h4 {
margin: 0 0 8px;
font-size: 16px;
}
.module-summary-card p {
margin: 0 0 8px;
color: var(--muted);
line-height: 1.7;
}
2026-03-19 15:25:19 +08:00
.stage-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.stage-card {
padding: 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.18);
}
.stage-card h4 {
margin: 0 0 8px;
font-size: 16px;
}
.stage-card p {
margin: 0 0 8px;
color: var(--muted);
line-height: 1.7;
}
2026-03-19 13:53:49 +08:00
.lesson-btn.done {
border-color: rgba(29, 155, 108, 0.4);
background: rgba(29, 155, 108, 0.1);
}
.ordered {
margin: 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.9;
}
2026-03-18 18:12:04 +08:00
.empty { padding: 12px; border: 1px dashed var(--line); border-radius: 14px; color: var(--muted); text-align: center; }
@media (max-width: 1180px) {
2026-03-10 07:30:42 +08:00
.layout { grid-template-columns: 1fr; }
2026-03-18 18:12:04 +08:00
.sidebar { position: static; }
.hero-grid, .detail-grid { grid-template-columns: 1fr; }
2026-03-19 13:53:49 +08:00
.mini-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2026-03-19 14:58:56 +08:00
.module-summary-grid { grid-template-columns: 1fr; }
2026-03-19 15:25:19 +08:00
.stage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2026-03-18 18:12:04 +08:00
}
@media (max-width: 720px) {
.shell { padding: 14px; }
.topbar, .hero-head, .search, .terminal-input { flex-direction: column; align-items: stretch; }
.stats { grid-template-columns: 1fr; }
2026-03-19 13:53:49 +08:00
.mini-stats { grid-template-columns: 1fr; }
2026-03-19 15:25:19 +08:00
.stage-grid { grid-template-columns: 1fr; }
2026-03-10 07:30:42 +08:00
}
< / style >
2026-03-07 05:43:51 +00:00
< / head >
< body >
2026-03-18 18:12:04 +08:00
< div class = "shell" >
< header class = "topbar" >
< div class = "title" >
< div class = "eyebrow" > Linux Ops Learning Lab< / div >
< h1 id = "courseTitle" > Linux Learning Lab< / h1 >
< p id = "courseDescription" > Search Linux lessons, practice commands in a sandbox, and build better troubleshooting habits.< / p >
2026-03-10 07:30:42 +08:00
< / div >
2026-03-18 18:12:04 +08:00
< div class = "actions" >
< button class = "btn-ghost" type = "button" id = "themeBtn" > Toggle theme< / button >
< button class = "btn-soft" type = "button" onclick = "resetSandbox()" > Reset sandbox< / button >
< a class = "btn-ghost" href = "/privacy" target = "_blank" rel = "noreferrer" > Privacy< / a >
2026-03-10 07:30:42 +08:00
< / div >
2026-03-18 18:12:04 +08:00
< / header >
< div class = "layout" >
< aside class = "sidebar card" >
< section class = "section" >
< div class = "eyebrow" > Overview< / div >
< h2 > Course overview< / h2 >
< p class = "muted" > Lessons are grouped by module and command family. The page focuses on readable summaries and guided sandbox practice.< / p >
< div class = "stats" >
< div class = "stat" > < span > Modules< / span > < strong id = "moduleCount" > 0< / strong > < / div >
< div class = "stat" > < span > Lessons< / span > < strong id = "lessonCount" > 0< / strong > < / div >
< div class = "stat" > < span > Exercises< / span > < strong id = "exerciseCount" > 0< / strong > < / div >
< div class = "stat" > < span > Commands< / span > < strong id = "commandCount" > 0< / strong > < / div >
2026-03-19 13:53:49 +08:00
< div class = "stat" > < span > Mastered< / span > < strong id = "masteredCount" > 0< / strong > < / div >
< div class = "stat" > < span > Completion< / span > < strong id = "completionRate" > 0%< / strong > < / div >
2026-03-18 18:12:04 +08:00
< / div >
< div class = "chip-list" id = "commandTags" > < / div >
< p class = "muted" id = "metaLine" style = "margin-top: 12px;" > Version --< / p >
< / section >
< section class = "section" >
< div class = "eyebrow" > Search< / div >
< h2 > Quick lookup< / h2 >
< div class = "search" >
< input id = "searchInput" type = "text" placeholder = "Try pwd, grep, module_1, or m1_l1_pwd" / >
< button class = "btn" type = "button" onclick = "runSearch()" > Search< / button >
< / div >
< div class = "results" id = "searchResults" > < div class = "empty" > Search by command, lesson id, or module id.< / div > < / div >
< / section >
< section class = "section" >
< div class = "eyebrow" > Modules< / div >
< h2 > Course map< / h2 >
< div class = "modules" id = "moduleList" > < div class = "empty" > Loading modules...< / div > < / div >
< / section >
2026-03-19 14:58:56 +08:00
< section class = "section" >
< div class = "eyebrow" > Diagnostics< / div >
< h2 > Course coverage check< / h2 >
< div class = "mini-stats" >
< div class = "mini-stat" > < span > Coverage< / span > < strong id = "coverageRate" > 0%< / strong > < / div >
< div class = "mini-stat" > < span > Operations< / span > < strong id = "operationCount" > 0< / strong > < / div >
< div class = "mini-stat" > < span > Reflection< / span > < strong id = "reflectionCount" > 0< / strong > < / div >
< div class = "mini-stat" > < span > Unsupported< / span > < strong id = "unsupportedCount" > 0< / strong > < / div >
< / div >
< div class = "diagnostic" id = "diagnosticNote" style = "margin-top: 12px;" > Checking course and sandbox alignment...< / div >
< / section >
2026-03-18 18:12:04 +08:00
< / aside >
< main class = "main" >
< section class = "hero card" >
< div class = "hero-head" >
< div >
< div class = "eyebrow" id = "moduleLabel" > Pick a lesson to begin< / div >
< h2 id = "lessonTitle" > Turn Linux practice into workflow thinking< / h2 >
< p class = "muted" id = "lessonGoal" > Choose a lesson from the left to see goals, examples, common pitfalls, and runnable exercises.< / p >
< / div >
< div class = "actions" >
2026-03-19 13:53:49 +08:00
< button class = "btn-soft" type = "button" id = "masteryBtn" > Mark mastered< / button >
2026-03-18 18:12:04 +08:00
< button class = "btn-ghost" type = "button" id = "prevBtn" > Previous< / button >
< button class = "btn" type = "button" id = "nextBtn" > Next< / button >
< / div >
< / div >
< div class = "cmd-list" id = "lessonCommands" > < / div >
< div class = "hero-grid" >
< div class = "panel" >
< h3 > Module summary< / h3 >
< p id = "moduleSummary" > This card shows the learning direction of the current module.< / p >
< / div >
< div class = "panel" >
< h3 > Sandbox runtime< / h3 >
< p > You can execute commands directly below. The platform returns simulated output and exercise feedback.< / p >
< ul >
< li > Current directory: < span id = "runtimeCwd" > /< / span > < / li >
< li > Current user: < span id = "runtimeUser" > sandbox_user< / span > < / li >
< / ul >
< / div >
< / div >
2026-03-19 13:53:49 +08:00
< div class = "mastery-note" id = "masteryNote" > Master a lesson after you can explain the command, predict the output, and connect it to a real operations step.< / div >
< / section >
2026-03-19 15:25:19 +08:00
< section class = "detail card" >
< div class = "eyebrow" > Learning stages< / div >
< h2 style = "margin: 8px 0 0;" > System route from Linux basics to operations habits< / h2 >
< p class = "muted" > This view helps you study in phases instead of bouncing between unrelated commands.< / p >
< div class = "stage-grid" id = "stageGrid" > < / div >
< / section >
2026-03-19 14:58:56 +08:00
< section class = "detail card" >
< div class = "eyebrow" > Module heatmap< / div >
< h2 style = "margin: 8px 0 0;" > See how the learning path is distributed< / h2 >
< p class = "muted" > This panel helps you check whether the project really teaches both command basics and operations workflows.< / p >
< div class = "module-summary-grid" id = "moduleSummaryGrid" > < / div >
< / section >
2026-03-19 13:53:49 +08:00
< section class = "detail card" >
< div class = "eyebrow" > Learning cockpit< / div >
< h2 style = "margin: 8px 0 0;" > Visual study map for the current lesson< / h2 >
< p class = "muted" > These cards help you move from memorizing a command to understanding the workflow around it.< / p >
< div class = "detail-grid" style = "margin-top: 16px;" >
< article class = "panel" >
< h3 > Lesson radar< / h3 >
< div class = "mini-stats" id = "lessonRadar" > < / div >
< / article >
< article class = "panel" >
< h3 > Experiment ladder< / h3 >
< ol class = "ordered" id = "experimentList" > < / ol >
< / article >
< article class = "panel" >
< h3 > Observation checklist< / h3 >
< ul id = "observationList" > < / ul >
< / article >
< article class = "panel" >
< h3 > Related commands< / h3 >
< div class = "chip-list" id = "relatedCommandList" > < / div >
< / article >
< / div >
2026-03-18 18:12:04 +08:00
< / section >
< section class = "detail card" >
< div class = "detail-grid" >
< article class = "panel" >
< h3 > Why this matters< / h3 >
< p id = "lessonWhy" > Understanding when to use a command matters more than memorizing flags in isolation.< / p >
< / article >
< article class = "panel" >
< h3 > Teaching angle< / h3 >
< p id = "classicView" > Each lesson connects the command to a broader troubleshooting flow.< / p >
< / article >
< article class = "panel" >
< h3 > Core ideas< / h3 >
< ul id = "conceptList" > < / ul >
< / article >
< article class = "panel" >
< h3 > Common scenarios< / h3 >
< ul id = "scenarioList" > < / ul >
< / article >
< article class = "panel" >
< h3 > Common pitfalls< / h3 >
< ul id = "pitfallList" > < / ul >
< / article >
< article class = "panel" >
< h3 > Troubleshooting flow< / h3 >
< ul id = "flowList" > < / ul >
< / article >
< article class = "panel" >
< h3 > Takeaways< / h3 >
< ul id = "takeawayList" > < / ul >
< p id = "afterClass" style = "margin-top: 10px;" > < / p >
< / article >
< article class = "panel" >
< h3 > Example commands< / h3 >
< div class = "examples" id = "exampleList" > < / div >
< / article >
< / div >
< / section >
2026-03-10 09:16:37 +08:00
2026-03-18 18:12:04 +08:00
< section class = "practice card" >
< div class = "eyebrow" > Practice< / div >
< h2 style = "margin: 8px 0 0;" > Exercises and sandbox terminal< / h2 >
< p class = "muted" > Operation tasks run in the simulated terminal. Reflection tasks keep the learning flow readable even when source data is noisy.< / p >
2026-03-10 07:41:38 +08:00
< div class = "exercise-list" id = "exerciseList" > < / div >
2026-03-18 18:12:04 +08:00
< / section >
< / main >
< / div >
2026-03-10 07:30:42 +08:00
< / div >
2026-03-07 05:43:51 +00:00
2026-03-10 07:30:42 +08:00
< script >
2026-03-19 13:53:49 +08:00
const state = {
overview: null,
lesson: null,
theme: localStorage.getItem('linux_lab_theme') || 'light',
progress: JSON.parse(localStorage.getItem('linux_lab_progress') || '{}')
};
2026-03-07 05:43:51 +00:00
2026-03-10 07:30:42 +08:00
document.addEventListener('DOMContentLoaded', async () => {
2026-03-18 18:12:04 +08:00
setTheme(state.theme);
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light'));
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') runSearch();
});
document.getElementById('prevBtn').addEventListener('click', () => {
if (state.lesson?.previous_lesson) openLesson(state.lesson.previous_lesson.id);
});
document.getElementById('nextBtn').addEventListener('click', () => {
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id);
});
2026-03-19 13:53:49 +08:00
document.getElementById('masteryBtn').addEventListener('click', toggleMastery);
2026-03-18 18:12:04 +08:00
await loadOverview();
2026-03-10 07:30:42 +08:00
});
2026-03-07 05:43:51 +00:00
2026-03-19 13:53:49 +08:00
function saveProgress() {
localStorage.setItem('linux_lab_progress', JSON.stringify(state.progress));
}
function masteredCount() {
return Object.values(state.progress).filter(Boolean).length;
}
function isMastered(lessonId) {
return Boolean(state.progress[lessonId]);
}
function completionRate() {
const total = state.overview?.meta?.lesson_count || 0;
if (!total) return 0;
return Math.round((masteredCount() / total) * 100);
}
function toggleMastery() {
if (!state.lesson?.id) return;
state.progress[state.lesson.id] = !state.progress[state.lesson.id];
saveProgress();
renderOverview();
renderModules();
renderLesson();
}
2026-03-18 18:12:04 +08:00
async function loadOverview() {
const response = await fetch('/api/overview');
state.overview = await response.json();
renderOverview();
const firstLesson = state.overview.modules?.[0]?.lessons?.[0];
if (firstLesson) await openLesson(firstLesson.id);
}
function renderOverview() {
const meta = state.overview.meta || {};
document.getElementById('courseTitle').textContent = meta.title || 'Linux Learning Lab';
document.getElementById('courseDescription').textContent = meta.description || '';
document.getElementById('moduleCount').textContent = meta.module_count || 0;
document.getElementById('lessonCount').textContent = meta.lesson_count || 0;
document.getElementById('exerciseCount').textContent = meta.exercise_count || 0;
document.getElementById('commandCount').textContent = meta.command_count || 0;
2026-03-19 13:53:49 +08:00
document.getElementById('masteredCount').textContent = masteredCount();
document.getElementById('completionRate').textContent = completionRate() + '%';
2026-03-19 14:58:56 +08:00
document.getElementById('coverageRate').textContent = (state.overview.diagnostics?.coverage_rate ?? 0) + '%';
document.getElementById('operationCount').textContent = state.overview.diagnostics?.operation_exercises ?? 0;
document.getElementById('reflectionCount').textContent = state.overview.diagnostics?.reflection_exercises ?? 0;
document.getElementById('unsupportedCount').textContent = state.overview.diagnostics?.unsupported_count ?? 0;
document.getElementById('diagnosticNote').textContent = (state.overview.diagnostics?.unsupported_count ?? 0) === 0
? 'All commands referenced by the course are covered by the sandbox command set.'
: 'Some course commands are not fully supported by the sandbox yet.';
2026-03-18 18:12:04 +08:00
document.getElementById('metaLine').textContent = `Version ${meta.version || '4.0'} | Updated ${meta.updated || '--'}`;
renderRuntime(state.overview.runtime || {});
renderCommandTags(state.overview.commands || []);
renderModules();
2026-03-19 15:25:19 +08:00
renderStageGrid();
2026-03-19 14:58:56 +08:00
renderModuleSummaryGrid();
2026-03-18 18:12:04 +08:00
}
function renderRuntime(runtime) {
document.getElementById('runtimeCwd').textContent = runtime.cwd || '/';
document.getElementById('runtimeUser').textContent = runtime.user || 'sandbox_user';
}
function renderCommandTags(commands) {
const container = document.getElementById('commandTags');
if (!commands.length) {
container.innerHTML = '< span class = "chip" > Waiting for command index< / span > ';
return;
}
container.innerHTML = commands.slice(0, 10).map((item) => `< span class = "chip" > ${escapeHtml(item)}< / span > `).join('');
2026-03-10 07:30:42 +08:00
}
2026-03-07 05:43:51 +00:00
2026-03-18 18:12:04 +08:00
function renderModules() {
const modules = state.overview.modules || [];
const container = document.getElementById('moduleList');
if (!modules.length) {
container.innerHTML = '< div class = "empty" > No modules found.< / div > ';
return;
}
container.innerHTML = modules.map((module) => `
< article class = "module-card" >
< h3 > ${escapeHtml(module.display_title)}< / h3 >
< p > ${escapeHtml(module.display_summary)}< / p >
< div class = "lesson-list" >
${(module.lessons || []).map((lesson) => `
2026-03-19 13:53:49 +08:00
< button class = "lesson-btn ${state.lesson?.id === lesson.id ? 'active' : ''} ${isMastered(lesson.id) ? 'done' : ''}" type = "button" onclick = "openLesson('${lesson.id}')" >
< strong > ${isMastered(lesson.id) ? 'Completed - ' : ''}${escapeHtml(lesson.display_title)}< / strong >
2026-03-18 18:12:04 +08:00
< span class = "muted" > ${escapeHtml((lesson.command_tokens || []).join(' | ') || lesson.command || 'mixed commands')} | ${lesson.exercise_count || 0} exercises< / span >
< / button >
`).join('')}
< / div >
2026-03-19 14:58:56 +08:00
< / article >
`).join('');
}
function renderModuleSummaryGrid() {
const modules = state.overview.modules || [];
const target = document.getElementById('moduleSummaryGrid');
if (!modules.length) {
target.innerHTML = '< div class = "empty" > No module diagnostics found.< / div > ';
return;
}
target.innerHTML = modules.map((module) => `
< article class = "module-summary-card" >
< h4 > ${escapeHtml(module.display_title)}< / h4 >
< p > ${escapeHtml(module.display_summary)}< / p >
< div class = "mini-stats" >
< div class = "mini-stat" > < span > Lessons< / span > < strong > ${module.lesson_count || 0}< / strong > < / div >
< div class = "mini-stat" > < span > Exercises< / span > < strong > ${module.exercise_count || 0}< / strong > < / div >
< div class = "mini-stat" > < span > Ops< / span > < strong > ${module.operation_count || 0}< / strong > < / div >
< div class = "mini-stat" > < span > Commands< / span > < strong > ${module.command_count || 0}< / strong > < / div >
< / div >
2026-03-18 18:12:04 +08:00
< / article >
`).join('');
}
2026-03-19 15:25:19 +08:00
function renderStageGrid() {
const groups = [
{ title: 'Stage 1: Foundations', summary: 'Orientation, listing, file operations, and permissions.', range: [0, 1] },
{ title: 'Stage 2: Search & Observation', summary: 'Text search, log preview, process, service, and network inspection.', range: [2, 3] },
{ title: 'Stage 3: Incidents', summary: 'Disk, auth, and service-path drills that connect commands into playbooks.', range: [4, 4] },
{ title: 'Stage 4: Automation & Platform', summary: 'Shell state, package tools, archives, monitoring, and scheduling.', range: [5, 7] }
];
const modules = state.overview.modules || [];
const target = document.getElementById('stageGrid');
target.innerHTML = groups.map((group) => {
const selectedModules = modules.slice(group.range[0], group.range[1] + 1);
const lessonTotal = selectedModules.reduce((sum, item) => sum + (item.lesson_count || 0), 0);
const exerciseTotal = selectedModules.reduce((sum, item) => sum + (item.exercise_count || 0), 0);
return `
< article class = "stage-card" >
< h4 > ${escapeHtml(group.title)}< / h4 >
< p > ${escapeHtml(group.summary)}< / p >
< div class = "mini-stats" >
< div class = "mini-stat" > < span > Modules< / span > < strong > ${selectedModules.length}< / strong > < / div >
< div class = "mini-stat" > < span > Lessons< / span > < strong > ${lessonTotal}< / strong > < / div >
< div class = "mini-stat" > < span > Exercises< / span > < strong > ${exerciseTotal}< / strong > < / div >
< div class = "mini-stat" > < span > Mastered< / span > < strong > ${selectedModules.reduce((sum, item) => sum + (item.lessons || []).filter((lesson) => isMastered(lesson.id)).length, 0)}< / strong > < / div >
< / div >
< / article >
`;
}).join('');
}
2026-03-18 18:12:04 +08:00
async function openLesson(lessonId, focusExerciseId = '') {
const response = await fetch('/api/lesson?id=' + encodeURIComponent(lessonId));
const payload = await response.json();
state.lesson = payload.lesson;
renderModules();
renderLesson();
if (focusExerciseId) {
setTimeout(() => {
document.getElementById('exercise-' + focusExerciseId)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 120);
}
2026-03-10 07:30:42 +08:00
}
2026-03-07 05:43:51 +00:00
2026-03-18 18:12:04 +08:00
function renderLesson() {
const lesson = state.lesson;
if (!lesson) return;
document.getElementById('moduleLabel').textContent = lesson.display_module_title || 'Current lesson';
document.getElementById('lessonTitle').textContent = lesson.display_title || 'Linux lesson';
document.getElementById('lessonGoal').textContent = lesson.display_goal || '';
document.getElementById('moduleSummary').textContent = lesson.display_module_summary || '';
document.getElementById('lessonWhy').textContent = lesson.display_why || '';
document.getElementById('classicView').textContent = lesson.classic_view || '';
document.getElementById('afterClass').textContent = lesson.after_class || '';
2026-03-19 13:53:49 +08:00
document.getElementById('masteryBtn').textContent = isMastered(lesson.id) ? 'Mark for review' : 'Mark mastered';
document.getElementById('masteryNote').textContent = isMastered(lesson.id)
? 'This lesson is marked as mastered. Revisit it if you cannot explain the command chain from memory.'
: 'Mark a lesson as mastered after you can explain the command, predict the output, and choose the next troubleshooting step.';
2026-03-18 18:12:04 +08:00
document.getElementById('prevBtn').disabled = !lesson.previous_lesson;
document.getElementById('nextBtn').disabled = !lesson.next_lesson;
document.getElementById('lessonCommands').innerHTML = (lesson.command_tokens || [lesson.command || 'mixed commands']).map((item) => `< span class = "chip" > ${escapeHtml(item)}< / span > `).join('');
2026-03-19 13:53:49 +08:00
renderLessonCockpit(lesson);
2026-03-18 18:12:04 +08:00
renderList('conceptList', lesson.concepts, 'No concept notes yet.');
renderList('scenarioList', lesson.scenarios, 'No scenario notes yet.');
renderList('pitfallList', lesson.pitfalls, 'No pitfall notes yet.');
renderList('flowList', lesson.troubleshooting_flow, 'No flow notes yet.');
renderList('takeawayList', lesson.takeaways, 'No takeaways yet.');
const examples = lesson.examples || [];
document.getElementById('exampleList').innerHTML = examples.length ? examples.map((item) => `< pre class = "code" > ${escapeHtml(item)}< / pre > `).join('') : '< div class = "empty" > No example commands yet.< / div > ';
renderExercises(lesson.exercises || []);
}
2026-03-19 13:53:49 +08:00
function renderLessonCockpit(lesson) {
document.getElementById('lessonRadar').innerHTML = [
{ label: 'Commands', value: lesson.command_tokens?.length || 1 },
{ label: 'Exercises', value: lesson.exercise_count || 0 },
{ label: 'Pitfalls', value: lesson.pitfalls?.length || 0 },
{ label: 'Related', value: lesson.related_commands?.length || 0 }
].map((item) => `
< div class = "mini-stat" >
< span > ${escapeHtml(item.label)}< / span >
< strong > ${item.value}< / strong >
< / div >
`).join('');
const experimentSteps = [
`Confirm the goal: ${lesson.display_goal || 'Understand the command objective.'}`,
`Run ${lesson.command || 'the main command'} and inspect the immediate output.`,
`Compare the output with one example or scenario from this lesson.`,
`Choose the next troubleshooting step based on what the command revealed.`
];
document.getElementById('experimentList').innerHTML = experimentSteps.map((item) => `< li > ${escapeHtml(item)}< / li > `).join('');
const observationItems = [
`Before running: confirm the current context and target path.`,
`During output review: look for the field or line that answers the lesson goal.`,
`After running: explain what should happen next in a real operations flow.`
];
document.getElementById('observationList').innerHTML = observationItems.map((item) => `< li > ${escapeHtml(item)}< / li > `).join('');
const related = lesson.related_commands || [];
document.getElementById('relatedCommandList').innerHTML = related.length
? related.map((item) => `< span class = "chip" > ${escapeHtml(item)}< / span > `).join('')
: '< span class = "chip" > No related commands yet< / span > ';
}
2026-03-18 18:12:04 +08:00
function renderList(id, items, emptyText) {
const target = document.getElementById(id);
target.innerHTML = (items & & items.length) ? items.map((item) => `< li > ${escapeHtml(item)}< / li > `).join('') : `< li > ${escapeHtml(emptyText)}< / li > `;
}
function renderExercises(exercises) {
const container = document.getElementById('exerciseList');
if (!exercises.length) {
container.innerHTML = '< div class = "empty" > This lesson does not have exercises yet.< / div > ';
return;
}
container.innerHTML = exercises.map((exercise) => {
const label = exercise.type === 'operation' ? 'Operation task' : exercise.type === 'scenario' ? 'Scenario reflection' : 'Understanding task';
if (exercise.type === 'operation') {
const placeholder = exercise.solution?.[0] || state.lesson.command || 'pwd';
2026-03-10 07:30:42 +08:00
return `
2026-03-18 18:12:04 +08:00
< article class = "exercise" id = "exercise-${exercise.id}" >
< span class = "badge" > ${label}< / span >
< h4 > ${escapeHtml(exercise.title || 'Operation task')}< / h4 >
< p class = "muted" > ${escapeHtml(exercise.hint || 'Enter a command and inspect the output.')}< / p >
< div class = "terminal" >
< div class = "terminal-head" > Linux sandbox terminal< / div >
< div class = "terminal-input" >
< span class = "prompt" > $< / span >
< input id = "input-${exercise.id}" type = "text" placeholder = "${escapeHtml(placeholder)}" onkeydown = "if(event.key==='Enter'){runExercise('${exercise.id}')}" / >
< button type = "button" onclick = "runExercise('${exercise.id}')" > Run< / button >
< / div >
< pre class = "output" id = "output-${exercise.id}" > Waiting for a command...< / pre >
< / div >
< div class = "feedback" id = "feedback-${exercise.id}" > < / div >
< / article >
`;
}
2026-03-10 07:30:42 +08:00
return `
2026-03-18 18:12:04 +08:00
< article class = "exercise" id = "exercise-${exercise.id}" >
< span class = "badge" > ${label}< / span >
< h4 > ${escapeHtml(exercise.question || 'Reflection task')}< / h4 >
< p > ${escapeHtml(exercise.answer || 'Summarize the purpose, output, and next step in your own words.')}< / p >
< / article >
2026-03-10 07:41:38 +08:00
`;
2026-03-18 18:12:04 +08:00
}).join('');
2026-03-10 07:30:42 +08:00
}
2026-03-10 07:41:38 +08:00
async function runExercise(exerciseId) {
2026-03-18 18:12:04 +08:00
const input = document.getElementById('input-' + exerciseId);
const output = document.getElementById('output-' + exerciseId);
const feedback = document.getElementById('feedback-' + exerciseId);
2026-03-10 07:30:42 +08:00
const cmd = input.value.trim();
2026-03-10 07:41:38 +08:00
if (!cmd) return;
2026-03-18 18:12:04 +08:00
output.textContent = `$ ${cmd}\n\nRunning...`;
2026-03-10 07:41:38 +08:00
feedback.className = 'feedback';
feedback.textContent = '';
2026-03-10 07:30:42 +08:00
try {
2026-03-18 18:12:04 +08:00
const runResponse = await fetch('/api/run?cmd=' + encodeURIComponent(cmd));
const runData = await runResponse.json();
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || '(no output)'}`;
renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' });
const checkResponse = await fetch('/api/check?exercise_id=' + encodeURIComponent(exerciseId) + '& last_cmd=' + encodeURIComponent(cmd) + '& output=' + encodeURIComponent(runData.output || ''));
const checkData = await checkResponse.json();
feedback.className = 'feedback show ' + (checkData.success ? 'success' : 'warn');
feedback.textContent = checkData.message + (checkData.next_suggestion ? '\n' + checkData.next_suggestion : '');
} catch (error) {
output.textContent = `$ ${cmd}\n\nRequest failed: ${error.message}`;
2026-03-10 07:41:38 +08:00
feedback.className = 'feedback show warn';
2026-03-18 18:12:04 +08:00
feedback.textContent = 'The request failed. Please try again.';
2026-03-10 07:30:42 +08:00
}
}
2026-03-10 07:41:38 +08:00
async function resetSandbox() {
await fetch('/api/reset', { method: 'POST' });
2026-03-18 18:12:04 +08:00
renderRuntime({ cwd: '/', user: 'sandbox_user' });
alert('Sandbox reset complete.');
2026-03-10 07:30:42 +08:00
}
2026-03-18 18:12:04 +08:00
async function runSearch() {
const query = document.getElementById('searchInput').value.trim();
const container = document.getElementById('searchResults');
if (!query) {
container.innerHTML = '< div class = "empty" > Search by command, lesson id, or module id.< / div > ';
return;
}
container.innerHTML = '< div class = "empty" > Searching...< / div > ';
const response = await fetch('/api/course/search?q=' + encodeURIComponent(query));
const payload = await response.json();
const results = payload.results || [];
if (!results.length) {
container.innerHTML = '< div class = "empty" > No results found. Try a command name or lesson id.< / div > ';
return;
}
container.innerHTML = results.map((item) => `
< button class = "result" type = "button" onclick = "openLesson('${item.lesson_id}', '${item.exercise_id || ''}')" >
< strong > ${escapeHtml(item.title)}< / strong >
< span class = "muted" > ${escapeHtml(item.subtitle || item.type)}< / span >
< / button >
`).join('');
2026-03-10 07:30:42 +08:00
}
2026-03-18 18:12:04 +08:00
function setTheme(theme) {
state.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('linux_lab_theme', theme);
2026-03-10 07:30:42 +08:00
}
2026-03-18 18:12:04 +08:00
function escapeHtml(value) {
return String(value)
2026-03-10 07:41:38 +08:00
.replaceAll('& ', '& ')
.replaceAll('< ', '< ')
.replaceAll('>', '> ')
.replaceAll('"', '" ')
.replaceAll("'", '' ');
2026-03-10 07:30:42 +08:00
}
2026-03-07 05:43:51 +00:00
2026-03-10 07:41:38 +08:00
window.openLesson = openLesson;
window.runExercise = runExercise;
2026-03-18 18:12:04 +08:00
window.runSearch = runSearch;
2026-03-10 07:30:42 +08:00
window.resetSandbox = resetSandbox;
< / script >
2026-03-07 05:43:51 +00:00
< / body >
< / html >