Merge pull request 'fix: feat: live factory metrics dashboard on disinto.ai (#395)' (#496) from fix/issue-395 into main

This commit is contained in:
johba 2026-03-21 15:23:51 +01:00
commit 038581e555
3 changed files with 763 additions and 0 deletions

234
site/collect-metrics.sh Normal file
View file

@ -0,0 +1,234 @@
#!/usr/bin/env bash
# =============================================================================
# collect-metrics.sh — Collect factory metrics and write JSON for the dashboard
#
# Queries Codeberg API for PR/issue stats across all managed projects,
# counts vault decisions, and checks CI pass rates. Writes a JSON snapshot
# to the live site directory so the dashboard can fetch it.
#
# Usage:
# bash site/collect-metrics.sh
#
# Cron: 0 */6 * * * cd /home/debian/dark-factory && bash site/collect-metrics.sh
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
# shellcheck source=../lib/env.sh
source "$FACTORY_ROOT/lib/env.sh"
# shellcheck source=../lib/ci-helpers.sh
source "$FACTORY_ROOT/lib/ci-helpers.sh" 2>/dev/null || true
LOGFILE="${FACTORY_ROOT}/site/collect-metrics.log"
log() {
printf '[%s] collect-metrics: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
# Output path: write to live site root if deployed, else to site/data/
SITE_ROOT="${DISINTO_SITE_ROOT:-/home/debian/disinto-site}"
if [ -d "$SITE_ROOT" ] || [ -L "$SITE_ROOT" ]; then
OUTPUT_DIR="$(readlink -f "$SITE_ROOT")/data"
else
OUTPUT_DIR="${SCRIPT_DIR}/data"
fi
mkdir -p "$OUTPUT_DIR"
NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
WEEK_AGO=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
MONTH_AGO=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
# ── Per-project metrics ─────────────────────────────────────────────────────
collect_project_metrics() {
local project_toml="$1"
local repo repo_name
repo=$(grep '^repo ' "$project_toml" | head -1 | sed 's/.*= *"//;s/"//')
repo_name=$(grep '^name ' "$project_toml" | head -1 | sed 's/.*= *"//;s/"//')
local api_base="https://codeberg.org/api/v1/repos/${repo}"
# PRs merged (all time via state=closed + merged marker)
local prs_merged_week=0 prs_merged_month=0 prs_merged_total=0
local closed_prs
closed_prs=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
"${api_base}/pulls?state=closed&sort=updated&limit=50" 2>/dev/null || echo "[]")
prs_merged_total=$(printf '%s' "$closed_prs" | jq '[.[] | select(.merged)] | length' 2>/dev/null || echo 0)
if [ -n "$WEEK_AGO" ]; then
prs_merged_week=$(printf '%s' "$closed_prs" | \
jq --arg since "$WEEK_AGO" '[.[] | select(.merged and .merged_at >= $since)] | length' 2>/dev/null || echo 0)
fi
if [ -n "$MONTH_AGO" ]; then
prs_merged_month=$(printf '%s' "$closed_prs" | \
jq --arg since "$MONTH_AGO" '[.[] | select(.merged and .merged_at >= $since)] | length' 2>/dev/null || echo 0)
fi
# Issues closed
local issues_closed_week=0 issues_closed_month=0
local closed_issues
closed_issues=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
"${api_base}/issues?state=closed&sort=updated&type=issues&limit=50" 2>/dev/null || echo "[]")
if [ -n "$WEEK_AGO" ]; then
issues_closed_week=$(printf '%s' "$closed_issues" | \
jq --arg since "$WEEK_AGO" '[.[] | select(.closed_at >= $since)] | length' 2>/dev/null || echo 0)
fi
if [ -n "$MONTH_AGO" ]; then
issues_closed_month=$(printf '%s' "$closed_issues" | \
jq --arg since "$MONTH_AGO" '[.[] | select(.closed_at >= $since)] | length' 2>/dev/null || echo 0)
fi
local total_closed_header
total_closed_header=$(curl -sf -I -H "Authorization: token ${CODEBERG_TOKEN}" \
"${api_base}/issues?state=closed&type=issues&limit=1" 2>/dev/null | grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}' || echo "0")
local issues_closed_total="${total_closed_header:-0}"
# Open issues by label
local backlog_count in_progress_count blocked_count
backlog_count=$(curl -sf -I -H "Authorization: token ${CODEBERG_TOKEN}" \
"${api_base}/issues?state=open&labels=backlog&type=issues&limit=1" 2>/dev/null | \
grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}' || echo "0")
in_progress_count=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
"${api_base}/issues?state=open&labels=in-progress&type=issues&limit=50" 2>/dev/null | \
jq 'length' 2>/dev/null || echo 0)
blocked_count=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
"${api_base}/issues?state=open&labels=blocked&type=issues&limit=50" 2>/dev/null | \
jq 'length' 2>/dev/null || echo 0)
jq -nc \
--arg name "$repo_name" \
--arg repo "$repo" \
--argjson prs_week "$prs_merged_week" \
--argjson prs_month "$prs_merged_month" \
--argjson prs_total "${prs_merged_total:-0}" \
--argjson issues_week "$issues_closed_week" \
--argjson issues_month "$issues_closed_month" \
--argjson issues_total "${issues_closed_total:-0}" \
--argjson backlog "${backlog_count:-0}" \
--argjson in_progress "${in_progress_count:-0}" \
--argjson blocked "${blocked_count:-0}" \
'{
name: $name,
repo: $repo,
prs_merged: { week: $prs_week, month: $prs_month, total: $prs_total },
issues_closed: { week: $issues_week, month: $issues_month, total: $issues_total },
backlog: { queued: $backlog, in_progress: $in_progress, blocked: $blocked }
}'
}
# ── Vault decisions ─────────────────────────────────────────────────────────
collect_vault_metrics() {
local vault_dir="${FACTORY_ROOT}/vault"
local approved=0 rejected=0 escalated=0 pending=0 fired=0
[ -d "$vault_dir/fired" ] && fired=$(find "$vault_dir/fired" -name '*.json' 2>/dev/null | wc -l)
[ -d "$vault_dir/approved" ] && approved=$(find "$vault_dir/approved" -name '*.json' 2>/dev/null | wc -l)
[ -d "$vault_dir/rejected" ] && rejected=$(find "$vault_dir/rejected" -name '*.json' 2>/dev/null | wc -l)
[ -d "$vault_dir/pending" ] && {
pending=$(find "$vault_dir/pending" -name '*.json' 2>/dev/null | wc -l)
escalated=$(find "$vault_dir/pending" -name '*.json' -exec grep -l '"escalated"' {} + 2>/dev/null | wc -l)
pending=$((pending - escalated))
}
jq -nc \
--argjson approved "$((approved + fired))" \
--argjson escalated "$escalated" \
--argjson rejected "$rejected" \
--argjson pending "$pending" \
'{ approved: $approved, escalated: $escalated, rejected: $rejected, pending: $pending }'
}
# ── CI pass rate ────────────────────────────────────────────────────────────
collect_ci_metrics() {
local total=0 passed=0 failed=0 rate=0
# Query Woodpecker DB for last 30 days across all repos
local ci_stats
ci_stats=$(wpdb -A -c "
SELECT status, count(*)
FROM pipelines
WHERE finished > 0
AND to_timestamp(finished) > now() - interval '30 days'
GROUP BY status;" 2>/dev/null || echo "")
if [ -n "$ci_stats" ]; then
passed=$(echo "$ci_stats" | awk '$1 == "success" {print $3}' | tr -d ' ' || echo 0)
failed=$(echo "$ci_stats" | awk '$1 == "failure" {print $3}' | tr -d ' ' || echo 0)
passed=${passed:-0}
failed=${failed:-0}
total=$((passed + failed))
if [ "$total" -gt 0 ]; then
rate=$(( (passed * 100) / total ))
fi
fi
jq -nc \
--argjson total "$total" \
--argjson passed "$passed" \
--argjson failed "$failed" \
--argjson rate "$rate" \
'{ total: $total, passed: $passed, failed: $failed, pass_rate: $rate }'
}
# ── Agent activity ──────────────────────────────────────────────────────────
collect_agent_metrics() {
local active_sessions=0
active_sessions=$(tmux list-sessions 2>/dev/null | wc -l || echo 0)
local agents='[]'
local agent_name log_path age_min last_active
for log_entry in dev/dev-agent.log review/review.log gardener/gardener.log \
planner/planner.log predictor/predictor.log supervisor/supervisor.log \
action/action.log vault/vault.log; do
agent_name=$(basename "$(dirname "$log_entry")")
log_path="${FACTORY_ROOT}/${log_entry}"
if [ -f "$log_path" ]; then
age_min=$(( ($(date +%s) - $(stat -c %Y "$log_path" 2>/dev/null || echo 0)) / 60 ))
last_active=$(date -u -d "@$(stat -c %Y "$log_path" 2>/dev/null || echo 0)" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "unknown")
agents=$(printf '%s' "$agents" | jq --arg n "$agent_name" --arg t "$last_active" --argjson a "$age_min" \
'. + [{ name: $n, last_active: $t, idle_minutes: $a }]')
fi
done
jq -nc --argjson sessions "$active_sessions" --argjson agents "$agents" \
'{ active_sessions: $sessions, agents: $agents }'
}
# ── Assemble ────────────────────────────────────────────────────────────────
log "Starting metrics collection"
PROJECTS_JSON="[]"
for toml in "$FACTORY_ROOT"/projects/*.toml; do
[ -f "$toml" ] || continue
log "Collecting metrics for $(basename "$toml")"
project_json=$(collect_project_metrics "$toml" 2>/dev/null || echo '{}')
PROJECTS_JSON=$(printf '%s\n%s' "$PROJECTS_JSON" "$project_json" | jq -s '.[0] + [.[1]]')
done
VAULT_JSON=$(collect_vault_metrics 2>/dev/null || echo '{}')
CI_JSON=$(collect_ci_metrics 2>/dev/null || echo '{}')
AGENTS_JSON=$(collect_agent_metrics 2>/dev/null || echo '{}')
# Build final output
jq -nc \
--arg ts "$NOW_ISO" \
--argjson projects "$PROJECTS_JSON" \
--argjson vault "$VAULT_JSON" \
--argjson ci "$CI_JSON" \
--argjson agents "$AGENTS_JSON" \
'{
generated_at: $ts,
projects: $projects,
vault: $vault,
ci: $ci,
agents: $agents
}' > "${OUTPUT_DIR}/metrics.json"
log "Metrics written to ${OUTPUT_DIR}/metrics.json"

527
site/dashboard.html Normal file
View file

@ -0,0 +1,527 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Disinto — Factory Dashboard</title>
<meta name="description" content="Live metrics from the Disinto autonomous code factory. PRs merged, issues closed, CI pass rate, vault decisions.">
<link rel="icon" href="favicon.ico" sizes="32x32">
<link rel="icon" href="favicon-192.png" sizes="192x192" type="image/png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="canonical" href="https://disinto.ai/dashboard">
<meta property="og:title" content="Disinto — Factory Dashboard">
<meta property="og:description" content="Live metrics from the autonomous code factory.">
<meta property="og:url" content="https://disinto.ai/dashboard">
<meta property="og:type" content="website">
<style>
:root {
--bg: #0a0a0a;
--fg: #e0e0e0;
--dim: #707070;
--accent: #c8a46e;
--accent-dim: #8a7044;
--surface: #141414;
--border: #222;
--green: #4a7;
--red: #a54;
--yellow: #a93;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
background: var(--bg);
color: var(--fg);
line-height: 1.7;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 3rem 2rem;
}
/* Header */
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
font-size: 2rem;
font-weight: 300;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.25rem;
}
.header .subtitle {
color: var(--dim);
font-size: 0.85rem;
letter-spacing: 0.1em;
}
.header .back {
display: inline-block;
margin-top: 0.75rem;
color: var(--accent-dim);
text-decoration: none;
font-size: 0.75rem;
}
.header .back:hover { color: var(--accent); }
/* Timestamp */
.updated {
text-align: center;
font-size: 0.7rem;
color: var(--dim);
margin-bottom: 2rem;
}
.updated .dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
margin-right: 0.4rem;
vertical-align: middle;
}
/* Section headers */
.section-title {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent-dim);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
/* Stat grid */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--border);
margin-bottom: 2rem;
}
.stat {
background: var(--surface);
padding: 1.2rem;
text-align: center;
}
.stat .value {
font-size: 1.8rem;
font-weight: 300;
color: var(--accent);
}
.stat .label {
font-size: 0.65rem;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-top: 0.25rem;
}
/* Project cards */
.projects {
display: grid;
grid-template-columns: 1fr;
gap: 1px;
background: var(--border);
margin-bottom: 2rem;
}
.project {
background: var(--surface);
padding: 1.2rem 1.5rem;
}
.project .name {
font-size: 0.85rem;
color: var(--accent);
margin-bottom: 0.75rem;
}
.project .name a {
color: var(--accent);
text-decoration: none;
}
.project .name a:hover { text-decoration: underline; }
.project .metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.project .metric {
text-align: center;
}
.project .metric .val {
font-size: 1.2rem;
font-weight: 300;
color: var(--fg);
}
.project .metric .lbl {
font-size: 0.6rem;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Vault section */
.vault-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--border);
margin-bottom: 2rem;
}
.vault-item {
background: var(--surface);
padding: 1rem;
text-align: center;
}
.vault-item .val {
font-size: 1.4rem;
font-weight: 300;
}
.vault-item .val.approved { color: var(--green); }
.vault-item .val.escalated { color: var(--yellow); }
.vault-item .val.rejected { color: var(--red); }
.vault-item .val.pending { color: var(--dim); }
.vault-item .lbl {
font-size: 0.6rem;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.2rem;
}
/* CI bar */
.ci-section {
margin-bottom: 2rem;
}
.ci-bar-container {
background: var(--surface);
border: 1px solid var(--border);
padding: 1.2rem 1.5rem;
}
.ci-rate {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.ci-rate .val {
font-size: 1.8rem;
font-weight: 300;
color: var(--green);
}
.ci-rate .lbl {
font-size: 0.7rem;
color: var(--dim);
}
.ci-bar {
height: 6px;
background: var(--red);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.ci-bar .fill {
height: 100%;
background: var(--green);
border-radius: 3px;
transition: width 0.5s ease;
}
.ci-detail {
font-size: 0.65rem;
color: var(--dim);
}
/* Agents */
.agents {
margin-bottom: 2rem;
}
.agent-list {
background: var(--surface);
border: 1px solid var(--border);
}
.agent-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 1.2rem;
border-bottom: 1px solid var(--border);
font-size: 0.75rem;
}
.agent-row:last-child { border-bottom: none; }
.agent-row .agent-name {
color: var(--fg);
}
.agent-row .agent-status {
color: var(--dim);
}
.agent-row .agent-status.active {
color: var(--green);
}
/* Loading / error states */
.loading, .error {
text-align: center;
padding: 3rem;
color: var(--dim);
font-size: 0.85rem;
}
.error { color: var(--red); }
/* Footer */
.footer {
text-align: center;
padding-top: 2rem;
border-top: 1px solid var(--border);
font-size: 0.7rem;
color: var(--dim);
}
.footer a {
color: var(--accent-dim);
text-decoration: none;
}
.footer a:hover { color: var(--accent); }
/* Mobile */
@media (max-width: 600px) {
.header h1 { font-size: 1.5rem; }
.stats { grid-template-columns: repeat(2, 1fr); }
.vault-grid { grid-template-columns: repeat(2, 1fr); }
.project .metrics { grid-template-columns: 1fr; gap: 0.5rem; }
.container { padding: 2rem 1rem; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Factory Dashboard</h1>
<div class="subtitle">live metrics from the disinto code factory</div>
<a class="back" href="/">&larr; disinto.ai</a>
</div>
<div class="updated" id="updated">
<span class="dot"></span>
Loading metrics...
</div>
<div id="content">
<div class="loading">Fetching factory data...</div>
</div>
<div class="footer">
<div>Data refreshed every 6 hours by the metrics collector.</div>
<div style="margin-top:0.5rem">
<a href="https://codeberg.org/johba/disinto">source</a>
</div>
</div>
</div>
<script>
(function () {
var content = document.getElementById('content');
var updated = document.getElementById('updated');
function el(tag, cls, html) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (html !== undefined) e.innerHTML = html;
return e;
}
function timeAgo(iso) {
var diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
function render(data) {
content.innerHTML = '';
// Updated timestamp
updated.innerHTML = '<span class="dot"></span> Updated ' + timeAgo(data.generated_at);
// Aggregate stats across projects
var totalPRsWeek = 0, totalPRsMonth = 0;
var totalIssuesWeek = 0, totalIssuesMonth = 0;
(data.projects || []).forEach(function (p) {
totalPRsWeek += (p.prs_merged || {}).week || 0;
totalPRsMonth += (p.prs_merged || {}).month || 0;
totalIssuesWeek += (p.issues_closed || {}).week || 0;
totalIssuesMonth += (p.issues_closed || {}).month || 0;
});
// Top stats
var statsDiv = el('div', 'stats');
var topMetrics = [
{ value: totalPRsWeek, label: 'PRs merged (week)' },
{ value: totalPRsMonth, label: 'PRs merged (month)' },
{ value: totalIssuesWeek, label: 'Issues closed (week)' },
{ value: totalIssuesMonth, label: 'Issues closed (month)' }
];
topMetrics.forEach(function (m) {
var stat = el('div', 'stat');
stat.appendChild(el('div', 'value', String(m.value)));
stat.appendChild(el('div', 'label', m.label));
statsDiv.appendChild(stat);
});
content.appendChild(statsDiv);
// Projects
if (data.projects && data.projects.length > 0) {
content.appendChild(el('div', 'section-title', 'Projects'));
var projectsDiv = el('div', 'projects');
data.projects.forEach(function (p) {
var card = el('div', 'project');
var nameDiv = el('div', 'name');
var nameLink = document.createElement('a');
nameLink.href = 'https://codeberg.org/' + p.repo;
nameLink.textContent = p.name;
nameDiv.appendChild(nameLink);
card.appendChild(nameDiv);
var metricsDiv = el('div', 'metrics');
var pm = [
{ val: ((p.prs_merged || {}).total || 0), lbl: 'PRs merged' },
{ val: ((p.issues_closed || {}).total || 0), lbl: 'Issues closed' },
{ val: ((p.backlog || {}).queued || 0), lbl: 'Backlog' }
];
pm.forEach(function (m) {
var metric = el('div', 'metric');
metric.appendChild(el('div', 'val', String(m.val)));
metric.appendChild(el('div', 'lbl', m.lbl));
metricsDiv.appendChild(metric);
});
card.appendChild(metricsDiv);
projectsDiv.appendChild(card);
});
content.appendChild(projectsDiv);
}
// Vault
if (data.vault) {
content.appendChild(el('div', 'section-title', 'Vault Decisions'));
var vaultDiv = el('div', 'vault-grid');
var vaultItems = [
{ val: data.vault.approved || 0, cls: 'approved', lbl: 'Auto-approved' },
{ val: data.vault.escalated || 0, cls: 'escalated', lbl: 'Escalated' },
{ val: data.vault.rejected || 0, cls: 'rejected', lbl: 'Rejected' },
{ val: data.vault.pending || 0, cls: 'pending', lbl: 'Pending' }
];
vaultItems.forEach(function (v) {
var item = el('div', 'vault-item');
item.appendChild(el('div', 'val ' + v.cls, String(v.val)));
item.appendChild(el('div', 'lbl', v.lbl));
vaultDiv.appendChild(item);
});
content.appendChild(vaultDiv);
}
// CI
if (data.ci) {
content.appendChild(el('div', 'section-title', 'CI Pipeline (30 days)'));
var ciSection = el('div', 'ci-section');
var ciBar = el('div', 'ci-bar-container');
var rate = data.ci.pass_rate || 0;
var rateDiv = el('div', 'ci-rate');
var rateColor = rate >= 80 ? 'var(--green)' : rate >= 50 ? 'var(--yellow)' : 'var(--red)';
rateDiv.innerHTML = '<span class="val" style="color:' + rateColor + '">' + rate + '%</span>' +
'<span class="lbl">pass rate</span>';
ciBar.appendChild(rateDiv);
var bar = el('div', 'ci-bar');
bar.innerHTML = '<div class="fill" style="width:' + rate + '%"></div>';
ciBar.appendChild(bar);
ciBar.appendChild(el('div', 'ci-detail',
(data.ci.passed || 0) + ' passed / ' +
(data.ci.failed || 0) + ' failed / ' +
(data.ci.total || 0) + ' total'));
ciSection.appendChild(ciBar);
content.appendChild(ciSection);
}
// Agents
if (data.agents && data.agents.agents && data.agents.agents.length > 0) {
content.appendChild(el('div', 'section-title', 'Agent Activity'));
var agentsDiv = el('div', 'agents');
var agentList = el('div', 'agent-list');
data.agents.agents.forEach(function (a) {
var row = el('div', 'agent-row');
row.appendChild(el('span', 'agent-name', a.name));
var statusCls = a.idle_minutes < 60 ? 'agent-status active' : 'agent-status';
var statusText = a.idle_minutes < 60 ? 'active (' + a.idle_minutes + 'm)' : timeAgo(a.last_active);
row.appendChild(el('span', statusCls, statusText));
agentList.appendChild(row);
});
agentsDiv.appendChild(agentList);
content.appendChild(agentsDiv);
}
}
fetch('data/metrics.json')
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(render)
.catch(function (err) {
content.innerHTML = '<div class="error">Could not load metrics.<br>' +
'<span style="font-size:0.7rem;color:var(--dim)">The collector runs every 6 hours. ' +
'Data may not be available yet.</span></div>';
updated.innerHTML = '<span class="dot" style="background:var(--red)"></span> Offline';
});
})();
</script>
</body>
</html>

View file

@ -529,6 +529,7 @@
<div class="cta-links">
<a href="https://codeberg.org/johba/disinto">Browse the source</a>
<a href="https://codeberg.org/johba/disinto/issues">Watch it work</a>
<a href="/dashboard">Live dashboard</a>
</div>
</div>
@ -536,6 +537,7 @@
<div>Built from scrap, powered by a single battery.</div>
<div class="links">
<a href="https://codeberg.org/johba/disinto">codeberg.org/johba/disinto</a>
<a href="/dashboard">dashboard</a>
</div>
<div class="under-hood">
Under the hood: dev, review, planner, gardener, supervisor, vault — six agents orchestrated by cron and bash.