From 115063f4ce76113665cb252690c824f771f78a13 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 21 Mar 2026 14:08:22 +0000 Subject: [PATCH 1/2] fix: feat: live factory metrics dashboard on disinto.ai (#395) Add metrics collector (site/collect-metrics.sh) and dashboard page (site/dashboard.html) showing PRs merged, issues closed, CI pass rate, vault decisions, and agent activity across all managed projects. Co-Authored-By: Claude Opus 4.6 (1M context) --- site/collect-metrics.sh | 245 +++++++++++++++++++ site/dashboard.html | 524 ++++++++++++++++++++++++++++++++++++++++ site/index.html | 2 + 3 files changed, 771 insertions(+) create mode 100644 site/collect-metrics.sh create mode 100644 site/dashboard.html diff --git a/site/collect-metrics.sh b/site/collect-metrics.sh new file mode 100644 index 0000000..f03699b --- /dev/null +++ b/site/collect-metrics.sh @@ -0,0 +1,245 @@ +#!/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 + + # Use the X-Total-Count header for total merged count + local total_header + total_header=$(curl -sf -I -H "Authorization: token ${CODEBERG_TOKEN}" \ + "${api_base}/pulls?state=closed&limit=1" 2>/dev/null | grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}' || echo "") + if [ -n "$total_header" ]; then + prs_merged_total="$total_header" + 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 -H "Authorization: token ${CODEBERG_TOKEN}" \ + "${api_base}/issues?state=open&labels=backlog&type=issues&limit=1" -o /dev/null \ + -w '' 2>/dev/null; \ + 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" diff --git a/site/dashboard.html b/site/dashboard.html new file mode 100644 index 0000000..019e367 --- /dev/null +++ b/site/dashboard.html @@ -0,0 +1,524 @@ + + + + + + Disinto — Factory Dashboard + + + + + + + + + + + + +
+ +
+

Factory Dashboard

+
live metrics from the disinto code factory
+ ← disinto.ai +
+ +
+ + Loading metrics... +
+ +
+
Fetching factory data...
+
+ + + +
+ + + + diff --git a/site/index.html b/site/index.html index db998e0..6887bea 100644 --- a/site/index.html +++ b/site/index.html @@ -529,6 +529,7 @@ @@ -536,6 +537,7 @@
Built from scrap, powered by a single battery.
Under the hood: dev, review, planner, gardener, supervisor, vault — six agents orchestrated by cron and bash. From 874095b689101fc11690e0bd3b9562ac81b0fa09 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 21 Mar 2026 14:15:00 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20review=20feedback=20=E2=80=94=20drop?= =?UTF-8?q?=20inflated=20X-Total-Count=20for=20PRs,=20remove=20redundant?= =?UTF-8?q?=20curl,=20use=20textContent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove X-Total-Count header override for prs_merged_total: header counts all closed PRs (including unmerged/rejected), inflating the dashboard metric. Keep the jq-filtered count from the 50-item page instead. - Remove redundant first curl in backlog_count that discards its response. - Use textContent/createElement instead of innerHTML for project names. Co-Authored-By: Claude Opus 4.6 (1M context) --- site/collect-metrics.sh | 13 +------------ site/dashboard.html | 5 ++++- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/site/collect-metrics.sh b/site/collect-metrics.sh index f03699b..facc8af 100644 --- a/site/collect-metrics.sh +++ b/site/collect-metrics.sh @@ -66,14 +66,6 @@ collect_project_metrics() { jq --arg since "$MONTH_AGO" '[.[] | select(.merged and .merged_at >= $since)] | length' 2>/dev/null || echo 0) fi - # Use the X-Total-Count header for total merged count - local total_header - total_header=$(curl -sf -I -H "Authorization: token ${CODEBERG_TOKEN}" \ - "${api_base}/pulls?state=closed&limit=1" 2>/dev/null | grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}' || echo "") - if [ -n "$total_header" ]; then - prs_merged_total="$total_header" - fi - # Issues closed local issues_closed_week=0 issues_closed_month=0 local closed_issues @@ -96,10 +88,7 @@ collect_project_metrics() { # Open issues by label local backlog_count in_progress_count blocked_count - backlog_count=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ - "${api_base}/issues?state=open&labels=backlog&type=issues&limit=1" -o /dev/null \ - -w '' 2>/dev/null; \ - curl -sf -I -H "Authorization: token ${CODEBERG_TOKEN}" \ + 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}" \ diff --git a/site/dashboard.html b/site/dashboard.html index 019e367..5e78ec2 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -420,7 +420,10 @@ data.projects.forEach(function (p) { var card = el('div', 'project'); var nameDiv = el('div', 'name'); - nameDiv.innerHTML = '' + p.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');