Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ type RuntimeUsageBucket = {
bucket: string;
seconds: number;
minutes: number;
runs?: number;
jobs: number;
work_seconds: number;
review_seconds: number;
work_runs: number;
review_runs: number;
work_jobs: number;
review_jobs: number;
};
Expand Down Expand Up @@ -3942,14 +3945,15 @@ function RuntimeUsageChart({ usage, loading, totalJobs }: { usage: RuntimeUsage
label: runtimeBucketLabel(row.bucket, grouping),
}));
const totalSeconds = rows.reduce((total, row) => total + row.seconds, 0);
const totalRuns = rows.reduce((total, row) => total + (row.runs ?? row.jobs), 0);
if (loading && data.length === 0) return <EmptyState text="Loading runtime usage..." />;
if (data.length === 0) return <EmptyState text={totalJobs > 0 ? "No jobs have recorded runtime yet." : "No job history available."} />;
return (
<div className="grid gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted">
<TimerReset className="h-4 w-4" aria-hidden />
<span>{formatRuntimeUsageSeconds(totalSeconds)} consumed across {rows.reduce((total, row) => total + row.jobs, 0)} job{rows.reduce((total, row) => total + row.jobs, 0) === 1 ? "" : "s"}</span>
<span>{formatRuntimeUsageSeconds(totalSeconds)} consumed across {totalRuns} run{totalRuns === 1 ? "" : "s"}</span>
</div>
<div className="inline-flex h-8 rounded-md border border-border bg-white p-0.5" aria-label="Runtime grouping">
{(["day", "month"] as const).map((value) => (
Expand Down
24 changes: 24 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,30 @@ before it is returned to the authenticated dashboard. The process activity panel
uses persisted process samples for a compact CPU history line chart when monitor
samples exist, and falls back to the live executor snapshot otherwise.

### Run history and runtime accounting

Every successful queue claim creates a `job_runs` row with its own attempt,
worker, OpenClaw session id, and start time. Every transition out of `running`
closes that run with a finish time and a `done`, `blocked`, `requeued`, or
`cancelled` result. The `started_at` and `finished_at` columns on `jobs` remain a
compatibility summary of the current or latest attempt; runtime metrics use
completed `job_runs` as their source of truth.

`GET /api/metrics/summary` assigns a completed run to the dashboard-local day
that contains the largest share of its elapsed runtime. A tie is assigned to the
local start day, and monthly totals are derived from that selected day. Runtime
is measured in UTC elapsed seconds, so DST changes do not add or lose execution
time. The response retains the legacy `jobs` count fields as aliases for run
counts and also returns explicit `runs`, `work_runs`, and `review_runs` fields.

Schema initialization backfills at most one recoverable interval per legacy
job. That row has result `historical` and `is_estimated=1`; earlier attempts are
not reconstructed from the old aggregate fields. Run rows have the same
retention lifecycle as their parent job and are removed by `ON DELETE CASCADE`
when that job is deleted. They are not independently age-pruned because doing
so would silently rewrite historical usage totals; deployments should apply any
future job-retention policy to `jobs`, which also bounds run storage.

When publishing the dashboard through nginx, disable buffering for the proxied
dashboard location so SSE events flush immediately. Also intercept upstream
restart errors so browser users see a short auto-refreshing maintenance page
Expand Down
61 changes: 52 additions & 9 deletions src/github_agent_bridge/dashboard_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import os
import sqlite3
from collections import Counter
from datetime import UTC, datetime
from datetime import UTC, datetime, time, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
Expand Down Expand Up @@ -47,7 +47,8 @@ def parse_utc(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
except ValueError:
return None

Expand Down Expand Up @@ -396,6 +397,14 @@ def get_job_detail(db: str | Path, job_id: int) -> dict[str, Any] | None:
(job_id,),
).fetchall()
] if table_exists(con, "job_progress") else []
job["runs"] = [
dict(run)
for run in con.execute(
"""SELECT id, attempt, started_at, finished_at, result, worker_id, session_id, is_estimated
FROM job_runs WHERE job_id=? ORDER BY attempt""",
(job_id,),
).fetchall()
] if table_exists(con, "job_runs") else []
job["coalesced_notifications"] = [
{
"id": row["id"],
Expand Down Expand Up @@ -704,13 +713,19 @@ def metrics_summary(db: str | Path, *, timezone_name: str = "UTC") -> dict[str,
if not table_exists(con, "jobs"):
return {"db_exists": True, "schema_ok": False, "status_counts": {}, "runtime_seconds": {}}
rows = con.execute("SELECT status, repo, action, work_intent, created_at, started_at, finished_at FROM jobs").fetchall()
run_rows = con.execute(
"""SELECT job_runs.started_at, job_runs.finished_at, jobs.work_intent
FROM job_runs
JOIN jobs ON jobs.id=job_runs.job_id
WHERE job_runs.finished_at IS NOT NULL"""
).fetchall() if table_exists(con, "job_runs") else rows
status_counts = Counter(row["status"] for row in rows)
by_repo = Counter(row["repo"] or "unknown" for row in rows)
by_action = Counter(row["action"] for row in rows)
by_intent = Counter(row["work_intent"] for row in rows)
by_created_day = Counter(day for day in (created_day(row["created_at"], timezone) for row in rows) if day)
runtimes = sorted(
seconds for seconds in (completed_duration_seconds(row["started_at"], row["finished_at"]) for row in rows) if seconds is not None
seconds for seconds in (completed_duration_seconds(row["started_at"], row["finished_at"]) for row in run_rows) if seconds is not None
)
waits = sorted(
seconds for seconds in (duration_seconds(row["created_at"], row["started_at"]) for row in rows if row["started_at"]) if seconds is not None
Expand All @@ -723,7 +738,7 @@ def metrics_summary(db: str | Path, *, timezone_name: str = "UTC") -> dict[str,
"by_action": dict(by_action),
"by_intent": dict(by_intent),
"by_created_day": dict(sorted(by_created_day.items())),
"runtime_usage": runtime_usage(rows, timezone),
"runtime_usage": runtime_usage(run_rows, timezone),
"runtime_seconds": percentiles(runtimes),
"queue_wait_seconds": percentiles(waits),
}
Expand Down Expand Up @@ -754,10 +769,10 @@ def runtime_usage(rows: list[sqlite3.Row], timezone: ZoneInfo) -> dict[str, list
seconds = completed_duration_seconds(row["started_at"], row["finished_at"])
if started is None or finished is None or seconds is None:
continue
bucket_at = finished
local = bucket_at.astimezone(timezone)
day = local.date().isoformat()
month = f"{local.year:04d}-{local.month:02d}"
day = majority_runtime_day(started, finished, timezone)
if day is None:
continue
month = day[:7]
mode = runtime_usage_mode(row["work_intent"])
add_runtime_bucket(daily, day, seconds, mode)
add_runtime_bucket(monthly, month, seconds, mode)
Expand All @@ -767,15 +782,40 @@ def runtime_usage(rows: list[sqlite3.Row], timezone: ZoneInfo) -> dict[str, list
}


def majority_runtime_day(started: datetime, finished: datetime, timezone: ZoneInfo) -> str | None:
"""Return the local day containing most elapsed runtime, preferring the start day on ties."""
if finished < started:
return None
start_day = started.astimezone(timezone).date()
end_day = finished.astimezone(timezone).date()
current_day = start_day
best_day = start_day
best_seconds = -1.0
while current_day <= end_day:
day_start = datetime.combine(current_day, time.min, tzinfo=timezone).astimezone(UTC)
next_day = current_day + timedelta(days=1)
day_end = datetime.combine(next_day, time.min, tzinfo=timezone).astimezone(UTC)
overlap_start = max(started, day_start)
overlap_end = min(finished, day_end)
overlap_seconds = max(0.0, (overlap_end - overlap_start).total_seconds())
if overlap_seconds > best_seconds:
best_day = current_day
best_seconds = overlap_seconds
current_day = next_day
return best_day.isoformat()


def runtime_usage_mode(work_intent: str | None) -> str:
return "work" if work_intent == "work_allowed" else "review"


def add_runtime_bucket(buckets: dict[str, dict[str, int]], bucket: str, seconds: int, mode: str) -> None:
current = buckets.setdefault(bucket, {"seconds": 0, "jobs": 0, "work_seconds": 0, "review_seconds": 0, "work_jobs": 0, "review_jobs": 0})
current = buckets.setdefault(bucket, {"seconds": 0, "runs": 0, "jobs": 0, "work_seconds": 0, "review_seconds": 0, "work_runs": 0, "review_runs": 0, "work_jobs": 0, "review_jobs": 0})
current["seconds"] += seconds
current["runs"] += 1
current["jobs"] += 1
current[f"{mode}_seconds"] += seconds
current[f"{mode}_runs"] += 1
current[f"{mode}_jobs"] += 1


Expand All @@ -785,9 +825,12 @@ def runtime_bucket_rows(buckets: dict[str, dict[str, int]]) -> list[dict[str, An
"bucket": bucket,
"seconds": values["seconds"],
"minutes": round(values["seconds"] / 60, 2),
"runs": values["runs"],
"jobs": values["jobs"],
"work_seconds": values["work_seconds"],
"review_seconds": values["review_seconds"],
"work_runs": values["work_runs"],
"review_runs": values["review_runs"],
"work_jobs": values["work_jobs"],
"review_jobs": values["review_jobs"],
}
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/github_agent_bridge/dashboard_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Agent Bridge Dashboard</title>
<script type="module" crossorigin src="/assets/index-B5yiYDzv.js"></script>
<script type="module" crossorigin src="/assets/index-QvZxB06B.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-SqBiqy9C.js">
<link rel="stylesheet" crossorigin href="/assets/index-BDe9F9HC.css">
</head>
Expand Down
Loading
Loading