diff --git a/changelog.d/fixed-10697.md b/changelog.d/fixed-10697.md new file mode 100644 index 0000000000..b231608762 --- /dev/null +++ b/changelog.d/fixed-10697.md @@ -0,0 +1 @@ +- dashboard: Overview 'Actionable now' shows the exact reconciling equation; drop median-age tile (#10697) diff --git a/src/docs/api-reference.md b/src/docs/api-reference.md index 4857d06a6d..5b8e494798 100644 --- a/src/docs/api-reference.md +++ b/src/docs/api-reference.md @@ -10,17 +10,17 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok |---|---|---|---|---| | `GET` | `/api/version` | Dashboard auth/session | Build/version metadata; includes `upgradeMarker` (`target`, `current`, `attempts`, `maxAttempts`, `failed`, `requestedAt`, `lastError`) while a self-upgrade is in flight or has failed ([#6765](https://github.com/hivecommons/hive/issues/6765)), the hub-delivered `upgradePolicy` (`hub_managed`, `spoke_managed`, `schedule`, `schedule_hour`, `schedule_timezone`, `schedule_weekday`, `paused`, `branch`, `channel`, `target_sha`, `target_resolved`, `armed_target`, and `next_update_at` — the hub's RFC3339 ETA for the next promotion into the `stable` channel, omitted when unknown ([#10256](https://github.com/hivecommons/hive/issues/10256))) when available, and an `autoUpdate` object (`enabled`, `state` — one of `disabled`/`up_to_date`/`behind`/`retrying`/`failed`/`unknown` — `healthy`, `period`, `targetBranch`, `targetCommit`, `currentCommit`, `commitsBehind`, `lastAttemptAt`, `lastError`, `detail`, and `nextUpdateAt` — the hub's RFC3339 ETA for the next update, omitted when unknown, disabled or paused ([#10257](https://github.com/hivecommons/hive/issues/10257))) that never reports a failed or unknown update as healthy ([#6962](https://github.com/hivecommons/hive/issues/6962), [#6963](https://github.com/hivecommons/hive/issues/6963)) | `pkg/dashboard/api.go:36` | | `POST` | `/api/release-channel` | Owner only | Hosted spoke self-service release-channel selector; relays `stable`/`candidate`/`edge` to the hub's existing switch-branch endpoint with the spoke dashboard-token proof, and reports the requested channel as pending until the Deployment image lands | `pkg/dashboard/api.go:57` | -| `GET` | `/api/health` | Public | Basic health probe | `pkg/dashboard/server.go:1213` | -| `GET` | `/api/health/deep` | Public | Deep health probe | `pkg/dashboard/server.go:1214` | -| `GET` | `/api/livez` | Public | Kubernetes liveness probe | `pkg/dashboard/server.go:1215` | -| `GET` | `/metrics` | Registered only when `HIVE_METRICS_ENABLED`; requires `Authorization: ****** (403 if the token is unset) | Prometheus metrics | `pkg/dashboard/server.go:1221` | -| `GET` | `/api/status` | Dashboard auth/session | Dashboard aggregate status; accepts `?fields=a,b` for top-level selection and `?omit=repos` to drop heavy sections, and honors `Accept-Encoding: gzip`; `agents[]` includes busy-pane visibility fields (`kicksUndeliverable`, `busySince`, `lastTranscriptActivity`, `condition`, `conditionMessage`) when a Working pane cannot accept kicks | `pkg/dashboard/server.go:1226` | -| `GET` | `/api/status/summary` | Dashboard auth/session | Compact agent/governor/budget status summary for pollers; honors `Accept-Encoding: gzip` | `pkg/dashboard/server.go:1227` | -| `GET` | `/api/overview/issues.csv` | Dashboard auth/session | Cached Overview issue-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1228` | -| `GET` | `/api/overview/issues.json` | Dashboard auth/session | Cached Overview issue-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1229` | -| `GET` | `/api/overview/prs.csv` | Dashboard auth/session | Cached Overview PR-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1230` | -| `GET` | `/api/overview/prs.json` | Dashboard auth/session | Cached Overview PR-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1231` | -| `GET` | `/api/events` | Dashboard auth/session | Server-sent event stream | `pkg/dashboard/server.go:1232` | +| `GET` | `/api/health` | Public | Basic health probe | `pkg/dashboard/server.go:1232` | +| `GET` | `/api/health/deep` | Public | Deep health probe | `pkg/dashboard/server.go:1233` | +| `GET` | `/api/livez` | Public | Kubernetes liveness probe | `pkg/dashboard/server.go:1234` | +| `GET` | `/metrics` | Registered only when `HIVE_METRICS_ENABLED`; requires `Authorization: ****** (403 if the token is unset) | Prometheus metrics | `pkg/dashboard/server.go:1240` | +| `GET` | `/api/status` | Dashboard auth/session | Dashboard aggregate status; accepts `?fields=a,b` for top-level selection and `?omit=repos` to drop heavy sections, and honors `Accept-Encoding: gzip`; `agents[]` includes busy-pane visibility fields (`kicksUndeliverable`, `busySince`, `lastTranscriptActivity`, `condition`, `conditionMessage`) when a Working pane cannot accept kicks | `pkg/dashboard/server.go:1245` | +| `GET` | `/api/status/summary` | Dashboard auth/session | Compact agent/governor/budget status summary for pollers; honors `Accept-Encoding: gzip` | `pkg/dashboard/server.go:1246` | +| `GET` | `/api/overview/issues.csv` | Dashboard auth/session | Cached Overview issue-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1247` | +| `GET` | `/api/overview/issues.json` | Dashboard auth/session | Cached Overview issue-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1248` | +| `GET` | `/api/overview/prs.csv` | Dashboard auth/session | Cached Overview PR-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1249` | +| `GET` | `/api/overview/prs.json` | Dashboard auth/session | Cached Overview PR-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1250` | +| `GET` | `/api/events` | Dashboard auth/session | Server-sent event stream | `pkg/dashboard/server.go:1251` | | `GET` | `/api/swarm` | Dashboard auth/session | Current swarm status: display name, duration, active repo, expiry, persisted prep metrics when available, and idle-unlock fields. | `pkg/dashboard/swarm.go:310` | | `POST` | `/api/swarm` | Owner only | Start a 24h repo swarm for a configured repo, returning 409 when another swarm is active and 423 when a previous swarm keeps the next swarm locked until enough agents are idle unless body `{"repo":"owner/name","force":true}` is used; saves the active record before repo prep, persists prep metrics, and announces the start to Discord when configured. | `pkg/dashboard/swarm.go:311` | | `DELETE` | `/api/swarm` | Owner only | End the active swarm, score issues closed and PRs merged in the window, append it to history, and announce the result to Discord when configured. | `pkg/dashboard/swarm.go:312` | @@ -62,7 +62,7 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| | `GET` | `/api/style` | Public | Sanitized custom dashboard CSS | `pkg/dashboard/api.go:37` | -| `GET` | `/branding/custom.css` | Dashboard auth/session | Operator branding stylesheet override, read per request (see [branding](branding.md)) | `pkg/dashboard/server.go:1293` | +| `GET` | `/branding/custom.css` | Dashboard auth/session | Operator branding stylesheet override, read per request (see [branding](branding.md)) | `pkg/dashboard/server.go:1312` | | `GET` | `/api/snapshot/frame-ancestors` | Public | Snapshot framing allowlist | `pkg/dashboard/api.go:70` | | `GET` | `/api/snapshot` | Public | Snapshot data | `pkg/dashboard/api.go:71` | | `GET` | `/snapshot` | Public | Public read-only snapshot page | `pkg/dashboard/api.go:72` | @@ -90,9 +90,9 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok | `GET` | `/api/openrouter/models` | Dashboard auth/session | Open Router Models | `pkg/dashboard/openrouter.go:47` | | `GET` | `/api/openrouter/credit` | Dashboard auth/session | Open Router Credit | `pkg/dashboard/openrouter.go:48` | | `GET` | `/openrouter/callback` | Public | Open Router Callback | `pkg/dashboard/openrouter.go:49` | -| `POST` | `/api/github-app/recheck` | Dashboard auth/session | GitHub App Recheck | `pkg/dashboard/server.go:1233` | -| `POST` | `/api/github-app/install-clicked` | Dashboard auth/session | GitHub App Install Clicked | `pkg/dashboard/server.go:1234` | -| `GET` | `/gh-setup` | Public | GitHub App Setup Callback | `pkg/dashboard/server.go:1235` | +| `POST` | `/api/github-app/recheck` | Dashboard auth/session | GitHub App Recheck | `pkg/dashboard/server.go:1252` | +| `POST` | `/api/github-app/install-clicked` | Dashboard auth/session | GitHub App Install Clicked | `pkg/dashboard/server.go:1253` | +| `GET` | `/gh-setup` | Public | GitHub App Setup Callback | `pkg/dashboard/server.go:1254` | ## Configuration @@ -539,7 +539,7 @@ always resolved server-side from the validated token. | `POST` | `/api/hives/{id}/heartbeat` | Dashboard auth/session | Hives Heartbeat | `pkg/dashboard/api_contribute.go:263` | | `DELETE` | `/api/hives/{id}` | Owner only | Hives Delete | `pkg/dashboard/api_contribute.go:264` | | `POST` | `/api/hives/onboard` | Dashboard auth/session | Hives Onboard | `pkg/dashboard/api_contribute.go:265` | -| `GET` | `/sso` | Public | SSO | `pkg/dashboard/server.go:1240` | +| `GET` | `/sso` | Public | SSO | `pkg/dashboard/server.go:1259` | ## Hub SaaS diff --git a/src/docs/dashboard.md b/src/docs/dashboard.md index c41b559698..52e97d25b3 100644 --- a/src/docs/dashboard.md +++ b/src/docs/dashboard.md @@ -61,6 +61,14 @@ posture, and cadence controls for the hive. Its collapsible **PRs by model** nes sub-section reads `GET /api/governor/pr-models` with the selected `7d`, `30d`, or `all` window. The nested header keeps the window/sort toggles visible and its collapsed summary shows the current top-ranked model plus model count. + +The Governor **actionable issues** and **actionable PRs** tiles use the shared +server-side actionable partition described in [Repository card legend, issue +bands, and PR bands](#repository-card-legend-issue-bands-and-pr-bands). Their +subtitles show the per-kind reconciliation from total open work to the +actionable split, and those two results always add back to the Overview +**Actionable now** total. + The cadence table starts each agent row with the same `1`/`0` Agent power rocker used in the agent settings panel, so owners can enable or disable an agent from the table; disabled rows are dimmed, and read-only viewers see the @@ -196,18 +204,21 @@ client-side issue and PR bands across the selected repository view. Its SVG charts reuse the repository-card classifiers for actionable plus held issues/PRs, so their totals match the visible band counters and respect the Overview settings repo filter without a separate API call. A compact KPI strip -shows total open issues, total open PRs, actionable now, held, -blocked/needs-human, and the median actionable age. **Total open issues** and -**Total open PRs** are forge totals for the selected configured repositories -from the existing scanner tick: open GitHub issues exclude pull requests, while -open GitHub pull requests include drafts. Their muted sub-lines name how many -items are present in the Overview's actionable/held bands and how many were -outside those bands because of hard-suppress labels, exempt filters, reporter -triage, standing meta issues, dependency dashboards, drafts, or other scanner -filters. **Actionable now** is the shared server-side count used by -the Overview KPI and the Governor collapsed split: open enumerated issues plus -open enumerated PRs after hold/exempt filters, excluding items in the waiting, -done, draft, or blocked Overview bands. Its `actionableNow.issues` and +shows total open issues, total open PRs, actionable now, held, and +blocked/needs-human. This is the same partition used by the Governor +actionable issue/PR subtitles. **Total open issues** and **Total open PRs** are +forge totals for the selected configured repositories from the existing scanner tick: +open GitHub issues exclude pull requests, while open GitHub pull requests +include drafts. Their subtitles stay empty so the reconciliation is not split +across tiles. **Actionable now** is the shared server-side count used by the +Overview KPI and the Governor collapsed split: open enumerated issues plus open +enumerated PRs after hold/exempt filters, excluding items in the waiting, done, +draft, or blocked Overview bands. Its subtitle carries the complete partition, +for example `41 issues + 17 PRs = 13 actionable + 13 held + 10 +blocked/needs-human + 22 outside`; the held and blocked/needs-human tiles use +the same server-side terms, and outside covers scanner-filtered work such as +hard-suppress labels other than needs-human, exempt filters, reporter triage, standing meta issues, +dependency dashboards, drafts, or other filters. Its `actionableNow.issues` and `actionableNow.prs` splits always sum to `actionableNow.total`. Operators can view each Issues or PRs panel as a donut, pie, horizontal bar, single 100% stacked bar, line/spark trend, or age @@ -231,12 +242,12 @@ pages include their API token in the download link's query string. The Overview header's ⚙️ popover stores browser-local chart preferences under `hive-overview-charts`: which chart types are in rotation, whether the carousel is enabled, the 5-second to 5-minute interval, transition style, duration, donut -label mode, KPI visibility, export format, default age basis, and the bounded +label mode, KPI visibility, export format, and the bounded client-side line/spark history. The repo multi-select is stored separately under `hive.overview.repos`, with All/None shortcuts and an Org shortcut when the hive spans multiple GitHub organizations. The default remains donut-only with the carousel off, a 30-second interval, fade transition, normal duration, KPI -strip on, CSV exports, updated-time age basis, and all repos selected. +strip on, CSV exports, and all repos selected. Manual arrows and dot indicators are available even when timed rotation is off; timed rotation pauses while the panel is hovered or the tab is hidden, and reduced-motion users get instant swaps. diff --git a/src/pkg/dashboard/actionable_now_10561_test.go b/src/pkg/dashboard/actionable_now_10561_test.go index b101fb0604..9c8169c140 100644 --- a/src/pkg/dashboard/actionable_now_10561_test.go +++ b/src/pkg/dashboard/actionable_now_10561_test.go @@ -45,6 +45,9 @@ func TestOverviewAndGovernorBindSharedActionableNowField10561(t *testing.T) { "const sharedActionable = (data && data.actionableNow) || {};", "sharedActionable.issues", "sharedActionable.prs", + "sharedActionable.issueEquation", + "sharedActionable.prEquation", + "issueEquationText ? `
${escapeHtml(issueEquationText)}
` : ''", } { if !strings.Contains(html, want) { t.Fatalf("dashboard missing shared actionable binding %q", want) diff --git a/src/pkg/dashboard/overview_kpi_sparklines_test.go b/src/pkg/dashboard/overview_kpi_sparklines_test.go index 22fec5affc..7bc44b3c8a 100644 --- a/src/pkg/dashboard/overview_kpi_sparklines_test.go +++ b/src/pkg/dashboard/overview_kpi_sparklines_test.go @@ -34,15 +34,9 @@ func TestOverviewKPIHistoryAppendCapPersistRestoreDownsample(t *testing.T) { t.Fatalf("history len = %d, want cap %d", len(got), trendHistoryMaxEntries) } last := got[len(got)-1] - if last.OverviewOpenIssues != 3 || last.OverviewOpenPRs != 1 || last.OverviewActionable != 1 || last.OverviewHeld != 2 || last.OverviewBlockedHuman != 2 { + if last.OverviewOpenIssues != 3 || last.OverviewOpenPRs != 1 || last.OverviewActionable != 1 || last.OverviewHeld != 2 || last.OverviewBlockedHuman != 1 { t.Fatalf("overview KPI sample = %+v", last) } - lastAt := start.Add(time.Duration(trendHistoryMaxEntries+4) * timeHistoryStep()) - wantMedianAgeSec := int(lastAt.Sub(start.Add(-1 * time.Hour)).Seconds()) - if last.OverviewMedianAgeSec != wantMedianAgeSec { - t.Fatalf("median age sec = %d, want %d", last.OverviewMedianAgeSec, wantMedianAgeSec) - } - dir := t.TempDir() path := filepath.Join(dir, "overview-history.json") data, err := json.Marshal(got) @@ -95,20 +89,6 @@ func TestOverviewKPIHistoryJSONIncludesZeroOverviewFields(t *testing.T) { if strings.Contains(string(data), `"overviewMedianAgeSec"`) { t.Fatalf("marshaled overview history reported unavailable median as zero: %s", data) } - - at := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) - withMedian := &Server{} - withMedian.appendTrendHistoryAt(&StatusPayload{Repos: []FrontendRepo{{ - Name: "hive", - ActionableIssues: []any{github.Issue{UpdatedAt: at}}, - }}}, at) - data, err = json.Marshal(withMedian.OverviewKPIHistory(0)) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"overviewMedianAgeSec":0`) { - t.Fatalf("marshaled real zero median missing: %s", data) - } } func timeHistoryStep() time.Duration { @@ -133,7 +113,7 @@ let _overviewLastRepos = [{name: 'hive', full: 'hivecommons/hive'}]; global.window = {_lastStatus: {hiveId: 'test'}}; global.localStorage = { getItem(){ return null; }, setItem(){}, removeItem(){} }; let _overviewKPIHistory = [ - {t: Date.now(), overviewOpenIssues: 58, overviewOpenPrs: 12, overviewActionable: 36, overviewHeld: 6, overviewBlockedHuman: 3, overviewMedianAgeSec: 3600} + {t: Date.now(), overviewOpenIssues: 58, overviewOpenPrs: 12, overviewActionable: 36, overviewHeld: 6, overviewBlockedHuman: 3} ]; const OVERVIEW_ISSUE_BREAKDOWN_LABELS = { needs_human: 'needs-human', needs_direction: 'needs-direction', needs_decision: 'needs-decision', needs_spec: 'needs-spec', exempt: 'exempt', filtered: 'filtered', reporter_triage: 'reporter triage', hive_advisory: 'hive advisory', dependency_dashboard: 'dependency dashboard', other: 'other' }; const OVERVIEW_PR_BREAKDOWN_LABELS = { hold: 'held', draft: 'draft', filtered: 'filtered', other: 'other' }; @@ -144,11 +124,11 @@ function overviewItemAgeMinutes(){ return NaN; } `) for _, name := range []string{ "fmtSparkVal", "sparklineSeriesKey", "sparklineReducedMotion", "sparklineValueSummary", "renderSparkline", - "fmtDurationFromSeconds", "overviewMedianAgeSeconds", "overviewMedianAgeLabel", "overviewKPIRepoScope", "overviewKPIHistoryEntries", + "overviewKPIRepoScope", "overviewKPIHistoryEntries", "overviewRepoName", "overviewAllRepoNames", "overviewSavedRepoNames", "overviewSelectedRepoNames", "overviewFilterRepos", "overviewRepoNumber", "overviewRepoOpenIssueCount", "overviewRepoOpenPRCount", "overviewBreakdownTotal", "overviewRepoForgeTotals", "overviewKPIForgeTotals", "overviewKPIBreakdownSubline", - "overviewKPISparkTitle", "overviewKPISpark", "overviewKPICurrentSample", "renderOverviewKPIs", + "overviewKPITerm", "overviewKPIEquation", "overviewKPISparkTitle", "overviewKPISpark", "overviewKPICurrentSample", "renderOverviewKPIs", } { source.WriteString(jsFunc(t, html, name)) source.WriteByte('\n') @@ -161,14 +141,13 @@ const prSlices = [{key: 'ready', count: 12, items: []}, {key: 'blocked', count: // the selected objects returned by overviewFilterRepos. const liveRepos = [{name: 'hive', full: 'hivecommons/hive', issues: 39, prs: 31, actionableIssues: [1,2,3,4,5,6,7], heldIssues: [8,9], openPrs: [10,11], heldPrs: [12,13]}]; _overviewLastRepos = liveRepos; -const markup = renderOverviewKPIs(overviewFilterRepos(liveRepos), issueSlices, prSlices, {showKPIs: true, timeBasis: 'updated'}); -assert.equal((markup.match(/class="overview-kpi"/g) || []).length, 6); -assert.equal((markup.match(/39<\/span>Total open issues<\/span>/); assert.match(markup, />31<\/span>Total open PRs<\/span>/); -assert.match(markup, />—<\/span>Median actionable age \(updated\)<\/span>/); -assert.match(markup, /Median time since updated for actionable open issues and PRs in the selected repos \(not MTTR, which measures time-to-resolve for closed items\)\./); -for (const key of ['open-issues','open-prs','actionable-now','held','blocked-needs-human','median-age']) { +assert.match(markup, />39 issues \+ 31 PRs = /); +for (const key of ['open-issues','open-prs','actionable-now','held','blocked-needs-human']) { assert.match(markup, new RegExp('overview-kpi:' + key)); } `) diff --git a/src/pkg/dashboard/overview_kpi_values_10139_test.go b/src/pkg/dashboard/overview_kpi_values_10139_test.go index 4eb2aab424..1cc1e2529c 100644 --- a/src/pkg/dashboard/overview_kpi_values_10139_test.go +++ b/src/pkg/dashboard/overview_kpi_values_10139_test.go @@ -30,14 +30,12 @@ function renderSparkline(){ return ''; } const OVERVIEW_ISSUE_BREAKDOWN_LABELS = { needs_human: 'needs-human', needs_direction: 'needs-direction', needs_decision: 'needs-decision', needs_spec: 'needs-spec', exempt: 'exempt', filtered: 'filtered', reporter_triage: 'reporter triage', hive_advisory: 'hive advisory', dependency_dashboard: 'dependency dashboard', other: 'other' }; const OVERVIEW_PR_BREAKDOWN_LABELS = { hold: 'held', draft: 'draft', filtered: 'filtered', other: 'other' }; ` + jsFunc(t, html, "esc") + ` -` + jsFunc(t, html, "overviewItemAgeMinutes") + ` -` + jsFunc(t, html, "overviewMedianAgeLabel") + ` -` + jsFunc(t, html, "overviewMedianAgeSeconds") + ` -` + jsFunc(t, html, "fmtDurationFromSeconds") + ` ` + jsFunc(t, html, "overviewBreakdownTotal") + ` ` + jsFunc(t, html, "overviewRepoForgeTotals") + ` ` + jsFunc(t, html, "overviewKPIForgeTotals") + ` ` + jsFunc(t, html, "overviewKPIBreakdownSubline") + ` +` + jsFunc(t, html, "overviewKPITerm") + ` +` + jsFunc(t, html, "overviewKPIEquation") + ` ` + jsFunc(t, html, "overviewKPIRepoScope") + ` ` + jsFunc(t, html, "overviewKPILocalKey") + ` ` + jsFunc(t, html, "overviewKPILoadLocal") + ` @@ -52,8 +50,8 @@ const OVERVIEW_PR_BREAKDOWN_LABELS = { hold: 'held', draft: 'draft', filtered: ' ` + jsFunc(t, html, "overviewRepoOpenIssueCount") + ` ` + jsFunc(t, html, "overviewRepoOpenPRCount") + ` ` + jsFunc(t, html, "renderOverviewKPIs") + ` -const state = { showKPIs: true, timeBasis: 'updated' }; -const repos = [{ heldIssues: [{ number: 501 }], heldPrs: [] }]; +const state = { showKPIs: true }; +const repos = [{ issues: 100, prs: 7, heldIssues: [{ number: 501 }], heldPrs: [] }]; _overviewLastRepos = repos; const issueSlices = [ { key: 'ready', count: 93, items: [{ updated_at: new Date(Date.now() - 7 * 60000).toISOString() }] }, @@ -72,16 +70,16 @@ const prSlices = [ ]; const out = renderOverviewKPIs(repos, issueSlices, prSlices, state); const values = [...out.matchAll(/]*>([^<]*)<\/span>([^<]*)<\/span>/g)].map(m => [m[2], m[1]]); -assert.equal(values.length, 6); +assert.equal(values.length, 5); for (const [label, value] of values) assert.notEqual(value, '', label + ' rendered an empty KPI value'); assert.deepEqual(Object.fromEntries(values), { - 'Total open issues': '99', + 'Total open issues': '100', 'Total open PRs': '7', 'Actionable now (issues and PRs)': '103', 'Held': '1', 'Blocked / needs-human': '1', - 'Median actionable age (updated)': '7m', }); +assert.match(out, />100 issues \+ 7 PRs = 103 actionable \+ 1 held \+ 1 blocked\/needs-human \+ 2 confirm\/close]*>([^<]*)<\/span>([^<]*)<\/span>/g)].map(m => [m[2], m[1]])); assert.equal(rawValues['Total open issues'], '123'); assert.equal(rawValues['Total open PRs'], '45'); -assert.match(out, /title="Median time since updated for actionable open issues and PRs in the selected repos \(not MTTR, which measures time-to-resolve for closed items\)\."/); -const createdOut = renderOverviewKPIs(repos, issueSlices, prSlices, { showKPIs: true, timeBasis: 'created' }); -assert.match(createdOut, />7m<\/span>Median actionable age \(created\)<\/span>/); -assert.match(createdOut, /title="Median time since created for actionable open issues and PRs in the selected repos \(not MTTR, which measures time-to-resolve for closed items\)\."/); +assert.doesNotMatch(out, /Median actionable age/); ` out, err := exec.Command(node, "-e", script).CombinedOutput() if err != nil { diff --git a/src/pkg/dashboard/overview_status.go b/src/pkg/dashboard/overview_status.go index ff92c27c39..9f0c86b495 100644 --- a/src/pkg/dashboard/overview_status.go +++ b/src/pkg/dashboard/overview_status.go @@ -1,6 +1,8 @@ package dashboard import ( + "fmt" + "strings" "time" "github.com/hivecommons/hive/pkg/config" @@ -177,22 +179,210 @@ func overviewActionableNow(status *StatusPayload) FrontendActionableNow { if status == nil { return counts } + var issueHeld, prHeld, issueBlockedHuman, prBlockedHuman, confirmClose, draft int for _, repo := range status.Repos { for _, raw := range repo.ActionableIssues { - if !overviewKPIExcludedBand(overviewStatusIssueBand(raw)) { + switch overviewStatusIssueBand(raw) { + case "waiting": + issueBlockedHuman++ + case "done": + confirmClose++ + default: counts.Issues++ } } + issueHeld += len(repo.HeldIssues) for _, raw := range repo.OpenPrs { - if !overviewKPIExcludedBand(overviewStatusPRBand(raw)) { + switch overviewStatusPRBand(raw) { + case "waiting", "blocked": + prBlockedHuman++ + case "draft": + draft++ + default: counts.PRs++ } } + prHeld += len(repo.HeldPrs) } counts.Total = counts.Issues + counts.PRs + totals := status.OverviewTotals + if totals.Issues.Forge == 0 && totals.PRs.Forge == 0 && (len(status.Repos) > 0 || counts.Total > 0 || issueHeld > 0 || prHeld > 0 || issueBlockedHuman > 0 || prBlockedHuman > 0 || confirmClose > 0 || draft > 0) { + totals = overviewTotals(status) + } + counts.Equation, counts.IssueEquation, counts.PREquation = overviewActionableEquations(totals, counts.Issues, counts.PRs, issueHeld, prHeld, issueBlockedHuman, prBlockedHuman, confirmClose, draft) return counts } +func overviewActionableEquations(totals FrontendOverviewTotals, actionableIssues, actionablePRs, issueHeld, prHeld, issueBlockedHuman, prBlockedHuman, confirmClose, draft int) (*FrontendActionableEquation, *FrontendActionableEquation, *FrontendActionableEquation) { + openIssues := totals.Issues.Forge + openPRs := totals.PRs.Forge + outsideNeedsHuman := totals.Issues.Breakdown["needs_human"] + issueBlockedHuman += outsideNeedsHuman + issueOutside := max(0, totals.Issues.Outside-outsideNeedsHuman) + prOutside := max(0, totals.PRs.Outside) + issueEq := overviewActionableKindEquation("issues", openIssues, actionableIssues, []FrontendActionableEquationTerm{ + {Key: "held", Label: "held", Count: issueHeld}, + {Key: "blocked_needs_human", Label: "blocked/needs-human", Count: issueBlockedHuman}, + {Key: "confirm_close", Label: "confirm/close", Count: confirmClose}, + {Key: "outside", Label: "outside", Count: issueOutside}, + }) + prEq := overviewActionableKindEquation("PRs", openPRs, actionablePRs, []FrontendActionableEquationTerm{ + {Key: "held", Label: "held", Count: prHeld}, + {Key: "blocked_needs_human", Label: "blocked/needs-human", Count: prBlockedHuman}, + {Key: "draft", Label: "draft", Count: draft}, + {Key: "outside", Label: "outside", Count: prOutside}, + }) + combined := overviewActionableCombinedEquation(totals, actionableIssues+actionablePRs, issueHeld+prHeld, issueBlockedHuman+prBlockedHuman, confirmClose, draft, issueEq, prEq) + return combined, issueEq, prEq +} + +func overviewActionableKindEquation(kind string, open, actionable int, subtract []FrontendActionableEquationTerm) *FrontendActionableEquation { + terms := make([]FrontendActionableEquationTerm, 0, len(subtract)+1) + sum := actionable + for _, term := range subtract { + if term.Count <= 0 { + continue + } + terms = append(terms, term) + sum += term.Count + } + if remainder := open - sum; remainder > 0 { + terms = append(terms, FrontendActionableEquationTerm{Key: "other", Label: "other", Count: remainder}) + } + eq := &FrontendActionableEquation{Kind: kind, Open: open, TotalOpen: open, Result: actionable, Terms: terms} + eq.Text = overviewKindEquationText(eq) + eq.Title = overviewKindEquationTitle(eq) + return eq +} + +func overviewActionableCombinedEquation(totals FrontendOverviewTotals, actionable, held, blockedHuman, confirmClose, draft int, issueEq, prEq *FrontendActionableEquation) *FrontendActionableEquation { + openIssues := totals.Issues.Forge + openPRs := totals.PRs.Forge + outside := overviewTermCount(issueEq, "outside") + overviewTermCount(prEq, "outside") + other := overviewTermCount(issueEq, "other") + overviewTermCount(prEq, "other") + terms := []FrontendActionableEquationTerm{ + {Key: "actionable", Label: "actionable", Count: actionable}, + {Key: "held", Label: "held", Count: held}, + {Key: "blocked_needs_human", Label: "blocked/needs-human", Count: blockedHuman}, + } + if confirmClose > 0 { + terms = append(terms, FrontendActionableEquationTerm{Key: "confirm_close", Label: "confirm/close", Count: confirmClose}) + } + if draft > 0 { + terms = append(terms, FrontendActionableEquationTerm{Key: "draft", Label: "draft", Count: draft}) + } + if outside > 0 { + terms = append(terms, FrontendActionableEquationTerm{Key: "outside", Label: "outside", Count: outside}) + } + if other > 0 { + terms = append(terms, FrontendActionableEquationTerm{Key: "other", Label: "other", Count: other}) + } + eq := &FrontendActionableEquation{OpenIssues: openIssues, OpenPRs: openPRs, TotalOpen: openIssues + openPRs, Result: actionable, Terms: terms} + eq.Text = overviewEquationText(eq) + eq.Title = overviewEquationTitle(eq, totals) + return eq +} + +func overviewTermCount(eq *FrontendActionableEquation, key string) int { + if eq == nil { + return 0 + } + for _, term := range eq.Terms { + if term.Key == key { + return term.Count + } + } + return 0 +} + +func overviewKindEquationText(eq *FrontendActionableEquation) string { + if eq == nil { + return "" + } + parts := make([]string, 0, len(eq.Terms)+1) + parts = append(parts, fmt.Sprintf("%d open", eq.Open)) + for _, term := range eq.Terms { + if term.Count > 0 { + parts = append(parts, fmt.Sprintf("− %d %s", term.Count, term.Label)) + } + } + parts = append(parts, fmt.Sprintf("= %d", eq.Result)) + return strings.Join(parts, " ") +} + +func overviewKindEquationTitle(eq *FrontendActionableEquation) string { + if eq == nil { + return "" + } + what := "issue work" + if strings.EqualFold(eq.Kind, "PRs") { + what = "pull requests" + } + return strings.ToUpper("actionable "+eq.Kind) + " — " + what + " that can move without waiting.\n\n" + + "WHAT: The " + eq.Kind + " split of the shared Overview Actionable now set.\n\n" + + "HOW: " + eq.Text + ". Held, blocked/needs-human, confirm/close, draft, outside, and other terms use the same server-side partition as Overview. Claimed/in-progress work remains actionable unless it is in one of those excluded terms." +} + +func overviewEquationText(eq *FrontendActionableEquation) string { + if eq == nil { + return "" + } + parts := make([]string, 0, len(eq.Terms)) + for _, term := range eq.Terms { + if term.Count == 0 { + continue + } + parts = append(parts, fmt.Sprintf("%d %s", term.Count, term.Label)) + } + if len(parts) == 0 { + parts = append(parts, "0 actionable") + } + return fmt.Sprintf("%d issues + %d PRs = %s", eq.OpenIssues, eq.OpenPRs, strings.Join(parts, " + ")) +} + +func overviewEquationTitle(eq *FrontendActionableEquation, totals FrontendOverviewTotals) string { + if eq == nil { + return "" + } + lines := []string{"Open issue/PR partition: " + eq.Text} + if detail := overviewOutsideDetail("outside issues", totals.Issues.Breakdown); detail != "" { + lines = append(lines, detail) + } + if detail := overviewOutsideDetail("outside PRs", totals.PRs.Breakdown); detail != "" { + lines = append(lines, detail) + } + return strings.Join(lines, "\n") +} + +func overviewOutsideDetail(prefix string, breakdown map[string]int) string { + if len(breakdown) == 0 { + return "" + } + labels := map[string]string{ + "needs_direction": "needs-direction", + "needs_decision": "needs-decision", + "needs_spec": "needs-spec", + "exempt": "exempt", + "filtered": "filtered", + "reporter_triage": "reporter triage", + "hive_advisory": "hive advisory", + "dependency_dashboard": "dependency dashboard", + "other": "other", + } + order := []string{"needs_direction", "needs_decision", "needs_spec", "exempt", "filtered", "reporter_triage", "hive_advisory", "dependency_dashboard", "other"} + parts := make([]string, 0, len(breakdown)) + for _, key := range order { + count := breakdown[key] + if count > 0 { + parts = append(parts, fmt.Sprintf("%d %s", count, labels[key])) + } + } + if len(parts) == 0 { + return "" + } + return prefix + ": " + strings.Join(parts, " · ") +} + func overviewStatusIssueBand(raw any) string { switch v := raw.(type) { case overviewStatusIssue: diff --git a/src/pkg/dashboard/overview_totals_10629_test.go b/src/pkg/dashboard/overview_totals_10629_test.go index 6d8e1d9e66..4bf2f824fd 100644 --- a/src/pkg/dashboard/overview_totals_10629_test.go +++ b/src/pkg/dashboard/overview_totals_10629_test.go @@ -44,6 +44,102 @@ func TestOverviewTotalsUseForgeCountsAndExplainScannerGap10629(t *testing.T) { } } +func TestOverviewActionableEquationPartitionsOpenWork(t *testing.T) { + now := time.Date(2026, 10, 5, 15, 0, 0, 0, time.UTC) + status := &StatusPayload{Repos: []FrontendRepo{{ + Name: "hive", Full: "hivecommons/hive", Issues: 9, PRs: 5, + ActionableIssues: []any{ + github.Issue{Repo: "hivecommons/hive", Number: 1, Title: "ready"}, + github.Issue{Repo: "hivecommons/hive", Number: 2, Title: "claimed", Assignees: []string{"alice"}}, + github.Issue{Repo: "hivecommons/hive", Number: 3, Title: "needs human", Labels: []string{"needs-human"}}, + github.Issue{Repo: "hivecommons/hive", Number: 4, Title: "covered by PR", Labels: []string{"hive/covered-by-pr"}}, + }, + HeldIssues: []any{github.HoldItem{Repo: "hivecommons/hive", Number: 5, Type: "issue", Labels: []string{"hold"}}}, + OpenPrs: []any{ + FrontendPR{PullRequest: github.PullRequest{Repo: "hivecommons/hive", Number: 10, Title: "open"}}, + FrontendPR{PullRequest: github.PullRequest{Repo: "hivecommons/hive", Number: 11, Title: "dependency blocked", Mergeable: github.MergeableNo}}, + FrontendPR{PullRequest: github.PullRequest{Repo: "hivecommons/hive", Number: 12, Title: "draft", Draft: true}}, + }, + HeldPrs: []any{FrontendPR{PullRequest: github.PullRequest{Repo: "hivecommons/hive", Number: 13, Title: "held", Labels: []string{"hold"}}}}, + WorkBreakdown: &github.RepoWorkBreakdown{ + Issues: github.RepoIssueBreakdown{Actionable: 4, Hold: 1, Filtered: 2, NeedsHuman: 1, Exempt: 1, HiveAdvisory: 1, DependencyDashboard: 1}, + PRs: github.RepoPRBreakdown{Actionable: 2, Hold: 1, Draft: 1, Filtered: 1}, + }, + }}} + + got := (&Server{}).statusWithOverviewBands(status, now).ActionableNow + if got.Equation == nil { + t.Fatal("missing actionable equation") + } + terms := map[string]int{} + sum := 0 + for _, term := range got.Equation.Terms { + terms[term.Key] = term.Count + sum += term.Count + } + if got.Equation.OpenIssues != 9 || got.Equation.OpenPRs != 5 || got.Equation.TotalOpen != 14 { + t.Fatalf("open side = %+v, want 9 issues + 5 PRs", got.Equation) + } + if got.Total != 3 || terms["actionable"] != 3 || terms["held"] != 2 || terms["blocked_needs_human"] != 3 || terms["confirm_close"] != 1 || terms["draft"] != 1 || terms["outside"] != 4 { + t.Fatalf("partition terms = %+v, actionableNow=%+v", terms, got) + } + if sum != got.Equation.TotalOpen { + t.Fatalf("partition sum = %d, want total open %d (%s)", sum, got.Equation.TotalOpen, got.Equation.Text) + } + if want := "9 issues + 5 PRs = 3 actionable + 2 held + 3 blocked/needs-human + 1 confirm/close + 1 draft + 4 outside"; got.Equation.Text != want { + t.Fatalf("equation text = %q, want %q", got.Equation.Text, want) + } + for _, want := range []string{"1 exempt", "1 hive advisory", "1 dependency dashboard", "outside PRs: 1 filtered"} { + if !strings.Contains(got.Equation.Title, want) { + t.Fatalf("equation title missing %q: %q", want, got.Equation.Title) + } + } +} + +func TestGovernorActionableSplitsShareOverviewPartition(t *testing.T) { + now := time.Date(2026, 10, 5, 16, 0, 0, 0, time.UTC) + status := &StatusPayload{Repos: []FrontendRepo{{ + Name: "hive", Full: "hivecommons/hive", Issues: 6, PRs: 4, + ActionableIssues: []any{ + github.Issue{Repo: "hivecommons/hive", Number: 1, Title: "ready"}, + github.Issue{Repo: "hivecommons/hive", Number: 2, Title: "claimed", Assignees: []string{"alice"}}, + github.Issue{Repo: "hivecommons/hive", Number: 3, Title: "needs human", Labels: []string{"needs-human"}}, + github.Issue{Repo: "hivecommons/hive", Number: 4, Title: "confirm close", Labels: []string{"hive/likely-done"}}, + }, + HeldIssues: []any{github.HoldItem{Repo: "hivecommons/hive", Number: 5, Type: "issue", Labels: []string{"hold"}}}, + OpenPrs: []any{ + FrontendPR{PullRequest: github.PullRequest{Repo: "hivecommons/hive", Number: 10, Title: "open"}}, + FrontendPR{PullRequest: github.PullRequest{Repo: "hivecommons/hive", Number: 11, Title: "blocked", Mergeable: github.MergeableNo}}, + FrontendPR{PullRequest: github.PullRequest{Repo: "hivecommons/hive", Number: 12, Title: "draft", Draft: true}}, + }, + HeldPrs: []any{FrontendPR{PullRequest: github.PullRequest{Repo: "hivecommons/hive", Number: 13, Title: "held", Labels: []string{"hold"}}}}, + WorkBreakdown: &github.RepoWorkBreakdown{ + Issues: github.RepoIssueBreakdown{Actionable: 4, Hold: 1, Filtered: 1, Exempt: 1}, + PRs: github.RepoPRBreakdown{Actionable: 2, Hold: 1, Draft: 1}, + }, + }}} + + got := (&Server{}).statusWithOverviewBands(status, now).ActionableNow + if got.IssueEquation == nil || got.PREquation == nil || got.Equation == nil { + t.Fatalf("missing equations: %+v", got) + } + if got.Total != got.Issues+got.PRs { + t.Fatalf("overview total = %d, want issue+pr split %d", got.Total, got.Issues+got.PRs) + } + if got.Total != got.IssueEquation.Result+got.PREquation.Result { + t.Fatalf("overview actionable %d != governor split %d + %d", got.Total, got.IssueEquation.Result, got.PREquation.Result) + } + if got.Issues != got.IssueEquation.Result || got.PRs != got.PREquation.Result { + t.Fatalf("governor equations do not match splits: actionable=%+v issueEq=%+v prEq=%+v", got, got.IssueEquation, got.PREquation) + } + if got.IssueEquation.Text != "6 open − 1 held − 1 blocked/needs-human − 1 confirm/close − 1 outside = 2" { + t.Fatalf("issue equation = %q", got.IssueEquation.Text) + } + if got.PREquation.Text != "4 open − 1 held − 1 blocked/needs-human − 1 draft = 1" { + t.Fatalf("PR equation = %q", got.PREquation.Text) + } +} + func TestOverviewKPIBindsForgeTotalsAndTooltips10629(t *testing.T) { raw, err := staticFS.ReadFile("static/index.html") if err != nil { diff --git a/src/pkg/dashboard/server.go b/src/pkg/dashboard/server.go index 67a3a27258..ee24faa6ad 100644 --- a/src/pkg/dashboard/server.go +++ b/src/pkg/dashboard/server.go @@ -761,9 +761,30 @@ type FrontendConfiguredAgent struct { } type FrontendActionableNow struct { - Issues int `json:"issues"` - PRs int `json:"prs"` - Total int `json:"total"` + Issues int `json:"issues"` + PRs int `json:"prs"` + Total int `json:"total"` + Equation *FrontendActionableEquation `json:"equation,omitempty"` + IssueEquation *FrontendActionableEquation `json:"issueEquation,omitempty"` + PREquation *FrontendActionableEquation `json:"prEquation,omitempty"` +} + +type FrontendActionableEquation struct { + Kind string `json:"kind,omitempty"` + Open int `json:"open,omitempty"` + OpenIssues int `json:"openIssues"` + OpenPRs int `json:"openPrs"` + TotalOpen int `json:"totalOpen"` + Result int `json:"result,omitempty"` + Terms []FrontendActionableEquationTerm `json:"terms"` + Text string `json:"text"` + Title string `json:"title,omitempty"` +} + +type FrontendActionableEquationTerm struct { + Key string `json:"key"` + Label string `json:"label"` + Count int `json:"count"` } type FrontendGovernor struct { @@ -1072,8 +1093,6 @@ type TrendHistoryEntry struct { OverviewActionable int `json:"overviewActionable,omitempty"` OverviewHeld int `json:"overviewHeld,omitempty"` OverviewBlockedHuman int `json:"overviewBlockedHuman,omitempty"` - OverviewMedianSample bool `json:"overviewMedianSample,omitempty"` - OverviewMedianAgeSec int `json:"overviewMedianAgeSec,omitempty"` // Beads worker/supervisor counts. BeadsWorkers int `json:"beadsWorkers"` BeadsSupervisor int `json:"beadsSupervisor"` @@ -3457,7 +3476,6 @@ func (s *Server) attachOverviewKPI(e *TrendHistoryEntry, status *StatusPayload, if s != nil && s.deps != nil && s.deps.Config != nil { cfg = s.deps.Config.Dashboard.IssueBands } - var ages []int for _, r := range status.Repos { repoName := overviewRepoName(r) e.OverviewOpenIssues += r.Issues @@ -3472,11 +3490,8 @@ func (s *Server) attachOverviewKPI(e *TrendHistoryEntry, status *StatusPayload, } if !overviewKPIExcludedBand(band) { e.OverviewActionable++ - if sec, ok := overviewItemAgeSec(item, now); ok { - ages = append(ages, sec) - } } - if band == "waiting" || band == "blocked" || overviewItemNeedsHumanBlocked(item) { + if band == "waiting" { e.OverviewBlockedHuman++ } } @@ -3488,33 +3503,12 @@ func (s *Server) attachOverviewKPI(e *TrendHistoryEntry, status *StatusPayload, } if !overviewKPIExcludedBand(band) { e.OverviewActionable++ - if sec, ok := overviewItemAgeSec(item, now); ok { - ages = append(ages, sec) - } - } - if band == "waiting" || band == "blocked" || overviewItemNeedsHumanBlocked(item) { - e.OverviewBlockedHuman++ - } - } - for _, item := range r.HeldIssues { - if overviewItemNeedsHumanBlocked(item) { - e.OverviewBlockedHuman++ } - } - for _, item := range r.HeldPrs { - if overviewItemNeedsHumanBlocked(item) { + if band == "waiting" || band == "blocked" { e.OverviewBlockedHuman++ } } } - if e.OverviewHeld == 0 { - e.OverviewHeld = status.Hold.Total - } - if len(ages) > 0 { - sort.Ints(ages) - e.OverviewMedianSample = true - e.OverviewMedianAgeSec = ages[len(ages)/2] - } } func overviewKPIExcludedBand(band string) bool { @@ -3526,90 +3520,6 @@ func overviewKPIExcludedBand(band string) bool { } } -func overviewItemAgeSec(item any, now time.Time) (int, bool) { - var created, updated time.Time - switch v := item.(type) { - case github.Issue: - created, updated = v.CreatedAt, v.UpdatedAt - case *github.Issue: - if v != nil { - created, updated = v.CreatedAt, v.UpdatedAt - } - case github.PullRequest: - created, updated = v.CreatedAt, v.UpdatedAt - case *github.PullRequest: - if v != nil { - created, updated = v.CreatedAt, v.UpdatedAt - } - case FrontendPR: - created, updated = v.CreatedAt, v.UpdatedAt - case *FrontendPR: - if v != nil { - created, updated = v.CreatedAt, v.UpdatedAt - } - case github.HoldItem: - created = v.CreatedAt - case *github.HoldItem: - if v != nil { - created = v.CreatedAt - } - } - basis := updated - if basis.IsZero() { - basis = created - } - if basis.IsZero() || now.Before(basis) { - return 0, false - } - return int(now.Sub(basis).Seconds()), true -} - -func overviewItemHasLabel(item any, want string) bool { - want = strings.ToLower(want) - var labels []string - switch v := item.(type) { - case github.Issue: - labels = v.Labels - case *github.Issue: - if v != nil { - labels = v.Labels - } - case github.PullRequest: - labels = v.Labels - case *github.PullRequest: - if v != nil { - labels = v.Labels - } - case FrontendPR: - labels = v.Labels - case *FrontendPR: - if v != nil { - labels = v.Labels - } - case github.HoldItem: - labels = v.Labels - case *github.HoldItem: - if v != nil { - labels = v.Labels - } - } - for _, label := range labels { - if strings.EqualFold(label, want) { - return true - } - } - return false -} - -func overviewItemNeedsHumanBlocked(item any) bool { - for _, label := range []string{"needs-human", "blocked", "waiting", "needs-decision"} { - if overviewItemHasLabel(item, label) { - return true - } - } - return false -} - // TrendHistory returns a copy of the trend history. func (s *Server) TrendHistory() []TrendHistoryEntry { s.trendHistoryMu.RLock() @@ -3629,7 +3539,6 @@ type OverviewKPIHistoryEntry struct { OverviewActionable *int `json:"overviewActionable,omitempty"` OverviewHeld *int `json:"overviewHeld,omitempty"` OverviewBlockedHuman *int `json:"overviewBlockedHuman,omitempty"` - OverviewMedianAgeSec *int `json:"overviewMedianAgeSec,omitempty"` } // OverviewKPIHistory returns the recent overview KPI samples downsampled to a @@ -3642,16 +3551,13 @@ func overviewKPIHistoryPayload(entries []TrendHistoryEntry) []OverviewKPIHistory out := make([]OverviewKPIHistoryEntry, 0, len(entries)) for _, e := range entries { row := OverviewKPIHistoryEntry{Timestamp: e.Timestamp} - if e.OverviewKPI || e.OverviewOpenIssues != 0 || e.OverviewOpenPRs != 0 || e.OverviewActionable != 0 || e.OverviewHeld != 0 || e.OverviewBlockedHuman != 0 || e.OverviewMedianAgeSec != 0 { + if e.OverviewKPI || e.OverviewOpenIssues != 0 || e.OverviewOpenPRs != 0 || e.OverviewActionable != 0 || e.OverviewHeld != 0 || e.OverviewBlockedHuman != 0 { row.OverviewOpenIssues = intPtr(e.OverviewOpenIssues) row.OverviewOpenPRs = intPtr(e.OverviewOpenPRs) row.OverviewActionable = intPtr(e.OverviewActionable) row.OverviewHeld = intPtr(e.OverviewHeld) row.OverviewBlockedHuman = intPtr(e.OverviewBlockedHuman) } - if e.OverviewMedianSample || e.OverviewMedianAgeSec != 0 { - row.OverviewMedianAgeSec = intPtr(e.OverviewMedianAgeSec) - } out = append(out, row) } return out diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index 19ffb07414..8c6e487600 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -11458,6 +11458,13 @@

What sh const issueCount = Number(sharedActionable.issues ?? gov.issues ?? 0) || 0; const prCount = Number(sharedActionable.prs ?? gov.prs ?? 0) || 0; const govPressure = Number(sharedActionable.total ?? (issueCount + prCount)) || 0; + const fallbackGovEquations = overviewKPIGovernorFallbackEquations(data, issueCount, prCount); + const issueEquation = sharedActionable.issueEquation || fallbackGovEquations.issueEq; + const prEquation = sharedActionable.prEquation || fallbackGovEquations.prEq; + const issueEquationText = issueEquation?.text || ''; + const prEquationText = prEquation?.text || ''; + const issueEquationTitle = issueEquation?.title || issueEquationText || 'ACTIONABLE ISSUES — issue work that can move without waiting.'; + const prEquationTitle = prEquation?.title || prEquationText || 'ACTIONABLE PRS — pull requests that can move without waiting.'; const currentMode = gov.mode || 'idle'; const modes = ['idle', 'quiet', 'busy', 'surge']; @@ -11505,8 +11512,8 @@

What sh
timer
${gov.active ? '● active' : (isStarting ? '⏳ starting' : '⚠ DEAD')}
mode
${escapeHtml(gov.mode)}
-
actionable issues
${issueCount}${issuesSpark}
-
actionable PRs
${prCount}${prsSpark}
+
actionable issues
${issueCount}${issuesSpark}
${issueEquationText ? `
${escapeHtml(issueEquationText)}
` : ''}
+
actionable PRs
${prCount}${prsSpark}
${prEquationText ? `
${escapeHtml(prEquationText)}
` : ''}
MTTR
${formatFixTime(itmMedian)}${itmSpark}
on hold
${holdTotal}${holdSpark}
${(() => { @@ -13082,7 +13089,6 @@

What sh const OVERVIEW_REPOS_KEY = 'hive.overview.repos'; const OVERVIEW_EXPORT_FORMATS = ['csv', 'json']; const OVERVIEW_LABEL_MODES = ['both', 'counts', 'percent']; - const OVERVIEW_TIME_BASES = ['updated', 'created']; const OVERVIEW_BAR_MIN_PERCENT = 1.5; const OVERVIEW_STACK_W = 220, OVERVIEW_STACK_H = 98, OVERVIEW_STACK_X = 10, OVERVIEW_STACK_Y = 48, OVERVIEW_STACK_BAR_W = 200, OVERVIEW_STACK_BAR_H = 18, OVERVIEW_STACK_TOTAL_Y = 24; const OVERVIEW_LINE_W = 220, OVERVIEW_LINE_H = 150, OVERVIEW_LINE_PAD = 12; @@ -13182,7 +13188,7 @@

What sh function overviewDefaultChartState() { const enabled = {}; OVERVIEW_CHART_TYPES.forEach(type => { enabled[type] = type === 'donut'; }); - return { enabled, carousel: false, intervalMs: OVERVIEW_CAROUSEL_DEFAULT_MS, transition: 'fade', duration: 'normal', active: { issue: 'donut', pr: 'donut' }, history: { issue: [], pr: [] }, labelMode: 'both', showKPIs: true, exportFormat: 'csv', timeBasis: 'updated' }; + return { enabled, carousel: false, intervalMs: OVERVIEW_CAROUSEL_DEFAULT_MS, transition: 'fade', duration: 'normal', active: { issue: 'donut', pr: 'donut' }, history: { issue: [], pr: [] }, labelMode: 'both', showKPIs: true, exportFormat: 'csv' }; } function overviewChartsState() { @@ -13199,8 +13205,7 @@

What sh const history = saved.history && typeof saved.history === 'object' ? saved.history : base.history; const labelMode = OVERVIEW_LABEL_MODES.includes(saved.labelMode) ? saved.labelMode : base.labelMode; const exportFormat = OVERVIEW_EXPORT_FORMATS.includes(saved.exportFormat) ? saved.exportFormat : base.exportFormat; - const timeBasis = OVERVIEW_TIME_BASES.includes(saved.timeBasis) ? saved.timeBasis : base.timeBasis; - return { enabled, carousel: !!saved.carousel, intervalMs, transition, duration, active, history, labelMode, showKPIs: saved.showKPIs !== false, exportFormat, timeBasis }; + return { enabled, carousel: !!saved.carousel, intervalMs, transition, duration, active, history, labelMode, showKPIs: saved.showKPIs !== false, exportFormat }; } const OVERVIEW_KPI_WINDOWS = { '24h': 24 * 3600e3, '7d': 7 * 24 * 3600e3 }; @@ -13498,32 +13503,6 @@

What sh return `
${download}${body}
${esc(title)}
${esc(subtitle)}
${overviewLegendHTML(slices, csvKind, state)}
`; } - function overviewMedianAgeLabel(slices, state) { - const median = overviewMedianAgeSeconds(slices, state); - if (!Number.isFinite(median)) return '—'; - return fmtDurationFromSeconds(median); - } - - function fmtDurationFromSeconds(seconds) { - if (!Number.isFinite(Number(seconds))) return '—'; - const minutes = Math.max(0, Number(seconds)) / 60; - if (minutes < 60) return Math.round(minutes) + 'm'; - if (minutes < 1440) return Math.round(minutes / 60) + 'h'; - return Math.round(minutes / 1440) + 'd'; - } - - function overviewMedianAgeSeconds(slices, state) { - const ages = (slices || []).flatMap(s => (s.items || []).map(item => { - const raw = (item && (item.item || item.issue || item.pr)) || item || {}; - const basis = state.timeBasis === 'created' ? (raw.created_at || raw.updated_at) : (raw.updated_at || raw.created_at); - const t = Date.parse(basis || ''); - return Number.isFinite(t) ? Math.max(0, (Date.now() - t) / 60000) : overviewItemAgeMinutes(item); - })).filter(n => Number.isFinite(n)).sort((a, b) => a - b); - if (!ages.length) return NaN; - const median = ages[Math.floor(ages.length / 2)]; - return Math.round(median * 60); - } - function overviewRepoNumber(repo, fields) { for (const field of fields) { if (!repo || !Object.prototype.hasOwnProperty.call(repo, field)) continue; @@ -13627,6 +13606,66 @@

What sh return bits.join(' · '); } + function overviewKPITerm(equation, key, fallback) { + const term = (equation?.terms || []).find(t => t && t.key === key); + const n = Number(term?.count); + return Number.isFinite(n) ? n : fallback; + } + + function overviewKPIEquation(openIssues, openPRs, actionable, held, blockedNeedsHuman, issueSlices, prSlices, forgeTotals) { + const confirmClose = (issueSlices || []).filter(s => s.key === 'done').reduce((n, s) => n + Number(s.count || 0), 0); + const draft = (prSlices || []).filter(s => s.key === 'draft').reduce((n, s) => n + Number(s.count || 0), 0); + const totalOpen = Number(openIssues || 0) + Number(openPRs || 0); + const outsideNeedsHuman = Number(forgeTotals?.issues?.breakdown?.needs_human || 0); + const blockedTerm = Number(blockedNeedsHuman || 0) + outsideNeedsHuman; + const known = Number(actionable || 0) + Number(held || 0) + blockedTerm + confirmClose + draft; + const outside = Math.min(Math.max(0, Number(forgeTotals?.issues?.outside || 0) + Number(forgeTotals?.prs?.outside || 0) - outsideNeedsHuman), Math.max(0, totalOpen - known)); + const terms = [ + { key: 'actionable', label: 'actionable', count: actionable }, + { key: 'held', label: 'held', count: held }, + { key: 'blocked_needs_human', label: 'blocked/needs-human', count: blockedTerm }, + ]; + if (confirmClose > 0) terms.push({ key: 'confirm_close', label: 'confirm/close', count: confirmClose }); + if (draft > 0) terms.push({ key: 'draft', label: 'draft', count: draft }); + if (outside > 0) terms.push({ key: 'outside', label: 'outside', count: outside }); + const sum = terms.reduce((n, t) => n + Number(t.count || 0), 0); + if (totalOpen > sum) terms.push({ key: 'other', label: 'other', count: totalOpen - sum }); + const text = `${openIssues} issues + ${openPRs} PRs = ` + terms.filter(t => Number(t.count || 0) > 0).map(t => `${Number(t.count || 0)} ${t.label}`).join(' + '); + return { openIssues, openPrs: openPRs, totalOpen, terms, text, title: text }; + } + + function overviewKPIKindEquation(kind, open, actionable, terms) { + const kept = (terms || []).filter(t => Number(t?.count || 0) > 0); + const sum = kept.reduce((n, t) => n + Number(t.count || 0), Number(actionable || 0)); + if (Number(open || 0) > sum) kept.push({ key: 'other', label: 'other', count: Number(open || 0) - sum }); + const middle = kept.map(t => `− ${Number(t.count || 0)} ${t.label}`).join(' '); + const text = `${Number(open || 0)} open${middle ? ' ' + middle : ''} = ${Number(actionable || 0)}`; + return { kind, open, result: actionable, terms: kept, text, title: text }; + } + + function overviewKPIGovernorFallbackEquations(data, issueCount, prCount) { + const totals = data?.overviewTotals || {}; + const bands = data?.overview_bands || {}; + const issueCounts = Object.fromEntries((bands.issues || []).map(b => [b.key, Number(b.count || 0)])); + const prCounts = Object.fromEntries((bands.prs || []).map(b => [b.key, Number(b.count || 0)])); + const issueHeld = Number(totals.issues?.held || 0); + const prHeld = Number(totals.prs?.held || 0); + const outsideNeedsHuman = Number(totals.issues?.breakdown?.needs_human || 0); + const issueEq = overviewKPIKindEquation('issues', Number(totals.issues?.forge || issueCount || 0), Number(issueCount || 0), [ + { key: 'held', label: 'held', count: issueHeld }, + { key: 'blocked_needs_human', label: 'blocked/needs-human', count: Number(issueCounts.waiting || 0) + outsideNeedsHuman }, + { key: 'confirm_close', label: 'confirm/close', count: Number(issueCounts.done || 0) }, + { key: 'outside', label: 'outside', count: Math.max(0, Number(totals.issues?.outside || 0) - outsideNeedsHuman) }, + ]); + const prEq = overviewKPIKindEquation('PRs', Number(totals.prs?.forge || prCount || 0), Number(prCount || 0), [ + { key: 'held', label: 'held', count: prHeld }, + { key: 'blocked_needs_human', label: 'blocked/needs-human', count: Number(prCounts.waiting || 0) + Number(prCounts.blocked || 0) }, + { key: 'draft', label: 'draft', count: Number(prCounts.draft || 0) }, + { key: 'outside', label: 'outside', count: Number(totals.prs?.outside || 0) }, + ]); + return { issueEq, prEq }; + } + function renderOverviewKPIs(repos, issueSlices, prSlices, state) { if (!state.showKPIs) return ''; _overviewKPILocalScope = overviewKPIRepoScope(repos); @@ -13638,19 +13677,22 @@

What sh const computedActionableNow = (issueSlices || []).concat(prSlices || []).filter(s => !['waiting', 'done', 'draft', 'blocked'].includes(s.key)).reduce((n, s) => n + Number(s.count || 0), 0); const serverActionableNow = Number(window._lastStatus?.actionableNow?.total); const actionableNow = _overviewKPILocalScope === 'all' && Number.isFinite(serverActionableNow) ? serverActionableNow : computedActionableNow; - const held = (repos || []).reduce((n, r) => n + (r.heldIssues || []).length + (r.heldPrs || []).length, 0); - const blockedNeedsHuman = (prSlices || []).filter(s => s.key === 'blocked' || s.key === 'waiting').reduce((n, s) => n + Number(s.count || 0), 0) + const computedHeld = (repos || []).reduce((n, r) => n + (r.heldIssues || []).length + (r.heldPrs || []).length, 0); + const computedBlockedNeedsHuman = (prSlices || []).filter(s => s.key === 'blocked' || s.key === 'waiting').reduce((n, s) => n + Number(s.count || 0), 0) + (issueSlices || []).filter(s => s.key === 'waiting').reduce((n, s) => n + Number(s.count || 0), 0); - const age = overviewMedianAgeLabel((issueSlices || []).concat(prSlices || []).filter(s => !['waiting', 'done', 'draft', 'blocked'].includes(s.key)), state); - const ageSec = overviewMedianAgeSeconds((issueSlices || []).concat(prSlices || []).filter(s => !['waiting', 'done', 'draft', 'blocked'].includes(s.key)), state); + const serverEquation = _overviewKPILocalScope === 'all' ? window._lastStatus?.actionableNow?.equation : null; + const equation = serverEquation || overviewKPIEquation(openIssues, openPRs, actionableNow, computedHeld, computedBlockedNeedsHuman, issueSlices, prSlices, forgeTotals); + const held = overviewKPITerm(equation, 'held', computedHeld); + const blockedNeedsHuman = overviewKPITerm(equation, 'blocked_needs_human', computedBlockedNeedsHuman); + const equationText = equation?.text || ''; + const equationTitle = equation?.title || equationText || 'Items in bands that can move without waiting.'; const useServerHistory = _overviewKPILocalScope === 'all'; const cards = [ - { key: 'open-issues', field: 'overviewOpenIssues', label: 'Total open issues', value: openIssues, rawValue: openIssues, color: 'var(--orange)', tip: 'All open GitHub issues (pull requests excluded) across the selected configured repos; the sub-line explains items outside the Overview bands.', subline: overviewKPIBreakdownSubline(forgeTotals?.issues || { tracked: trackedIssues }, OVERVIEW_ISSUE_BREAKDOWN_LABELS) }, - { key: 'open-prs', field: 'overviewOpenPrs', label: 'Total open PRs', value: openPRs, rawValue: openPRs, color: 'var(--green)', tip: 'All open GitHub pull requests across the selected configured repos, including drafts; the sub-line explains items outside the Overview bands.', subline: overviewKPIBreakdownSubline(forgeTotals?.prs || { tracked: trackedPRs }, OVERVIEW_PR_BREAKDOWN_LABELS) }, - { key: 'actionable-now', field: 'overviewActionable', label: 'Actionable now (issues and PRs)', value: actionableNow, rawValue: actionableNow, color: 'var(--accent)', tip: 'Items in bands that can move without waiting.' }, + { key: 'open-issues', field: 'overviewOpenIssues', label: 'Total open issues', value: openIssues, rawValue: openIssues, color: 'var(--orange)', tip: 'All open GitHub issues (pull requests excluded) across the selected configured repos.' }, + { key: 'open-prs', field: 'overviewOpenPrs', label: 'Total open PRs', value: openPRs, rawValue: openPRs, color: 'var(--green)', tip: 'All open GitHub pull requests across the selected configured repos, including drafts.' }, + { key: 'actionable-now', field: 'overviewActionable', label: 'Actionable now (issues and PRs)', value: actionableNow, rawValue: actionableNow, color: 'var(--accent)', tip: equationTitle, subline: equationText }, { key: 'held', field: 'overviewHeld', label: 'Held', value: held, rawValue: held, color: 'color-mix(in srgb, var(--orange) 65%, var(--muted))', tip: 'Issues and PRs deliberately paused by a hold.' }, - { key: 'blocked-needs-human', field: 'overviewBlockedHuman', label: 'Blocked / needs-human', value: blockedNeedsHuman, rawValue: blockedNeedsHuman, color: 'var(--red)', tip: 'Waiting, blocked, or escalated work needing a person or dependency.' }, - { key: 'median-age', field: 'overviewMedianAgeSec', label: `Median actionable age (${state.timeBasis === 'created' ? 'created' : 'updated'})`, value: age, rawValue: ageSec, color: 'var(--purple)', sparkFmt: fmtDurationFromSeconds, tip: `Median time since ${state.timeBasis === 'created' ? 'created' : 'updated'} for actionable open issues and PRs in the selected repos (not MTTR, which measures time-to-resolve for closed items).` } + { key: 'blocked-needs-human', field: 'overviewBlockedHuman', label: 'Blocked / needs-human', value: blockedNeedsHuman, rawValue: blockedNeedsHuman, color: 'var(--red)', tip: 'Waiting, blocked, or escalated work needing a person or dependency.' } ]; overviewKPIRecordLocal(overviewKPICurrentSample(cards)); return `
${overviewKPIWindowControls()}
${cards.map(card => { @@ -13678,7 +13720,6 @@

What sh const durationOptions = OVERVIEW_DURATIONS.map(d => ``).join(''); const exportOptions = OVERVIEW_EXPORT_FORMATS.map(f => ``).join(''); const labelOptions = OVERVIEW_LABEL_MODES.map(m => ``).join(''); - const timeOptions = OVERVIEW_TIME_BASES.map(t => ``).join(''); const seconds = Math.round(state.intervalMs / 1000); const cardControls = (kind, label) => { const enabled = overviewEnabledTypes(state); @@ -13686,7 +13727,7 @@

What sh const autoTitle = state.carousel ? 'Auto-play on — rotates every ' + Math.round(overviewCarouselDwellMs(state) / 1000) + 's through the ticked charts. Click to pause.' : 'Auto-play off — click to rotate through the ticked charts every ' + Math.round(overviewCarouselDwellMs(state) / 1000) + 's'; return `
${esc(label)}
${renderOverviewDots(enabled, active)}
`; }; - setIfChanged(el, `
Overview charts
${typeRows}
${overviewRepoFilterHTML(_overviewLastRepos || [])}${cardControls('issue', 'Issues')}${cardControls('pr', 'PRs')}`); + setIfChanged(el, `
Overview charts
${typeRows}
${overviewRepoFilterHTML(_overviewLastRepos || [])}${cardControls('issue', 'Issues')}${cardControls('pr', 'PRs')}`); } function rerenderOverviewChartsFromState(state) { @@ -13752,12 +13793,6 @@

What sh rerenderOverviewChartsFromState(state); } - function setOverviewTimeBasis(value) { - const state = overviewChartsState(); - if (OVERVIEW_TIME_BASES.includes(value)) state.timeBasis = value; - rerenderOverviewChartsFromState(state); - } - function setOverviewKPIs(checked) { const state = overviewChartsState(); state.showKPIs = !!checked;