diff --git a/changelog.d/fixes/11102-combo-suggestion-count.md b/changelog.d/fixes/11102-combo-suggestion-count.md new file mode 100644 index 0000000000..3cbf6f11d3 --- /dev/null +++ b/changelog.d/fixes/11102-combo-suggestion-count.md @@ -0,0 +1 @@ +- **Autopilot suggestion counter:** the combo health autopilot summary now reports `suggestionCount` (the real number of suggested actions across all issues) instead of conflating it with link counts, while keeping `actionableCount` as a deprecated alias for backward compatibility. The `run_combo_test` action now links to the dashboard with the combo id (`/dashboard/combos?test=`) rather than the read-only API route, so operators can actually trigger a test from the UI ([#11102](https://github.com/diegosouzapw/OmniRoute/pull/11102)). diff --git a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx index 7970c40a8e..8d8c5c5573 100644 --- a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx @@ -291,7 +291,9 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) { icon="monitor_heart" label={t("comboHealthIssues")} value={report.summary.issueCount.toLocaleString()} - subValue={t("comboHealthActionable", { count: report.summary.actionableCount })} + subValue={t("comboHealthActionable", { + count: report.summary.suggestionCount ?? report.summary.actionableCount ?? 0, + })} /> [entry.comboId, entry])); @@ -470,7 +478,7 @@ export async function buildComboHealthAutopilotReport( const degradedCount = allCombos.filter((combo) => combo.state === "degraded").length; const healthyCount = allCombos.filter((combo) => combo.state === "healthy").length; const issueCount = allCombos.reduce((sum, combo) => sum + combo.issues.length, 0); - const actionableCount = allCombos.reduce( + const suggestionCount = allCombos.reduce( (sum, combo) => sum + combo.issues.reduce((issueSum, issue) => issueSum + issue.actions.length, 0), 0 @@ -487,7 +495,8 @@ export async function buildComboHealthAutopilotReport( degradedCount, downCount, issueCount, - actionableCount, + suggestionCount, + actionableCount: suggestionCount, }, combos, }; diff --git a/src/shared/types/utilization.ts b/src/shared/types/utilization.ts index 36f0d7dabc..0a1e9dc900 100644 --- a/src/shared/types/utilization.ts +++ b/src/shared/types/utilization.ts @@ -260,7 +260,9 @@ export interface ComboAutopilotReport { degradedCount: number; downCount: number; issueCount: number; - actionableCount: number; + suggestionCount: number; + /** @deprecated Use suggestionCount instead. Kept as an alias for backward compatibility; remove after 2 releases. */ + actionableCount?: number; }; combos: ComboAutopilotCombo[]; } diff --git a/tests/unit/combo-health-autopilot-counter.test.ts b/tests/unit/combo-health-autopilot-counter.test.ts new file mode 100644 index 0000000000..1bd8330753 --- /dev/null +++ b/tests/unit/combo-health-autopilot-counter.test.ts @@ -0,0 +1,108 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import type { + ComboForecastResponse, + ComboHealthResponse, + ProviderAutopilotReport, +} from "../../src/shared/types/utilization.ts"; +import { buildComboHealthAutopilotReport } from "../../src/lib/monitoring/comboHealthAutopilot.ts"; + +function healthResponse(): ComboHealthResponse { + return { + timeRange: "24h", + combos: [ + { + comboId: "c1", + comboName: "my-combo", + strategy: "fallback", + models: [], + targetHealth: [ + { + executionKey: "e1", + stepId: "s1", + model: "m", + provider: "p", + connectionId: null, + label: null, + requests: 5, + successRate: 90, + avgLatencyMs: 100, + lastStatus: "error", + lastUsedAt: null, + quotaRemainingPct: 50, + quotaIsExhausted: false, + quotaTrend: "stable", + quotaScope: "provider", + }, + ], + quotaHealth: { providers: [], worstRemainingPct: 100 }, + usageSkew: { modelDistribution: [], giniCoefficient: 0 }, + performance: { avgLatencyMs: 100, successRate: 1.0, totalRequests: 10 }, + }, + ], + }; +} + +function forecastResponse(): ComboForecastResponse { + return { + timeRange: "24h", + horizon: "30d", + asOf: new Date(0).toISOString(), + method: "linear_history", + combos: [], + }; +} + +function providerHealthResponse(): ProviderAutopilotReport { + return { providers: [] } as unknown as ProviderAutopilotReport; +} + +function buildOptions() { + return { + range: "24h" as const, + horizon: "30d" as const, + healthResponse: healthResponse(), + forecastResponse: forecastResponse(), + providerHealthResponse: providerHealthResponse(), + }; +} + +describe("combo health autopilot counter", () => { + it("exposes suggestionCount and keeps actionableCount alias", async () => { + const report = await buildComboHealthAutopilotReport(buildOptions()); + assert.equal(typeof report.summary.suggestionCount, "number"); + assert.equal(report.summary.actionableCount, report.summary.suggestionCount); + const expected = report.combos.reduce( + (sum, combo) => + sum + combo.issues.reduce((issueSum, issue) => issueSum + issue.actions.length, 0), + 0 + ); + assert.equal(report.summary.suggestionCount, expected); + }); + + it("run_combo_test action links the dashboard with the combo id", async () => { + const report = await buildComboHealthAutopilotReport(buildOptions()); + const actions = report.combos.flatMap((combo) => combo.issues.flatMap((i) => i.actions)); + const runTest = actions.find((a) => a.type === "run_combo_test"); + assert.ok(runTest, "run_combo_test action should exist"); + assert.equal(typeof runTest.href, "string"); + assert.ok(runTest.href?.includes("c1"), "href must carry the combo id"); + assert.equal( + runTest.href?.includes("/api/combos/test?comboId="), + false, + "href must not target the GET-only API route (405)" + ); + }); + + it("keeps every action in manual mode", async () => { + const report = await buildComboHealthAutopilotReport(buildOptions()); + for (const combo of report.combos) { + for (const issue of combo.issues) { + for (const action of issue.actions) { + assert.equal(action.mode, "manual"); + } + } + } + }); +});