diff --git a/10-databases/visualizer/LAB-VISUALIZER.md b/10-databases/visualizer/LAB-VISUALIZER.md
index 95c1dad..48b3248 100644
--- a/10-databases/visualizer/LAB-VISUALIZER.md
+++ b/10-databases/visualizer/LAB-VISUALIZER.md
@@ -166,6 +166,22 @@ graph LR
└── style.css # Dark theme styling
```
+## Data Model
+
+The lab uses a **university enrollment** scenario pre-loaded with
+sample data:
+
+| Table | Rows | Description |
+| --- | --- | --- |
+| `students` | 10 | Name, email, major (e.g., Alice Johnson, Computer Science) |
+| `courses` | 4 | CS101, CS201, MATH101, PHYS101 with capacity and enrolled count |
+| `enrollments` | 10 | Links students to courses (foreign keys with unique constraint) |
+| `access_log` | 10,000 | Simulated resource access records for indexing exercises |
+
+This is the same data model used across all Module 10 labs (MySQL,
+MongoDB, Cassandra), so you can compare how each database handles the
+same scenario differently.
+
---
## Task 1: Explore the Environment
@@ -342,7 +358,7 @@ remain unchanged.
In the SQL Console (Primary), run:
```sql
-SELECT c.code, c.enrolled FROM courses ORDER BY code;
+SELECT code, enrolled FROM courses ORDER BY code;
```
Confirm the counts match what the sidebar shows. The failed
@@ -376,7 +392,7 @@ accessing resource-10.
1. Set Student ID to `3`, Resource to `resource-10`
1. Click **Run EXPLAIN**
-The result shows **Rows scanned: ~9,894** in red, with Key: **NONE
+The result shows **Rows scanned: ~10,000** in red, with Key: **NONE
(full scan)**. MySQL examined nearly all 10,000 rows to find a
handful of matches. Check the sidebar: Indexes shows **None**:
diff --git a/10-databases/visualizer/app.js b/10-databases/visualizer/app.js
index 8c1f5fb..aacdca0 100644
--- a/10-databases/visualizer/app.js
+++ b/10-databases/visualizer/app.js
@@ -473,7 +473,7 @@ async function doSqlExec() {
const query = input.value.trim();
if (!query) return;
- const target = document.querySelector('input[name="sql-target"]:checked').value;
+ const target = document.querySelector('.target-btn.active')?.dataset.target || 'primary';
const output = $('#console-output');
// Add to history
@@ -564,6 +564,24 @@ function initConsole() {
consoleHistoryIndex >= 0 ? consoleHistory[consoleHistoryIndex] : '';
}
});
+
+ // Target toggle buttons
+ $$('.target-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ $$('.target-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ const target = btn.dataset.target;
+ const prompt = $('.console-prompt');
+ if (prompt) {
+ prompt.textContent = target === 'replica' ? 'replica>' : 'mysql>';
+ }
+ });
+ });
+
+ // Clear button
+ $('#console-clear').addEventListener('click', () => {
+ $('#console-output').innerHTML = '';
+ });
}
function initButtons() {
diff --git a/10-databases/visualizer/index.html b/10-databases/visualizer/index.html
index 1c75233..76718ad 100644
--- a/10-databases/visualizer/index.html
+++ b/10-databases/visualizer/index.html
@@ -260,20 +260,20 @@
Event Log
diff --git a/10-databases/visualizer/server.py b/10-databases/visualizer/server.py
index 1ba7138..28fd31e 100644
--- a/10-databases/visualizer/server.py
+++ b/10-databases/visualizer/server.py
@@ -368,8 +368,13 @@ def sql_exec(body):
if not query:
return {"error": "Empty query"}
+ # Strip mysql CLI formatting suffix (\G) -- not valid SQL
+ query = query.rstrip(";").rstrip()
+ if query.endswith("\\G"):
+ query = query[:-2].rstrip()
+
# Reject multi-statement queries
- if ";" in query.rstrip(";"):
+ if ";" in query:
return {"error": "Multi-statement queries are not allowed"}
# Allowlist: only permit known safe SQL commands
diff --git a/10-databases/visualizer/style.css b/10-databases/visualizer/style.css
index f105e05..e47fb04 100644
--- a/10-databases/visualizer/style.css
+++ b/10-databases/visualizer/style.css
@@ -435,12 +435,12 @@ h3 {
color: var(--color-text-muted);
}
-/* --- Bottom Bar (Event Log) --- */
+/* --- Bottom Bar --- */
.bottom-bar {
background: var(--color-bg-surface);
border-top: 1px solid var(--color-border);
padding: 0.75rem 1rem;
- max-height: var(--bottom-bar-height);
+ height: 260px;
}
.event-log {
@@ -519,68 +519,121 @@ h3 {
.sql-console {
flex: 1;
min-width: 0;
+ display: flex;
+ flex-direction: column;
+ background: #0a0e17;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--color-border);
+ overflow: hidden;
}
-.sql-console details {
- height: 100%;
+.console-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 6px 12px;
+ background: rgba(255, 255, 255, 0.03);
+ border-bottom: 1px solid var(--color-border);
}
-.sql-console summary {
- cursor: pointer;
- font-size: 0.9rem;
+.console-title {
+ font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
- margin-bottom: 0.5rem;
}
-.console-body {
+.console-target-toggle {
display: flex;
- flex-direction: column;
- gap: 0.5rem;
+ background: var(--color-bg);
+ border-radius: 6px;
+ padding: 2px;
+ gap: 2px;
}
-.console-target {
- display: flex;
- gap: 1rem;
- font-size: 0.8rem;
+.target-btn {
+ padding: 3px 12px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
color: var(--color-text-muted);
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
}
-.console-target label {
- display: flex;
- align-items: center;
- gap: 4px;
+.target-btn.active {
+ background: var(--color-primary);
+ color: #fff;
+}
+
+.target-btn:hover:not(.active) {
+ color: var(--color-text);
+}
+
+.console-clear-btn {
+ margin-left: auto;
+ padding: 2px 8px;
+ border: 1px solid var(--color-border);
+ border-radius: 4px;
+ background: transparent;
+ color: var(--color-text-muted);
+ font-size: 0.7rem;
cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.console-clear-btn:hover {
+ color: var(--color-error);
+ border-color: var(--color-error);
}
.console-output {
font-family: var(--font-mono);
font-size: 0.75rem;
- background: var(--color-bg);
- border-radius: var(--radius);
- padding: 0.5rem;
- max-height: 100px;
+ background: #0a0e17;
+ padding: 8px 12px;
+ flex: 1;
overflow-y: auto;
white-space: pre-wrap;
- color: var(--color-text);
+ color: #a3e635;
}
.console-input-row {
display: flex;
- gap: 0.5rem;
+ align-items: center;
+ gap: 0;
+ border-top: 1px solid var(--color-border);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.console-prompt {
+ padding: 8px 4px 8px 12px;
+ color: #22d3ee;
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ font-weight: 600;
+ white-space: nowrap;
}
.console-input-row input {
flex: 1;
- padding: 6px 10px;
- border-radius: var(--radius);
- border: 1px solid var(--color-border);
- background: var(--color-bg);
+ padding: 8px 8px;
+ border: none;
+ background: transparent;
color: var(--color-text);
font-family: var(--font-mono);
font-size: 0.8rem;
+ outline: none;
+}
+
+.console-input-row .btn {
+ border-radius: 0;
+ padding: 8px 16px;
+ border: none;
+ border-left: 1px solid var(--color-border);
}
.console-result-table {
@@ -602,14 +655,25 @@ h3 {
}
.console-query {
- color: var(--color-primary);
+ color: #22d3ee;
+ margin-top: 6px;
}
.console-error {
- color: var(--color-error);
+ color: #f87171;
}
.console-meta {
- color: var(--color-text-muted);
+ color: #94a3b8;
font-size: 0.7rem;
+ margin-bottom: 4px;
+}
+
+.console-result-table th {
+ color: #a3e635;
+ background: rgba(163, 230, 53, 0.08);
+}
+
+.console-result-table td {
+ color: #e2e8f0;
}