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
+
+
+ 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: