mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
3 Commits
fix/cloud-
...
feat/usage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de8f73e262 | ||
|
|
3878219ee8 | ||
|
|
3cc75c30db |
@@ -847,11 +847,12 @@ export class ApiClient {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getDashboardUsageDaily(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null; squad_id?: string | null },
|
||||
): Promise<DashboardUsageDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.squad_id) search.set("squad_id", params.squad_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
|
||||
return parseWithFallback<DashboardUsageDaily[]>(
|
||||
raw,
|
||||
@@ -862,11 +863,12 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardUsageByAgent(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null; squad_id?: string | null },
|
||||
): Promise<DashboardUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.squad_id) search.set("squad_id", params.squad_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
|
||||
return parseWithFallback<DashboardUsageByAgent[]>(
|
||||
raw,
|
||||
@@ -877,11 +879,12 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardAgentRunTime(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null; squad_id?: string | null },
|
||||
): Promise<DashboardAgentRunTime[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.squad_id) search.set("squad_id", params.squad_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
|
||||
return parseWithFallback<DashboardAgentRunTime[]>(
|
||||
raw,
|
||||
@@ -892,11 +895,12 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardRunTimeDaily(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null; squad_id?: string | null },
|
||||
): Promise<DashboardRunTimeDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.squad_id) search.set("squad_id", params.squad_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
|
||||
return parseWithFallback<DashboardRunTimeDaily[]>(
|
||||
raw,
|
||||
|
||||
@@ -1,45 +1,74 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
// Workspace dashboard query options. All three endpoints share the same
|
||||
// (wsId, days, projectId) key shape so workspace switching, time-range
|
||||
// changes, and the project filter each invalidate the cache cleanly.
|
||||
// Workspace dashboard query options. All four endpoints share the same
|
||||
// (wsId, days, projectId, squadId) key shape so workspace switching,
|
||||
// time-range changes, the project filter, and the squad filter each
|
||||
// invalidate the cache cleanly.
|
||||
//
|
||||
// The cache key includes `wsId` explicitly: TanStack Query already isolates
|
||||
// per workspace via the key, but threading wsId into the queryFn lets
|
||||
// callers fail fast (return [] on empty wsId) instead of issuing a request
|
||||
// the server would reject.
|
||||
//
|
||||
// `projectId` is normalised to `null` (not undefined / "all") so the
|
||||
// queryKey shape is stable across renders even when the dropdown sits on
|
||||
// "all projects".
|
||||
// `projectId` and `squadId` are normalised to `null` (not undefined /
|
||||
// "all") so the queryKey shape is stable across renders when either
|
||||
// dropdown sits on its "all" sentinel.
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: (wsId: string) => ["dashboard", wsId] as const,
|
||||
daily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
|
||||
byAgent: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
|
||||
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
|
||||
runTimeDaily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "runtime-daily", days, projectId] as const,
|
||||
daily: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) => [...dashboardKeys.all(wsId), "daily", days, projectId, squadId] as const,
|
||||
byAgent: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) =>
|
||||
[...dashboardKeys.all(wsId), "by-agent", days, projectId, squadId] as const,
|
||||
agentRuntime: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) =>
|
||||
[
|
||||
...dashboardKeys.all(wsId),
|
||||
"agent-runtime",
|
||||
days,
|
||||
projectId,
|
||||
squadId,
|
||||
] as const,
|
||||
runTimeDaily: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) =>
|
||||
[
|
||||
...dashboardKeys.all(wsId),
|
||||
"runtime-daily",
|
||||
days,
|
||||
projectId,
|
||||
squadId,
|
||||
] as const,
|
||||
};
|
||||
|
||||
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
|
||||
// driven on the server (5-min rollup cadence) and the dashboard isn't a
|
||||
// real-time view, so background refetches every minute are plenty.
|
||||
const STALE_TIME = 60 * 1000;
|
||||
|
||||
export function dashboardUsageDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId),
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId, squadId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
|
||||
api.getDashboardUsageDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
squad_id: squadId ?? undefined,
|
||||
}),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -49,11 +78,16 @@ export function dashboardUsageByAgentOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId, squadId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
|
||||
api.getDashboardUsageByAgent({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
squad_id: squadId ?? undefined,
|
||||
}),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -63,11 +97,16 @@ export function dashboardAgentRunTimeOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId, squadId),
|
||||
queryFn: () =>
|
||||
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
|
||||
api.getDashboardAgentRunTime({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
squad_id: squadId ?? undefined,
|
||||
}),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -77,11 +116,16 @@ export function dashboardRunTimeDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId),
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId, squadId),
|
||||
queryFn: () =>
|
||||
api.getDashboardRunTimeDaily({ days, project_id: projectId ?? undefined }),
|
||||
api.getDashboardRunTimeDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
squad_id: squadId ?? undefined,
|
||||
}),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { BarChart3, FolderKanban } from "lucide-react";
|
||||
import { BarChart3, FolderKanban, Users } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
agentListOptions,
|
||||
squadListOptions,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import {
|
||||
dashboardUsageDailyOptions,
|
||||
@@ -62,6 +65,9 @@ type TimeRange = (typeof TIME_RANGES)[number]["days"];
|
||||
// so it survives a refactor that ever lets a project be slug-keyed.
|
||||
const ALL_PROJECTS = "__all__";
|
||||
|
||||
// Sentinel for "no squad filter" — same pattern as ALL_PROJECTS.
|
||||
const ALL_SQUADS = "__all__";
|
||||
|
||||
// Stable references — `data ?? []` would create a new empty array on
|
||||
// every render while the query is loading, which breaks useMemo's
|
||||
// reference-equality dep check and trips the exhaustive-deps lint rule.
|
||||
@@ -124,6 +130,7 @@ export function DashboardPage() {
|
||||
const wsId = useWorkspaceId();
|
||||
const [days, setDays] = useState<TimeRange>(30);
|
||||
const [projectValue, setProjectValue] = useState<string>(ALL_PROJECTS);
|
||||
const [squadValue, setSquadValue] = useState<string>(ALL_SQUADS);
|
||||
// Default to the browser's resolved zone so day-boundary buckets match the
|
||||
// user's local clock on first render. Pure client-state — the rollup queries
|
||||
// are zone-agnostic today; this is the UI affordance the user can pin.
|
||||
@@ -135,6 +142,7 @@ export function DashboardPage() {
|
||||
|
||||
const { data: projects = [] } = useQuery(projectListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: squads = [] } = useQuery(squadListOptions(wsId));
|
||||
|
||||
// Validate the picked project against the current workspace's list. A
|
||||
// stale UUID — left over from a project that's been deleted, or from the
|
||||
@@ -147,11 +155,23 @@ export function DashboardPage() {
|
||||
return projects.some((p) => p.id === projectValue) ? projectValue : null;
|
||||
}, [projectValue, projects]);
|
||||
|
||||
const dailyQuery = useQuery(dashboardUsageDailyOptions(wsId, days, projectId));
|
||||
const byAgentQuery = useQuery(dashboardUsageByAgentOptions(wsId, days, projectId));
|
||||
const runTimeQuery = useQuery(dashboardAgentRunTimeOptions(wsId, days, projectId));
|
||||
// Same stale-UUID guard as projectId — see comment above.
|
||||
const squadId = useMemo(() => {
|
||||
if (squadValue === ALL_SQUADS) return null;
|
||||
return squads.some((s) => s.id === squadValue) ? squadValue : null;
|
||||
}, [squadValue, squads]);
|
||||
|
||||
const dailyQuery = useQuery(
|
||||
dashboardUsageDailyOptions(wsId, days, projectId, squadId),
|
||||
);
|
||||
const byAgentQuery = useQuery(
|
||||
dashboardUsageByAgentOptions(wsId, days, projectId, squadId),
|
||||
);
|
||||
const runTimeQuery = useQuery(
|
||||
dashboardAgentRunTimeOptions(wsId, days, projectId, squadId),
|
||||
);
|
||||
const runTimeDailyQuery = useQuery(
|
||||
dashboardRunTimeDailyOptions(wsId, days, projectId),
|
||||
dashboardRunTimeDailyOptions(wsId, days, projectId, squadId),
|
||||
);
|
||||
|
||||
const dailyUsage = dailyQuery.data ?? EMPTY_DAILY;
|
||||
@@ -229,6 +249,11 @@ export function DashboardPage() {
|
||||
value={projectValue}
|
||||
onChange={setProjectValue}
|
||||
/>
|
||||
<SquadFilter
|
||||
squads={squads}
|
||||
value={squadValue}
|
||||
onChange={setSquadValue}
|
||||
/>
|
||||
<Segmented
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
@@ -373,6 +398,68 @@ function ProjectFilter({
|
||||
);
|
||||
}
|
||||
|
||||
function SquadFilter({
|
||||
squads,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
squads: { id: string; name: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useT("usage");
|
||||
const allLabel = t(($) => $.filter.all_squads);
|
||||
const selected = squads.find((s) => s.id === value);
|
||||
const selectedTitle =
|
||||
value === ALL_SQUADS ? allLabel : selected?.name ?? allLabel;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => onChange(v ?? ALL_SQUADS)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-[160px]">
|
||||
<SelectValue>
|
||||
{() => (
|
||||
<>
|
||||
{selected ? (
|
||||
<ActorAvatar
|
||||
actorType="squad"
|
||||
actorId={selected.id}
|
||||
size={14}
|
||||
profileLink={false}
|
||||
/>
|
||||
) : (
|
||||
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{selectedTitle}</span>
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
{/* alignItemWithTrigger=false: same viewport-clipping reason as ProjectFilter. */}
|
||||
<SelectContent align="start" alignItemWithTrigger={false} className="max-h-72">
|
||||
<SelectItem value={ALL_SQUADS}>
|
||||
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{allLabel}</span>
|
||||
</SelectItem>
|
||||
{squads.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
<ActorAvatar
|
||||
actorType="squad"
|
||||
actorId={s.id}
|
||||
size={14}
|
||||
profileLink={false}
|
||||
className="self-center"
|
||||
/>
|
||||
<span className="truncate">{s.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
type DailyMetric = "tokens" | "cost" | "time" | "tasks";
|
||||
|
||||
function DailyTrendBlock({
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"filter": {
|
||||
"project_label": "Project",
|
||||
"all_projects": "All projects",
|
||||
"squad_label": "Squad",
|
||||
"all_squads": "All squads",
|
||||
"period_label": "Period"
|
||||
},
|
||||
"kpi": {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"filter": {
|
||||
"project_label": "项目",
|
||||
"all_projects": "全部项目",
|
||||
"squad_label": "小队",
|
||||
"all_squads": "全部小队",
|
||||
"period_label": "时间范围"
|
||||
},
|
||||
"kpi": {
|
||||
|
||||
@@ -51,6 +51,24 @@ func parseProjectIDParam(w http.ResponseWriter, r *http.Request) (pgtype.UUID, b
|
||||
return u, true
|
||||
}
|
||||
|
||||
// parseSquadIDParam reads ?squad_id=<uuid> off the URL with the same
|
||||
// nullable-narg semantics as parseProjectIDParam. When the param is
|
||||
// non-empty the handler forces the raw-stream code path because the
|
||||
// dashboard rollup table (migration 084) does not carry a squad
|
||||
// dimension.
|
||||
func parseSquadIDParam(w http.ResponseWriter, r *http.Request) (pgtype.UUID, bool) {
|
||||
raw := r.URL.Query().Get("squad_id")
|
||||
if raw == "" {
|
||||
return pgtype.UUID{}, true
|
||||
}
|
||||
u, err := util.ParseUUID(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid squad_id")
|
||||
return pgtype.UUID{}, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// DashboardUsageDailyResponse is one (date, model) bucket. Cost-side math
|
||||
// happens on the client from a per-model pricing table; model stays on the
|
||||
// wire for that reason.
|
||||
@@ -65,10 +83,11 @@ type DashboardUsageDailyResponse struct {
|
||||
}
|
||||
|
||||
// GetDashboardUsageDaily returns per-(date, model) token rows for the
|
||||
// workspace, optionally scoped to a project. When the dashboard rollup
|
||||
// is enabled (USAGE_DASHBOARD_ROLLUP_ENABLED=true) reads come from
|
||||
// `task_usage_dashboard_daily` (migration 084); otherwise from the raw
|
||||
// task_usage stream.
|
||||
// workspace, optionally scoped to a project and/or squad. When the
|
||||
// dashboard rollup is enabled (USAGE_DASHBOARD_ROLLUP_ENABLED=true) reads
|
||||
// come from `task_usage_dashboard_daily` (migration 084); otherwise from
|
||||
// the raw task_usage stream. A squad filter always forces the raw path
|
||||
// because the rollup table has no squad dimension.
|
||||
func (h *Handler) GetDashboardUsageDaily(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
||||
@@ -78,9 +97,13 @@ func (h *Handler) GetDashboardUsageDaily(w http.ResponseWriter, r *http.Request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
squadID, ok := parseSquadIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
since := parseSinceParam(r, 30)
|
||||
|
||||
resp, err := h.listDashboardUsageDaily(r.Context(), parseUUID(workspaceID), since, projectID)
|
||||
resp, err := h.listDashboardUsageDaily(r.Context(), parseUUID(workspaceID), since, projectID, squadID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list usage")
|
||||
return
|
||||
@@ -93,8 +116,13 @@ func (h *Handler) listDashboardUsageDaily(
|
||||
workspaceID pgtype.UUID,
|
||||
since pgtype.Timestamptz,
|
||||
projectID pgtype.UUID,
|
||||
squadID pgtype.UUID,
|
||||
) ([]DashboardUsageDailyResponse, error) {
|
||||
if h.cfg.UseDailyRollupForDashboard {
|
||||
// The dashboard rollup table (migration 084) does not carry a squad
|
||||
// dimension. When a squad filter is requested we force the raw-stream
|
||||
// path regardless of the rollup feature flag — see the squad-predicate
|
||||
// comment on ListDashboardUsageDaily for the rationale.
|
||||
if h.cfg.UseDailyRollupForDashboard && !squadID.Valid {
|
||||
rows, err := h.Queries.ListDashboardUsageDailyRollup(ctx, db.ListDashboardUsageDailyRollupParams{
|
||||
WorkspaceID: workspaceID,
|
||||
Since: since,
|
||||
@@ -121,6 +149,7 @@ func (h *Handler) listDashboardUsageDaily(
|
||||
WorkspaceID: workspaceID,
|
||||
Since: since,
|
||||
ProjectID: projectID,
|
||||
SquadID: squadID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -152,9 +181,9 @@ type DashboardUsageByAgentResponse struct {
|
||||
}
|
||||
|
||||
// GetDashboardUsageByAgent returns per-(agent, model) token aggregates for
|
||||
// the workspace, optionally scoped to a project. Switches to the rollup
|
||||
// table when UseDailyRollupForDashboard is on (same gating as the daily
|
||||
// endpoint above).
|
||||
// the workspace, optionally scoped to a project and/or squad. Switches to
|
||||
// the rollup table when UseDailyRollupForDashboard is on (same gating as
|
||||
// the daily endpoint above). A squad filter always forces the raw path.
|
||||
func (h *Handler) GetDashboardUsageByAgent(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
||||
@@ -164,9 +193,13 @@ func (h *Handler) GetDashboardUsageByAgent(w http.ResponseWriter, r *http.Reques
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
squadID, ok := parseSquadIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
since := parseSinceParam(r, 30)
|
||||
|
||||
resp, err := h.listDashboardUsageByAgent(r.Context(), parseUUID(workspaceID), since, projectID)
|
||||
resp, err := h.listDashboardUsageByAgent(r.Context(), parseUUID(workspaceID), since, projectID, squadID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list usage by agent")
|
||||
return
|
||||
@@ -179,8 +212,9 @@ func (h *Handler) listDashboardUsageByAgent(
|
||||
workspaceID pgtype.UUID,
|
||||
since pgtype.Timestamptz,
|
||||
projectID pgtype.UUID,
|
||||
squadID pgtype.UUID,
|
||||
) ([]DashboardUsageByAgentResponse, error) {
|
||||
if h.cfg.UseDailyRollupForDashboard {
|
||||
if h.cfg.UseDailyRollupForDashboard && !squadID.Valid {
|
||||
rows, err := h.Queries.ListDashboardUsageByAgentRollup(ctx, db.ListDashboardUsageByAgentRollupParams{
|
||||
WorkspaceID: workspaceID,
|
||||
Since: since,
|
||||
@@ -207,6 +241,7 @@ func (h *Handler) listDashboardUsageByAgent(
|
||||
WorkspaceID: workspaceID,
|
||||
Since: since,
|
||||
ProjectID: projectID,
|
||||
SquadID: squadID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -237,8 +272,8 @@ type DashboardAgentRunTimeResponse struct {
|
||||
}
|
||||
|
||||
// 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
|
||||
// and task counts for the workspace, optionally scoped to a project and/or
|
||||
// squad. 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) {
|
||||
@@ -250,12 +285,17 @@ func (h *Handler) GetDashboardAgentRunTime(w http.ResponseWriter, r *http.Reques
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
squadID, ok := parseSquadIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
since := parseSinceParam(r, 30)
|
||||
|
||||
rows, err := h.Queries.ListDashboardAgentRunTime(r.Context(), db.ListDashboardAgentRunTimeParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Since: since,
|
||||
ProjectID: projectID,
|
||||
SquadID: squadID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agent runtime")
|
||||
@@ -285,10 +325,10 @@ type DashboardRunTimeDailyResponse struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// counts for the workspace, optionally scoped to a project and/or squad.
|
||||
// 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 {
|
||||
@@ -298,12 +338,17 @@ func (h *Handler) GetDashboardRunTimeDaily(w http.ResponseWriter, r *http.Reques
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
squadID, ok := parseSquadIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
since := parseSinceParam(r, 30)
|
||||
|
||||
rows, err := h.Queries.ListDashboardRunTimeDaily(r.Context(), db.ListDashboardRunTimeDailyParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Since: since,
|
||||
ProjectID: projectID,
|
||||
SquadID: squadID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list daily runtime")
|
||||
|
||||
@@ -588,3 +588,252 @@ func TestDashboardRollupReattributesOnLinkTaskToIssue(t *testing.T) {
|
||||
t.Errorf("NULL bucket: expected 0 tokens after link, got %d", nullAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardEndpoints_SquadFilter covers ?squad_id=<uuid> across the
|
||||
// four dashboard endpoints:
|
||||
// - issues whose assignee_type='squad' AND assignee_id matches are IN
|
||||
// - issues with any other assignee (or unassigned) are OUT
|
||||
// - the rollup table doesn't carry squad, so the handler must downgrade
|
||||
// to the raw stream even when UseDailyRollupForDashboard is on
|
||||
func TestDashboardEndpoints_SquadFilter(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
var runtimeID, agentID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT id FROM agent_runtime WHERE workspace_id = $1 LIMIT 1
|
||||
`, testWorkspaceID).Scan(&runtimeID); err != nil {
|
||||
t.Fatalf("fetch runtime: %v", err)
|
||||
}
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1
|
||||
`, testWorkspaceID).Scan(&agentID); err != nil {
|
||||
t.Fatalf("fetch agent: %v", err)
|
||||
}
|
||||
|
||||
// Build a squad with the agent as leader. Leader is implicitly a squad
|
||||
// member for the filter, so no squad_member row is needed for it. A
|
||||
// second agent is provisioned in this workspace but NOT added to the
|
||||
// squad — it represents the "outsider" case that must be excluded
|
||||
// from squad usage even when it runs against a squad-assigned issue.
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, leader_id, creator_id)
|
||||
VALUES ($1, 'dashboard-squad-test', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("insert squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM squad WHERE id = $1`, squadID) })
|
||||
|
||||
outsiderAgentID := createHandlerTestAgent(t, "dashboard-squad-outsider", []byte("[]"))
|
||||
|
||||
mkIssueWithAssignee := func(assigneeType string, assigneeID any) string {
|
||||
var id string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO issue (workspace_id, title, creator_id, creator_type, assignee_type, assignee_id, number)
|
||||
VALUES (
|
||||
$1, 'squad test', $2, 'member', $3, $4,
|
||||
(SELECT COALESCE(MAX(number), 0) + 1 FROM issue WHERE workspace_id = $1)
|
||||
)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, testUserID, assigneeType, assigneeID).Scan(&id); err != nil {
|
||||
t.Fatalf("insert issue: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id) })
|
||||
return id
|
||||
}
|
||||
squadIssueID := mkIssueWithAssignee("squad", squadID)
|
||||
memberIssueID := mkIssueWithAssignee("member", testUserID)
|
||||
|
||||
now := time.Now().UTC()
|
||||
started := now.Add(-30 * time.Minute)
|
||||
completed := started.Add(10 * time.Minute) // 600s run
|
||||
|
||||
mkTaskWithUsage := func(taskAgentID, issueID string, status string, tokens int64) {
|
||||
var taskID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_task_queue (agent_id, issue_id, runtime_id, status, started_at, completed_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
RETURNING id
|
||||
`, taskAgentID, issueID, runtimeID, status, started, completed).Scan(&taskID); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
if _, err := testPool.Exec(ctx, `
|
||||
INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, created_at)
|
||||
VALUES ($1, 'claude', 'claude-3-5-sonnet', $2, 0, now())
|
||||
`, taskID, tokens); err != nil {
|
||||
t.Fatalf("insert task_usage: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
||||
}
|
||||
|
||||
mkTaskWithUsage(agentID, squadIssueID, "completed", 1000)
|
||||
mkTaskWithUsage(agentID, memberIssueID, "completed", 500)
|
||||
// Outsider agent runs on the squad-assigned issue. Per Jiayuan's
|
||||
// definition, the squad filter scopes "squad-assigned issues AND tasks
|
||||
// by squad-member/leader agents", so this 2000-token row must NOT
|
||||
// appear under squad_id. If the SQL ever drops the agent-membership
|
||||
// predicate, the <1500 / ==1 assertions below all trip.
|
||||
mkTaskWithUsage(outsiderAgentID, squadIssueID, "completed", 2000)
|
||||
|
||||
type dailyRow struct {
|
||||
Model string `json:"model"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
}
|
||||
type runtimeRow struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
TaskCount int32 `json:"task_count"`
|
||||
}
|
||||
|
||||
// daily — squad-scoped
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.GetDashboardUsageDaily(w, newRequest("GET", "/api/dashboard/usage/daily?days=1&squad_id="+squadID, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("daily squad: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var rows []dailyRow
|
||||
_ = json.NewDecoder(w.Body).Decode(&rows)
|
||||
var total int64
|
||||
for _, r := range rows {
|
||||
if r.Model == "claude-3-5-sonnet" {
|
||||
total += r.InputTokens
|
||||
}
|
||||
}
|
||||
if total < 1000 {
|
||||
t.Errorf("daily squad: expected >=1000 tokens, got %d", total)
|
||||
}
|
||||
if total >= 1500 {
|
||||
t.Errorf("daily squad: filter leaked — expected <1500 tokens, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// by-agent — squad-scoped. Also asserts the outsider agent (which ran
|
||||
// 2000 tokens against the squad-assigned issue) is fully absent from
|
||||
// the squad-filtered rows — the membership predicate must reject it
|
||||
// at the row level, not just exclude its tokens from a sum.
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.GetDashboardUsageByAgent(w, newRequest("GET", "/api/dashboard/usage/by-agent?days=1&squad_id="+squadID, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("by-agent squad: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var rows []struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&rows)
|
||||
var total, outsiderTotal int64
|
||||
for _, r := range rows {
|
||||
switch r.AgentID {
|
||||
case agentID:
|
||||
total += r.InputTokens
|
||||
case outsiderAgentID:
|
||||
outsiderTotal += r.InputTokens
|
||||
}
|
||||
}
|
||||
if total < 1000 {
|
||||
t.Errorf("by-agent squad: expected >=1000 tokens for leader agent, got %d", total)
|
||||
}
|
||||
if total >= 1500 {
|
||||
t.Errorf("by-agent squad: filter leaked — expected <1500 tokens for leader agent, got %d", total)
|
||||
}
|
||||
if outsiderTotal != 0 {
|
||||
t.Errorf("by-agent squad: non-member agent must be excluded from squad filter, got %d tokens for outsider", outsiderTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// agent-runtime — squad-scoped
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.GetDashboardAgentRunTime(w, newRequest("GET", "/api/dashboard/agent-runtime?days=1&squad_id="+squadID, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("agent-runtime squad: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var rows []runtimeRow
|
||||
if err := json.NewDecoder(w.Body).Decode(&rows); err != nil {
|
||||
t.Fatalf("agent-runtime squad: decode: %v", err)
|
||||
}
|
||||
var tasks int32
|
||||
for _, r := range rows {
|
||||
if r.AgentID == agentID {
|
||||
tasks += r.TaskCount
|
||||
}
|
||||
}
|
||||
// Exactly one task is squad-assigned; the member-assigned task must
|
||||
// be excluded. The squad UUID is fresh so this is safe even in the
|
||||
// shared fixture workspace.
|
||||
if tasks != 1 {
|
||||
t.Errorf("agent-runtime squad: expected exactly 1 task, got %d", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
// runtime/daily — squad-scoped
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.GetDashboardRunTimeDaily(w, newRequest("GET", "/api/dashboard/runtime/daily?days=1&squad_id="+squadID, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("runtime daily squad: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var rows []struct {
|
||||
TaskCount int32 `json:"task_count"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&rows); err != nil {
|
||||
t.Fatalf("runtime daily squad: decode: %v", err)
|
||||
}
|
||||
var tasks int32
|
||||
for _, r := range rows {
|
||||
tasks += r.TaskCount
|
||||
}
|
||||
if tasks != 1 {
|
||||
t.Errorf("runtime daily squad: expected 1 task, got %d", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
// rollup-flag downgrade — with UseDailyRollupForDashboard=true and a
|
||||
// squad filter set, the handler MUST bypass the rollup table (which
|
||||
// has no squad column) and run the raw stream query. We prove this by
|
||||
// flipping the flag without populating the rollup table — if the
|
||||
// downgrade fails the response will be empty and the >=1000 assertion
|
||||
// will fail. This locks down the `&& !squadID.Valid` guard so a future
|
||||
// edit changing it to `||` (or dropping it entirely) gets caught.
|
||||
{
|
||||
orig := testHandler.cfg.UseDailyRollupForDashboard
|
||||
testHandler.cfg.UseDailyRollupForDashboard = true
|
||||
t.Cleanup(func() { testHandler.cfg.UseDailyRollupForDashboard = orig })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.GetDashboardUsageDaily(w, newRequest("GET", "/api/dashboard/usage/daily?days=1&squad_id="+squadID, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("daily squad rollup-downgrade: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var rows []dailyRow
|
||||
if err := json.NewDecoder(w.Body).Decode(&rows); err != nil {
|
||||
t.Fatalf("daily squad rollup-downgrade: decode: %v", err)
|
||||
}
|
||||
var total int64
|
||||
for _, r := range rows {
|
||||
if r.Model == "claude-3-5-sonnet" {
|
||||
total += r.InputTokens
|
||||
}
|
||||
}
|
||||
if total < 1000 {
|
||||
t.Errorf("daily squad rollup-downgrade: expected >=1000 tokens (proves raw path was used), got %d", total)
|
||||
}
|
||||
if total >= 1500 {
|
||||
t.Errorf("daily squad rollup-downgrade: filter leaked — expected <1500 tokens, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// invalid squad_id rejected with 400
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.GetDashboardUsageDaily(w, newRequest("GET", "/api/dashboard/usage/daily?squad_id=not-a-uuid", nil))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid squad_id: expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,14 @@ WHERE a.workspace_id = $1
|
||||
AND atq.completed_at IS NOT NULL
|
||||
AND atq.completed_at >= DATE_TRUNC('day', $2::timestamptz)
|
||||
AND ($3::uuid IS NULL OR i.project_id = $3)
|
||||
AND ($4::uuid IS NULL
|
||||
OR (i.assignee_type = 'squad' AND i.assignee_id = $4
|
||||
AND atq.agent_id IN (
|
||||
SELECT sm.member_id FROM squad_member sm
|
||||
WHERE sm.squad_id = $4 AND sm.member_type = 'agent'
|
||||
UNION
|
||||
SELECT s.leader_id FROM squad s WHERE s.id = $4
|
||||
)))
|
||||
GROUP BY atq.agent_id
|
||||
ORDER BY total_seconds DESC
|
||||
`
|
||||
@@ -232,6 +240,7 @@ type ListDashboardAgentRunTimeParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Since pgtype.Timestamptz `json:"since"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
SquadID pgtype.UUID `json:"squad_id"`
|
||||
}
|
||||
|
||||
type ListDashboardAgentRunTimeRow struct {
|
||||
@@ -242,12 +251,13 @@ type ListDashboardAgentRunTimeRow struct {
|
||||
}
|
||||
|
||||
// Per-agent total task run time and task count for the workspace, optionally
|
||||
// scoped to a single project. Counts only terminal runs (completed or failed)
|
||||
// with both started_at and completed_at populated — queued/running tasks have
|
||||
// no finite duration. Anchored on completed_at so the window matches the
|
||||
// token cost window (which is anchored on tu.created_at, ~= completion time).
|
||||
// scoped to a single project AND/OR a single squad. Counts only terminal
|
||||
// runs (completed or failed) with both started_at and completed_at
|
||||
// populated — queued/running tasks have no finite duration. Anchored on
|
||||
// completed_at so the window matches the token cost window (which is
|
||||
// anchored on tu.created_at, ~= completion time).
|
||||
func (q *Queries) ListDashboardAgentRunTime(ctx context.Context, arg ListDashboardAgentRunTimeParams) ([]ListDashboardAgentRunTimeRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDashboardAgentRunTime, arg.WorkspaceID, arg.Since, arg.ProjectID)
|
||||
rows, err := q.db.Query(ctx, listDashboardAgentRunTime, arg.WorkspaceID, arg.Since, arg.ProjectID, arg.SquadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -289,6 +299,14 @@ WHERE a.workspace_id = $1
|
||||
AND atq.completed_at IS NOT NULL
|
||||
AND atq.completed_at >= DATE_TRUNC('day', $2::timestamptz)
|
||||
AND ($3::uuid IS NULL OR i.project_id = $3)
|
||||
AND ($4::uuid IS NULL
|
||||
OR (i.assignee_type = 'squad' AND i.assignee_id = $4
|
||||
AND atq.agent_id IN (
|
||||
SELECT sm.member_id FROM squad_member sm
|
||||
WHERE sm.squad_id = $4 AND sm.member_type = 'agent'
|
||||
UNION
|
||||
SELECT s.leader_id FROM squad s WHERE s.id = $4
|
||||
)))
|
||||
GROUP BY DATE(atq.completed_at)
|
||||
ORDER BY DATE(atq.completed_at) DESC
|
||||
`
|
||||
@@ -297,6 +315,7 @@ type ListDashboardRunTimeDailyParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Since pgtype.Timestamptz `json:"since"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
SquadID pgtype.UUID `json:"squad_id"`
|
||||
}
|
||||
|
||||
type ListDashboardRunTimeDailyRow struct {
|
||||
@@ -307,14 +326,14 @@ type ListDashboardRunTimeDailyRow struct {
|
||||
}
|
||||
|
||||
// Daily per-date run time + task counts for the workspace, optionally
|
||||
// scoped to a single project. Powers the workspace dashboard's "Time"
|
||||
// and "Tasks" metrics on the same toggle as Tokens / Cost. Bucketed by
|
||||
// completed_at (terminal time) — same anchor as ListDashboardAgentRunTime
|
||||
// so the day boundaries line up with the per-agent run-time card. Only
|
||||
// terminal tasks (completed or failed) with both started_at and
|
||||
// completed_at populated contribute.
|
||||
// scoped to a single project AND/OR a single squad. Powers the workspace
|
||||
// dashboard's "Time" and "Tasks" metrics on the same toggle as Tokens /
|
||||
// Cost. Bucketed by completed_at (terminal time) — same anchor as
|
||||
// ListDashboardAgentRunTime so the day boundaries line up with the
|
||||
// per-agent run-time card. Only terminal tasks (completed or failed)
|
||||
// with both started_at and completed_at populated contribute.
|
||||
func (q *Queries) ListDashboardRunTimeDaily(ctx context.Context, arg ListDashboardRunTimeDailyParams) ([]ListDashboardRunTimeDailyRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDashboardRunTimeDaily, arg.WorkspaceID, arg.Since, arg.ProjectID)
|
||||
rows, err := q.db.Query(ctx, listDashboardRunTimeDaily, arg.WorkspaceID, arg.Since, arg.ProjectID, arg.SquadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -354,6 +373,14 @@ LEFT JOIN issue i ON i.id = atq.issue_id
|
||||
WHERE a.workspace_id = $1
|
||||
AND tu.created_at >= DATE_TRUNC('day', $2::timestamptz)
|
||||
AND ($3::uuid IS NULL OR i.project_id = $3)
|
||||
AND ($4::uuid IS NULL
|
||||
OR (i.assignee_type = 'squad' AND i.assignee_id = $4
|
||||
AND atq.agent_id IN (
|
||||
SELECT sm.member_id FROM squad_member sm
|
||||
WHERE sm.squad_id = $4 AND sm.member_type = 'agent'
|
||||
UNION
|
||||
SELECT s.leader_id FROM squad s WHERE s.id = $4
|
||||
)))
|
||||
GROUP BY atq.agent_id, tu.model
|
||||
ORDER BY atq.agent_id, tu.model
|
||||
`
|
||||
@@ -362,6 +389,7 @@ type ListDashboardUsageByAgentParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Since pgtype.Timestamptz `json:"since"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
SquadID pgtype.UUID `json:"squad_id"`
|
||||
}
|
||||
|
||||
type ListDashboardUsageByAgentRow struct {
|
||||
@@ -375,11 +403,12 @@ type ListDashboardUsageByAgentRow struct {
|
||||
}
|
||||
|
||||
// Per-(agent, model) token aggregates for the workspace, optionally scoped
|
||||
// to a single project. Model dimension is preserved so the client can
|
||||
// compute cost from its per-model pricing table; the client folds rows by
|
||||
// agent for the "by agent" list on the dashboard.
|
||||
// to a single project AND/OR a single squad (see ListDashboardUsageDaily
|
||||
// for the squad-predicate rationale). Model dimension is preserved so the
|
||||
// client can compute cost from its per-model pricing table; the client
|
||||
// folds rows by agent for the "by agent" list on the dashboard.
|
||||
func (q *Queries) ListDashboardUsageByAgent(ctx context.Context, arg ListDashboardUsageByAgentParams) ([]ListDashboardUsageByAgentRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDashboardUsageByAgent, arg.WorkspaceID, arg.Since, arg.ProjectID)
|
||||
rows, err := q.db.Query(ctx, listDashboardUsageByAgent, arg.WorkspaceID, arg.Since, arg.ProjectID, arg.SquadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -489,6 +518,14 @@ LEFT JOIN issue i ON i.id = atq.issue_id
|
||||
WHERE a.workspace_id = $1
|
||||
AND tu.created_at >= DATE_TRUNC('day', $2::timestamptz)
|
||||
AND ($3::uuid IS NULL OR i.project_id = $3)
|
||||
AND ($4::uuid IS NULL
|
||||
OR (i.assignee_type = 'squad' AND i.assignee_id = $4
|
||||
AND atq.agent_id IN (
|
||||
SELECT sm.member_id FROM squad_member sm
|
||||
WHERE sm.squad_id = $4 AND sm.member_type = 'agent'
|
||||
UNION
|
||||
SELECT s.leader_id FROM squad s WHERE s.id = $4
|
||||
)))
|
||||
GROUP BY DATE(tu.created_at), tu.model
|
||||
ORDER BY DATE(tu.created_at) DESC, tu.model
|
||||
`
|
||||
@@ -497,6 +534,7 @@ type ListDashboardUsageDailyParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Since pgtype.Timestamptz `json:"since"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
SquadID pgtype.UUID `json:"squad_id"`
|
||||
}
|
||||
|
||||
type ListDashboardUsageDailyRow struct {
|
||||
@@ -510,13 +548,22 @@ type ListDashboardUsageDailyRow struct {
|
||||
}
|
||||
|
||||
// Daily per-(date, model) token aggregates for the workspace, optionally
|
||||
// scoped to a single project via sqlc.narg('project_id'). Bucketed by
|
||||
// scoped to a single project AND/OR a single squad. Bucketed by
|
||||
// tu.created_at (token-production time) to match GetWorkspaceUsageByDay,
|
||||
// so a task that queues one day and finishes the next is attributed to
|
||||
// the day the tokens actually landed. Powers the workspace dashboard's
|
||||
// daily cost chart.
|
||||
//
|
||||
// The squad predicate is two-layered: (1) the issue must be assigned to
|
||||
// the squad (`issue.assignee_type='squad' AND issue.assignee_id = :squad_id`),
|
||||
// AND (2) the task's agent must be a member of that squad — either a
|
||||
// `squad_member` row of type 'agent', or the squad's leader_id. A task
|
||||
// run by a non-member agent on a squad-assigned issue is NOT counted in
|
||||
// that squad's usage. The rollup variant below does NOT carry a squad
|
||||
// dimension; the handler forces this raw-stream query whenever a squad
|
||||
// filter is requested.
|
||||
func (q *Queries) ListDashboardUsageDaily(ctx context.Context, arg ListDashboardUsageDailyParams) ([]ListDashboardUsageDailyRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDashboardUsageDaily, arg.WorkspaceID, arg.Since, arg.ProjectID)
|
||||
rows, err := q.db.Query(ctx, listDashboardUsageDaily, arg.WorkspaceID, arg.Since, arg.ProjectID, arg.SquadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -70,11 +70,20 @@ WHERE atq.issue_id = $1;
|
||||
|
||||
-- name: ListDashboardUsageDaily :many
|
||||
-- Daily per-(date, model) token aggregates for the workspace, optionally
|
||||
-- scoped to a single project via sqlc.narg('project_id'). Bucketed by
|
||||
-- scoped to a single project AND/OR a single squad. Bucketed by
|
||||
-- tu.created_at (token-production time) to match GetWorkspaceUsageByDay,
|
||||
-- so a task that queues one day and finishes the next is attributed to
|
||||
-- the day the tokens actually landed. Powers the workspace dashboard's
|
||||
-- daily cost chart.
|
||||
--
|
||||
-- The squad predicate is two-layered: (1) the issue must be assigned to
|
||||
-- the squad (`issue.assignee_type='squad' AND issue.assignee_id = :squad_id`),
|
||||
-- AND (2) the task's agent must be a member of that squad — either a
|
||||
-- `squad_member` row of type 'agent', or the squad's leader_id. A task
|
||||
-- run by a non-member agent on a squad-assigned issue is NOT counted in
|
||||
-- that squad's usage. The rollup variant below does NOT carry a squad
|
||||
-- dimension; the handler forces this raw-stream query whenever a squad
|
||||
-- filter is requested.
|
||||
SELECT
|
||||
DATE(tu.created_at) AS date,
|
||||
tu.model,
|
||||
@@ -90,14 +99,23 @@ LEFT JOIN issue i ON i.id = atq.issue_id
|
||||
WHERE a.workspace_id = $1
|
||||
AND tu.created_at >= DATE_TRUNC('day', @since::timestamptz)
|
||||
AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id'))
|
||||
AND (sqlc.narg('squad_id')::uuid IS NULL
|
||||
OR (i.assignee_type = 'squad' AND i.assignee_id = sqlc.narg('squad_id')
|
||||
AND atq.agent_id IN (
|
||||
SELECT sm.member_id FROM squad_member sm
|
||||
WHERE sm.squad_id = sqlc.narg('squad_id') AND sm.member_type = 'agent'
|
||||
UNION
|
||||
SELECT s.leader_id FROM squad s WHERE s.id = sqlc.narg('squad_id')
|
||||
)))
|
||||
GROUP BY DATE(tu.created_at), tu.model
|
||||
ORDER BY DATE(tu.created_at) DESC, tu.model;
|
||||
|
||||
-- name: ListDashboardUsageByAgent :many
|
||||
-- Per-(agent, model) token aggregates for the workspace, optionally scoped
|
||||
-- to a single project. Model dimension is preserved so the client can
|
||||
-- compute cost from its per-model pricing table; the client folds rows by
|
||||
-- agent for the "by agent" list on the dashboard.
|
||||
-- to a single project AND/OR a single squad (see ListDashboardUsageDaily
|
||||
-- for the squad-predicate rationale). Model dimension is preserved so the
|
||||
-- client can compute cost from its per-model pricing table; the client
|
||||
-- folds rows by agent for the "by agent" list on the dashboard.
|
||||
SELECT
|
||||
atq.agent_id,
|
||||
tu.model,
|
||||
@@ -113,6 +131,14 @@ LEFT JOIN issue i ON i.id = atq.issue_id
|
||||
WHERE a.workspace_id = $1
|
||||
AND tu.created_at >= DATE_TRUNC('day', @since::timestamptz)
|
||||
AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id'))
|
||||
AND (sqlc.narg('squad_id')::uuid IS NULL
|
||||
OR (i.assignee_type = 'squad' AND i.assignee_id = sqlc.narg('squad_id')
|
||||
AND atq.agent_id IN (
|
||||
SELECT sm.member_id FROM squad_member sm
|
||||
WHERE sm.squad_id = sqlc.narg('squad_id') AND sm.member_type = 'agent'
|
||||
UNION
|
||||
SELECT s.leader_id FROM squad s WHERE s.id = sqlc.narg('squad_id')
|
||||
)))
|
||||
GROUP BY atq.agent_id, tu.model
|
||||
ORDER BY atq.agent_id, tu.model;
|
||||
|
||||
@@ -161,12 +187,12 @@ ORDER BY agent_id, model;
|
||||
|
||||
-- name: ListDashboardRunTimeDaily :many
|
||||
-- Daily per-date run time + task counts for the workspace, optionally
|
||||
-- scoped to a single project. Powers the workspace dashboard's "Time"
|
||||
-- and "Tasks" metrics on the same toggle as Tokens / Cost. Bucketed by
|
||||
-- completed_at (terminal time) — same anchor as ListDashboardAgentRunTime
|
||||
-- so the day boundaries line up with the per-agent run-time card. Only
|
||||
-- terminal tasks (completed or failed) with both started_at and
|
||||
-- completed_at populated contribute.
|
||||
-- scoped to a single project AND/OR a single squad. Powers the workspace
|
||||
-- dashboard's "Time" and "Tasks" metrics on the same toggle as Tokens /
|
||||
-- Cost. Bucketed by completed_at (terminal time) — same anchor as
|
||||
-- ListDashboardAgentRunTime so the day boundaries line up with the
|
||||
-- per-agent run-time card. Only terminal tasks (completed or failed)
|
||||
-- with both started_at and completed_at populated contribute.
|
||||
SELECT
|
||||
DATE(atq.completed_at) AS date,
|
||||
COALESCE(
|
||||
@@ -184,15 +210,24 @@ WHERE a.workspace_id = $1
|
||||
AND atq.completed_at IS NOT NULL
|
||||
AND atq.completed_at >= DATE_TRUNC('day', @since::timestamptz)
|
||||
AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id'))
|
||||
AND (sqlc.narg('squad_id')::uuid IS NULL
|
||||
OR (i.assignee_type = 'squad' AND i.assignee_id = sqlc.narg('squad_id')
|
||||
AND atq.agent_id IN (
|
||||
SELECT sm.member_id FROM squad_member sm
|
||||
WHERE sm.squad_id = sqlc.narg('squad_id') AND sm.member_type = 'agent'
|
||||
UNION
|
||||
SELECT s.leader_id FROM squad s WHERE s.id = sqlc.narg('squad_id')
|
||||
)))
|
||||
GROUP BY DATE(atq.completed_at)
|
||||
ORDER BY DATE(atq.completed_at) DESC;
|
||||
|
||||
-- name: ListDashboardAgentRunTime :many
|
||||
-- Per-agent total task run time and task count for the workspace, optionally
|
||||
-- scoped to a single project. Counts only terminal runs (completed or failed)
|
||||
-- with both started_at and completed_at populated — queued/running tasks have
|
||||
-- no finite duration. Anchored on completed_at so the window matches the
|
||||
-- token cost window (which is anchored on tu.created_at, ~= completion time).
|
||||
-- scoped to a single project AND/OR a single squad. Counts only terminal
|
||||
-- runs (completed or failed) with both started_at and completed_at
|
||||
-- populated — queued/running tasks have no finite duration. Anchored on
|
||||
-- completed_at so the window matches the token cost window (which is
|
||||
-- anchored on tu.created_at, ~= completion time).
|
||||
SELECT
|
||||
atq.agent_id,
|
||||
COALESCE(
|
||||
@@ -210,5 +245,13 @@ WHERE a.workspace_id = $1
|
||||
AND atq.completed_at IS NOT NULL
|
||||
AND atq.completed_at >= DATE_TRUNC('day', @since::timestamptz)
|
||||
AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id'))
|
||||
AND (sqlc.narg('squad_id')::uuid IS NULL
|
||||
OR (i.assignee_type = 'squad' AND i.assignee_id = sqlc.narg('squad_id')
|
||||
AND atq.agent_id IN (
|
||||
SELECT sm.member_id FROM squad_member sm
|
||||
WHERE sm.squad_id = sqlc.narg('squad_id') AND sm.member_type = 'agent'
|
||||
UNION
|
||||
SELECT s.leader_id FROM squad s WHERE s.id = sqlc.narg('squad_id')
|
||||
)))
|
||||
GROUP BY atq.agent_id
|
||||
ORDER BY total_seconds DESC;
|
||||
|
||||
Reference in New Issue
Block a user