mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
* feat(usage): add error/failure visibility to the Usage dashboard The Usage page could only answer "how much did we spend"; nothing on it showed how often agents fail, what kind of failure it was, or which agent is responsible. Operators had to open failed tasks one at a time to spot a pattern. `agent_task_queue.failure_reason` already carries the refined 21-value taxonomy from server/pkg/taskfailure, so this is a read path over data that already exists. Backend — two rollups, both scoped by workspace/project/window like the existing dashboard endpoints: GET /api/dashboard/failures/daily per-(date, failure_reason) GET /api/dashboard/failures/by-agent per-(agent, failure_reason) They return every terminal task, not just failures: the `failure_reason: ""` row carries the succeeded count. That is what makes the error rate's denominator share filters with its numerator. The run-time rollups can't serve as that denominator — they require `started_at IS NOT NULL`, so a task that expired in the queue (the signature of a runtime outage) contributes nothing to their failed_count. A failed row with an empty reason column lands in an `unclassified` bucket rather than being mistaken for a success. Frontend: - "Errors" joins the trend toggle, daily and weekly, stacked by failure class with the bucket's error rate in the tooltip. - An Errors card breaks the window down by class and by agent, with the raw failure_reason strings behind a disclosure (unlocalised — an operator pastes them into a log search). Each agent row links to its Work tab, which lists the actual failed runs. - The 21 backend reasons fold into 7 display classes in @multica/core/dashboard. Unknown reasons — including ones from a backend newer than the client — land in "other" instead of being dropped, so the class totals always reconcile with the failure count. The Tasks KPI tile is deliberately left alone: its value counts started tasks only, so quoting the failure rollup's larger count there would put two denominators in one tile. The Errors card states its rate with the denominator spelled out instead. Migration 225 adds a partial index on agent_task_queue(completed_at) for terminal statuses. The table had no completed_at index at all, so the two pre-existing run-time rollups were already scanning it; these two new queries would have doubled that. Closes #4429 (MUL-5352) Co-authored-by: multica-agent <github@multica.ai> * fix(usage): correct the Errors drill-down, window and agent exposure Review findings on PR #5991. 1. The drill-down pointed at the wrong page. `?view=work` renders ActorIssuesPanel — the issues assigned to the agent — while its runs live in the Overview pane's ActivityTab. Link to Overview. That page also could not show why a run failed: `failureReasonLabel` was a `Record<TaskFailureReason, string>` indexed with a cast to the old 6-value coarse enum, so every refined reason the backend has written since MUL-1949 resolved to `undefined`. It is now a function over the full 21-value taxonomy plus the legacy coarse values, falling back to the raw wire string for anything newer than the client. Fixes the issue execution log too, which had the same cast. 2. The Errors card covered one more calendar day than the chart above it. `parseSinceParamInTZ` returns N+1 days of headroom on purpose and the dashboard trims the surplus client-side — but only a series carrying a date can be trimmed that way. Totals / classes / reasons now derive from the date-bucketed rollup after that trim, and the per-agent rollup (which has no date to trim on) closes its window server-side via a new `parseExactSinceParamInTZ`. At days=1 the card previously reported yesterday's failures beside a chart showing none. 3. The top-offenders list leaked agents the viewer cannot see. The failure rollups are workspace-scoped and deliberately skip per-agent visibility, but the agent list they are joined against does not — members only see a private agent when they own it or are owner/admin. `name ?? row.agentId` therefore rendered a bare UUID along with that agent's failure count, rate and dominant error class. Unresolvable agents now fold into one anonymous row, and the renderer never falls back to an id. Stricter than `bucketUnknownAgentRows` while the agent list loads: a transient flash of UUIDs is the leak, not a cosmetic glitch. Also from the review: the Errors tooltip echoed the raw Recharts dataKey ("rate_limit") instead of the translated label the legend already carries. Not changed — the schema's `failure_reason` default stays `""`. Defaulting a missing field to a failure bucket guards against a deflated rate, but the realistic drift is `omitempty` on the Go struct tag, which would strip the field from exactly the SUCCESS rows and read as a 100% error rate. Added TestDashboardFailureWireContractKeepsEmptyReason to pin that the server always emits the field, which is the assumption the default rests on. Co-authored-by: multica-agent <github@multica.ai> * fix(usage): renumber migration and fix the anonymous bucket's failure class Review findings on PR #5991, round 2. 1. Migration prefix 225 collided with `225_chat_message_channel_media_pending`, which landed on main while this branch was open — backend CI failed on TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Merged main and renumbered to 231; main now carries 225 through 230, so 226 is taken too. 2. The anonymous "Other agents" bucket could announce the wrong failure class. It merged rows that had ALREADY collapsed to one dominant class per agent, then credited each agent's entire failure count to that class. An agent failing auth 6 / timeout 5 contributed 11 to auth and 0 to timeout, so a bucket whose real composition was timeout 15 / auth 6 rendered as Auth. Fixed by anonymizing the raw per-(agent, reason) rows instead: the sentinel becomes just another agent_id and `aggregateAgentFailures` computes its classes from real counts. That also deletes the parallel bucketing pass — one identity rewrite replaces it. `knownAgentIds` moves up to where both consumers can see it. Also from the review: - The wire-contract test decoded both payloads into one map. json.Unmarshal merges into a non-nil map rather than resetting it, so a residual failure_reason from the first case could have masked an omitempty regression in the second — exactly what the test is meant to catch. Now table-driven with a fresh map per case. - A test comment still described the drill-down as pointing at the Work tab. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
442 lines
17 KiB
Go
442 lines
17 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace / Project dashboard
|
|
//
|
|
// Six read endpoints power the workspace dashboard:
|
|
//
|
|
// GET /api/dashboard/usage/daily per-(date, model) token rows
|
|
// GET /api/dashboard/usage/by-agent per-(agent, model) token rows
|
|
// GET /api/dashboard/agent-runtime per-agent run-time + task counts
|
|
// GET /api/dashboard/runtime/daily per-date run-time + task counts
|
|
// GET /api/dashboard/failures/daily per-(date, failure_reason) counts
|
|
// GET /api/dashboard/failures/by-agent per-(agent, failure_reason) counts
|
|
//
|
|
// All of them accept ?days=N (defaults to 30, capped at 365) and an optional
|
|
// ?project_id=<uuid> to scope the rollup to a single project. With no
|
|
// project_id the data spans the whole workspace.
|
|
//
|
|
// Cost is computed client-side from a per-model pricing table — the model
|
|
// dimension is intentionally preserved on the wire (same convention as the
|
|
// per-runtime usage endpoints).
|
|
//
|
|
// Access control: workspace membership only — we don't filter by per-agent
|
|
// visibility on the dashboard because token spend / run time are workspace-
|
|
// level operational metrics. Agent-detail pages still gate on per-agent
|
|
// access (see GetWorkspaceAgentRunCounts).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// parseProjectIDParam reads ?project_id=<uuid> off the URL. Returns a
|
|
// pgtype.UUID with Valid=false when the param is absent so sqlc's nullable
|
|
// argument resolves to SQL NULL and the WHERE clause degrades to "no
|
|
// project filter". On a malformed UUID it writes a 400 and returns
|
|
// ok=false; callers must return immediately.
|
|
func parseProjectIDParam(w http.ResponseWriter, r *http.Request) (pgtype.UUID, bool) {
|
|
raw := r.URL.Query().Get("project_id")
|
|
if raw == "" {
|
|
return pgtype.UUID{}, true
|
|
}
|
|
u, err := util.ParseUUID(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid project_id")
|
|
return pgtype.UUID{}, false
|
|
}
|
|
return u, true
|
|
}
|
|
|
|
// DashboardUsageDailyResponse is one (date, provider, model) bucket. Cost-side
|
|
// math happens on the client from a per-model pricing table; provider + model
|
|
// stay on the wire so the client can disambiguate bare model ids that collide
|
|
// across providers (e.g. Cursor's `auto`).
|
|
type DashboardUsageDailyResponse struct {
|
|
Date string `json:"date"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
|
// Cost split: `CostUSDTicks` is what the provider itself charged for the
|
|
// rows behind this aggregate (1e-10 USD), and the `Uncosted*` token
|
|
// counts are the tokens from rows the provider did NOT price. The client
|
|
// reports authoritative + estimate(uncosted), so a window mixing both
|
|
// kinds of row stays whole. See migration 213.
|
|
CostUSDTicks int64 `json:"cost_usd_ticks"`
|
|
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
|
|
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
|
|
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
|
|
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// GetDashboardUsageDaily returns per-(date, model) token rows for the
|
|
// workspace, optionally scoped to a project. Backed by task_usage_hourly,
|
|
// sliced into calendar days under the viewer's tz.
|
|
func (h *Handler) GetDashboardUsageDaily(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
resp, err := h.listDashboardUsageDaily(r.Context(), parseUUID(workspaceID), tz, since, projectID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list usage")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) listDashboardUsageDaily(
|
|
ctx context.Context,
|
|
workspaceID pgtype.UUID,
|
|
tz string,
|
|
since pgtype.Timestamptz,
|
|
projectID pgtype.UUID,
|
|
) ([]DashboardUsageDailyResponse, error) {
|
|
rows, err := h.Queries.ListDashboardUsageDaily(ctx, db.ListDashboardUsageDailyParams{
|
|
WorkspaceID: workspaceID,
|
|
Tz: tz,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := make([]DashboardUsageDailyResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardUsageDailyResponse{
|
|
Date: row.Date.Time.Format("2006-01-02"),
|
|
Provider: row.Provider,
|
|
Model: row.Model,
|
|
InputTokens: row.InputTokens,
|
|
OutputTokens: row.OutputTokens,
|
|
CacheReadTokens: row.CacheReadTokens,
|
|
CacheWriteTokens: row.CacheWriteTokens,
|
|
CostUSDTicks: row.CostUsdTicks,
|
|
UncostedInputTokens: row.UncostedInputTokens,
|
|
UncostedOutputTokens: row.UncostedOutputTokens,
|
|
UncostedCacheReadTokens: row.UncostedCacheReadTokens,
|
|
UncostedCacheWriteTokens: row.UncostedCacheWriteTokens,
|
|
TaskCount: row.TaskCount,
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// DashboardUsageByAgentResponse is one (agent, provider, model) row. provider
|
|
// rides along for the same cross-provider pricing disambiguation as the daily
|
|
// response; the client folds by agent_id and sums cost.
|
|
type DashboardUsageByAgentResponse struct {
|
|
AgentID string `json:"agent_id"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
|
// Cost split: `CostUSDTicks` is what the provider itself charged for the
|
|
// rows behind this aggregate (1e-10 USD), and the `Uncosted*` token
|
|
// counts are the tokens from rows the provider did NOT price. The client
|
|
// reports authoritative + estimate(uncosted), so a window mixing both
|
|
// kinds of row stays whole. See migration 213.
|
|
CostUSDTicks int64 `json:"cost_usd_ticks"`
|
|
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
|
|
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
|
|
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
|
|
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// GetDashboardUsageByAgent returns per-(agent, model) token aggregates
|
|
// for the workspace, optionally scoped to a project. Backed by
|
|
// task_usage_hourly with the viewer's tz applied to the `?days=` cutoff.
|
|
func (h *Handler) GetDashboardUsageByAgent(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// "By agent" has no date grouping in the SQL — tz only determines
|
|
// the cutoff boundary, not the bucket axis.
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
resp, err := h.listDashboardUsageByAgent(r.Context(), parseUUID(workspaceID), since, projectID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list usage by agent")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) listDashboardUsageByAgent(
|
|
ctx context.Context,
|
|
workspaceID pgtype.UUID,
|
|
since pgtype.Timestamptz,
|
|
projectID pgtype.UUID,
|
|
) ([]DashboardUsageByAgentResponse, error) {
|
|
rows, err := h.Queries.ListDashboardUsageByAgent(ctx, db.ListDashboardUsageByAgentParams{
|
|
WorkspaceID: workspaceID,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := make([]DashboardUsageByAgentResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardUsageByAgentResponse{
|
|
AgentID: uuidToString(row.AgentID),
|
|
Provider: row.Provider,
|
|
Model: row.Model,
|
|
InputTokens: row.InputTokens,
|
|
OutputTokens: row.OutputTokens,
|
|
CacheReadTokens: row.CacheReadTokens,
|
|
CacheWriteTokens: row.CacheWriteTokens,
|
|
CostUSDTicks: row.CostUsdTicks,
|
|
UncostedInputTokens: row.UncostedInputTokens,
|
|
UncostedOutputTokens: row.UncostedOutputTokens,
|
|
UncostedCacheReadTokens: row.UncostedCacheReadTokens,
|
|
UncostedCacheWriteTokens: row.UncostedCacheWriteTokens,
|
|
TaskCount: row.TaskCount,
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// DashboardAgentRunTimeResponse is one agent's total terminal-task run time
|
|
// over the window. Includes failed tasks so the dashboard can surface how
|
|
// much execution time was spent on runs that didn't succeed.
|
|
type DashboardAgentRunTimeResponse struct {
|
|
AgentID string `json:"agent_id"`
|
|
TotalSeconds int64 `json:"total_seconds"`
|
|
TaskCount int32 `json:"task_count"`
|
|
FailedCount int32 `json:"failed_count"`
|
|
}
|
|
|
|
// GetDashboardAgentRunTime returns per-agent total task run time (seconds)
|
|
// and task counts for the workspace, optionally scoped to a project. Only
|
|
// terminal tasks (completed or failed) with both started_at and
|
|
// completed_at populated contribute, since queued/running tasks have no
|
|
// finite duration.
|
|
func (h *Handler) GetDashboardAgentRunTime(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Cutoff in the viewer's tz so the "last N days" window matches the
|
|
// per-agent cost card (GetDashboardUsageByAgent).
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
rows, err := h.Queries.ListDashboardAgentRunTime(r.Context(), db.ListDashboardAgentRunTimeParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list agent runtime")
|
|
return
|
|
}
|
|
|
|
resp := make([]DashboardAgentRunTimeResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardAgentRunTimeResponse{
|
|
AgentID: uuidToString(row.AgentID),
|
|
TotalSeconds: row.TotalSeconds,
|
|
TaskCount: row.TaskCount,
|
|
FailedCount: row.FailedCount,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// DashboardRunTimeDailyResponse is one (date) bucket of terminal-task run
|
|
// time and counts. Powers the workspace dashboard's daily Time and Tasks
|
|
// charts — same toggle as Tokens / Cost, different metric.
|
|
type DashboardRunTimeDailyResponse struct {
|
|
Date string `json:"date"`
|
|
TotalSeconds int64 `json:"total_seconds"`
|
|
TaskCount int32 `json:"task_count"`
|
|
FailedCount int32 `json:"failed_count"`
|
|
}
|
|
|
|
// GetDashboardRunTimeDaily returns per-date total task run time and task
|
|
// counts for the workspace, optionally scoped to a project. Only terminal
|
|
// tasks (completed or failed) with both started_at and completed_at
|
|
// populated contribute. Bucketed by completed_at so the day boundaries
|
|
// line up with the per-agent run-time card.
|
|
func (h *Handler) GetDashboardRunTimeDaily(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Slice day buckets in the viewer's tz so the Time / Tasks charts cut
|
|
// their calendar day identically to the Cost / Tokens charts.
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
rows, err := h.Queries.ListDashboardRunTimeDaily(r.Context(), db.ListDashboardRunTimeDailyParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Tz: tz,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list daily runtime")
|
|
return
|
|
}
|
|
|
|
resp := make([]DashboardRunTimeDailyResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardRunTimeDailyResponse{
|
|
Date: row.Date.Time.Format("2006-01-02"),
|
|
TotalSeconds: row.TotalSeconds,
|
|
TaskCount: row.TaskCount,
|
|
FailedCount: row.FailedCount,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Failure rollups
|
|
//
|
|
// Both endpoints return EVERY terminal task, not just the failed ones: the
|
|
// row whose FailureReason is "" carries that bucket's succeeded count. The
|
|
// client needs that denominator to render an error *rate*, and shipping it
|
|
// in the same payload keeps numerator and denominator on identical filters —
|
|
// deriving the denominator from the run-time endpoints instead would silently
|
|
// disagree, because those require started_at IS NOT NULL and a task that
|
|
// expired in the queue never started.
|
|
//
|
|
// FailureReason values are the canonical taxonomy from server/pkg/taskfailure
|
|
// (21 reasons), plus "unclassified" for failed rows with a NULL / empty
|
|
// column. The client folds them into a handful of display classes; the raw
|
|
// reason stays on the wire so that mapping can change without a backend
|
|
// deploy.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// DashboardFailureDailyResponse is one (date, failure_reason) bucket of
|
|
// terminal-task counts. FailureReason == "" is the succeeded bucket.
|
|
type DashboardFailureDailyResponse struct {
|
|
Date string `json:"date"`
|
|
FailureReason string `json:"failure_reason"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// GetDashboardFailuresDaily returns per-(date, failure_reason) terminal-task
|
|
// counts for the workspace, optionally scoped to a project. Powers the Usage
|
|
// page's Errors trend and errors-by-class breakdown.
|
|
func (h *Handler) GetDashboardFailuresDaily(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Same viewer-tz day boundary as every other daily series so the Errors
|
|
// tab lines up with Cost / Tokens / Time / Tasks.
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
rows, err := h.Queries.ListDashboardFailuresDaily(r.Context(), db.ListDashboardFailuresDailyParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Tz: tz,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list daily failures")
|
|
return
|
|
}
|
|
|
|
resp := make([]DashboardFailureDailyResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardFailureDailyResponse{
|
|
Date: row.Date.Time.Format("2006-01-02"),
|
|
FailureReason: row.FailureReason,
|
|
TaskCount: row.TaskCount,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// DashboardFailureByAgentResponse is one (agent, failure_reason) bucket of
|
|
// terminal-task counts. FailureReason == "" is the succeeded bucket.
|
|
type DashboardFailureByAgentResponse struct {
|
|
AgentID string `json:"agent_id"`
|
|
FailureReason string `json:"failure_reason"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// GetDashboardFailuresByAgent returns per-(agent, failure_reason)
|
|
// terminal-task counts for the workspace, optionally scoped to a project.
|
|
// Powers the Usage page's "top offenders" list.
|
|
func (h *Handler) GetDashboardFailuresByAgent(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// No date grouping in the SQL, so the client cannot trim this response the
|
|
// way it trims the date-bucketed series. Close the window server-side to
|
|
// exactly `days` calendar buckets — the same span the Errors chart renders
|
|
// after its `-(days-1)` filter. With the default N+1 cutoff this list
|
|
// covered one extra day, so at days=1 the card could report yesterday's
|
|
// failures next to a chart showing none.
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseExactSinceParamInTZ(r, 30, tz)
|
|
|
|
rows, err := h.Queries.ListDashboardFailuresByAgent(r.Context(), db.ListDashboardFailuresByAgentParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list failures by agent")
|
|
return
|
|
}
|
|
|
|
resp := make([]DashboardFailureByAgentResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardFailureByAgentResponse{
|
|
AgentID: uuidToString(row.AgentID),
|
|
FailureReason: row.FailureReason,
|
|
TaskCount: row.TaskCount,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|