Imports 67 files from VPS (/opt/teleo-eval/) into repo as the single source of truth. Previously only 8 of 67 files existed in repo — the rest were deployed directly to VPS via SCP, causing massive drift. Includes: - pipeline/lib/: 33 Python modules (daemon core, extraction, evaluation, merge, cascade, cross-domain, costs, attribution, etc.) - pipeline/: main daemon (teleo-pipeline.py), reweave.py, batch-extract-50.sh - diagnostics/: 19 files (4-page dashboard, alerting, daily digest, review queue, tier1 metrics) - agent-state/: bootstrap, lib-state, cascade inbox processor, schema - systemd/: service unit files for reference - deploy.sh: rsync-based deploy with --dry-run, syntax checks, dirty-tree gate - research-session.sh: updated with Step 8.5 digest + cascade inbox processing No new code written — all files are exact copies from VPS as of 2026-04-06. From this point forward: edit in repo, commit, then deploy.sh. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
464 lines
20 KiB
Python
464 lines
20 KiB
Python
"""Page 1: Pipeline Operations — "Is the machine running?"
|
|
|
|
Renders: queue depth, throughput, error rate, stage flow, breakers,
|
|
funnel, rejection reasons, fix cycle, time-series charts.
|
|
|
|
All data comes from existing endpoints: /api/metrics, /api/snapshots,
|
|
/api/stage-times, /api/alerts, /api/fix-rates.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
from shared_ui import render_page
|
|
|
|
|
|
def render_ops_page(metrics: dict, snapshots: list, changes: list,
|
|
vital_signs: dict, now: datetime) -> str:
|
|
"""Render the Pipeline Operations page."""
|
|
|
|
# --- Prepare chart data ---
|
|
timestamps = [s["ts"] for s in snapshots]
|
|
throughput_data = [s.get("throughput_1h", 0) for s in snapshots]
|
|
approval_data = [(s.get("approval_rate") or 0) * 100 for s in snapshots]
|
|
open_prs_data = [s.get("open_prs", 0) for s in snapshots]
|
|
merged_data = [s.get("merged_total", 0) for s in snapshots]
|
|
|
|
rej_wiki = [s.get("rejection_broken_wiki_links", 0) for s in snapshots]
|
|
rej_schema = [s.get("rejection_frontmatter_schema", 0) for s in snapshots]
|
|
rej_dup = [s.get("rejection_near_duplicate", 0) for s in snapshots]
|
|
rej_conf = [s.get("rejection_confidence", 0) for s in snapshots]
|
|
rej_other = [s.get("rejection_other", 0) for s in snapshots]
|
|
|
|
# origin_agent/origin_human removed — replaced by /api/growth chart
|
|
|
|
annotations_js = json.dumps([
|
|
{
|
|
"type": "line", "xMin": c["ts"], "xMax": c["ts"],
|
|
"borderColor": "#d29922" if c["type"] == "prompt" else "#58a6ff",
|
|
"borderWidth": 1, "borderDash": [4, 4],
|
|
"label": {"display": True, "content": f"{c['type']}: {c.get('to', '?')}",
|
|
"position": "start", "backgroundColor": "#161b22",
|
|
"color": "#8b949e", "font": {"size": 10}},
|
|
}
|
|
for c in changes
|
|
])
|
|
|
|
# --- Status helpers ---
|
|
sm = metrics["status_map"]
|
|
ar = metrics["approval_rate"]
|
|
ar_color = "green" if ar > 0.5 else ("yellow" if ar > 0.2 else "red")
|
|
fr_color = "green" if metrics["fix_rate"] > 0.3 else ("yellow" if metrics["fix_rate"] > 0.1 else "red")
|
|
|
|
vs_review = vital_signs["review_throughput"]
|
|
vs_status_color = {"healthy": "green", "warning": "yellow", "critical": "red"}.get(vs_review["status"], "yellow")
|
|
|
|
# --- Rejection reasons table ---
|
|
reason_rows = "".join(
|
|
f'<tr><td><code>{r["tag"]}</code></td><td>{r["unique_prs"]}</td>'
|
|
f'<td style="color:#8b949e">{r["count"]}</td></tr>'
|
|
for r in metrics["rejection_reasons"]
|
|
)
|
|
|
|
# --- Breaker rows ---
|
|
breaker_rows = ""
|
|
for name, info in metrics["breakers"].items():
|
|
state = info["state"]
|
|
color = "green" if state == "closed" else ("red" if state == "open" else "yellow")
|
|
age = f'{info.get("age_s", "?")}s ago' if "age_s" in info else "-"
|
|
breaker_rows += f'<tr><td>{name}</td><td class="{color}">{state}</td><td>{info["failures"]}</td><td>{age}</td></tr>'
|
|
|
|
# --- Funnel ---
|
|
funnel = vital_signs["funnel"]
|
|
|
|
# --- Queue staleness ---
|
|
qs = vital_signs.get("queue_staleness", {})
|
|
stale_count = qs.get("stale_count", 0)
|
|
stale_status = qs.get("status", "healthy")
|
|
stale_color = {"healthy": "green", "warning": "yellow", "critical": "red"}.get(stale_status, "")
|
|
|
|
body = f"""
|
|
<!-- Hero Cards -->
|
|
<div class="grid">
|
|
<div class="card">
|
|
<div class="label">Throughput</div>
|
|
<div class="value">{metrics["throughput_1h"]}<span style="font-size:14px;color:#8b949e">/hr</span></div>
|
|
<div class="detail">merged last hour</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="label">Approval Rate (24h)</div>
|
|
<div class="value {ar_color}">{ar:.1%}</div>
|
|
<div class="detail">{metrics["approved_24h"]}/{metrics["evaluated_24h"]} evaluated</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="label">Review Backlog</div>
|
|
<div class="value {vs_status_color}">{vs_review["backlog"]}</div>
|
|
<div class="detail">{vs_review["open_prs"]} open + {vs_review["reviewing_prs"]} reviewing + {vs_review["approved_waiting"]} approved</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="label">Merged Total</div>
|
|
<div class="value green">{sm.get("merged", 0)}</div>
|
|
<div class="detail">{sm.get("closed", 0)} closed</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="label">Fix Success</div>
|
|
<div class="value {fr_color}">{metrics["fix_rate"]:.1%}</div>
|
|
<div class="detail">{metrics["fix_succeeded"]}/{metrics["fix_attempted"]} fixed</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="label">Time to Merge</div>
|
|
<div class="value">{f"{metrics['median_ttm_minutes']:.0f}" if metrics["median_ttm_minutes"] else "—"}<span style="font-size:14px;color:#8b949e">min</span></div>
|
|
<div class="detail">median (24h)</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Alert Banner (loaded via JS) -->
|
|
<div id="alert-banner"></div>
|
|
|
|
<!-- Pipeline Funnel -->
|
|
<div class="section">
|
|
<div class="section-title">Pipeline Funnel</div>
|
|
<div class="funnel">
|
|
<div class="funnel-step"><div class="num">{funnel["sources_total"]}</div><div class="lbl">Sources</div></div>
|
|
<div class="funnel-arrow">→</div>
|
|
<div class="funnel-step"><div class="num" style="color:#f0883e">{funnel["sources_queued"]}</div><div class="lbl">In Queue</div></div>
|
|
<div class="funnel-arrow">→</div>
|
|
<div class="funnel-step"><div class="num">{funnel["sources_extracted"]}</div><div class="lbl">Extracted</div></div>
|
|
<div class="funnel-arrow">→</div>
|
|
<div class="funnel-step"><div class="num">{funnel["prs_total"]}</div><div class="lbl">PRs Created</div></div>
|
|
<div class="funnel-arrow">→</div>
|
|
<div class="funnel-step"><div class="num green">{funnel["prs_merged"]}</div><div class="lbl">Merged</div></div>
|
|
<div class="funnel-arrow">→</div>
|
|
<div class="funnel-step"><div class="num blue">{funnel["conversion_rate"]:.1%}</div><div class="lbl">Conversion</div></div>
|
|
</div>
|
|
<div style="margin-top:8px;font-size:12px;color:#8b949e">
|
|
Queue staleness: <span class="{stale_color}">{stale_count} stale</span>
|
|
{f'(oldest: {qs.get("oldest_age_days", "?")}d)' if stale_count > 0 else ""}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stage Dwell Times (loaded via JS) -->
|
|
<div class="section">
|
|
<div class="section-title">Stage Dwell Times</div>
|
|
<div id="stage-times-container" class="grid"></div>
|
|
</div>
|
|
|
|
<!-- Charts -->
|
|
<div id="no-chart-data" class="card" style="text-align:center;padding:40px;margin:16px 0;display:none">
|
|
<p style="color:#8b949e">No time-series data yet.</p>
|
|
</div>
|
|
<div id="chart-section">
|
|
<div class="row">
|
|
<div class="chart-container">
|
|
<h2>Throughput & Approval Rate</h2>
|
|
<canvas id="throughputChart"></canvas>
|
|
</div>
|
|
<div class="chart-container">
|
|
<h2>Rejection Reasons Over Time</h2>
|
|
<canvas id="rejectionChart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="chart-container">
|
|
<h2>PR Backlog</h2>
|
|
<canvas id="backlogChart"></canvas>
|
|
</div>
|
|
<div class="chart-container">
|
|
<h2>Cumulative Growth</h2>
|
|
<canvas id="growthChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PR Trace Lookup -->
|
|
<div class="section">
|
|
<div class="section-title">PR Trace Lookup</div>
|
|
<div class="card">
|
|
<div style="display:flex;gap:8px;align-items:center">
|
|
<input id="trace-pr-input" type="number" placeholder="Enter PR number"
|
|
style="background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:8px 12px;border-radius:6px;width:180px;font-size:14px">
|
|
<button onclick="loadTrace()" style="background:#238636;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">Trace</button>
|
|
</div>
|
|
<div id="trace-result" style="margin-top:12px"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tables -->
|
|
<div class="row">
|
|
<div class="section">
|
|
<div class="section-title">Top Rejection Reasons (24h)</div>
|
|
<div class="card">
|
|
<table>
|
|
<tr><th>Issue</th><th>PRs</th><th style="color:#8b949e">Events</th></tr>
|
|
{reason_rows if reason_rows else "<tr><td colspan='3' style='color:#8b949e'>No rejections in 24h</td></tr>"}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-title">Circuit Breakers</div>
|
|
<div class="card">
|
|
<table>
|
|
<tr><th>Stage</th><th>State</th><th>Failures</th><th>Last Success</th></tr>
|
|
{breaker_rows if breaker_rows else "<tr><td colspan='4' style='color:#8b949e'>No breaker data</td></tr>"}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
scripts = f"""<script>
|
|
const timestamps = {json.dumps(timestamps)};
|
|
|
|
// --- Alerts banner ---
|
|
fetch('/api/alerts')
|
|
.then(r => r.json())
|
|
.then(data => {{
|
|
if (data.alerts && data.alerts.length > 0) {{
|
|
const critical = data.alerts.filter(a => a.severity === 'critical');
|
|
const warning = data.alerts.filter(a => a.severity === 'warning');
|
|
let html = '';
|
|
if (critical.length > 0) {{
|
|
html += '<div class="alert-banner alert-critical">' +
|
|
critical.map(a => '!! ' + esc(a.title)).join('<br>') + '</div>';
|
|
}}
|
|
if (warning.length > 0) {{
|
|
html += '<div class="alert-banner alert-warning">' +
|
|
warning.map(a => '! ' + esc(a.title)).join('<br>') + '</div>';
|
|
}}
|
|
document.getElementById('alert-banner').innerHTML = html;
|
|
}}
|
|
}}).catch(() => {{}});
|
|
|
|
// --- Stage dwell times ---
|
|
fetch('/api/stage-times?hours=24')
|
|
.then(r => r.json())
|
|
.then(data => {{
|
|
const container = document.getElementById('stage-times-container');
|
|
const stages = data.stages || {{}};
|
|
if (Object.keys(stages).length === 0) {{
|
|
container.innerHTML = '<div class="card" style="grid-column:1/-1;text-align:center;color:#8b949e">No stage timing data yet</div>';
|
|
return;
|
|
}}
|
|
let html = '';
|
|
for (const [label, info] of Object.entries(stages)) {{
|
|
const color = info.median_minutes < 5 ? 'green' : info.median_minutes < 30 ? 'yellow' : 'red';
|
|
html += '<div class="card"><div class="label">' + esc(label) + '</div>' +
|
|
'<div class="value ' + color + '">' + info.median_minutes.toFixed(1) + '<span style="font-size:14px;color:#8b949e">min</span></div>' +
|
|
'<div class="detail">median (' + info.count + ' PRs)' +
|
|
(info.p90_minutes ? ' · p90: ' + info.p90_minutes.toFixed(1) + 'min' : '') +
|
|
'</div></div>';
|
|
}}
|
|
container.innerHTML = html;
|
|
}}).catch(() => {{}});
|
|
|
|
// --- Time-series charts ---
|
|
if (timestamps.length === 0) {{
|
|
document.getElementById('chart-section').style.display = 'none';
|
|
document.getElementById('no-chart-data').style.display = 'block';
|
|
}} else {{
|
|
|
|
const throughputData = {json.dumps(throughput_data)};
|
|
const approvalData = {json.dumps(approval_data)};
|
|
const openPrsData = {json.dumps(open_prs_data)};
|
|
const mergedData = {json.dumps(merged_data)};
|
|
const rejWiki = {json.dumps(rej_wiki)};
|
|
const rejSchema = {json.dumps(rej_schema)};
|
|
const rejDup = {json.dumps(rej_dup)};
|
|
const rejConf = {json.dumps(rej_conf)};
|
|
const rejOther = {json.dumps(rej_other)};
|
|
const annotations = {annotations_js};
|
|
|
|
new Chart(document.getElementById('throughputChart'), {{
|
|
type: 'line',
|
|
data: {{
|
|
labels: timestamps,
|
|
datasets: [
|
|
{{ label: 'Throughput/hr', data: throughputData, borderColor: '#58a6ff', backgroundColor: 'rgba(88,166,255,0.1)', fill: true, tension: 0.3, yAxisID: 'y', pointRadius: 1 }},
|
|
{{ label: 'Approval %', data: approvalData, borderColor: '#3fb950', borderDash: [4,2], tension: 0.3, yAxisID: 'y1', pointRadius: 1 }},
|
|
],
|
|
}},
|
|
options: {{
|
|
responsive: true,
|
|
interaction: {{ mode: 'index', intersect: false }},
|
|
scales: {{
|
|
x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
|
y: {{ position: 'left', title: {{ display: true, text: 'PRs/hr' }}, min: 0 }},
|
|
y1: {{ position: 'right', title: {{ display: true, text: 'Approval %' }}, min: 0, max: 100, grid: {{ drawOnChartArea: false }} }},
|
|
}},
|
|
plugins: {{ annotation: {{ annotations }}, legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
|
}},
|
|
}});
|
|
|
|
new Chart(document.getElementById('rejectionChart'), {{
|
|
type: 'line',
|
|
data: {{
|
|
labels: timestamps,
|
|
datasets: [
|
|
{{ label: 'Wiki Links', data: rejWiki, borderColor: '#f85149', backgroundColor: 'rgba(248,81,73,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
|
{{ label: 'Schema', data: rejSchema, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
|
{{ label: 'Duplicate', data: rejDup, borderColor: '#8b949e', backgroundColor: 'rgba(139,148,158,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
|
{{ label: 'Confidence', data: rejConf, borderColor: '#bc8cff', backgroundColor: 'rgba(188,140,255,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
|
{{ label: 'Other', data: rejOther, borderColor: '#6e7681', backgroundColor: 'rgba(110,118,129,0.15)', fill: true, tension: 0.3, pointRadius: 0 }},
|
|
],
|
|
}},
|
|
options: {{
|
|
responsive: true,
|
|
scales: {{
|
|
x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
|
y: {{ stacked: true, min: 0, title: {{ display: true, text: 'Count (24h)' }} }},
|
|
}},
|
|
plugins: {{ annotation: {{ annotations }}, legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
|
}},
|
|
}});
|
|
|
|
new Chart(document.getElementById('backlogChart'), {{
|
|
type: 'line',
|
|
data: {{
|
|
labels: timestamps,
|
|
datasets: [
|
|
{{ label: 'Open PRs', data: openPrsData, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.15)', fill: true, tension: 0.3, pointRadius: 1 }},
|
|
{{ label: 'Merged (total)', data: mergedData, borderColor: '#3fb950', tension: 0.3, pointRadius: 1 }},
|
|
],
|
|
}},
|
|
options: {{
|
|
responsive: true,
|
|
scales: {{
|
|
x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
|
y: {{ min: 0, title: {{ display: true, text: 'PRs' }} }},
|
|
}},
|
|
plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
|
}},
|
|
}});
|
|
|
|
}} // end if timestamps
|
|
|
|
// Growth chart loaded async from /api/growth (independent of snapshots)
|
|
fetch('/api/growth?days=90')
|
|
.then(r => r.json())
|
|
.then(data => {{
|
|
if (!data.dates || data.dates.length === 0) return;
|
|
new Chart(document.getElementById('growthChart'), {{
|
|
type: 'line',
|
|
data: {{
|
|
labels: data.dates,
|
|
datasets: [
|
|
{{ label: 'Sources', data: data.sources, borderColor: '#58a6ff', backgroundColor: 'rgba(88,166,255,0.1)', fill: true, tension: 0.3, pointRadius: 1 }},
|
|
{{ label: 'PRs Created', data: data.prs, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.1)', fill: false, tension: 0.3, pointRadius: 1 }},
|
|
{{ label: 'Merged', data: data.merged, borderColor: '#3fb950', backgroundColor: 'rgba(63,185,80,0.1)', fill: false, tension: 0.3, pointRadius: 1 }},
|
|
],
|
|
}},
|
|
options: {{
|
|
responsive: true,
|
|
interaction: {{ mode: 'index', intersect: false }},
|
|
scales: {{
|
|
x: {{ type: 'time', time: {{ unit: 'day', displayFormats: {{ day: 'MMM d' }} }}, grid: {{ display: false }} }},
|
|
y: {{ min: 0, title: {{ display: true, text: 'Cumulative Count' }} }},
|
|
}},
|
|
plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
|
}},
|
|
}});
|
|
}}).catch(() => {{}});
|
|
|
|
// --- PR Trace Lookup ---
|
|
document.getElementById('trace-pr-input').addEventListener('keydown', e => {{ if (e.key === 'Enter') loadTrace(); }});
|
|
|
|
function loadTrace() {{
|
|
const pr = document.getElementById('trace-pr-input').value.trim();
|
|
const container = document.getElementById('trace-result');
|
|
if (!pr) {{ container.innerHTML = '<p style="color:#8b949e">Enter a PR number</p>'; return; }}
|
|
container.innerHTML = '<p style="color:#8b949e">Loading...</p>';
|
|
|
|
fetch('/api/trace/' + encodeURIComponent(pr))
|
|
.then(r => r.json())
|
|
.then(data => {{
|
|
if (!data.pr && data.timeline.length === 0) {{
|
|
container.innerHTML = '<p style="color:#8b949e">No trace found for PR ' + esc(pr) + '</p>';
|
|
return;
|
|
}}
|
|
|
|
const stageColors = {{
|
|
ingest: '#58a6ff', validate: '#d29922', evaluate: '#f0883e',
|
|
merge: '#3fb950', cascade: '#bc8cff', cross_domain: '#79c0ff'
|
|
}};
|
|
|
|
let html = '';
|
|
|
|
// PR summary
|
|
if (data.pr) {{
|
|
const p = data.pr;
|
|
html += '<div style="margin-bottom:12px;padding:8px 12px;background:#21262d;border-radius:6px;font-size:13px">' +
|
|
'<strong>PR #' + esc(String(p.number)) + '</strong> · ' +
|
|
'<span style="color:' + (p.status === 'merged' ? '#3fb950' : '#d29922') + '">' + esc(p.status) + '</span>' +
|
|
' · ' + esc(p.domain || 'general') +
|
|
' · ' + esc(p.agent || '?') +
|
|
' · ' + esc(p.tier || '?') +
|
|
' · created ' + esc(p.created_at || '') +
|
|
(p.merged_at ? ' · merged ' + esc(p.merged_at) : '') +
|
|
'</div>';
|
|
}}
|
|
|
|
// Timeline
|
|
if (data.timeline.length > 0) {{
|
|
html += '<div style="font-size:12px;font-weight:600;color:#8b949e;margin-bottom:6px;text-transform:uppercase">Timeline</div>';
|
|
html += '<table style="font-size:12px"><tr><th>Time</th><th>Stage</th><th>Event</th><th>Details</th></tr>';
|
|
for (const evt of data.timeline) {{
|
|
const sc = stageColors[evt.stage] || '#8b949e';
|
|
const detail = evt.detail || {{}};
|
|
// Show key fields inline, expandable full JSON
|
|
const keyFields = [];
|
|
if (detail.issues) keyFields.push('issues: ' + detail.issues.join(', '));
|
|
if (detail.agent) keyFields.push('agent: ' + detail.agent);
|
|
if (detail.tier) keyFields.push('tier: ' + detail.tier);
|
|
if (detail.leo) keyFields.push('leo: ' + detail.leo);
|
|
if (detail.domain) keyFields.push('domain: ' + detail.domain);
|
|
if (detail.pass != null) keyFields.push('pass: ' + detail.pass);
|
|
if (detail.attempt) keyFields.push('attempt: ' + detail.attempt);
|
|
const summary = keyFields.length > 0 ? esc(keyFields.join(' | ')) : '';
|
|
const fullJson = JSON.stringify(detail, null, 2);
|
|
const detailId = 'trace-detail-' + Math.random().toString(36).substr(2, 6);
|
|
|
|
html += '<tr>' +
|
|
'<td style="white-space:nowrap;color:#8b949e">' + esc(evt.timestamp) + '</td>' +
|
|
'<td><span style="color:' + sc + ';font-weight:600">' + esc(evt.stage) + '</span></td>' +
|
|
'<td>' + esc(evt.event) + '</td>' +
|
|
'<td>' + summary +
|
|
(Object.keys(detail).length > 0
|
|
? ' <a style="color:#58a6ff;cursor:pointer;font-size:11px" onclick="document.getElementById(\\\'' + detailId + '\\\').style.display=document.getElementById(\\\'' + detailId + '\\\').style.display===\\\'none\\\'?\\\'block\\\':\\\'none\\\'">[json]</a>' +
|
|
'<pre id="' + detailId + '" style="display:none;margin-top:4px;background:#0d1117;padding:6px;border-radius:4px;font-size:11px;overflow-x:auto;max-width:500px">' + esc(fullJson) + '</pre>'
|
|
: '') +
|
|
'</td></tr>';
|
|
}}
|
|
html += '</table>';
|
|
}}
|
|
|
|
// Reviews
|
|
if (data.reviews && data.reviews.length > 0) {{
|
|
html += '<div style="font-size:12px;font-weight:600;color:#8b949e;margin:12px 0 6px;text-transform:uppercase">Reviews</div>';
|
|
html += '<table style="font-size:12px"><tr><th>Claim</th><th>Outcome</th><th>Reviewer</th><th>Reason</th></tr>';
|
|
for (const rv of data.reviews) {{
|
|
const outColor = rv.outcome === 'approved' ? '#3fb950' : rv.outcome === 'rejected' ? '#f85149' : '#d29922';
|
|
html += '<tr>' +
|
|
'<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis">' + esc(rv.claim_path || '-') + '</td>' +
|
|
'<td><span class="badge" style="background:' + outColor + '33;color:' + outColor + '">' + esc(rv.outcome || '-') + '</span></td>' +
|
|
'<td>' + esc(rv.reviewer || '-') + '</td>' +
|
|
'<td>' + esc(rv.rejection_reason || '') + '</td></tr>';
|
|
}}
|
|
html += '</table>';
|
|
}}
|
|
|
|
container.innerHTML = html;
|
|
}})
|
|
.catch(err => {{
|
|
container.innerHTML = '<p style="color:#f85149">Error: ' + esc(err.message) + '</p>';
|
|
}});
|
|
}}
|
|
</script>"""
|
|
|
|
return render_page(
|
|
title="Pipeline Operations",
|
|
subtitle="Is the machine running?",
|
|
active_path="/ops",
|
|
body_html=body,
|
|
scripts=scripts,
|
|
timestamp=now.strftime("%Y-%m-%d %H:%M UTC"),
|
|
)
|