From f61376aa8a0fe08442a83440b7d507dd5d941246 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 19 Mar 2026 14:58:56 +0800 Subject: [PATCH] feat: add linux course diagnostics --- index.html | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++ sandbox.py | 14 ++++++++++ server.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 170 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 221ba47..8b21a68 100644 --- a/index.html +++ b/index.html @@ -259,6 +259,35 @@ color: var(--muted); line-height: 1.8; } + .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; + } .lesson-btn.done { border-color: rgba(29, 155, 108, 0.4); background: rgba(29, 155, 108, 0.1); @@ -275,6 +304,7 @@ .sidebar { position: static; } .hero-grid, .detail-grid { grid-template-columns: 1fr; } .mini-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .module-summary-grid { grid-template-columns: 1fr; } } @media (max-width: 720px) { .shell { padding: 14px; } @@ -332,6 +362,18 @@

Course map

Loading modules...
+ +
+
Diagnostics
+

Course coverage check

+
+
Coverage0%
+
Operations0
+
Reflection0
+
Unsupported0
+
+
Checking course and sandbox alignment...
+
@@ -366,6 +408,13 @@
Master a lesson after you can explain the command, predict the output, and connect it to a real operations step.
+
+
Module heatmap
+

See how the learning path is distributed

+

This panel helps you check whether the project really teaches both command basics and operations workflows.

+
+
+
Learning cockpit

Visual study map for the current lesson

@@ -507,10 +556,18 @@ document.getElementById('commandCount').textContent = meta.command_count || 0; document.getElementById('masteredCount').textContent = masteredCount(); document.getElementById('completionRate').textContent = completionRate() + '%'; + 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.'; document.getElementById('metaLine').textContent = `Version ${meta.version || '4.0'} | Updated ${meta.updated || '--'}`; renderRuntime(state.overview.runtime || {}); renderCommandTags(state.overview.commands || []); renderModules(); + renderModuleSummaryGrid(); } function renderRuntime(runtime) { @@ -550,6 +607,27 @@ `).join(''); } + function renderModuleSummaryGrid() { + const modules = state.overview.modules || []; + const target = document.getElementById('moduleSummaryGrid'); + if (!modules.length) { + target.innerHTML = '
No module diagnostics found.
'; + return; + } + target.innerHTML = modules.map((module) => ` +
+

${escapeHtml(module.display_title)}

+

${escapeHtml(module.display_summary)}

+
+
Lessons${module.lesson_count || 0}
+
Exercises${module.exercise_count || 0}
+
Ops${module.operation_count || 0}
+
Commands${module.command_count || 0}
+
+
+ `).join(''); + } + async function openLesson(lessonId, focusExerciseId = '') { const response = await fetch('/api/lesson?id=' + encodeURIComponent(lessonId)); const payload = await response.json(); diff --git a/sandbox.py b/sandbox.py index 4a388a8..a22f990 100644 --- a/sandbox.py +++ b/sandbox.py @@ -75,6 +75,17 @@ COMMAND_INDEX = { "python3": "/usr/bin/python3", } +SUPPORTED_COMMANDS = { + "alias", "apt", "awk", "bash", "bc", "cat", "cd", "chmod", "chgrp", "chown", + "clear", "cp", "crontab", "curl", "cut", "date", "df", "dig", "du", "echo", + "env", "export", "fdisk", "find", "free", "grep", "head", "history", "id", + "ifconfig", "ip", "journalctl", "kill", "last", "less", "ls", "lsof", "mkdir", + "more", "mount", "mv", "netstat", "nohup", "passwd", "ping", "pkill", "ps", + "pwd", "rm", "sed", "service", "sort", "ss", "stat", "su", "systemctl", "tail", + "tar", "top", "touch", "traceroute", "uniq", "uptime", "vi", "vim", "w", + "wc", "wget", "whereis", "which", "yum" +} + class LinuxSandbox: def __init__(self): @@ -121,6 +132,9 @@ class LinuxSandbox: node = self.get_node(self.resolve_path(path)) return bool(node and len(node.get("perm", "")) >= 3 and node["perm"][2] == "1" or node and "x" in node.get("perm_human", "")) + def supported_commands(self) -> set[str]: + return set(SUPPORTED_COMMANDS) + def _perm_human(self, perm: str, is_dir: bool) -> str: mapping = { "0": "---", "1": "--x", "2": "-w-", "3": "-wx", diff --git a/server.py b/server.py index e5ccea6..bb912a8 100755 --- a/server.py +++ b/server.py @@ -28,6 +28,7 @@ PUBLIC_GET_PATHS = { "/privacy.html", "/api/course", "/api/course/search", + "/api/diagnostics", "/api/health", "/api/lesson", "/api/overview", @@ -243,16 +244,80 @@ def build_lesson_preview(module: dict[str, Any], lesson: dict[str, Any]) -> dict def build_module_preview(module: dict[str, Any]) -> dict[str, Any]: lessons = [build_lesson_preview(module, lesson) for lesson in module.get("lessons", [])] + exercises = [exercise for lesson in module.get("lessons", []) for exercise in lesson.get("exercises", [])] + operations = [exercise for exercise in exercises if exercise.get("type") == "operation"] + reflections = [exercise for exercise in exercises if exercise.get("type") != "operation"] + commands = sorted( + { + command + for lesson in module.get("lessons", []) + for command in split_commands(lesson.get("command"), lesson.get("id")) + if command + } + ) return { "id": module.get("id"), "display_title": module_title(module), "display_summary": module_summary(module), "lesson_count": len(module.get("lessons", [])), - "exercise_count": sum(len(lesson.get("exercises", [])) for lesson in module.get("lessons", [])), + "exercise_count": len(exercises), + "operation_count": len(operations), + "reflection_count": len(reflections), + "command_count": len(commands), + "command_tokens": commands, "lessons": lessons, } +def extract_solution_commands() -> list[str]: + commands: list[str] = [] + for module in COURSE.get("modules", []): + for lesson in module.get("lessons", []): + for exercise in lesson.get("exercises", []): + for solution in exercise.get("solution", []): + for segment in str(solution).split("|"): + head = segment.strip().split(" ")[0].strip() + if head: + commands.append(head) + return commands + + +def build_diagnostics() -> dict[str, Any]: + lessons = flatten_lessons() + course_commands = sorted( + { + command + for lesson in lessons + for command in split_commands(lesson.get("command"), lesson.get("id")) + if command + } + ) + solution_commands = sorted(set(extract_solution_commands())) + required_commands = sorted(set(course_commands) | set(solution_commands)) + supported_commands = sorted(SANDBOX.supported_commands()) + unsupported_commands = [command for command in required_commands if command not in SANDBOX.supported_commands()] + total_exercises = sum(len(lesson.get("exercises", [])) for lesson in lessons) + operation_exercises = sum( + 1 + for lesson in lessons + for exercise in lesson.get("exercises", []) + if exercise.get("type") == "operation" + ) + reflection_exercises = total_exercises - operation_exercises + + return { + "supported_command_count": len(supported_commands), + "required_command_count": len(required_commands), + "covered_command_count": len(required_commands) - len(unsupported_commands), + "coverage_rate": 100 if not required_commands else round(((len(required_commands) - len(unsupported_commands)) / len(required_commands)) * 100)), + "unsupported_commands": unsupported_commands, + "course_commands": required_commands, + "operation_exercises": operation_exercises, + "reflection_exercises": reflection_exercises, + "module_checks": [build_module_preview(module) for module in COURSE.get("modules", [])], + } + + def build_exercise(exercise: dict[str, Any], lesson_stub: dict[str, Any]) -> dict[str, Any]: commands = split_commands(lesson_stub.get("command"), lesson_stub.get("id")) label = command_label(commands) @@ -343,6 +408,7 @@ def build_overview() -> dict[str, Any]: ) exercise_count = sum(len(lesson.get("exercises", [])) for lesson in lessons) meta = COURSE.get("meta", {}) + diagnostics = build_diagnostics() return { "meta": { @@ -362,6 +428,12 @@ def build_overview() -> dict[str, Any]: "cwd": SANDBOX.cwd, "user": SANDBOX.user, }, + "diagnostics": { + "coverage_rate": diagnostics["coverage_rate"], + "unsupported_count": len(diagnostics["unsupported_commands"]), + "operation_exercises": diagnostics["operation_exercises"], + "reflection_exercises": diagnostics["reflection_exercises"], + }, "modules": modules, "commands": commands[:24], } @@ -531,6 +603,7 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler): "module_count": overview["meta"]["module_count"], "lesson_count": overview["meta"]["lesson_count"], "exercise_count": overview["meta"]["exercise_count"], + "coverage_rate": overview["diagnostics"]["coverage_rate"], } ) return @@ -543,6 +616,10 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler): self.send_json(build_overview()) return + if path == "/api/diagnostics": + self.send_json(build_diagnostics()) + return + if path == "/api/lesson": lesson_id = urllib.parse.parse_qs(parsed.query).get("id", [""])[0] if not lesson_id: