Full history page with tags, notes, vector API, and bulk ops

New /history page (replaces slide-out panel):
- Full-page data table: ID, Mode, Prompt, Models, Tags, Date
- Active/Archived/All view toggle
- Filter by mode, tag, or search text
- Checkbox select for bulk archive/restore
- Click any row → detail panel with full responses

Per-run detail:
- Inline tag editor: add tags (Enter), remove tags (click ✕)
- Notes textarea with auto-save (1s debounce)
- Archive/Restore/Delete buttons
- Collapsible response cards (click header to expand)

Database:
- tags TEXT[] column with GIN index for fast tag queries
- notes TEXT column for freeform annotations

APIs:
- POST /api/runs/:id/tags — update tags and/or notes
- GET /api/runs/tags — list all unique tags in use
- GET /api/runs/vectors — structured text documents for AI/embedding
  Returns: mode, prompt, models, date, tags, notes + all response text
  Filters: ?mode=, ?tag=, ?limit=
  Each doc includes token estimate for embedding planning

Main UI: History button now links to /history page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-03-26 04:19:22 -05:00
parent aeab1f0194
commit efa547bb68

View File

@ -1698,7 +1698,7 @@ HTML = r"""
<h1><span>LLM</span> Team</h1> <h1><span>LLM</span> Team</h1>
<div class="badge" id="model-count"><span class="dot"></span>0 models</div> <div class="badge" id="model-count"><span class="dot"></span>0 models</div>
<nav style="margin-left:auto;display:flex;align-items:center;gap:4px"> <nav style="margin-left:auto;display:flex;align-items:center;gap:4px">
<button onclick="toggleHistory()" style="color:var(--text2);background:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;cursor:pointer;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">History</button> <a href="/history" style="color:var(--text2);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">History</a>
<a href="/lab" style="color:var(--green);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid rgba(74,222,128,0.2);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Lab</a> <a href="/lab" style="color:var(--green);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid rgba(74,222,128,0.2);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Lab</a>
<a href="/admin" style="color:var(--text2);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Admin</a> <a href="/admin" style="color:var(--text2);text-decoration:none;font-size:10px;padding:5px 10px;border:2px solid var(--border);border-radius:2px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:0.5px">Admin</a>
<span style="width:2px;height:16px;background:var(--border);margin:0 4px"></span> <span style="width:2px;height:16px;background:var(--border);margin:0 4px"></span>
@ -4992,6 +4992,404 @@ def bulk_archive_runs():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route("/api/runs/<int:run_id>/tags", methods=["POST"])
@login_required
def update_run_tags(run_id):
data = request.json or {}
tags = data.get("tags", [])
notes = data.get("notes")
try:
with get_db() as conn:
with conn.cursor() as cur:
if notes is not None:
cur.execute("UPDATE team_runs SET tags = %s, notes = %s WHERE id = %s", (tags, notes, run_id))
else:
cur.execute("UPDATE team_runs SET tags = %s WHERE id = %s", (tags, run_id))
conn.commit()
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/tags")
@login_required
def get_all_tags():
"""Get all unique tags in use."""
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT DISTINCT unnest(tags) as tag FROM team_runs ORDER BY tag")
tags = [r[0] for r in cur.fetchall()]
return jsonify({"tags": tags})
except Exception as e:
return jsonify({"tags": [], "error": str(e)})
@app.route("/api/runs/vectors")
@login_required
def get_run_vectors():
"""Return runs as structured text documents for AI/embedding consumption."""
limit = min(int(request.args.get("limit", 50)), 500)
mode_filter = request.args.get("mode", "")
tag_filter = request.args.get("tag", "")
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
conditions = ["archived = false"]
params = []
if mode_filter:
conditions.append("mode = %s")
params.append(mode_filter)
if tag_filter:
conditions.append("%s = ANY(tags)")
params.append(tag_filter)
where = " AND ".join(conditions)
params.append(limit)
cur.execute(f"SELECT * FROM team_runs WHERE {where} ORDER BY created_at DESC LIMIT %s", params)
runs = cur.fetchall()
vectors = []
for run in runs:
responses = run.get("responses") or []
# Build structured document
doc_parts = [
f"MODE: {run['mode']}",
f"PROMPT: {run['prompt']}",
f"MODELS: {', '.join(run.get('models_used') or [])}",
f"DATE: {run['created_at'].isoformat()}",
f"TAGS: {', '.join(run.get('tags') or [])}",
]
if run.get("notes"):
doc_parts.append(f"NOTES: {run['notes']}")
for i, resp in enumerate(responses):
doc_parts.append(f"\n--- RESPONSE {i+1} [{resp.get('role','?')}] by {resp.get('model','?')} ---")
doc_parts.append(resp.get("text", "")[:2000])
document = "\n".join(doc_parts)
vectors.append({
"id": run["id"],
"mode": run["mode"],
"prompt": run["prompt"],
"tags": run.get("tags") or [],
"document": document,
"char_count": len(document),
"token_estimate": len(document) // 4
})
return jsonify({"vectors": vectors, "total": len(vectors)})
except Exception as e:
return jsonify({"vectors": [], "error": str(e)})
@app.route("/history")
@login_required
def history_page():
return HISTORY_HTML
HISTORY_HTML = r"""<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>LLM Team History</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#08090c;--surface:rgba(14,16,22,0.85);--border:#2a2d35;--text:#e8e6e3;--text2:#7a7872;--accent:#e2b55a;--green:#4ade80;--red:#e05252;--blue:#5b9cf5}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:20px 28px}
canvas#bg-grid{position:fixed;inset:0;z-index:0;pointer-events:none}
.scanlines{position:fixed;inset:0;z-index:1;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.02) 2px,rgba(0,0,0,0.02) 4px)}
.wrap{position:relative;z-index:10;max-width:1400px;margin:0 auto}
header{display:flex;align-items:center;gap:14px;padding-bottom:18px;border-bottom:2px solid var(--border);margin-bottom:20px}
h1{font-family:'JetBrains Mono',monospace;font-size:18px;font-weight:700}
h1 span{color:var(--accent)}
.back{color:var(--text2);text-decoration:none;font-size:10px;font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:1px;border:2px solid var(--border);padding:5px 12px;border-radius:2px}
.back:hover{border-color:var(--accent);color:var(--accent)}
.toolbar{display:flex;gap:6px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.tool-btn{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:5px 12px;border:2px solid var(--border);border-radius:2px;background:transparent;color:var(--text2);cursor:pointer}
.tool-btn:hover{border-color:var(--accent);color:var(--accent)}
.tool-btn.active{border-color:var(--accent);color:var(--accent);background:rgba(226,181,90,0.06)}
.tool-btn.mag{border-color:rgba(217,70,239,0.3);color:#d946ef}
.tool-btn.mag:hover{background:rgba(217,70,239,0.06)}
.tool-btn.grn{border-color:rgba(74,222,128,0.3);color:var(--green)}
.tool-btn.red{border-color:rgba(224,82,82,0.3);color:var(--red)}
.tool-select{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px}
.tool-input{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 10px;flex:1;min-width:120px}
.spacer{flex:1}
.count-badge{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text2)}
.run-table{width:100%}
.run-row{display:grid;grid-template-columns:30px 50px 90px 1fr 80px 100px 80px;gap:8px;padding:8px 10px;border-bottom:1px solid rgba(42,45,53,0.3);align-items:center;font-size:11px;cursor:pointer;transition:background 0.1s}
.run-row:hover{background:rgba(226,181,90,0.03)}
.run-row.archived{opacity:0.4}
.run-hdr{font-family:'JetBrains Mono',monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);cursor:default;border-bottom:2px solid var(--border)}
.run-hdr:hover{background:transparent}
.run-id{color:var(--text2);font-family:'JetBrains Mono',monospace}
.run-mode{font-family:'JetBrains Mono',monospace;font-weight:700;color:var(--accent);font-size:10px;text-transform:uppercase}
.run-prompt{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.run-models{color:var(--text2);font-family:'JetBrains Mono',monospace;font-size:10px}
.run-date{color:var(--text2);font-family:'JetBrains Mono',monospace;font-size:9px}
.run-tags{display:flex;gap:3px;flex-wrap:wrap}
.tag-pill{font-family:'JetBrains Mono',monospace;font-size:8px;padding:1px 6px;border:1px solid rgba(192,132,252,0.3);border-radius:1px;color:#c084fc}
.detail-panel{display:none;background:var(--surface);border:2px solid var(--accent);border-radius:2px;padding:20px;margin-bottom:20px;backdrop-filter:blur(16px)}
.detail-panel.open{display:block}
.detail-header{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.detail-prompt{font-size:13px;line-height:1.6;margin-bottom:12px}
.detail-actions{display:flex;gap:4px;margin-bottom:14px;flex-wrap:wrap}
.tag-editor{display:flex;gap:4px;align-items:center;margin-bottom:12px}
.tag-editor input{font-family:'JetBrains Mono',monospace;font-size:10px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:4px 8px;width:120px}
.notes-area{width:100%;min-height:40px;background:rgba(0,0,0,0.4);border:2px solid var(--border);color:var(--text);border-radius:2px;padding:8px;font-size:12px;font-family:'JetBrains Mono',monospace;resize:vertical;margin-bottom:12px}
.notes-area:focus{border-color:var(--accent);outline:none}
.resp-card{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;margin-bottom:6px;overflow:hidden}
.resp-head{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--border);font-family:'JetBrains Mono',monospace;font-size:11px;cursor:pointer}
.resp-head:hover{background:rgba(226,181,90,0.03)}
.resp-role{font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#c084fc;margin-left:auto}
.resp-body{display:none;padding:12px;font-size:12px;line-height:1.6;white-space:pre-wrap;max-height:400px;overflow-y:auto}
.resp-body.open{display:block}
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:40px;text-align:center;opacity:0.5}
.toast{position:fixed;top:20px;right:20px;padding:8px 14px;border-radius:2px;font-size:10px;z-index:100;font-family:'JetBrains Mono',monospace;border:2px solid;backdrop-filter:blur(16px)}
.toast.ok{background:rgba(74,222,128,0.1);border-color:var(--green);color:var(--green)}
.toast.err{background:rgba(224,82,82,0.1);border-color:var(--red);color:var(--red)}
@media(max-width:768px){.run-row{grid-template-columns:30px 60px 1fr 60px}.run-models,.run-date,.run-tags{display:none}}
</style></head><body>
<canvas id="bg-grid"></canvas>
<div class="scanlines"></div>
<div class="wrap">
<header>
<h1><span>History</span> // Run Archive</h1>
<nav style="margin-left:auto;display:flex;gap:6px">
<a class="back" href="/">Team</a>
<a class="back" href="/admin/monitor">Monitor</a>
<a class="back" href="/logs">Logs</a>
<a class="back" href="/admin">Admin</a>
</nav>
</header>
<!-- Toolbar -->
<div class="toolbar">
<button class="tool-btn active" id="tb-active" onclick="setView('active')">Active</button>
<button class="tool-btn" id="tb-archived" onclick="setView('archived')">Archived</button>
<button class="tool-btn" id="tb-all" onclick="setView('all')">All</button>
<span style="width:2px;height:16px;background:var(--border);margin:0 2px"></span>
<select class="tool-select" id="filter-mode" onchange="loadRuns()"><option value="">All Modes</option></select>
<select class="tool-select" id="filter-tag" onchange="loadRuns()"><option value="">All Tags</option></select>
<input class="tool-input" id="filter-search" placeholder="Search prompts..." oninput="filterLocal()">
<span class="spacer"></span>
<span class="count-badge" id="run-count"></span>
<button class="tool-btn mag" onclick="archiveSelected()">Archive Sel.</button>
<button class="tool-btn grn" onclick="restoreSelected()">Restore Sel.</button>
</div>
<!-- Detail panel -->
<div class="detail-panel" id="detail-panel"></div>
<!-- Run table -->
<div class="run-table" id="run-table">
<div class="run-row run-hdr">
<span></span><span>ID</span><span>Mode</span><span>Prompt</span><span>Models</span><span>Tags</span><span>Date</span>
</div>
<div id="run-list"><div class="empty">Loading...</div></div>
</div>
</div>
<script>
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}t++;requestAnimationFrame(draw)}draw()}();
var currentView = 'active';
var allRuns = [];
function setView(v) {
currentView = v;
document.querySelectorAll('.toolbar .tool-btn').forEach(function(b){b.classList.remove('active')});
document.getElementById('tb-'+v).classList.add('active');
loadRuns();
}
function toast(msg, ok) {
var t = document.createElement('div'); t.className = 'toast ' + (ok?'ok':'err');
t.textContent = msg; document.body.appendChild(t);
setTimeout(function(){t.remove()},2500);
}
async function loadRuns() {
var mode = document.getElementById('filter-mode').value;
var tag = document.getElementById('filter-tag').value;
var url = '/api/runs?show=' + currentView;
var r = await fetch(url);
var d = await r.json();
allRuns = d.runs || [];
// Client-side filter by mode/tag
if (mode) allRuns = allRuns.filter(function(r){return r.mode === mode});
if (tag) allRuns = allRuns.filter(function(r){return (r.tags||[]).indexOf(tag)>=0});
filterLocal();
document.getElementById('run-count').textContent = allRuns.length + ' runs';
}
function filterLocal() {
var q = (document.getElementById('filter-search').value||'').toLowerCase();
var filtered = q ? allRuns.filter(function(r){return (r.prompt||'').toLowerCase().indexOf(q)>=0 || r.mode.toLowerCase().indexOf(q)>=0}) : allRuns;
renderTable(filtered);
}
function renderTable(runs) {
var el = document.getElementById('run-list');
if (!runs.length) { el.textContent = ''; var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No runs found'; el.appendChild(e); return; }
el.textContent = '';
runs.forEach(function(r) {
var row = document.createElement('div');
row.className = 'run-row' + (r.archived ? ' archived' : '');
// Checkbox
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.dataset.id = r.id;
cb.style.cssText = 'width:14px;height:14px;accent-color:#e2b55a;cursor:pointer';
cb.onclick = function(e){e.stopPropagation()};
row.appendChild(cb);
// ID
var idEl = document.createElement('span'); idEl.className = 'run-id'; idEl.textContent = '#'+r.id; row.appendChild(idEl);
// Mode
var modeEl = document.createElement('span'); modeEl.className = 'run-mode'; modeEl.textContent = r.mode; row.appendChild(modeEl);
// Prompt
var promptEl = document.createElement('span'); promptEl.className = 'run-prompt'; promptEl.textContent = (r.prompt||'').substring(0,100); promptEl.title = r.prompt||''; row.appendChild(promptEl);
// Models
var modelsEl = document.createElement('span'); modelsEl.className = 'run-models'; modelsEl.textContent = (r.models_used||[]).length + ' models'; row.appendChild(modelsEl);
// Tags
var tagsEl = document.createElement('span'); tagsEl.className = 'run-tags';
(r.tags||[]).forEach(function(t){ var pill = document.createElement('span'); pill.className = 'tag-pill'; pill.textContent = t; tagsEl.appendChild(pill); });
row.appendChild(tagsEl);
// Date
var dateEl = document.createElement('span'); dateEl.className = 'run-date';
var dt = new Date(r.created_at); dateEl.textContent = dt.toLocaleDateString()+' '+dt.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
row.appendChild(dateEl);
row.onclick = function(){openDetail(r.id)};
el.appendChild(row);
});
}
async function openDetail(id) {
var panel = document.getElementById('detail-panel');
panel.className = 'detail-panel open';
panel.textContent = 'Loading...';
var r = await fetch('/api/runs/'+id);
var run = await r.json();
if (run.error) { panel.textContent = 'Error: '+run.error; return; }
panel.textContent = '';
// Header
var hdr = document.createElement('div'); hdr.className = 'detail-header';
var closeBtn = document.createElement('button'); closeBtn.className = 'tool-btn';
closeBtn.textContent = '✕ Close'; closeBtn.onclick = function(){panel.className='detail-panel';};
hdr.appendChild(closeBtn);
var modeTag = document.createElement('span'); modeTag.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);font-weight:700';
modeTag.textContent = run.mode + ' #' + id; hdr.appendChild(modeTag);
var dateTag = document.createElement('span'); dateTag.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:var(--text2)';
dateTag.textContent = new Date(run.created_at).toLocaleString(); hdr.appendChild(dateTag);
panel.appendChild(hdr);
// Prompt
var prompt = document.createElement('div'); prompt.className = 'detail-prompt'; prompt.textContent = run.prompt; panel.appendChild(prompt);
// Tags editor
var tagEd = document.createElement('div'); tagEd.className = 'tag-editor';
var tagLabel = document.createElement('span'); tagLabel.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1px;color:var(--text2)';
tagLabel.textContent = 'Tags: '; tagEd.appendChild(tagLabel);
var currentTags = run.tags || [];
currentTags.forEach(function(t){
var pill = document.createElement('span'); pill.className = 'tag-pill'; pill.style.cursor = 'pointer';
pill.textContent = t + ''; pill.title = 'Remove tag';
pill.onclick = function(){ currentTags = currentTags.filter(function(x){return x!==t}); saveTags(id,currentTags,null); };
tagEd.appendChild(pill);
});
var tagInput = document.createElement('input'); tagInput.placeholder = 'Add tag...';
tagInput.onkeydown = function(e){ if(e.key==='Enter'&&tagInput.value.trim()){ currentTags.push(tagInput.value.trim()); saveTags(id,currentTags,null); tagInput.value=''; }};
tagEd.appendChild(tagInput);
panel.appendChild(tagEd);
// Notes
var notesArea = document.createElement('textarea'); notesArea.className = 'notes-area';
notesArea.placeholder = 'Add notes...'; notesArea.value = run.notes || '';
var saveTimer;
notesArea.oninput = function(){ clearTimeout(saveTimer); saveTimer = setTimeout(function(){ saveTags(id, null, notesArea.value); }, 1000); };
panel.appendChild(notesArea);
// Actions
var actions = document.createElement('div'); actions.className = 'detail-actions';
if (run.archived) {
var restBtn = document.createElement('button'); restBtn.className = 'tool-btn grn'; restBtn.textContent = 'Restore';
restBtn.onclick = function(){ fetch('/api/runs/'+id+'/restore',{method:'POST'}).then(function(){toast('Restored',true);loadRuns()}); };
actions.appendChild(restBtn);
} else {
var archBtn = document.createElement('button'); archBtn.className = 'tool-btn mag'; archBtn.textContent = 'Archive';
archBtn.onclick = function(){ fetch('/api/runs/'+id+'/archive',{method:'POST'}).then(function(){toast('Archived',true);loadRuns();panel.className='detail-panel'}); };
actions.appendChild(archBtn);
}
var delBtn = document.createElement('button'); delBtn.className = 'tool-btn red'; delBtn.textContent = 'Delete';
delBtn.onclick = function(){ if(confirm('Delete permanently?')){fetch('/api/runs/'+id,{method:'DELETE'}).then(function(){toast('Deleted',true);loadRuns();panel.className='detail-panel'})} };
actions.appendChild(delBtn);
panel.appendChild(actions);
// Responses
var responses = run.responses || [];
var respTitle = document.createElement('div');
respTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:2px;color:var(--accent);margin-bottom:8px';
respTitle.textContent = responses.length + ' Responses'; panel.appendChild(respTitle);
responses.forEach(function(resp,i){
var card = document.createElement('div'); card.className = 'resp-card';
var head = document.createElement('div'); head.className = 'resp-head';
head.textContent = resp.model || '?';
var role = document.createElement('span'); role.className = 'resp-role'; role.textContent = resp.role||'';
head.appendChild(role);
var body = document.createElement('div'); body.className = 'resp-body';
body.textContent = resp.text || '';
head.onclick = function(){ body.classList.toggle('open'); };
card.appendChild(head); card.appendChild(body); panel.appendChild(card);
});
panel.scrollIntoView({behavior:'smooth',block:'start'});
}
async function saveTags(id, tags, notes) {
var body = {};
if (tags !== null) body.tags = tags;
if (notes !== null) body.notes = notes;
await fetch('/api/runs/'+id+'/tags', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
toast('Saved', true);
if (tags !== null) loadRuns();
}
function getSelectedIds() {
var ids = [];
document.querySelectorAll('#run-list input[type=checkbox]:checked').forEach(function(cb){ids.push(parseInt(cb.dataset.id))});
return ids;
}
async function archiveSelected() {
var ids = getSelectedIds();
if (!ids.length) return toast('Select runs first', false);
if (!confirm('Archive '+ids.length+' runs?')) return;
await fetch('/api/runs/bulk-archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'archive',ids:ids})});
toast('Archived '+ids.length, true); loadRuns();
}
async function restoreSelected() {
var ids = getSelectedIds();
if (!ids.length) return toast('Select runs first', false);
await fetch('/api/runs/bulk-archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'restore',ids:ids})});
toast('Restored '+ids.length, true); loadRuns();
}
async function loadFilters() {
var modeSelect = document.getElementById('filter-mode');
var tagSelect = document.getElementById('filter-tag');
// Get unique modes from runs
var r = await fetch('/api/runs?show=all');
var d = await r.json();
var modes = new Set(); (d.runs||[]).forEach(function(r){modes.add(r.mode)});
modes.forEach(function(m){ var o = document.createElement('option'); o.value = m; o.textContent = m; modeSelect.appendChild(o); });
// Tags
var tr = await fetch('/api/runs/tags');
var td = await tr.json();
(td.tags||[]).forEach(function(t){ var o = document.createElement('option'); o.value = t; o.textContent = t; tagSelect.appendChild(o); });
}
loadFilters();
loadRuns();
</script>
</body></html>"""
@app.route("/api/pipelines") @app.route("/api/pipelines")
@login_required @login_required
def get_pipelines(): def get_pipelines():