31d4ba67fa
* perf(stats): split stats endpoint per-section and rewrite GetGlobalStats Stats page used to wait on a single endpoint that ran 7 SQL queries in parallel and rendered nothing until the slowest one returned. Cold first-paint was the bottleneck users actually hit. - Backend: split /api/v1/workspaces/:id/stats into 7 per-section endpoints (global, tasks, daily-activity, completed-activity, agent-usage, repositories, git). Each handler runs one repo call and returns its DTO. - Backend: rewrote GetGlobalStats from 9 independent correlated subqueries into 4 CTEs (task / session / turn / message). Each underlying table is now scanned at most once per request instead of 1-3 times. - Frontend: replaced the monolithic fetchStats + single PanelState with useStatsSections hook (7 parallel fetches, per-section SectionStatus). Each panel renders skeleton -> data -> error independently; light queries (overview, agent usage, git) paint immediately, heavy ones stream in. - Tests: added TestGetGlobalStats_AggregatesAcrossTables to lock the CTE-based query against regressions for turns, user/tool messages, and total duration. * fix(stats): address PR review feedback - Restore cache: "no-store" on section fetches; Gin emits no Cache-Control headers so the browser could otherwise serve stale stats on revisit (Greptile P1, Cubic). - Stop blanking the Overview panel when /git fails: render OverviewCards as soon as `global` is ready and treat `git` as optional, falling back to the Averages card. Updates GitOrAveragesCard / OverviewCards types (Claude). - Fix TaskStatsHasMore off-by-one at exactly taskStatsLimit rows: probe taskStatsLimit+1, slice back to the page size, only flag hasMore on a confirmed extra row (Claude, Cubic). - Rename anyError → firstError with a comment documenting the deterministic insertion-order iteration; clearer signal that callers should use it as "any section failed?" and surface per-section errors in their own panels (Claude). --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
160 lines
5.6 KiB
TypeScript
160 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import type {
|
|
AgentUsageDTO,
|
|
CompletedTaskActivityDTO,
|
|
DailyActivityDTO,
|
|
GitStatsDTO,
|
|
GlobalStatsDTO,
|
|
RepositoryStatsDTO,
|
|
StatsResponse,
|
|
TaskStatsDTO,
|
|
} from "@/lib/types/http";
|
|
import {
|
|
fetchAgentUsage,
|
|
fetchCompletedActivity,
|
|
fetchDailyActivity,
|
|
fetchGitStats,
|
|
fetchGlobalStats,
|
|
fetchRepositoryStats,
|
|
fetchTaskStats,
|
|
type TaskStatsResponse,
|
|
} from "@/lib/api/domains/stats-api";
|
|
import type { RangeKey } from "./stats-utils";
|
|
|
|
export type SectionStatus<T> =
|
|
| { kind: "loading" }
|
|
| { kind: "ready"; data: T }
|
|
| { kind: "error"; message: string };
|
|
|
|
export type StatsSections = {
|
|
global: SectionStatus<GlobalStatsDTO>;
|
|
tasks: SectionStatus<TaskStatsResponse>;
|
|
daily: SectionStatus<DailyActivityDTO[]>;
|
|
completed: SectionStatus<CompletedTaskActivityDTO[]>;
|
|
agents: SectionStatus<AgentUsageDTO[]>;
|
|
repos: SectionStatus<RepositoryStatsDTO[]>;
|
|
git: SectionStatus<GitStatsDTO>;
|
|
};
|
|
|
|
const LOADING: SectionStatus<never> = { kind: "loading" };
|
|
|
|
const INITIAL_SECTIONS: StatsSections = {
|
|
global: LOADING,
|
|
tasks: LOADING,
|
|
daily: LOADING,
|
|
completed: LOADING,
|
|
agents: LOADING,
|
|
repos: LOADING,
|
|
git: LOADING,
|
|
};
|
|
|
|
function errorMessage(e: unknown): string {
|
|
return e instanceof Error ? e.message : "Failed to load section";
|
|
}
|
|
|
|
export function useStatsSections(workspaceId: string | undefined, range: RangeKey): StatsSections {
|
|
// Render-time reset: when (workspaceId, range) flips we wipe section state
|
|
// before the effect runs so panels show skeletons while new fetches are in
|
|
// flight, instead of stale data from the previous range. Matches the React
|
|
// docs "reset state when a prop changes" pattern (no setState-in-effect).
|
|
const fetchKey = workspaceId ? `${workspaceId}::${range}` : null;
|
|
const [trackedKey, setTrackedKey] = useState<string | null>(null);
|
|
const [sections, setSections] = useState<StatsSections>(INITIAL_SECTIONS);
|
|
if (fetchKey !== trackedKey) {
|
|
setTrackedKey(fetchKey);
|
|
setSections(INITIAL_SECTIONS);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!workspaceId) return;
|
|
const controller = new AbortController();
|
|
|
|
// cache: "no-store" matches the previous useStatsData behaviour — Gin emits
|
|
// no Cache-Control headers, so without this the browser is free to serve a
|
|
// heuristically-cached response when the user navigates back to the page.
|
|
const opts = { cache: "no-store" as const, init: { signal: controller.signal } };
|
|
const apply = <K extends keyof StatsSections>(key: K, next: StatsSections[K]) => {
|
|
if (controller.signal.aborted) return;
|
|
setSections((prev) => ({ ...prev, [key]: next }));
|
|
};
|
|
|
|
const run = <K extends keyof StatsSections>(
|
|
key: K,
|
|
promise: Promise<StatsSections[K] extends SectionStatus<infer U> ? U : never>,
|
|
) => {
|
|
promise
|
|
.then((data) => apply(key, { kind: "ready", data } as StatsSections[K]))
|
|
.catch((e: unknown) => {
|
|
if (controller.signal.aborted) return;
|
|
apply(key, { kind: "error", message: errorMessage(e) } as StatsSections[K]);
|
|
});
|
|
};
|
|
|
|
run("global", fetchGlobalStats(workspaceId, opts, range));
|
|
run("tasks", fetchTaskStats(workspaceId, opts, range));
|
|
run("daily", fetchDailyActivity(workspaceId, opts, range));
|
|
run("completed", fetchCompletedActivity(workspaceId, opts, range));
|
|
run("agents", fetchAgentUsage(workspaceId, opts, range));
|
|
run("repos", fetchRepositoryStats(workspaceId, opts, range));
|
|
run("git", fetchGitStats(workspaceId, opts, range));
|
|
|
|
return () => controller.abort();
|
|
}, [workspaceId, range]);
|
|
|
|
return sections;
|
|
}
|
|
|
|
export function readyGlobal(sections: StatsSections): GlobalStatsDTO | null {
|
|
return sections.global.kind === "ready" ? sections.global.data : null;
|
|
}
|
|
|
|
// firstError returns the message of the first errored section in object-iteration
|
|
// order (insertion order: global → tasks → daily → completed → agents → repos →
|
|
// git). Callers use it only as a boolean "any section failed?" signal driving
|
|
// the header's "Failed to load stats" subtitle, so the deterministic ordering
|
|
// is fine — surface a specific section's error inside its own panel instead.
|
|
export function firstError(sections: StatsSections): string | null {
|
|
for (const key of Object.keys(sections) as (keyof StatsSections)[]) {
|
|
const s = sections[key];
|
|
if (s.kind === "error") return s.message;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// composeStatsResponse builds a full StatsResponse when every section is ready.
|
|
// Returns null otherwise — used to gate the Copy Stats button.
|
|
export function composeStatsResponse(sections: StatsSections): StatsResponse | null {
|
|
const { global, tasks, daily, completed, agents, repos, git } = sections;
|
|
if (
|
|
global.kind !== "ready" ||
|
|
tasks.kind !== "ready" ||
|
|
daily.kind !== "ready" ||
|
|
completed.kind !== "ready" ||
|
|
agents.kind !== "ready" ||
|
|
repos.kind !== "ready" ||
|
|
git.kind !== "ready"
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
global: global.data,
|
|
task_stats: tasks.data.task_stats,
|
|
daily_activity: daily.data,
|
|
completed_activity: completed.data,
|
|
agent_usage: agents.data,
|
|
repository_stats: repos.data,
|
|
git_stats: git.data,
|
|
};
|
|
}
|
|
|
|
export type TaskStatsSectionStatus = SectionStatus<TaskStatsDTO[]>;
|
|
|
|
// flattenTaskStats unwraps the {task_stats, has_more} envelope into the bare
|
|
// list expected by render helpers and the WorkloadSection component.
|
|
export function flattenTaskStats(status: SectionStatus<TaskStatsResponse>): TaskStatsSectionStatus {
|
|
if (status.kind === "ready") return { kind: "ready", data: status.data.task_stats };
|
|
return status;
|
|
}
|