dashboard: copy button, dedup files, /api/all single call

This commit is contained in:
Accusys
2026-05-09 18:00:46 +08:00
parent 4f1e546104
commit 6fc1d2b54d

View File

@@ -4,7 +4,7 @@ Momentry Dashboard — Flask web app
Reads pipeline status + Redis + system health on demand Reads pipeline status + Redis + system health on demand
""" """
import json, os, subprocess, sys import json, os, subprocess, sys, platform
from pathlib import Path from pathlib import Path
from flask import Flask, jsonify, render_template_string from flask import Flask, jsonify, render_template_string
@@ -12,6 +12,12 @@ app = Flask(__name__)
PROJECT = Path(__file__).resolve().parent.parent 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(): def run_status_json():
"""Run pipeline_status.py and return parsed JSON""" """Run pipeline_status.py and return parsed JSON"""
@@ -71,7 +77,7 @@ def run_redis_info():
def run_db_info(): def run_db_info():
"""Fetch DB metrics""" """Fetch DB metrics + current processing file"""
psql = "/Users/accusys/pgsql/18.3/bin/psql" psql = "/Users/accusys/pgsql/18.3/bin/psql"
cmd = [psql, "-U", "accusys", "-d", "momentry", "-t", "-A"] cmd = [psql, "-U", "accusys", "-d", "momentry", "-t", "-A"]
result = {} result = {}
@@ -91,6 +97,48 @@ def run_db_info():
result[parts[0].strip()] = int(parts[1]) result[parts[0].strip()] = int(parts[1])
except: except:
pass 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 return result
@@ -117,6 +165,7 @@ def api_db():
@app.route("/api/all") @app.route("/api/all")
def api_all(): def api_all():
return jsonify({ return jsonify({
"system": {"hostname": HOSTNAME, "role": SYSTEM_ROLE, "is_m5": IS_M5},
"status": run_status_json(), "status": run_status_json(),
"redis": run_redis_info(), "redis": run_redis_info(),
"db": run_db_info(), "db": run_db_info(),
@@ -162,11 +211,12 @@ button:hover { background: #2ea043; }
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="refresh-bar"> <div class="refresh-bar">
<h1>Momentry Dashboard</h1> <h1>Momentry Dashboard <span style="font-size:14px;background:#1f2937;color:#{{'58a6ff' if IS_M5 else 'f0883e'}};padding:4px 12px;border-radius:12px;margin-left:8px;vertical-align:middle">🤖 {{ SYSTEM_ROLE }}</span></h1>
<div> <div style="display:flex;align-items:center;gap:8px">
<span class="last-updated" id="lastUpdated">—</span> <span class="last-updated" id="lastUpdated">—</span>
<button onclick="fetchAll()" style="margin-left:12px">⟳ Refresh</button> <button onclick="copyStatus()" style="background:#1f6feb;padding:6px 14px;font-size:13px">📋 Copy</button>
<button onclick="fetchAll()" style="background:#238636;padding:6px 14px;font-size:13px">⟳ Refresh</button>
</div> </div>
</div> </div>
@@ -189,6 +239,11 @@ button:hover { background: #2ea043; }
</div> </div>
</div> </div>
<div class="section" id="fileProgressSection">
<h2>📁 Pipeline Progress</h2>
<div id="fileProgress" style="font-size:14px">Loading...</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="section"> <div class="section">
@@ -216,24 +271,28 @@ async function fetchAll() {
document.getElementById('lastUpdated').textContent = '🔄 ' + ts; document.getElementById('lastUpdated').textContent = '🔄 ' + ts;
try { 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); renderChecklist(status.job);
renderHealth(status.health); renderHealth(status.health);
renderTiming(status.health?.processors); renderTiming(status.health?.processors);
if (all.redis) renderRedis(all.redis);
if (all.db) { renderDb(all.db); renderFileProgress(all.db); }
document.getElementById('lastUpdated').textContent = '' + ts; document.getElementById('lastUpdated').textContent = '' + ts;
} catch(e) { } catch(e) {
document.getElementById('checklist').innerHTML = '<tr><td class="fail">Error: ' + e.message + '</td></tr>'; document.getElementById('checklist').innerHTML = '<tr><td class="fail">Error: ' + e.message + '</td></tr>';
// 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) { function renderChecklist(job) {
@@ -304,6 +363,38 @@ function renderRedis(r) {
document.getElementById('redis').innerHTML = h; 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 = '<div style="color:#8b949e">No files found</div>';
return;
}
let h = '<table><tr><th>File</th><th>Status</th>';
for (const s of PIPELINE_STAGES) h += '<th style="font-size:11px">' + s.slice(0,4) + '</th>';
h += '</tr>';
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 += '<tr><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + f.name + '">' + name + '</td>'
+ '<td>' + statusIcon + ' ' + (f.job_status || f.status) + '</td>';
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 += '<td style="text-align:center;font-size:13px">' + icon + '</td>';
}
h += '</tr>';
}
h += '</table>';
el.innerHTML = h;
}
function renderDb(d) { function renderDb(d) {
if (!d) return; if (!d) return;
const rows = ['videos','chunks','face_detections','identities','tkg_nodes','tkg_edges']; const rows = ['videos','chunks','face_detections','identities','tkg_nodes','tkg_edges'];
@@ -316,6 +407,64 @@ function renderDb(d) {
document.getElementById('db').innerHTML = h; 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(); fetchAll();
setInterval(fetchAll, 15000); setInterval(fetchAll, 15000);
</script> </script>