Archive/restore history: soft-delete with toggle and bulk ops

Database:
- Added 'archived' boolean column to team_runs (indexed)
- Active runs filtered by archived=false by default

API:
- GET /api/runs?show=active|archived|all
- POST /api/runs/:id/archive — archive single run
- POST /api/runs/:id/restore — restore single run
- POST /api/runs/bulk-archive — archive/restore by IDs or date

History panel UI:
- Active/Archived toggle tabs at top
- Per-run Archive button (magenta) in detail view
- Per-run Restore button (green) in detail view for archived runs
- "Archive All" bulk button when viewing active runs
- "Restore All" bulk button when viewing archived runs
- Archived runs hidden from active view, accessible anytime

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

View File

@ -2587,29 +2587,47 @@ function toggleHistory() {
}
}
var _historyView = 'active'; // active or archived
async function loadHistory() {
const r = await fetch('/api/runs');
const data = await r.json();
var show = _historyView === 'archived' ? 'archived' : 'active';
var r = await fetch('/api/runs?show=' + show);
var data = await r.json();
historyRuns = data.runs || [];
renderHistoryList();
}
function renderHistoryList() {
const el = document.getElementById('hp-content');
var el = document.getElementById('hp-content');
var isArchived = _historyView === 'archived';
// Toggle bar
var toggleBar = '<div style="display:flex;gap:4px;padding:8px;border-bottom:2px solid var(--border)">'
+ '<button class="hp-btn" style="'+ (!isArchived ? 'border-color:var(--accent);color:var(--accent)' : '') +'" onclick="_historyView=\'active\';loadHistory()">Active</button>'
+ '<button class="hp-btn" style="'+ (isArchived ? 'border-color:var(--accent);color:var(--accent)' : '') +'" onclick="_historyView=\'archived\';loadHistory()">Archived</button>'
+ '<span style="flex:1"></span>';
if (!isArchived && historyRuns.length > 0) {
toggleBar += '<button class="hp-btn" style="border-color:rgba(217,70,239,0.3);color:#d946ef;font-size:9px" onclick="bulkArchive()">Archive All</button>';
}
if (isArchived && historyRuns.length > 0) {
toggleBar += '<button class="hp-btn" style="border-color:rgba(74,222,128,0.3);color:var(--green);font-size:9px" onclick="bulkRestore()">Restore All</button>';
}
toggleBar += '</div>';
if (!historyRuns.length) {
el.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text2)">No runs saved yet. Run a team to see history here.</div>';
el.innerHTML = toggleBar + '<div style="text-align:center;padding:40px;color:var(--text2);font-family:JetBrains Mono,monospace;font-size:11px">' + (isArchived ? 'No archived runs.' : 'No active runs. Run a team to see history here.') + '</div>';
return;
}
el.innerHTML = '<div class="hp-list">' + historyRuns.map(r => {
const d = new Date(r.created_at);
const time = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
const models = (r.models_used || []).length;
const prompt = (r.prompt || '').substring(0, 80);
return `<div class="hp-item" onclick="viewRun(${r.id})">
<div class="hp-mode">${r.mode}</div>
<div class="hp-prompt">${escapeHtml(prompt)}</div>
<div class="hp-meta"><span>${time}</span><span>${models} model${models!==1?'s':''}</span></div>
</div>`;
el.innerHTML = toggleBar + '<div class="hp-list">' + historyRuns.map(function(r) {
var d = new Date(r.created_at);
var time = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
var models = (r.models_used || []).length;
var prompt = (r.prompt || '').substring(0, 80);
return '<div class="hp-item" onclick="viewRun(' + r.id + ')">'
+ '<div class="hp-mode">' + r.mode + '</div>'
+ '<div class="hp-prompt">' + escapeHtml(prompt) + '</div>'
+ '<div class="hp-meta"><span>' + time + '</span><span>' + models + ' model' + (models!==1?'s':'') + '</span></div>'
+ '</div>';
}).join('') + '</div>';
}
@ -2625,6 +2643,12 @@ async function viewRun(id) {
html += `<div style="font-size:13px;margin-bottom:8px">${escapeHtml(run.prompt)}</div>`;
html += `<div class="hp-actions">`;
html += `<button class="hp-btn" onclick="rerunFromHistory(${id})">Re-run</button>`;
var isArch = run.archived;
if (isArch) {
html += `<button class="hp-btn" style="border-color:rgba(74,222,128,0.3);color:var(--green)" onclick="restoreRun(${id})">Restore</button>`;
} else {
html += `<button class="hp-btn" style="border-color:rgba(217,70,239,0.3);color:#d946ef" onclick="archiveRun(${id})">Archive</button>`;
}
html += `<button class="hp-btn hp-btn-del" onclick="deleteRun(${id})">Delete</button>`;
html += `</div>`;
responses.forEach((resp, ri) => {
@ -2651,10 +2675,33 @@ async function rerunFromHistory(id) {
toggleHistory();
}
async function archiveRun(id) {
await fetch('/api/runs/' + id + '/archive', {method: 'POST'});
await loadHistory();
}
async function restoreRun(id) {
await fetch('/api/runs/' + id + '/restore', {method: 'POST'});
await loadHistory();
}
async function bulkArchive() {
if (!confirm('Archive all ' + historyRuns.length + ' active runs?')) return;
var ids = historyRuns.map(function(r) { return r.id; });
await fetch('/api/runs/bulk-archive', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({action: 'archive', ids: ids})});
await loadHistory();
}
async function bulkRestore() {
if (!confirm('Restore all ' + historyRuns.length + ' archived runs?')) return;
var ids = historyRuns.map(function(r) { return r.id; });
await fetch('/api/runs/bulk-archive', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({action: 'restore', ids: ids})});
await loadHistory();
}
async function deleteRun(id) {
await fetch('/api/runs/' + id, {method: 'DELETE'});
await loadHistory();
renderHistoryList();
}
// DEMO MODE
@ -4844,10 +4891,16 @@ setInterval(poll,3000);
@app.route("/api/runs")
@login_required
def get_runs():
show = request.args.get("show", "active") # active, archived, all
try:
with get_db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT id, mode, prompt, models_used, created_at FROM team_runs ORDER BY created_at DESC LIMIT 50")
if show == "archived":
cur.execute("SELECT id, mode, prompt, models_used, created_at, archived FROM team_runs WHERE archived = true ORDER BY created_at DESC LIMIT 200")
elif show == "all":
cur.execute("SELECT id, mode, prompt, models_used, created_at, archived FROM team_runs ORDER BY created_at DESC LIMIT 200")
else:
cur.execute("SELECT id, mode, prompt, models_used, created_at, archived FROM team_runs WHERE archived = false ORDER BY created_at DESC LIMIT 50")
runs = cur.fetchall()
for r in runs:
r["created_at"] = r["created_at"].isoformat()
@ -4885,6 +4938,60 @@ def delete_run(run_id):
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/<int:run_id>/archive", methods=["POST"])
@login_required
def archive_run(run_id):
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE team_runs SET archived = true WHERE id = %s", (run_id,))
conn.commit()
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/<int:run_id>/restore", methods=["POST"])
@login_required
def restore_run(run_id):
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("UPDATE team_runs SET archived = false WHERE id = %s", (run_id,))
conn.commit()
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/runs/bulk-archive", methods=["POST"])
@admin_required
def bulk_archive_runs():
data = request.json or {}
action = data.get("action", "archive") # archive or restore
ids = data.get("ids", [])
before = data.get("before") # archive all before this date
try:
with get_db() as conn:
with conn.cursor() as cur:
archived_val = action == "archive"
if ids:
cur.execute("UPDATE team_runs SET archived = %s WHERE id = ANY(%s)", (archived_val, ids))
count = cur.rowcount
elif before:
cur.execute("UPDATE team_runs SET archived = %s WHERE created_at < %s AND archived = %s",
(archived_val, before, not archived_val))
count = cur.rowcount
else:
# Archive all
cur.execute("UPDATE team_runs SET archived = true WHERE archived = false")
count = cur.rowcount
conn.commit()
return jsonify({"ok": True, "count": count})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/pipelines")
@login_required
def get_pipelines():