Skip to content

Commit 1b575fe

Browse files
committed
feat: multi-project switcher + restart + remove chart panels
Multi-project support: - Backend: /api/projects, /api/projects/switch, /api/projects/add, /api/projects/remove endpoints - Projects saved to ~/.humanize/viz-projects.json - Switch project dynamically (restarts watcher, clears cache) - Home page shows project bar with current project name/path, Switch dropdown for other projects, + Add button Restart command: - viz-restart.sh: stop + start in one step - /humanize:viz restart subcommand added Analytics cleanup: - Removed 6 Chart.js panels (Rounds/Duration/Verdicts/P-Issues/ FirstComplete/BitLesson) — kept stats overview + timeline + table - Session Comparison table defaults to time descending (newest first) Signed-off-by: Chao Liu <chao.liu.zevorn@gmail.com>
1 parent 2e96e06 commit 1b575fe

11 files changed

Lines changed: 367 additions & 89 deletions

File tree

commands/viz.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Manage the local web visualization dashboard for RLCR loop sessions.
1212

1313
- `start` — Launch the dashboard server (creates venv on first run, opens browser)
1414
- `stop` — Stop the dashboard server
15+
- `restart` — Restart the dashboard server
1516
- `status` — Check if the dashboard server is running
1617

1718
## Implementation
@@ -25,10 +26,11 @@ PROJECT_DIR="$(pwd)"
2526

2627
# Route subcommand
2728
case "$1" in
28-
start) bash "$VIZ_DIR/viz-start.sh" "$PROJECT_DIR" ;;
29-
stop) bash "$VIZ_DIR/viz-stop.sh" "$PROJECT_DIR" ;;
30-
status) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;;
31-
*) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;;
29+
start) bash "$VIZ_DIR/viz-start.sh" "$PROJECT_DIR" ;;
30+
stop) bash "$VIZ_DIR/viz-stop.sh" "$PROJECT_DIR" ;;
31+
restart) bash "$VIZ_DIR/viz-restart.sh" "$PROJECT_DIR" ;;
32+
status) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;;
33+
*) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;;
3234
esac
3335
```
3436

viz/scripts/viz-restart.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env bash
2+
# Restart the Humanize Viz dashboard server.
3+
# Usage: viz-restart.sh [--project <path>]
4+
5+
set -euo pipefail
6+
7+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8+
PROJECT_DIR="${1:-.}"
9+
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
10+
11+
bash "$SCRIPT_DIR/viz-stop.sh" "$PROJECT_DIR" 2>/dev/null || true
12+
sleep 1
13+
exec bash "$SCRIPT_DIR/viz-start.sh" "$PROJECT_DIR"

viz/scripts/viz-start.sh

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,21 @@ if [[ ! -d "$HUMANIZE_DIR" ]]; then
2424
exit 1
2525
fi
2626

27-
# Check if already running
28-
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
29-
if [[ -f "$PORT_FILE" ]]; then
30-
port=$(cat "$PORT_FILE")
31-
# Verify port is still alive
32-
if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then
33-
echo "Viz server already running at http://localhost:$port"
34-
exit 0
35-
fi
36-
# Stale session — clean up
37-
echo "Cleaning up stale session..."
38-
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true
39-
rm -f "$PORT_FILE"
40-
else
41-
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true
27+
# Check if THIS project already has a running server
28+
if [[ -f "$PORT_FILE" ]]; then
29+
port=$(cat "$PORT_FILE")
30+
if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then
31+
echo "Viz server already running at http://localhost:$port"
32+
exit 0
4233
fi
34+
# Stale port file for this project
35+
rm -f "$PORT_FILE"
4336
fi
4437

45-
# Clean up stale port file if tmux session is gone
46-
if [[ -f "$PORT_FILE" ]] && ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
47-
rm -f "$PORT_FILE"
38+
# If a tmux session exists, it may serve another project — kill it to reuse the session name
39+
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
40+
echo "Stopping existing viz session (may be from another project)..."
41+
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true
4842
fi
4943

5044
# Create venv if it doesn't exist

viz/scripts/viz-status.sh

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/usr/bin/env bash
22
# Check the status of the Humanize Viz dashboard server.
33
# Usage: viz-status.sh [--project <path>]
4+
#
5+
# Only checks/cleans up state for the specified project.
6+
# Will NOT kill tmux sessions that may belong to other projects.
47

58
set -euo pipefail
69

@@ -11,23 +14,25 @@ HUMANIZE_DIR="$PROJECT_DIR/.humanize"
1114
PORT_FILE="$HUMANIZE_DIR/viz.port"
1215
TMUX_SESSION="humanize-viz"
1316

14-
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
15-
if [[ -f "$PORT_FILE" ]]; then
16-
port=$(cat "$PORT_FILE")
17-
if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then
18-
echo "Viz server running at http://localhost:$port"
19-
exit 0
20-
fi
17+
# If this project has a port file, check if the server is healthy
18+
if [[ -f "$PORT_FILE" ]]; then
19+
port=$(cat "$PORT_FILE")
20+
if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then
21+
echo "Viz server running at http://localhost:$port"
22+
exit 0
2123
fi
22-
# tmux session exists but server not responding — stale
23-
echo "Viz server is not running (stale session detected, cleaning up)."
24-
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true
24+
# Port file exists but server not responding — stale for THIS project
25+
echo "Viz server is not running (stale port file, cleaning up)."
2526
rm -f "$PORT_FILE"
26-
exit 1
27-
else
28-
if [[ -f "$PORT_FILE" ]]; then
29-
rm -f "$PORT_FILE"
27+
# Only kill tmux if we can confirm it's ours (check if tmux session exists)
28+
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
29+
# Verify the tmux session is serving this project by checking port
30+
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true
3031
fi
31-
echo "Viz server is not running."
3232
exit 1
3333
fi
34+
35+
# No port file for this project — server is not running here
36+
# Do NOT kill any tmux session, it may serve another project
37+
echo "Viz server is not running."
38+
exit 1

viz/server/app.py

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import argparse
1111
import subprocess
1212
import threading
13-
from flask import Flask, jsonify, send_from_directory, abort
13+
from flask import Flask, jsonify, request, send_from_directory, abort
1414
from flask_sock import Sock
1515

1616
# Add server directory to path
@@ -118,6 +118,121 @@ def health():
118118
return jsonify({'status': 'ok'})
119119

120120

121+
# --- Project Management ---
122+
123+
_PROJECTS_FILE = os.path.expanduser('~/.humanize/viz-projects.json')
124+
125+
126+
def _load_projects():
127+
"""Load saved project list."""
128+
if os.path.isfile(_PROJECTS_FILE):
129+
try:
130+
with open(_PROJECTS_FILE, 'r') as f:
131+
return json.loads(f.read())
132+
except (json.JSONDecodeError, OSError):
133+
pass
134+
return []
135+
136+
137+
def _save_projects(projects):
138+
os.makedirs(os.path.dirname(_PROJECTS_FILE), exist_ok=True)
139+
with open(_PROJECTS_FILE, 'w') as f:
140+
f.write(json.dumps(projects, indent=2))
141+
142+
143+
def _ensure_current_project():
144+
"""Make sure the current PROJECT_DIR is in the saved list."""
145+
projects = _load_projects()
146+
if PROJECT_DIR not in projects:
147+
projects.insert(0, PROJECT_DIR)
148+
_save_projects(projects)
149+
150+
151+
@app.route('/api/projects')
152+
def api_projects():
153+
_ensure_current_project()
154+
projects = _load_projects()
155+
result = []
156+
for p in projects:
157+
rlcr_dir = os.path.join(p, '.humanize', 'rlcr')
158+
session_count = 0
159+
if os.path.isdir(rlcr_dir):
160+
session_count = len([d for d in os.listdir(rlcr_dir) if os.path.isdir(os.path.join(rlcr_dir, d))])
161+
result.append({
162+
'path': p,
163+
'name': os.path.basename(p),
164+
'sessions': session_count,
165+
'active': p == PROJECT_DIR,
166+
})
167+
return jsonify(result)
168+
169+
170+
@app.route('/api/projects/switch', methods=['POST'])
171+
def api_switch_project():
172+
global PROJECT_DIR, _watcher
173+
data = json.loads(request.data) if request.data else {}
174+
new_path = data.get('path', '')
175+
176+
if not new_path or not os.path.isdir(new_path):
177+
return jsonify({'error': 'Invalid project path'}), 400
178+
179+
rlcr_dir = os.path.join(new_path, '.humanize', 'rlcr')
180+
if not os.path.isdir(os.path.join(new_path, '.humanize')):
181+
return jsonify({'error': 'No .humanize/ directory in this project'}), 400
182+
183+
# Stop old watcher
184+
if _watcher:
185+
_watcher.stop()
186+
187+
# Switch
188+
PROJECT_DIR = os.path.abspath(new_path)
189+
_invalidate_cache()
190+
191+
# Restart watcher
192+
_watcher = SessionWatcher(PROJECT_DIR, broadcast_message)
193+
_watcher.start()
194+
195+
# Save to project list
196+
_ensure_current_project()
197+
198+
return jsonify({'status': 'switched', 'path': PROJECT_DIR})
199+
200+
201+
@app.route('/api/projects/add', methods=['POST'])
202+
def api_add_project():
203+
data = json.loads(request.data) if request.data else {}
204+
new_path = data.get('path', '')
205+
206+
if not new_path:
207+
return jsonify({'error': 'Path required'}), 400
208+
209+
new_path = os.path.abspath(os.path.expanduser(new_path))
210+
if not os.path.isdir(new_path):
211+
return jsonify({'error': 'Directory does not exist'}), 400
212+
213+
if not os.path.isdir(os.path.join(new_path, '.humanize')):
214+
return jsonify({'error': 'No .humanize/ directory found'}), 400
215+
216+
projects = _load_projects()
217+
if new_path not in projects:
218+
projects.append(new_path)
219+
_save_projects(projects)
220+
221+
return jsonify({'status': 'added', 'path': new_path})
222+
223+
224+
@app.route('/api/projects/remove', methods=['POST'])
225+
def api_remove_project():
226+
data = json.loads(request.data) if request.data else {}
227+
path = data.get('path', '')
228+
229+
projects = _load_projects()
230+
projects = [p for p in projects if p != path]
231+
_save_projects(projects)
232+
233+
return jsonify({'status': 'removed'})
234+
235+
121236
# --- REST API ---
122237

123238
@app.route('/api/sessions')
@@ -196,18 +311,26 @@ def api_generate_report(session_id):
196311
with open(report_path, 'r', encoding='utf-8') as f:
197312
return jsonify({'status': 'exists', 'content': f.read()})
198313

199-
# Collect round summaries and review results
200-
summaries = []
314+
# Collect round summaries and review results (sorted numerically by round number)
201315
import glob as _glob
202-
for sf in sorted(_glob.glob(os.path.join(session_dir, 'round-*-summary.md'))):
316+
import re as _re_local
317+
318+
def _sort_round_files(files):
319+
def _round_num(path):
320+
m = _re_local.search(r'round-(\d+)-', os.path.basename(path))
321+
return int(m.group(1)) if m else 0
322+
return sorted(files, key=_round_num)
323+
324+
summaries = []
325+
for sf in _sort_round_files(_glob.glob(os.path.join(session_dir, 'round-*-summary.md'))):
203326
try:
204327
with open(sf, 'r', encoding='utf-8') as f:
205328
summaries.append(f'--- {os.path.basename(sf)} ---\n{f.read()}')
206329
except (PermissionError, OSError):
207330
pass
208331

209332
reviews = []
210-
for rf in sorted(_glob.glob(os.path.join(session_dir, 'round-*-review-result.md'))):
333+
for rf in _sort_round_files(_glob.glob(os.path.join(session_dir, 'round-*-review-result.md'))):
211334
try:
212335
with open(rf, 'r', encoding='utf-8') as f:
213336
reviews.append(f'--- {os.path.basename(rf)} ---\n{f.read()}')

viz/static/css/layout.css

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,52 @@
121121
background: var(--border-0);
122122
}
123123

124+
/* ─── Project Switcher Bar ─── */
125+
.project-bar {
126+
display: flex;
127+
align-items: center;
128+
justify-content: space-between;
129+
padding: var(--space-4) var(--space-5);
130+
background: var(--bg-1);
131+
border: 1px solid var(--border-1);
132+
border-radius: var(--radius-md);
133+
margin-bottom: var(--space-6);
134+
}
135+
136+
.project-current {
137+
display: flex;
138+
align-items: center;
139+
gap: var(--space-3);
140+
min-width: 0;
141+
}
142+
143+
.project-current-label {
144+
font-family: var(--font-display);
145+
font-size: 0.68rem;
146+
font-weight: 700;
147+
text-transform: uppercase;
148+
letter-spacing: 0.1em;
149+
color: var(--text-3);
150+
flex-shrink: 0;
151+
}
152+
153+
.project-current-path {
154+
font-family: var(--font-display);
155+
font-weight: 700;
156+
font-size: 0.95rem;
157+
color: var(--text-0);
158+
}
159+
160+
.project-current-full {
161+
font-family: var(--font-mono);
162+
font-size: 0.72rem;
163+
color: var(--text-3);
164+
overflow: hidden;
165+
text-overflow: ellipsis;
166+
white-space: nowrap;
167+
max-width: 300px;
168+
}
169+
124170
/* ─── Session Cards ─── */
125171
.cards-grid {
126172
display: grid;
@@ -266,6 +312,10 @@
266312
box-shadow var(--duration-base) var(--ease-out);
267313
overflow: hidden;
268314
z-index: 1;
315+
height: 68px;
316+
display: flex;
317+
flex-direction: column;
318+
justify-content: center;
269319
}
270320
.pl-node:hover {
271321
border-color: var(--border-2);

viz/static/index.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,10 @@
5959
</div>
6060

6161
<!-- Scripts -->
62-
<script src="/js/i18n.js?v=2"></script>
63-
<script src="/js/pipeline.js?v=2"></script>
64-
<script src="/js/charts.js?v=2"></script>
65-
<script src="/js/actions.js?v=2"></script>
66-
<script src="/js/app.js?v=2"></script>
62+
<script src="/js/i18n.js?v=4"></script>
63+
<script src="/js/pipeline.js?v=4"></script>
64+
<script src="/js/charts.js?v=4"></script>
65+
<script src="/js/actions.js?v=4"></script>
66+
<script src="/js/app.js?v=4"></script>
6767
</body>
6868
</html>

0 commit comments

Comments
 (0)