Compare commits

...

3 Commits

Author SHA1 Message Date
Jiayuan Zhang
de8f73e262 fix(usage): scope squad filter to squad-member agents
Squad usage was attributing any task on a squad-assigned issue to that
squad, regardless of which agent ran it. Per product definition, the
squad filter is "tasks on issues assigned to the squad AND run by an
agent that belongs to the squad (member or leader)". Add the membership
predicate to all four dashboard queries (daily/by-agent tokens,
daily/per-agent run-time) and lock the exclusion down with a new
fixture: an outsider agent runs against the squad-assigned issue and
must not appear in the squad-filtered totals or rows.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 10:38:47 +08:00
YYClaw
3878219ee8 feat(usage): wire squad filter into usage UI 2026-05-18 14:28:59 +08:00
YYClaw
3cc75c30db feat(usage): add squad filter to usage endpoints 2026-05-18 13:57:37 +08:00
9 changed files with 612 additions and 89 deletions

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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({

View File

@@ -4,6 +4,8 @@
"filter": {
"project_label": "Project",
"all_projects": "All projects",
"squad_label": "Squad",
"all_squads": "All squads",
"period_label": "Period"
},
"kpi": {

View File

@@ -4,6 +4,8 @@
"filter": {
"project_label": "项目",
"all_projects": "全部项目",
"squad_label": "小队",
"all_squads": "全部小队",
"period_label": "时间范围"
},
"kpi": {

View File

@@ -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")

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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;