mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
* docs(timezone): add scheduling/viewing timezone architecture RFC * feat(db): replace daily rollups with task_usage_hourly, add user.timezone Migrations 100-104: add "user".timezone (Viewing tz), build the UTC hourly task_usage_hourly rollup with its pipeline, drop the legacy task_usage_daily / task_usage_dashboard_daily pipelines, and drop the agent_runtime.timezone column. Report queries now slice day boundaries at read time by the caller-supplied @tz instead of materialising in a fixed tz. Regenerate sqlc. * feat(server): add task_usage_hourly backfill command Replace the two legacy backfill commands (daily / dashboard_daily) with a single backfill_task_usage_hourly that loads historical task_usage into the new UTC hourly rollup, sliced per workspace. * refactor(server): resolve viewing timezone in report handlers Report handlers resolve the Viewing tz per request (?tz query param, then user.timezone, then UTC) and pass it to the hourly-rollup queries. Drop the UseDailyRollup feature flags and the old raw-scan/daily-rollup dual paths, remove the /api/usage endpoints, and stop the daemon from reporting and the runtime handler from accepting host timezone. * refactor(core): switch report queries to viewing timezone API client and dashboard/runtime queries send ?tz with each report request, the user schema/types carry the new timezone field, and the runtime timezone field/mutation is removed. * feat(views): add viewing timezone preference and UI Add the useViewingTimezone hook and a Timezone setting in Preferences; report charts and the dashboard week boundary follow the viewer tz. Remove the runtime detail timezone editor and its locale strings. * fix(test): update fixtures and stabilize tests for timezone refactor The timezone architecture refactor changed several types without updating dependent test code: - RuntimeDevice no longer has a timezone field — drop it from the create-agent-dialog runtime fixture. - User now requires a timezone field — add it to the apps/web mockUser fixture. - The PreferencesTab timezone tests asserted on the async save handler (PATCH then store update) with a bare expect, racing the mutation's settle callback, and timed out querying the Select's ~600-option IANA list on a loaded CI runner. Wrap the assertions in waitFor and extend the timeout for those three tests. * docs(timezone): document self-host migration order and trigger invariant Add a SELF-HOST UPGRADE ORDER runbook to the backfill command's package comment: applying migrations 100-104 in a single migrate-up drops the legacy daily rollups before the hourly backfill runs, leaving dashboards empty until cron catches up. Add an INVARIANT comment on trg_atq_dirty_hourly noting that agent_id must be added to the trigger's OF list if it ever becomes mutable, otherwise dirty buckets for the old agent_id are silently missed. * style(runtimes): drop trailing blank line in runtime-detail
288 lines
9.9 KiB
Go
288 lines
9.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace / Project dashboard
|
|
//
|
|
// Three read endpoints power the workspace dashboard:
|
|
//
|
|
// GET /api/dashboard/usage/daily per-(date, model) token rows
|
|
// GET /api/dashboard/usage/by-agent per-(agent, model) token rows
|
|
// GET /api/dashboard/agent-runtime per-agent run-time + task counts
|
|
// GET /api/dashboard/runtime/daily per-date run-time + task counts
|
|
//
|
|
// All three accept ?days=N (defaults to 30, capped at 365) and an optional
|
|
// ?project_id=<uuid> to scope the rollup to a single project. With no
|
|
// project_id the data spans the whole workspace.
|
|
//
|
|
// Cost is computed client-side from a per-model pricing table — the model
|
|
// dimension is intentionally preserved on the wire (same convention as the
|
|
// per-runtime usage endpoints).
|
|
//
|
|
// Access control: workspace membership only — we don't filter by per-agent
|
|
// visibility on the dashboard because token spend / run time are workspace-
|
|
// level operational metrics. Agent-detail pages still gate on per-agent
|
|
// access (see GetWorkspaceAgentRunCounts).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// parseProjectIDParam reads ?project_id=<uuid> off the URL. Returns a
|
|
// pgtype.UUID with Valid=false when the param is absent so sqlc's nullable
|
|
// argument resolves to SQL NULL and the WHERE clause degrades to "no
|
|
// project filter". On a malformed UUID it writes a 400 and returns
|
|
// ok=false; callers must return immediately.
|
|
func parseProjectIDParam(w http.ResponseWriter, r *http.Request) (pgtype.UUID, bool) {
|
|
raw := r.URL.Query().Get("project_id")
|
|
if raw == "" {
|
|
return pgtype.UUID{}, true
|
|
}
|
|
u, err := util.ParseUUID(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid project_id")
|
|
return pgtype.UUID{}, false
|
|
}
|
|
return u, true
|
|
}
|
|
|
|
// DashboardUsageDailyResponse is one (date, model) bucket. Cost-side math
|
|
// happens on the client from a per-model pricing table; model stays on the
|
|
// wire for that reason.
|
|
type DashboardUsageDailyResponse struct {
|
|
Date string `json:"date"`
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// GetDashboardUsageDaily returns per-(date, model) token rows for the
|
|
// workspace, optionally scoped to a project. Backed by task_usage_hourly,
|
|
// sliced into calendar days under the viewer's tz.
|
|
func (h *Handler) GetDashboardUsageDaily(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
resp, err := h.listDashboardUsageDaily(r.Context(), parseUUID(workspaceID), tz, since, projectID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list usage")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) listDashboardUsageDaily(
|
|
ctx context.Context,
|
|
workspaceID pgtype.UUID,
|
|
tz string,
|
|
since pgtype.Timestamptz,
|
|
projectID pgtype.UUID,
|
|
) ([]DashboardUsageDailyResponse, error) {
|
|
rows, err := h.Queries.ListDashboardUsageDaily(ctx, db.ListDashboardUsageDailyParams{
|
|
WorkspaceID: workspaceID,
|
|
Tz: tz,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := make([]DashboardUsageDailyResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardUsageDailyResponse{
|
|
Date: row.Date.Time.Format("2006-01-02"),
|
|
Model: row.Model,
|
|
InputTokens: row.InputTokens,
|
|
OutputTokens: row.OutputTokens,
|
|
CacheReadTokens: row.CacheReadTokens,
|
|
CacheWriteTokens: row.CacheWriteTokens,
|
|
TaskCount: row.TaskCount,
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// DashboardUsageByAgentResponse is one (agent, model) row.
|
|
type DashboardUsageByAgentResponse struct {
|
|
AgentID string `json:"agent_id"`
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// GetDashboardUsageByAgent returns per-(agent, model) token aggregates
|
|
// for the workspace, optionally scoped to a project. Backed by
|
|
// task_usage_hourly with the viewer's tz applied to the `?days=` cutoff.
|
|
func (h *Handler) GetDashboardUsageByAgent(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// "By agent" has no date grouping in the SQL — tz only determines
|
|
// the cutoff boundary, not the bucket axis.
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
resp, err := h.listDashboardUsageByAgent(r.Context(), parseUUID(workspaceID), since, projectID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list usage by agent")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) listDashboardUsageByAgent(
|
|
ctx context.Context,
|
|
workspaceID pgtype.UUID,
|
|
since pgtype.Timestamptz,
|
|
projectID pgtype.UUID,
|
|
) ([]DashboardUsageByAgentResponse, error) {
|
|
rows, err := h.Queries.ListDashboardUsageByAgent(ctx, db.ListDashboardUsageByAgentParams{
|
|
WorkspaceID: workspaceID,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := make([]DashboardUsageByAgentResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardUsageByAgentResponse{
|
|
AgentID: uuidToString(row.AgentID),
|
|
Model: row.Model,
|
|
InputTokens: row.InputTokens,
|
|
OutputTokens: row.OutputTokens,
|
|
CacheReadTokens: row.CacheReadTokens,
|
|
CacheWriteTokens: row.CacheWriteTokens,
|
|
TaskCount: row.TaskCount,
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// DashboardAgentRunTimeResponse is one agent's total terminal-task run time
|
|
// over the window. Includes failed tasks so the dashboard can surface how
|
|
// much execution time was spent on runs that didn't succeed.
|
|
type DashboardAgentRunTimeResponse struct {
|
|
AgentID string `json:"agent_id"`
|
|
TotalSeconds int64 `json:"total_seconds"`
|
|
TaskCount int32 `json:"task_count"`
|
|
FailedCount int32 `json:"failed_count"`
|
|
}
|
|
|
|
// GetDashboardAgentRunTime returns per-agent total task run time (seconds)
|
|
// and task counts for the workspace, optionally scoped to a project. Only
|
|
// terminal tasks (completed or failed) with both started_at and
|
|
// completed_at populated contribute, since queued/running tasks have no
|
|
// finite duration.
|
|
func (h *Handler) GetDashboardAgentRunTime(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Cutoff in the viewer's tz so the "last N days" window matches the
|
|
// per-agent cost card (GetDashboardUsageByAgent).
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
rows, err := h.Queries.ListDashboardAgentRunTime(r.Context(), db.ListDashboardAgentRunTimeParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list agent runtime")
|
|
return
|
|
}
|
|
|
|
resp := make([]DashboardAgentRunTimeResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardAgentRunTimeResponse{
|
|
AgentID: uuidToString(row.AgentID),
|
|
TotalSeconds: row.TotalSeconds,
|
|
TaskCount: row.TaskCount,
|
|
FailedCount: row.FailedCount,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// DashboardRunTimeDailyResponse is one (date) bucket of terminal-task run
|
|
// time and counts. Powers the workspace dashboard's daily Time and Tasks
|
|
// charts — same toggle as Tokens / Cost, different metric.
|
|
type DashboardRunTimeDailyResponse struct {
|
|
Date string `json:"date"`
|
|
TotalSeconds int64 `json:"total_seconds"`
|
|
TaskCount int32 `json:"task_count"`
|
|
FailedCount int32 `json:"failed_count"`
|
|
}
|
|
|
|
// GetDashboardRunTimeDaily returns per-date total task run time and task
|
|
// counts for the workspace, optionally scoped to a project. Only terminal
|
|
// tasks (completed or failed) with both started_at and completed_at
|
|
// populated contribute. Bucketed by completed_at so the day boundaries
|
|
// line up with the per-agent run-time card.
|
|
func (h *Handler) GetDashboardRunTimeDaily(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return
|
|
}
|
|
projectID, ok := parseProjectIDParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Slice day buckets in the viewer's tz so the Time / Tasks charts cut
|
|
// their calendar day identically to the Cost / Tokens charts.
|
|
tz := h.resolveViewingTZ(r)
|
|
since := parseSinceParamInTZ(r, 30, tz)
|
|
|
|
rows, err := h.Queries.ListDashboardRunTimeDaily(r.Context(), db.ListDashboardRunTimeDailyParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Tz: tz,
|
|
Since: since,
|
|
ProjectID: projectID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list daily runtime")
|
|
return
|
|
}
|
|
|
|
resp := make([]DashboardRunTimeDailyResponse, len(rows))
|
|
for i, row := range rows {
|
|
resp[i] = DashboardRunTimeDailyResponse{
|
|
Date: row.Date.Time.Format("2006-01-02"),
|
|
TotalSeconds: row.TotalSeconds,
|
|
TaskCount: row.TaskCount,
|
|
FailedCount: row.FailedCount,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|