diff --git a/scripts/dashboard.py b/scripts/dashboard.py
index 8bf6b78..9998df4 100644
--- a/scripts/dashboard.py
+++ b/scripts/dashboard.py
@@ -4,7 +4,7 @@ Momentry Dashboard — Flask web app
Reads pipeline status + Redis + system health on demand
"""
-import json, os, subprocess, sys
+import json, os, subprocess, sys, platform
from pathlib import Path
from flask import Flask, jsonify, render_template_string
@@ -12,6 +12,12 @@ app = Flask(__name__)
PROJECT = Path(__file__).resolve().parent.parent
+# System role detection
+HOSTNAME = platform.node()
+IS_M5 = "MacBook" in HOSTNAME or "M5" in HOSTNAME
+SYSTEM_ROLE = "M5 (MacBook Pro)" if IS_M5 else "M4 (Mac Mini)"
+SYSTEM_COLOR = "#58a6ff" if IS_M5 else "#f0883e"
+
def run_status_json():
"""Run pipeline_status.py and return parsed JSON"""
@@ -71,7 +77,7 @@ def run_redis_info():
def run_db_info():
- """Fetch DB metrics"""
+ """Fetch DB metrics + current processing file"""
psql = "/Users/accusys/pgsql/18.3/bin/psql"
cmd = [psql, "-U", "accusys", "-d", "momentry", "-t", "-A"]
result = {}
@@ -91,6 +97,48 @@ def run_db_info():
result[parts[0].strip()] = int(parts[1])
except:
pass
+
+ # 所有檔案的 pipeline 進度(依檔案名去重,取最新)
+ try:
+ r = subprocess.run(cmd + ["-c", """
+ SELECT DISTINCT ON (v.file_name)
+ v.file_uuid, v.file_name, v.status,
+ COALESCE(v.processing_status::text, '{}') as pstatus,
+ m.status as job_status
+ FROM dev.videos v
+ LEFT JOIN dev.monitor_jobs m ON m.uuid = v.file_uuid
+ WHERE v.status IN ('completed', 'processing')
+ OR m.status IS NOT NULL
+ ORDER BY v.file_name, GREATEST(
+ COALESCE(v.registration_time::timestamp, '1970-01-01'),
+ COALESCE(m.updated_at, '1970-01-01')
+ ) DESC
+ LIMIT 20
+ """], capture_output=True, text=True, timeout=10)
+ seen_names = set()
+ files = []
+ for line in r.stdout.strip().split("\n"):
+ if not line.strip() or "|" not in line:
+ continue
+ parts = line.split("|", 4)
+ if len(parts) < 5:
+ continue
+ name = parts[1].strip()
+ if name in seen_names:
+ continue
+ seen_names.add(name)
+ f = {"uuid": parts[0].strip(), "name": name,
+ "status": parts[2].strip(), "job_status": parts[4].strip()}
+ try:
+ ps = json.loads(parts[3]) if parts[3] and parts[3] != '{}' else {}
+ f["progress"] = ps.get("progress", {})
+ except:
+ f["progress"] = {}
+ files.append(f)
+ result["files"] = files
+ except Exception as e:
+ result["files_error"] = str(e)
+
return result
@@ -117,6 +165,7 @@ def api_db():
@app.route("/api/all")
def api_all():
return jsonify({
+ "system": {"hostname": HOSTNAME, "role": SYSTEM_ROLE, "is_m5": IS_M5},
"status": run_status_json(),
"redis": run_redis_info(),
"db": run_db_info(),
@@ -162,11 +211,12 @@ button:hover { background: #2ea043; }
-
-
Momentry Dashboard
-
+
+
Momentry Dashboard 🤖 {{ SYSTEM_ROLE }}
+
—
-
+
+
@@ -189,6 +239,11 @@ button:hover { background: #2ea043; }
+
+
📁 Pipeline Progress
+
Loading...
+
+
@@ -216,24 +271,28 @@ async function fetchAll() {
document.getElementById('lastUpdated').textContent = '🔄 ' + ts;
try {
- const status = await (await fetch('/api/status')).json();
+ const all = await (await fetch('/api/all')).json();
+ _lastData = all;
+ const status = all.status;
renderChecklist(status.job);
renderHealth(status.health);
renderTiming(status.health?.processors);
+ if (all.redis) renderRedis(all.redis);
+ if (all.db) { renderDb(all.db); renderFileProgress(all.db); }
document.getElementById('lastUpdated').textContent = '✅ ' + ts;
} catch(e) {
document.getElementById('checklist').innerHTML = '
| Error: ' + e.message + ' |
';
+ // Fallback: try separate endpoints
+ try {
+ const s = await (await fetch('/api/status')).json(); renderChecklist(s.job); renderHealth(s.health); renderTiming(s.health?.processors);
+ } catch(e2) {}
+ try {
+ const r = await (await fetch('/api/redis')).json(); renderRedis(r);
+ } catch(e2) {}
+ try {
+ const d = await (await fetch('/api/db')).json(); renderDb(d); renderFileProgress(d);
+ } catch(e2) {}
}
-
- try {
- const redis = await (await fetch('/api/redis')).json();
- renderRedis(redis);
- } catch(e) {}
-
- try {
- const db = await (await fetch('/api/db')).json();
- renderDb(db);
- } catch(e) {}
}
function renderChecklist(job) {
@@ -304,6 +363,38 @@ function renderRedis(r) {
document.getElementById('redis').innerHTML = h;
}
+const PIPELINE_STAGES = ['cut','scene','asr','asrx','yolo','ocr','face','pose','visual_chunk','story'];
+
+function renderFileProgress(d) {
+ const el = document.getElementById('fileProgress');
+ if (!d || !d.files || d.files.length === 0) {
+ el.innerHTML = '
No files found
';
+ return;
+ }
+ let h = '
| File | Status | ';
+ for (const s of PIPELINE_STAGES) h += '' + s.slice(0,4) + ' | ';
+ h += '
';
+ for (const f of d.files) {
+ const name = f.name.length > 50 ? f.name.slice(0,50) + '...' : f.name;
+ const statusIcon = f.job_status === 'running' ? '▶️' : f.job_status === 'pending' ? '⏳' : f.status === 'completed' ? '✅' : '❌';
+ const progress = f.progress || {};
+ h += '| ' + name + ' | '
+ + '' + statusIcon + ' ' + (f.job_status || f.status) + ' | ';
+ for (const s of PIPELINE_STAGES) {
+ const ps = progress[s.toUpperCase()] || {};
+ const st = ps.status || '';
+ let icon = '⬜';
+ if (st === 'completed') icon = '✅';
+ else if (st === 'running') icon = '⏳';
+ else if (st === 'failed') icon = '❌';
+ h += '' + icon + ' | ';
+ }
+ h += '
';
+ }
+ h += '
';
+ el.innerHTML = h;
+}
+
function renderDb(d) {
if (!d) return;
const rows = ['videos','chunks','face_detections','identities','tkg_nodes','tkg_edges'];
@@ -316,6 +407,64 @@ function renderDb(d) {
document.getElementById('db').innerHTML = h;
}
+let _lastData = null;
+function copyStatus() {
+ if (!_lastData) { alert('No data loaded yet'); return; }
+ const d = _lastData;
+ const job = d.status?.job;
+ const h = d.status?.health;
+ const db = d.db;
+ const r = d.redis;
+ let lines = [];
+ lines.push('Momentry Pipeline Status');
+ lines.push('='.repeat(50));
+ lines.push('System: ' + (d.system?.role || '?') + ' | ' + new Date().toISOString().slice(0,19).replace('T',' '));
+ lines.push('');
+ if (job?.stages) {
+ lines.push('── Checklist ──');
+ for (const s of job.stages) {
+ lines.push(' ' + (s.passed ? '✅' : '❌') + ' ' + s.name.padEnd(14) + s.detail);
+ }
+ lines.push(' ' + (job.passed ? '✅' : '❌') + ' TOTAL'.padEnd(14) + job.total_elapsed + 's');
+ lines.push('');
+ }
+ if (h) {
+ lines.push('── Health ──');
+ lines.push(' CPU: ' + (h.cpu_load_1m ?? '?') + ' Memory: ' + (h.memory_used_mb ?? '?') + 'MB GPU: ' + (h.gpu_available ? '✅' : '❌'));
+ if (h.services) {
+ lines.push(' Services: ' + Object.entries(h.services).map(([k,v]) => k + '=' + (v ? '✓' : '✗')).join(' '));
+ }
+ lines.push('');
+ }
+ if (r) {
+ lines.push('── Redis ──');
+ lines.push(' Keys: ' + (r.momentry_keys ?? '?') + ' Hit Rate: ' + (r.hit_rate_pct ?? '?') + '% Uptime: ' + (r.uptime_in_seconds ? Math.round(r.uptime_in_seconds/3600)+'h' : '?'));
+ lines.push('');
+ }
+ if (db) {
+ lines.push('── Database ──');
+ const tbls = ['videos','chunks','face_detections','identities','tkg_nodes','tkg_edges'];
+ for (const t of tbls) {
+ if (db[t] !== undefined) lines.push(' ' + t + ': ' + db[t].toLocaleString());
+ }
+ if (db.files) {
+ lines.push('');
+ lines.push('── Files ──');
+ for (const f of db.files) {
+ lines.push(' ' + (f.job_status === 'running' ? '▶️' : f.job_status === 'pending' ? '⏳' : f.status === 'completed' ? '✅' : '❌') + ' ' + f.name.slice(0,60));
+ }
+ }
+ lines.push('');
+ }
+ const text = lines.join('\n');
+ navigator.clipboard.writeText(text).then(() => {
+ const btn = event.target;
+ const orig = btn.textContent;
+ btn.textContent = '✅ Copied!';
+ setTimeout(() => btn.textContent = orig, 2000);
+ }).catch(() => alert('Copy failed'));
+}
+
fetchAll();
setInterval(fetchAll, 15000);