From 9fbac49f24c1fd90286ce0682b9f976b4620314d Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sun, 29 Mar 2026 16:49:55 +0800 Subject: [PATCH 1/3] feat(issues): add human-readable issue identifiers (e.g. JIA-1) Add per-workspace auto-incrementing issue numbers with a configurable prefix, producing identifiers like "JIA-1" instead of truncated UUIDs. Database: - Add issue_prefix and issue_counter to workspace table - Add number column to issue table with UNIQUE(workspace_id, number) - Backfill existing issues with sequential numbers Backend: - Issue creation atomically increments counter in a transaction - API responses include number and identifier fields - Support issue lookup by identifier format (KEY-N) - Workspace prefix auto-generated from name, customizable via API Frontend: - Display identifier in list rows and issue detail breadcrumb - Add issue_prefix to Workspace type, number/identifier to Issue type --- .../app/(dashboard)/issues/[id]/page.test.tsx | 2 + apps/web/app/(dashboard)/issues/page.test.tsx | 6 ++ .../issues/components/issue-detail.tsx | 2 +- .../features/issues/components/list-row.tsx | 2 +- apps/web/shared/types/issue.ts | 2 + apps/web/shared/types/workspace.ts | 1 + apps/web/test/helpers.tsx | 1 + server/internal/handler/handler.go | 66 +++++++++++++++++++ server/internal/handler/handler_test.go | 6 +- server/internal/handler/issue.go | 44 +++++++++++-- server/internal/handler/workspace.go | 33 ++++++++++ server/internal/service/task.go | 45 ++++++++----- server/migrations/020_issue_number.down.sql | 5 ++ server/migrations/020_issue_number.up.sql | 36 ++++++++++ server/pkg/db/generated/issue.sql.go | 57 ++++++++++++++-- server/pkg/db/generated/models.go | 21 +++--- server/pkg/db/generated/workspace.sql.go | 42 ++++++++++-- server/pkg/db/queries/issue.sql | 8 ++- server/pkg/db/queries/workspace.sql | 10 ++- 19 files changed, 335 insertions(+), 54 deletions(-) create mode 100644 server/migrations/020_issue_number.down.sql create mode 100644 server/migrations/020_issue_number.up.sql diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 84b53d68..86847a30 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -134,6 +134,8 @@ vi.mock("@/shared/api", () => ({ const mockIssue: Issue = { id: "issue-1", workspace_id: "ws-1", + number: 1, + identifier: "TES-1", title: "Implement authentication", description: "Add JWT auth to the backend", status: "in_progress", diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index ad28a800..62a24c6c 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -211,6 +211,8 @@ const mockIssues: Issue[] = [ ...issueDefaults, id: "issue-1", workspace_id: "ws-1", + number: 1, + identifier: "TES-1", title: "Implement auth", description: "Add JWT authentication", status: "todo", @@ -227,6 +229,8 @@ const mockIssues: Issue[] = [ ...issueDefaults, id: "issue-2", workspace_id: "ws-1", + number: 2, + identifier: "TES-2", title: "Design landing page", description: null, status: "in_progress", @@ -243,6 +247,8 @@ const mockIssues: Issue[] = [ ...issueDefaults, id: "issue-3", workspace_id: "ws-1", + number: 3, + identifier: "TES-3", title: "Write tests", description: null, status: "backlog", diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index f770639c..0596796d 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -480,7 +480,7 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { )} - {issue.id.slice(0, 8)} + {issue.identifier} {issue.title} diff --git a/apps/web/features/issues/components/list-row.tsx b/apps/web/features/issues/components/list-row.tsx index 7f2cfe19..ce03b9f2 100644 --- a/apps/web/features/issues/components/list-row.tsx +++ b/apps/web/features/issues/components/list-row.tsx @@ -20,7 +20,7 @@ export function ListRow({ issue }: { issue: Issue }) { > - {issue.id.slice(0, 8)} + {issue.identifier} {issue.title} {issue.due_date && ( diff --git a/apps/web/shared/types/issue.ts b/apps/web/shared/types/issue.ts index 98620386..808d569d 100644 --- a/apps/web/shared/types/issue.ts +++ b/apps/web/shared/types/issue.ts @@ -14,6 +14,8 @@ export type IssueAssigneeType = "member" | "agent"; export interface Issue { id: string; workspace_id: string; + number: number; + identifier: string; title: string; description: string | null; status: IssueStatus; diff --git a/apps/web/shared/types/workspace.ts b/apps/web/shared/types/workspace.ts index 9fdc45a4..a6fb98e6 100644 --- a/apps/web/shared/types/workspace.ts +++ b/apps/web/shared/types/workspace.ts @@ -13,6 +13,7 @@ export interface Workspace { context: string | null; settings: Record; repos: WorkspaceRepo[]; + issue_prefix: string; created_at: string; updated_at: string; } diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx index 5bb9fe3f..3eb15b31 100644 --- a/apps/web/test/helpers.tsx +++ b/apps/web/test/helpers.tsx @@ -22,6 +22,7 @@ export const mockWorkspace: Workspace = { context: null, settings: {}, repos: [], + issue_prefix: "TES", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index db482fb6..aaee0cd5 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -179,6 +179,14 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue return db.Issue{}, false } + // Try identifier format first (e.g., "JIA-42"). + if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, resolveWorkspaceID(r)); ok { + if _, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "issue not found"); !ok { + return db.Issue{}, false + } + return issue, true + } + issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID)) if err != nil { writeError(w, http.StatusNotFound, "issue not found") @@ -192,6 +200,64 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue return issue, true } +// resolveIssueByIdentifier tries to look up an issue by "PREFIX-NUMBER" format. +func (h *Handler) resolveIssueByIdentifier(ctx context.Context, id, workspaceID string) (db.Issue, bool) { + parts := splitIdentifier(id) + if parts == nil { + return db.Issue{}, false + } + if workspaceID == "" { + return db.Issue{}, false + } + issue, err := h.Queries.GetIssueByNumber(ctx, db.GetIssueByNumberParams{ + WorkspaceID: parseUUID(workspaceID), + Number: parts.number, + }) + if err != nil { + return db.Issue{}, false + } + return issue, true +} + +type identifierParts struct { + prefix string + number int32 +} + +func splitIdentifier(id string) *identifierParts { + idx := -1 + for i := len(id) - 1; i >= 0; i-- { + if id[i] == '-' { + idx = i + break + } + } + if idx <= 0 || idx >= len(id)-1 { + return nil + } + numStr := id[idx+1:] + num := 0 + for _, c := range numStr { + if c < '0' || c > '9' { + return nil + } + num = num*10 + int(c-'0') + } + if num <= 0 { + return nil + } + return &identifierParts{prefix: id[:idx], number: int32(num)} +} + +// getIssuePrefix fetches the issue_prefix for a workspace. +func (h *Handler) getIssuePrefix(ctx context.Context, workspaceID pgtype.UUID) string { + ws, err := h.Queries.GetWorkspace(ctx, workspaceID) + if err != nil { + return "" + } + return ws.IssuePrefix +} + func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agentID string) (db.Agent, bool) { if _, ok := requireUserID(w, r); !ok { return db.Agent{}, false diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 25f1408b..e18630f6 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -90,10 +90,10 @@ func setupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, s var workspaceID string if err := pool.QueryRow(ctx, ` - INSERT INTO workspace (name, slug, description) - VALUES ($1, $2, $3) + INSERT INTO workspace (name, slug, description, issue_prefix) + VALUES ($1, $2, $3, $4) RETURNING id - `, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests").Scan(&workspaceID); err != nil { + `, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests", "HAN").Scan(&workspaceID); err != nil { return "", "", err } diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 08369f5d..7ad02c9a 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -20,6 +20,8 @@ import ( type IssueResponse struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` + Number int32 `json:"number"` + Identifier string `json:"identifier"` Title string `json:"title"` Description *string `json:"description"` Status string `json:"status"` @@ -41,10 +43,13 @@ type agentTriggerSnapshot struct { Config map[string]any `json:"config"` } -func issueToResponse(i db.Issue) IssueResponse { +func issueToResponse(i db.Issue, issuePrefix string) IssueResponse { + identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number)) return IssueResponse{ ID: uuidToString(i.ID), WorkspaceID: uuidToString(i.WorkspaceID), + Number: i.Number, + Identifier: identifier, Title: i.Title, Description: textToPtr(i.Description), Status: i.Status, @@ -109,9 +114,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { return } + prefix := h.getIssuePrefix(ctx, parseUUID(workspaceID)) resp := make([]IssueResponse, len(issues)) for i, issue := range issues { - resp[i] = issueToResponse(issue) + resp[i] = issueToResponse(issue, prefix) } writeJSON(w, http.StatusOK, map[string]any{ @@ -126,7 +132,8 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) { if !ok { return } - writeJSON(w, http.StatusOK, issueToResponse(issue)) + prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) + writeJSON(w, http.StatusOK, issueToResponse(issue, prefix)) } type CreateIssueRequest struct { @@ -196,7 +203,24 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { dueDate = pgtype.Timestamptz{Time: t, Valid: true} } - issue, err := h.Queries.CreateIssue(r.Context(), db.CreateIssueParams{ + // Use a transaction to atomically increment the workspace issue counter + // and create the issue with the assigned number. + tx, err := h.TxStarter.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create issue") + return + } + defer tx.Rollback(r.Context()) + + qtx := h.Queries.WithTx(tx) + issueNumber, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(workspaceID)) + if err != nil { + slog.Warn("increment issue counter failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...) + writeError(w, http.StatusInternalServerError, "failed to create issue") + return + } + + issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{ WorkspaceID: parseUUID(workspaceID), Title: req.Title, Description: ptrToText(req.Description), @@ -209,6 +233,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { ParentIssueID: parentIssueID, Position: 0, DueDate: dueDate, + Number: issueNumber, }) if err != nil { slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...) @@ -216,7 +241,13 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { return } - resp := issueToResponse(issue) + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "failed to create issue") + return + } + + prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) + resp := issueToResponse(issue, prefix) slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...) h.publish(protocol.EventIssueCreated, workspaceID, "member", creatorID, map[string]any{"issue": resp}) @@ -326,7 +357,8 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { return } - resp := issueToResponse(issue) + prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) + resp := issueToResponse(issue, prefix) slog.Info("issue updated", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", workspaceID)...) assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) && diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go index b8560555..2823be60 100644 --- a/server/internal/handler/workspace.go +++ b/server/internal/handler/workspace.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "regexp" "strings" "github.com/go-chi/chi/v5" @@ -13,6 +14,22 @@ import ( "github.com/multica-ai/multica/server/pkg/protocol" ) +var nonAlpha = regexp.MustCompile(`[^a-zA-Z]`) + +// generateIssuePrefix produces a 2-5 char uppercase prefix from a workspace name. +// Examples: "Jiayuan's Workspace" → "JIA", "My Team" → "MYT", "AB" → "AB". +func generateIssuePrefix(name string) string { + letters := nonAlpha.ReplaceAllString(name, "") + if len(letters) == 0 { + return "WS" + } + letters = strings.ToUpper(letters) + if len(letters) > 3 { + letters = letters[:3] + } + return letters +} + type WorkspaceResponse struct { ID string `json:"id"` Name string `json:"name"` @@ -21,6 +38,7 @@ type WorkspaceResponse struct { Context *string `json:"context"` Settings any `json:"settings"` Repos any `json:"repos"` + IssuePrefix string `json:"issue_prefix"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -48,6 +66,7 @@ func workspaceToResponse(w db.Workspace) WorkspaceResponse { Context: textToPtr(w.Context), Settings: settings, Repos: repos, + IssuePrefix: w.IssuePrefix, CreatedAt: timestampToString(w.CreatedAt), UpdatedAt: timestampToString(w.UpdatedAt), } @@ -110,6 +129,7 @@ type CreateWorkspaceRequest struct { Slug string `json:"slug"` Description *string `json:"description"` Context *string `json:"context"` + IssuePrefix *string `json:"issue_prefix"` } func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { @@ -138,12 +158,18 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) + issuePrefix := generateIssuePrefix(req.Name) + if req.IssuePrefix != nil && strings.TrimSpace(*req.IssuePrefix) != "" { + issuePrefix = strings.ToUpper(strings.TrimSpace(*req.IssuePrefix)) + } + qtx := h.Queries.WithTx(tx) ws, err := qtx.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{ Name: req.Name, Slug: req.Slug, Description: ptrToText(req.Description), Context: ptrToText(req.Context), + IssuePrefix: issuePrefix, }) if err != nil { if isUniqueViolation(err) { @@ -179,6 +205,7 @@ type UpdateWorkspaceRequest struct { Context *string `json:"context"` Settings any `json:"settings"` Repos any `json:"repos"` + IssuePrefix *string `json:"issue_prefix"` } func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { @@ -218,6 +245,12 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { reposJSON, _ := json.Marshal(req.Repos) params.Repos = reposJSON } + if req.IssuePrefix != nil { + prefix := strings.ToUpper(strings.TrimSpace(*req.IssuePrefix)) + if prefix != "" { + params.IssuePrefix = pgtype.Text{String: prefix, Valid: true} + } + } ws, err := h.Queries.UpdateWorkspace(r.Context(), params) if err != nil { diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 7fe74d6d..621fad09 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -7,6 +7,8 @@ import ( "fmt" "log/slog" + "strconv" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/events" @@ -361,15 +363,24 @@ func (s *TaskService) broadcastTaskEvent(ctx context.Context, eventType string, } func (s *TaskService) broadcastIssueUpdated(issue db.Issue) { + prefix := s.getIssuePrefix(issue.WorkspaceID) s.Bus.Publish(events.Event{ Type: protocol.EventIssueUpdated, WorkspaceID: util.UUIDToString(issue.WorkspaceID), ActorType: "system", ActorID: "", - Payload: map[string]any{"issue": issueToMap(issue)}, + Payload: map[string]any{"issue": issueToMap(issue, prefix)}, }) } +func (s *TaskService) getIssuePrefix(workspaceID pgtype.UUID) string { + ws, err := s.Queries.GetWorkspace(context.Background(), workspaceID) + if err != nil { + return "" + } + return ws.IssuePrefix +} + func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID pgtype.UUID, content, commentType string) { if content == "" { return @@ -410,23 +421,25 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p }) } -func issueToMap(issue db.Issue) map[string]any { +func issueToMap(issue db.Issue, issuePrefix string) map[string]any { return map[string]any{ - "id": util.UUIDToString(issue.ID), - "workspace_id": util.UUIDToString(issue.WorkspaceID), - "title": issue.Title, - "description": util.TextToPtr(issue.Description), - "status": issue.Status, - "priority": issue.Priority, - "assignee_type": util.TextToPtr(issue.AssigneeType), - "assignee_id": util.UUIDToPtr(issue.AssigneeID), - "creator_type": issue.CreatorType, - "creator_id": util.UUIDToString(issue.CreatorID), + "id": util.UUIDToString(issue.ID), + "workspace_id": util.UUIDToString(issue.WorkspaceID), + "number": issue.Number, + "identifier": issuePrefix + "-" + strconv.Itoa(int(issue.Number)), + "title": issue.Title, + "description": util.TextToPtr(issue.Description), + "status": issue.Status, + "priority": issue.Priority, + "assignee_type": util.TextToPtr(issue.AssigneeType), + "assignee_id": util.UUIDToPtr(issue.AssigneeID), + "creator_type": issue.CreatorType, + "creator_id": util.UUIDToString(issue.CreatorID), "parent_issue_id": util.UUIDToPtr(issue.ParentIssueID), - "position": issue.Position, - "due_date": util.TimestampToPtr(issue.DueDate), - "created_at": util.TimestampToString(issue.CreatedAt), - "updated_at": util.TimestampToString(issue.UpdatedAt), + "position": issue.Position, + "due_date": util.TimestampToPtr(issue.DueDate), + "created_at": util.TimestampToString(issue.CreatedAt), + "updated_at": util.TimestampToString(issue.UpdatedAt), } } diff --git a/server/migrations/020_issue_number.down.sql b/server/migrations/020_issue_number.down.sql new file mode 100644 index 00000000..5c6a3061 --- /dev/null +++ b/server/migrations/020_issue_number.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_issue_workspace_number; +ALTER TABLE issue DROP CONSTRAINT IF EXISTS uq_issue_workspace_number; +ALTER TABLE issue DROP COLUMN IF EXISTS number; +ALTER TABLE workspace DROP COLUMN IF EXISTS issue_prefix; +ALTER TABLE workspace DROP COLUMN IF EXISTS issue_counter; diff --git a/server/migrations/020_issue_number.up.sql b/server/migrations/020_issue_number.up.sql new file mode 100644 index 00000000..2800cb0e --- /dev/null +++ b/server/migrations/020_issue_number.up.sql @@ -0,0 +1,36 @@ +-- Add issue_prefix and issue_counter to workspace for human-readable issue IDs. +ALTER TABLE workspace + ADD COLUMN issue_prefix TEXT NOT NULL DEFAULT '', + ADD COLUMN issue_counter INT NOT NULL DEFAULT 0; + +-- Add per-workspace issue number. +ALTER TABLE issue + ADD COLUMN number INT NOT NULL DEFAULT 0; + +-- Backfill: generate issue_prefix from workspace name (first 3 uppercase chars). +UPDATE workspace SET issue_prefix = UPPER( + LEFT(REGEXP_REPLACE(name, '[^a-zA-Z]', '', 'g'), 3) +); + +-- Fallback for workspaces with empty prefix after cleanup. +UPDATE workspace SET issue_prefix = 'WS' WHERE issue_prefix = ''; + +-- Backfill: assign sequential numbers to existing issues per workspace. +WITH numbered AS ( + SELECT id, workspace_id, + ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC) AS rn + FROM issue +) +UPDATE issue SET number = numbered.rn +FROM numbered WHERE issue.id = numbered.id; + +-- Update workspace counters to match. +UPDATE workspace SET issue_counter = COALESCE( + (SELECT MAX(number) FROM issue WHERE issue.workspace_id = workspace.id), 0 +); + +-- Add unique constraint. +ALTER TABLE issue ADD CONSTRAINT uq_issue_workspace_number UNIQUE (workspace_id, number); + +-- Index for fast lookup by workspace + number. +CREATE INDEX idx_issue_workspace_number ON issue(workspace_id, number); diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 8279be45..aae29518 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -15,10 +15,10 @@ const createIssue = `-- name: CreateIssue :one INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date + parent_issue_id, position, due_date, number ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 -) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 +) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number ` type CreateIssueParams struct { @@ -34,6 +34,7 @@ type CreateIssueParams struct { ParentIssueID pgtype.UUID `json:"parent_issue_id"` Position float64 `json:"position"` DueDate pgtype.Timestamptz `json:"due_date"` + Number int32 `json:"number"` } func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) { @@ -50,6 +51,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue arg.ParentIssueID, arg.Position, arg.DueDate, + arg.Number, ) var i Issue err := row.Scan( @@ -70,6 +72,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, ) return i, err } @@ -84,7 +87,7 @@ func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error { } const getIssue = `-- name: GetIssue :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue WHERE id = $1 ` @@ -109,12 +112,49 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) { &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, + ) + return i, err +} + +const getIssueByNumber = `-- name: GetIssueByNumber :one +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue +WHERE workspace_id = $1 AND number = $2 +` + +type GetIssueByNumberParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + Number int32 `json:"number"` +} + +func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberParams) (Issue, error) { + row := q.db.QueryRow(ctx, getIssueByNumber, arg.WorkspaceID, arg.Number) + var i Issue + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Title, + &i.Description, + &i.Status, + &i.Priority, + &i.AssigneeType, + &i.AssigneeID, + &i.CreatorType, + &i.CreatorID, + &i.ParentIssueID, + &i.AcceptanceCriteria, + &i.ContextRefs, + &i.Position, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.Number, ) return i, err } const listIssues = `-- name: ListIssues :many -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue WHERE workspace_id = $1 AND ($4::text IS NULL OR status = $4) AND ($5::text IS NULL OR priority = $5) @@ -166,6 +206,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, ); err != nil { return nil, err } @@ -189,7 +230,7 @@ UPDATE issue SET due_date = $9, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number ` type UpdateIssueParams struct { @@ -235,6 +276,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, ) return i, err } @@ -244,7 +286,7 @@ UPDATE issue SET status = $2, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number ` type UpdateIssueStatusParams struct { @@ -273,6 +315,7 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, ) return i, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 546a3bad..91c8aad6 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -152,6 +152,7 @@ type Issue struct { DueDate pgtype.Timestamptz `json:"due_date"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Number int32 `json:"number"` } type IssueDependency struct { @@ -256,13 +257,15 @@ type VerificationCode struct { } type Workspace struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description pgtype.Text `json:"description"` - Settings []byte `json:"settings"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Context pgtype.Text `json:"context"` - Repos []byte `json:"repos"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description pgtype.Text `json:"description"` + Settings []byte `json:"settings"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Context pgtype.Text `json:"context"` + Repos []byte `json:"repos"` + IssuePrefix string `json:"issue_prefix"` + IssueCounter int32 `json:"issue_counter"` } diff --git a/server/pkg/db/generated/workspace.sql.go b/server/pkg/db/generated/workspace.sql.go index bd527a91..c751ca39 100644 --- a/server/pkg/db/generated/workspace.sql.go +++ b/server/pkg/db/generated/workspace.sql.go @@ -12,9 +12,9 @@ import ( ) const createWorkspace = `-- name: CreateWorkspace :one -INSERT INTO workspace (name, slug, description, context) -VALUES ($1, $2, $3, $4) -RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos +INSERT INTO workspace (name, slug, description, context, issue_prefix) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter ` type CreateWorkspaceParams struct { @@ -22,6 +22,7 @@ type CreateWorkspaceParams struct { Slug string `json:"slug"` Description pgtype.Text `json:"description"` Context pgtype.Text `json:"context"` + IssuePrefix string `json:"issue_prefix"` } func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) { @@ -30,6 +31,7 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams arg.Slug, arg.Description, arg.Context, + arg.IssuePrefix, ) var i Workspace err := row.Scan( @@ -42,6 +44,8 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ) return i, err } @@ -56,7 +60,7 @@ func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error { } const getWorkspace = `-- name: GetWorkspace :one -SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace +SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter FROM workspace WHERE id = $1 ` @@ -73,12 +77,14 @@ func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace, &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ) return i, err } const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one -SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace +SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter FROM workspace WHERE slug = $1 ` @@ -95,12 +101,27 @@ func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspac &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ) return i, err } +const incrementIssueCounter = `-- name: IncrementIssueCounter :one +UPDATE workspace SET issue_counter = issue_counter + 1 +WHERE id = $1 +RETURNING issue_counter +` + +func (q *Queries) IncrementIssueCounter(ctx context.Context, id pgtype.UUID) (int32, error) { + row := q.db.QueryRow(ctx, incrementIssueCounter, id) + var issue_counter int32 + err := row.Scan(&issue_counter) + return issue_counter, err +} + const listWorkspaces = `-- name: ListWorkspaces :many -SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context, w.repos FROM workspace w +SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context, w.repos, w.issue_prefix, w.issue_counter FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1 ORDER BY w.created_at ASC @@ -125,6 +146,8 @@ func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Wor &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ); err != nil { return nil, err } @@ -143,9 +166,10 @@ UPDATE workspace SET context = COALESCE($4, context), settings = COALESCE($5, settings), repos = COALESCE($6, repos), + issue_prefix = COALESCE($7, issue_prefix), updated_at = now() WHERE id = $1 -RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos +RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter ` type UpdateWorkspaceParams struct { @@ -155,6 +179,7 @@ type UpdateWorkspaceParams struct { Context pgtype.Text `json:"context"` Settings []byte `json:"settings"` Repos []byte `json:"repos"` + IssuePrefix pgtype.Text `json:"issue_prefix"` } func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) { @@ -165,6 +190,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams arg.Context, arg.Settings, arg.Repos, + arg.IssuePrefix, ) var i Workspace err := row.Scan( @@ -177,6 +203,8 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ) return i, err } diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index efabdc0d..ed5614ba 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -15,11 +15,15 @@ WHERE id = $1; INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date + parent_issue_id, position, due_date, number ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 ) RETURNING *; +-- name: GetIssueByNumber :one +SELECT * FROM issue +WHERE workspace_id = $1 AND number = $2; + -- name: UpdateIssue :one UPDATE issue SET title = COALESCE(sqlc.narg('title'), title), diff --git a/server/pkg/db/queries/workspace.sql b/server/pkg/db/queries/workspace.sql index 73172ede..b69a262c 100644 --- a/server/pkg/db/queries/workspace.sql +++ b/server/pkg/db/queries/workspace.sql @@ -13,8 +13,8 @@ SELECT * FROM workspace WHERE slug = $1; -- name: CreateWorkspace :one -INSERT INTO workspace (name, slug, description, context) -VALUES ($1, $2, $3, $4) +INSERT INTO workspace (name, slug, description, context, issue_prefix) +VALUES ($1, $2, $3, $4, $5) RETURNING *; -- name: UpdateWorkspace :one @@ -24,9 +24,15 @@ UPDATE workspace SET context = COALESCE(sqlc.narg('context'), context), settings = COALESCE(sqlc.narg('settings'), settings), repos = COALESCE(sqlc.narg('repos'), repos), + issue_prefix = COALESCE(sqlc.narg('issue_prefix'), issue_prefix), updated_at = now() WHERE id = $1 RETURNING *; +-- name: IncrementIssueCounter :one +UPDATE workspace SET issue_counter = issue_counter + 1 +WHERE id = $1 +RETURNING issue_counter; + -- name: DeleteWorkspace :exec DELETE FROM workspace WHERE id = $1; From ceba8556f57ccc25e1e895884c593e7b33a62a2f Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sun, 29 Mar 2026 17:02:25 +0800 Subject: [PATCH 2/3] refactor(runtimes): split monolithic page, add zustand store, time range selector, and delete support - Split 1189-line runtimes-page.tsx into focused sub-components (list, detail, ping, usage, 5 chart files, shared UI, utils) - Add useRuntimeStore zustand store for shared runtime state across pages (agents page now uses it too) - Add 7d/30d/90d time range selector to usage charts - Add full-stack runtime delete: SQL query, Go handler, API route, frontend with confirmation dialog - Remove unused daemon:heartbeat WS listener (server never broadcasts it) --- apps/web/app/(dashboard)/agents/page.tsx | 15 +- .../components/charts/activity-heatmap.tsx | 161 +++ .../components/charts/daily-cost-chart.tsx | 57 + .../components/charts/daily-token-chart.tsx | 93 ++ .../charts/hourly-activity-chart.tsx | 85 ++ .../runtimes/components/charts/index.ts | 5 + .../charts/model-distribution-chart.tsx | 99 ++ .../runtimes/components/ping-section.tsx | 120 ++ .../runtimes/components/runtime-detail.tsx | 158 +++ .../runtimes/components/runtime-list.tsx | 89 ++ .../runtimes/components/runtimes-page.tsx | 1155 +---------------- .../features/runtimes/components/shared.tsx | 57 + .../runtimes/components/usage-section.tsx | 172 +++ apps/web/features/runtimes/index.ts | 1 + apps/web/features/runtimes/store.ts | 84 ++ apps/web/features/runtimes/utils.ts | 141 ++ apps/web/shared/api/client.ts | 4 + server/cmd/server/router.go | 1 + server/internal/handler/runtime.go | 20 + server/pkg/db/generated/runtime.sql.go | 15 + server/pkg/db/queries/runtime.sql | 4 + 21 files changed, 1398 insertions(+), 1138 deletions(-) create mode 100644 apps/web/features/runtimes/components/charts/activity-heatmap.tsx create mode 100644 apps/web/features/runtimes/components/charts/daily-cost-chart.tsx create mode 100644 apps/web/features/runtimes/components/charts/daily-token-chart.tsx create mode 100644 apps/web/features/runtimes/components/charts/hourly-activity-chart.tsx create mode 100644 apps/web/features/runtimes/components/charts/index.ts create mode 100644 apps/web/features/runtimes/components/charts/model-distribution-chart.tsx create mode 100644 apps/web/features/runtimes/components/ping-section.tsx create mode 100644 apps/web/features/runtimes/components/runtime-detail.tsx create mode 100644 apps/web/features/runtimes/components/runtime-list.tsx create mode 100644 apps/web/features/runtimes/components/shared.tsx create mode 100644 apps/web/features/runtimes/components/usage-section.tsx create mode 100644 apps/web/features/runtimes/store.ts create mode 100644 apps/web/features/runtimes/utils.ts diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 63762143..985cb1dc 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -65,6 +65,7 @@ import { Label } from "@/components/ui/label"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; +import { useRuntimeStore } from "@/features/runtimes"; // --------------------------------------------------------------------------- @@ -1130,21 +1131,15 @@ export default function AgentsPage() { const refreshAgents = useWorkspaceStore((s) => s.refreshAgents); const [selectedId, setSelectedId] = useState(""); const [showCreate, setShowCreate] = useState(false); - const [runtimes, setRuntimes] = useState([]); + const runtimes = useRuntimeStore((s) => s.runtimes); + const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_agents_layout", }); useEffect(() => { - if (!workspace) { - setRuntimes([]); - return; - } - api - .listRuntimes({ workspace_id: workspace.id }) - .then(setRuntimes) - .catch(() => setRuntimes([])); - }, [workspace]); + if (workspace) fetchRuntimes(); + }, [workspace, fetchRuntimes]); // Select first agent on initial load useEffect(() => { diff --git a/apps/web/features/runtimes/components/charts/activity-heatmap.tsx b/apps/web/features/runtimes/components/charts/activity-heatmap.tsx new file mode 100644 index 00000000..63d1b42c --- /dev/null +++ b/apps/web/features/runtimes/components/charts/activity-heatmap.tsx @@ -0,0 +1,161 @@ +import { useMemo } from "react"; +import type { RuntimeUsage } from "@/shared/types"; +import { formatTokens } from "../../utils"; + +const HEATMAP_WEEKS = 13; +const CELL_SIZE = 11; +const CELL_GAP = 2; +const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; + +function getHeatmapColor(level: number): string { + const colors = [ + "var(--color-muted, hsl(var(--muted)))", + "hsl(var(--chart-3) / 0.3)", + "hsl(var(--chart-3) / 0.5)", + "hsl(var(--chart-3) / 0.75)", + "hsl(var(--chart-3) / 1)", + ]; + return colors[level] ?? colors[0]!; +} + +export function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) { + const { cells, monthLabels } = useMemo(() => { + const dateTokens = new Map(); + for (const u of usage) { + const total = + u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens; + dateTokens.set(u.date, (dateTokens.get(u.date) ?? 0) + total); + } + + const today = new Date(); + const todayDay = today.getDay(); + const startOffset = todayDay + (HEATMAP_WEEKS - 1) * 7; + const startDate = new Date(today); + startDate.setDate(today.getDate() - startOffset); + + const allCells: { + date: string; + dayOfWeek: number; + week: number; + tokens: number; + }[] = []; + const d = new Date(startDate); + for (let i = 0; i <= startOffset; i++) { + const dateStr = d.toISOString().slice(0, 10); + const dayOfWeek = d.getDay(); + const week = Math.floor(i / 7); + allCells.push({ + date: dateStr, + dayOfWeek, + week, + tokens: dateTokens.get(dateStr) ?? 0, + }); + d.setDate(d.getDate() + 1); + } + + const nonZero = allCells + .filter((c) => c.tokens > 0) + .map((c) => c.tokens); + nonZero.sort((a, b) => a - b); + const getLevel = (tokens: number) => { + if (tokens === 0) return 0; + if (nonZero.length <= 1) return 4; + const p = nonZero.indexOf(tokens) / (nonZero.length - 1); + if (p <= 0.25) return 1; + if (p <= 0.5) return 2; + if (p <= 0.75) return 3; + return 4; + }; + + const cellsWithLevel = allCells.map((c) => ({ + ...c, + level: getLevel(c.tokens), + })); + + const months: { label: string; week: number }[] = []; + let lastMonth = -1; + for (const c of cellsWithLevel) { + const month = new Date(c.date + "T00:00:00").getMonth(); + if (month !== lastMonth && c.dayOfWeek === 0) { + months.push({ + label: new Date(c.date + "T00:00:00").toLocaleString("en", { + month: "short", + }), + week: c.week, + }); + lastMonth = month; + } + } + + return { cells: cellsWithLevel, monthLabels: months }; + }, [usage]); + + const labelWidth = 28; + const svgWidth = labelWidth + HEATMAP_WEEKS * (CELL_SIZE + CELL_GAP); + const svgHeight = 14 + 7 * (CELL_SIZE + CELL_GAP); + + return ( +
+

Activity

+
+ + {monthLabels.map((m) => ( + + {m.label} + + ))} + {DAY_LABELS.map((label, i) => + label ? ( + + {label} + + ) : null, + )} + {cells.map((c) => ( + + + {c.date}:{" "} + {c.tokens > 0 + ? formatTokens(c.tokens) + " tokens" + : "No activity"} + + + ))} + +
+ {/* Legend */} +
+ Less + {[0, 1, 2, 3, 4].map((level) => ( +
+ ))} + More +
+
+ ); +} diff --git a/apps/web/features/runtimes/components/charts/daily-cost-chart.tsx b/apps/web/features/runtimes/components/charts/daily-cost-chart.tsx new file mode 100644 index 00000000..d96718fd --- /dev/null +++ b/apps/web/features/runtimes/components/charts/daily-cost-chart.tsx @@ -0,0 +1,57 @@ +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import type { DailyCostData } from "../../utils"; + +const costChartConfig = { + cost: { label: "Cost", color: "hsl(var(--chart-1))" }, +} satisfies ChartConfig; + +export function DailyCostChart({ data }: { data: DailyCostData[] }) { + if (data.every((d) => d.cost === 0)) return null; + + return ( +
+

Daily Estimated Cost

+ + + + + `$${v}`} + width={50} + /> + + typeof value === "number" ? `$${value.toFixed(2)}` : String(value) + } + /> + } + /> + + + +
+ ); +} diff --git a/apps/web/features/runtimes/components/charts/daily-token-chart.tsx b/apps/web/features/runtimes/components/charts/daily-token-chart.tsx new file mode 100644 index 00000000..a5bb6c8b --- /dev/null +++ b/apps/web/features/runtimes/components/charts/daily-token-chart.tsx @@ -0,0 +1,93 @@ +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + type ChartConfig, +} from "@/components/ui/chart"; +import type { DailyTokenData } from "../../utils"; +import { formatTokens } from "../../utils"; + +const tokenChartConfig = { + input: { label: "Input", color: "hsl(var(--chart-1))" }, + output: { label: "Output", color: "hsl(var(--chart-2))" }, + cacheRead: { label: "Cache Read", color: "hsl(var(--chart-3))" }, + cacheWrite: { label: "Cache Write", color: "hsl(var(--chart-4))" }, +} satisfies ChartConfig; + +export function DailyTokenChart({ data }: { data: DailyTokenData[] }) { + return ( +
+

Daily Token Usage

+ + + + + formatTokens(v)} + width={50} + /> + + typeof value === "number" ? formatTokens(value) : String(value) + } + /> + } + /> + } /> + + + + + + +
+ ); +} diff --git a/apps/web/features/runtimes/components/charts/hourly-activity-chart.tsx b/apps/web/features/runtimes/components/charts/hourly-activity-chart.tsx new file mode 100644 index 00000000..5fd1f68e --- /dev/null +++ b/apps/web/features/runtimes/components/charts/hourly-activity-chart.tsx @@ -0,0 +1,85 @@ +import { useState, useEffect, useMemo } from "react"; +import { BarChart3 } from "lucide-react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { api } from "@/shared/api"; +import type { RuntimeHourlyActivity } from "@/shared/types"; + +const hourlyChartConfig = { + count: { label: "Tasks", color: "hsl(var(--chart-2))" }, +} satisfies ChartConfig; + +export function HourlyActivityChart({ runtimeId }: { runtimeId: string }) { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + api + .getRuntimeTaskActivity(runtimeId) + .then(setData) + .catch(() => setData([])) + .finally(() => setLoading(false)); + }, [runtimeId]); + + const chartData = useMemo(() => { + const map = new Map(data.map((d) => [d.hour, d.count])); + return Array.from({ length: 24 }, (_, i) => ({ + hour: i, + label: `${i.toString().padStart(2, "0")}:00`, + count: map.get(i) ?? 0, + })); + }, [data]); + + const hasData = chartData.some((d) => d.count > 0); + + return ( +
+

Hourly Distribution

+ {loading ? ( +
+ Loading... +
+ ) : !hasData ? ( +
+ +

No task data yet

+
+ ) : ( + + + + + + } /> + + + + )} +
+ ); +} diff --git a/apps/web/features/runtimes/components/charts/index.ts b/apps/web/features/runtimes/components/charts/index.ts new file mode 100644 index 00000000..cc1ec80a --- /dev/null +++ b/apps/web/features/runtimes/components/charts/index.ts @@ -0,0 +1,5 @@ +export { DailyTokenChart } from "./daily-token-chart"; +export { DailyCostChart } from "./daily-cost-chart"; +export { ModelDistributionChart } from "./model-distribution-chart"; +export { ActivityHeatmap } from "./activity-heatmap"; +export { HourlyActivityChart } from "./hourly-activity-chart"; diff --git a/apps/web/features/runtimes/components/charts/model-distribution-chart.tsx b/apps/web/features/runtimes/components/charts/model-distribution-chart.tsx new file mode 100644 index 00000000..b8a7f1c0 --- /dev/null +++ b/apps/web/features/runtimes/components/charts/model-distribution-chart.tsx @@ -0,0 +1,99 @@ +import { PieChart, Pie, Cell, Label } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import type { ModelDistribution } from "../../utils"; +import { formatTokens } from "../../utils"; + +const MODEL_COLORS = [ + "hsl(var(--chart-1))", + "hsl(var(--chart-2))", + "hsl(var(--chart-3))", + "hsl(var(--chart-4))", + "hsl(var(--chart-5))", +]; + +export function ModelDistributionChart({ data }: { data: ModelDistribution[] }) { + if (data.length === 0) return null; + + const totalTokens = data.reduce((sum, d) => sum + d.tokens, 0); + const chartConfig = Object.fromEntries( + data.map((d, i) => [ + d.model, + { label: d.model, color: MODEL_COLORS[i % MODEL_COLORS.length] }, + ]), + ) satisfies ChartConfig; + + return ( +
+

Token Usage by Model

+ + + + typeof value === "number" ? formatTokens(value) : String(value) + } + nameKey="model" + /> + } + /> + + {data.map((entry, i) => ( + + ))} + + + + {/* Model legend with cost */} +
+ {data.map((d, i) => ( +
+
+
+ {d.model} +
+
+ {formatTokens(d.tokens)} + {d.cost > 0 && ${d.cost.toFixed(2)}} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/features/runtimes/components/ping-section.tsx b/apps/web/features/runtimes/components/ping-section.tsx new file mode 100644 index 00000000..10150477 --- /dev/null +++ b/apps/web/features/runtimes/components/ping-section.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Loader2, CheckCircle2, XCircle, Zap } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { api } from "@/shared/api"; +import type { RuntimePingStatus } from "@/shared/types"; + +const pingStatusConfig: Record< + RuntimePingStatus, + { label: string; icon: typeof Loader2; color: string } +> = { + pending: { label: "Waiting for daemon...", icon: Loader2, color: "text-muted-foreground" }, + running: { label: "Running test...", icon: Loader2, color: "text-info" }, + completed: { label: "Connected", icon: CheckCircle2, color: "text-success" }, + failed: { label: "Failed", icon: XCircle, color: "text-destructive" }, + timeout: { label: "Timeout", icon: XCircle, color: "text-warning" }, +}; + +export function PingSection({ runtimeId }: { runtimeId: string }) { + const [status, setStatus] = useState(null); + const [output, setOutput] = useState(""); + const [error, setError] = useState(""); + const [durationMs, setDurationMs] = useState(null); + const [testing, setTesting] = useState(false); + const pollRef = useRef | null>(null); + + const cleanup = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + useEffect(() => cleanup, [cleanup]); + + const handleTest = async () => { + cleanup(); + setTesting(true); + setStatus("pending"); + setOutput(""); + setError(""); + setDurationMs(null); + + try { + const ping = await api.pingRuntime(runtimeId); + + pollRef.current = setInterval(async () => { + try { + const result = await api.getPingResult(runtimeId, ping.id); + setStatus(result.status as RuntimePingStatus); + + if (result.status === "completed") { + setOutput(result.output ?? ""); + setDurationMs(result.duration_ms ?? null); + setTesting(false); + cleanup(); + } else if (result.status === "failed" || result.status === "timeout") { + setError(result.error ?? "Unknown error"); + setDurationMs(result.duration_ms ?? null); + setTesting(false); + cleanup(); + } + } catch { + // ignore poll errors + } + }, 2000); + } catch { + setStatus("failed"); + setError("Failed to initiate test"); + setTesting(false); + } + }; + + const config = status ? pingStatusConfig[status] : null; + const Icon = config?.icon; + const isActive = status === "pending" || status === "running"; + + return ( +
+
+ + + {config && Icon && ( + + + {config.label} + {durationMs != null && ( + + ({(durationMs / 1000).toFixed(1)}s) + + )} + + )} +
+ + {status === "completed" && output && ( +
+
{output}
+
+ )} + + {(status === "failed" || status === "timeout") && error && ( +
+

{error}

+
+ )} +
+ ); +} diff --git a/apps/web/features/runtimes/components/runtime-detail.tsx b/apps/web/features/runtimes/components/runtime-detail.tsx new file mode 100644 index 00000000..4e66f1cc --- /dev/null +++ b/apps/web/features/runtimes/components/runtime-detail.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { Trash2 } from "lucide-react"; +import type { AgentRuntime } from "@/shared/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { formatLastSeen } from "../utils"; +import { useRuntimeStore } from "../store"; +import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared"; +import { PingSection } from "./ping-section"; +import { UsageSection } from "./usage-section"; + +export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { + const [showDelete, setShowDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const deleteRuntime = useRuntimeStore((s) => s.deleteRuntime); + + const handleDelete = async () => { + setDeleting(true); + try { + await deleteRuntime(runtime.id); + } finally { + setDeleting(false); + setShowDelete(false); + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{runtime.name}

+
+
+
+ + +
+
+ + {/* Content */} +
+ {/* Info grid */} +
+ + + + + {runtime.device_info && ( + + )} + {runtime.daemon_id && ( + + )} +
+ + {/* Connection Test */} +
+

+ Connection Test +

+ +
+ + {/* Usage */} +
+

+ Token Usage +

+ +
+ + {/* Metadata */} + {runtime.metadata && Object.keys(runtime.metadata).length > 0 && ( +
+

+ Metadata +

+
+
+                {JSON.stringify(runtime.metadata, null, 2)}
+              
+
+
+ )} + + {/* Timestamps */} +
+ + +
+
+ + {/* Delete confirmation dialog */} + + + + Delete Runtime + + Are you sure you want to delete "{runtime.name}"? This + will remove the runtime and its usage data. This action cannot be + undone. + + + + + + + + +
+ ); +} diff --git a/apps/web/features/runtimes/components/runtime-list.tsx b/apps/web/features/runtimes/components/runtime-list.tsx new file mode 100644 index 00000000..07952458 --- /dev/null +++ b/apps/web/features/runtimes/components/runtime-list.tsx @@ -0,0 +1,89 @@ +import { Server } from "lucide-react"; +import type { AgentRuntime } from "@/shared/types"; +import { RuntimeModeIcon } from "./shared"; + +function RuntimeListItem({ + runtime, + isSelected, + onClick, +}: { + runtime: AgentRuntime; + isSelected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function RuntimeList({ + runtimes, + selectedId, + onSelect, +}: { + runtimes: AgentRuntime[]; + selectedId: string; + onSelect: (id: string) => void; +}) { + return ( +
+
+

Runtimes

+ + {runtimes.filter((r) => r.status === "online").length}/ + {runtimes.length} online + +
+ {runtimes.length === 0 ? ( +
+ +

+ No runtimes registered +

+

+ Run{" "} + + multica daemon start + {" "} + to register a local runtime. +

+
+ ) : ( +
+ {runtimes.map((runtime) => ( + onSelect(runtime.id)} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/features/runtimes/components/runtimes-page.tsx b/apps/web/features/runtimes/components/runtimes-page.tsx index 337453a5..f1f83324 100644 --- a/apps/web/features/runtimes/components/runtimes-page.tsx +++ b/apps/web/features/runtimes/components/runtimes-page.tsx @@ -1,1119 +1,44 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { - Monitor, - Cloud, - Wifi, - WifiOff, - Server, - BarChart3, - Loader2, - CheckCircle2, - XCircle, - Zap, -} from "lucide-react"; -import { - AreaChart, - Area, - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - PieChart, - Pie, - Cell, - Label, -} from "recharts"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, - type ChartConfig, -} from "@/components/ui/chart"; +import { useEffect, useCallback } from "react"; +import { Server } from "lucide-react"; import { useDefaultLayout } from "react-resizable-panels"; -import type { AgentRuntime, RuntimeUsage, RuntimeHourlyActivity, RuntimePingStatus } from "@/shared/types"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle, } from "@/components/ui/resizable"; -import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { useWSEvent } from "@/features/realtime"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function formatLastSeen(lastSeenAt: string | null): string { - if (!lastSeenAt) return "Never"; - const diff = Date.now() - new Date(lastSeenAt).getTime(); - if (diff < 60_000) return "Just now"; - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; - return `${Math.floor(diff / 86_400_000)}d ago`; -} - -function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return n.toLocaleString(); -} - -// Pricing per million tokens (USD) -const MODEL_PRICING: Record = { - "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, -}; - -function estimateCost(usage: RuntimeUsage): number { - // Try to find a matching model in pricing table - const model = usage.model; - let pricing = MODEL_PRICING[model]; - if (!pricing) { - // Try partial match - for (const [key, p] of Object.entries(MODEL_PRICING)) { - if (model.startsWith(key)) { - pricing = p; - break; - } - } - } - if (!pricing) return 0; - - return ( - (usage.input_tokens * pricing.input + - usage.output_tokens * pricing.output + - usage.cache_read_tokens * pricing.cacheRead + - usage.cache_write_tokens * pricing.cacheWrite) / - 1_000_000 - ); -} - -function RuntimeModeIcon({ mode }: { mode: string }) { - return mode === "cloud" ? ( - - ) : ( - - ); -} - -function StatusBadge({ status }: { status: string }) { - const isOnline = status === "online"; - return ( - - {isOnline ? ( - - ) : ( - - )} - {isOnline ? "Online" : "Offline"} - - ); -} - -// --------------------------------------------------------------------------- -// Runtime List Item -// --------------------------------------------------------------------------- - -function RuntimeListItem({ - runtime, - isSelected, - onClick, -}: { - runtime: AgentRuntime; - isSelected: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -// --------------------------------------------------------------------------- -// Usage Section -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Chart configs -// --------------------------------------------------------------------------- - -const tokenChartConfig = { - input: { label: "Input", color: "hsl(var(--chart-1))" }, - output: { label: "Output", color: "hsl(var(--chart-2))" }, - cacheRead: { label: "Cache Read", color: "hsl(var(--chart-3))" }, - cacheWrite: { label: "Cache Write", color: "hsl(var(--chart-4))" }, -} satisfies ChartConfig; - -const costChartConfig = { - cost: { label: "Cost", color: "hsl(var(--chart-1))" }, -} satisfies ChartConfig; - -const MODEL_COLORS = [ - "hsl(var(--chart-1))", - "hsl(var(--chart-2))", - "hsl(var(--chart-3))", - "hsl(var(--chart-4))", - "hsl(var(--chart-5))", -]; - -// --------------------------------------------------------------------------- -// Data aggregation helpers -// --------------------------------------------------------------------------- - -interface DailyTokenData { - date: string; - label: string; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; -} - -interface DailyCostData { - date: string; - label: string; - cost: number; -} - -interface ModelDistribution { - model: string; - tokens: number; - cost: number; -} - -function aggregateByDate(usage: RuntimeUsage[]): { - dailyTokens: DailyTokenData[]; - dailyCost: DailyCostData[]; - modelDist: ModelDistribution[]; -} { - // Aggregate tokens by date - const dateMap = new Map>(); - const costMap = new Map(); - const modelMap = new Map(); - - for (const u of usage) { - const existing = dateMap.get(u.date) ?? { - date: u.date, - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }; - existing.input += u.input_tokens; - existing.output += u.output_tokens; - existing.cacheRead += u.cache_read_tokens; - existing.cacheWrite += u.cache_write_tokens; - dateMap.set(u.date, existing); - - const dayCost = (costMap.get(u.date) ?? 0) + estimateCost(u); - costMap.set(u.date, dayCost); - - const modelName = u.model || u.provider; - const m = modelMap.get(modelName) ?? { tokens: 0, cost: 0 }; - m.tokens += u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens; - m.cost += estimateCost(u); - modelMap.set(modelName, m); - } - - const formatLabel = (d: string) => { - const date = new Date(d + "T00:00:00"); - return `${date.getMonth() + 1}/${date.getDate()}`; - }; - - const dailyTokens = [...dateMap.values()] - .sort((a, b) => a.date.localeCompare(b.date)) - .map((d) => ({ ...d, label: formatLabel(d.date) })); - - const dailyCost = [...costMap.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, cost]) => ({ date, label: formatLabel(date), cost: Math.round(cost * 100) / 100 })); - - const modelDist = [...modelMap.entries()] - .map(([model, data]) => ({ model, ...data })) - .sort((a, b) => b.tokens - a.tokens); - - return { dailyTokens, dailyCost, modelDist }; -} - -// --------------------------------------------------------------------------- -// Chart Components -// --------------------------------------------------------------------------- - -function DailyTokenChart({ data }: { data: DailyTokenData[] }) { - return ( -
-

Daily Token Usage

- - - - - formatTokens(v)} - width={50} - /> - - typeof value === "number" ? formatTokens(value) : String(value) - } - /> - } - /> - } /> - - - - - - -
- ); -} - -function DailyCostChart({ data }: { data: DailyCostData[] }) { - if (data.every((d) => d.cost === 0)) return null; - - return ( -
-

Daily Estimated Cost

- - - - - `$${v}`} - width={50} - /> - - typeof value === "number" ? `$${value.toFixed(2)}` : String(value) - } - /> - } - /> - - - -
- ); -} - -function ModelDistributionChart({ data }: { data: ModelDistribution[] }) { - if (data.length === 0) return null; - - const totalTokens = data.reduce((sum, d) => sum + d.tokens, 0); - const chartConfig = Object.fromEntries( - data.map((d, i) => [ - d.model, - { label: d.model, color: MODEL_COLORS[i % MODEL_COLORS.length] }, - ]), - ) satisfies ChartConfig; - - return ( -
-

Token Usage by Model

- - - - typeof value === "number" ? formatTokens(value) : String(value) - } - nameKey="model" - /> - } - /> - - {data.map((entry, i) => ( - - ))} - - - - {/* Model legend with cost */} -
- {data.map((d, i) => ( -
-
-
- {d.model} -
-
- {formatTokens(d.tokens)} - {d.cost > 0 && ${d.cost.toFixed(2)}} -
-
- ))} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Activity Heatmap (GitHub-style) -// --------------------------------------------------------------------------- - -const HEATMAP_WEEKS = 13; -const CELL_SIZE = 11; -const CELL_GAP = 2; -const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; - -function getHeatmapColor(level: number): string { - // 5 levels: 0=empty, 1-4=increasing intensity - const colors = [ - "var(--color-muted, hsl(var(--muted)))", - "hsl(var(--chart-3) / 0.3)", - "hsl(var(--chart-3) / 0.5)", - "hsl(var(--chart-3) / 0.75)", - "hsl(var(--chart-3) / 1)", - ]; - return colors[level] ?? colors[0]!; -} - -function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) { - const { cells, monthLabels } = useMemo(() => { - // Build a map of date -> total tokens - const dateTokens = new Map(); - for (const u of usage) { - const total = u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens; - dateTokens.set(u.date, (dateTokens.get(u.date) ?? 0) + total); - } - - // Generate all dates for the last HEATMAP_WEEKS weeks - const today = new Date(); - const todayDay = today.getDay(); // 0=Sun - // Start from the beginning of the week, HEATMAP_WEEKS weeks ago - const startOffset = todayDay + (HEATMAP_WEEKS - 1) * 7; - const startDate = new Date(today); - startDate.setDate(today.getDate() - startOffset); - - const allCells: { date: string; dayOfWeek: number; week: number; tokens: number }[] = []; - const d = new Date(startDate); - for (let i = 0; i <= startOffset; i++) { - const dateStr = d.toISOString().slice(0, 10); - const dayOfWeek = d.getDay(); // 0=Sun .. 6=Sat - const week = Math.floor(i / 7); - allCells.push({ date: dateStr, dayOfWeek, week, tokens: dateTokens.get(dateStr) ?? 0 }); - d.setDate(d.getDate() + 1); - } - - // Compute intensity levels (quantiles) - const nonZero = allCells.filter((c) => c.tokens > 0).map((c) => c.tokens); - nonZero.sort((a, b) => a - b); - const getLevel = (tokens: number) => { - if (tokens === 0) return 0; - if (nonZero.length <= 1) return 4; - const p = nonZero.indexOf(tokens) / (nonZero.length - 1); - if (p <= 0.25) return 1; - if (p <= 0.5) return 2; - if (p <= 0.75) return 3; - return 4; - }; - - const cellsWithLevel = allCells.map((c) => ({ ...c, level: getLevel(c.tokens) })); - - // Month labels: find the first day of each month that appears - const months: { label: string; week: number }[] = []; - let lastMonth = -1; - for (const c of cellsWithLevel) { - const month = new Date(c.date + "T00:00:00").getMonth(); - if (month !== lastMonth && c.dayOfWeek === 0) { - months.push({ - label: new Date(c.date + "T00:00:00").toLocaleString("en", { month: "short" }), - week: c.week, - }); - lastMonth = month; - } - } - - return { cells: cellsWithLevel, monthLabels: months }; - }, [usage]); - - const labelWidth = 28; - const svgWidth = labelWidth + HEATMAP_WEEKS * (CELL_SIZE + CELL_GAP); - const svgHeight = 14 + 7 * (CELL_SIZE + CELL_GAP); - - return ( -
-

Activity

-
- - {/* Month labels */} - {monthLabels.map((m) => ( - - {m.label} - - ))} - {/* Day labels */} - {DAY_LABELS.map((label, i) => - label ? ( - - {label} - - ) : null, - )} - {/* Cells */} - {cells.map((c) => ( - - - {c.date}: {c.tokens > 0 ? formatTokens(c.tokens) + " tokens" : "No activity"} - - - ))} - -
- {/* Legend */} -
- Less - {[0, 1, 2, 3, 4].map((level) => ( -
- ))} - More -
-
- ); -} - -// --------------------------------------------------------------------------- -// Hourly Activity Distribution -// --------------------------------------------------------------------------- - -const hourlyChartConfig = { - count: { label: "Tasks", color: "hsl(var(--chart-2))" }, -} satisfies ChartConfig; - -function HourlyActivityChart({ runtimeId }: { runtimeId: string }) { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - setLoading(true); - api - .getRuntimeTaskActivity(runtimeId) - .then(setData) - .catch(() => setData([])) - .finally(() => setLoading(false)); - }, [runtimeId]); - - // Fill all 24 hours - const chartData = useMemo(() => { - const map = new Map(data.map((d) => [d.hour, d.count])); - return Array.from({ length: 24 }, (_, i) => ({ - hour: i, - label: `${i.toString().padStart(2, "0")}:00`, - count: map.get(i) ?? 0, - })); - }, [data]); - - const hasData = chartData.some((d) => d.count > 0); - - return ( -
-

Hourly Distribution

- {loading ? ( -
- Loading... -
- ) : !hasData ? ( -
- -

No task data yet

-
- ) : ( - - - - - - } /> - - - - )} -
- ); -} - -// --------------------------------------------------------------------------- -// Usage Section -// --------------------------------------------------------------------------- - -function UsageSection({ runtimeId }: { runtimeId: string }) { - const [usage, setUsage] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - setLoading(true); - api - .getRuntimeUsage(runtimeId, { days: 90 }) - .then(setUsage) - .catch(() => setUsage([])) - .finally(() => setLoading(false)); - }, [runtimeId]); - - if (loading) { - return ( -
Loading usage...
- ); - } - - if (usage.length === 0) { - return ( -
- -

- No usage data yet -

-
- ); - } - - // Filter last 30 days for summary / detail charts - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const cutoff = thirtyDaysAgo.toISOString().slice(0, 10); - const recent = usage.filter((u) => u.date >= cutoff); - - // Compute totals (30d) - const totals = recent.reduce( - (acc, u) => ({ - input: acc.input + u.input_tokens, - output: acc.output + u.output_tokens, - cacheRead: acc.cacheRead + u.cache_read_tokens, - cacheWrite: acc.cacheWrite + u.cache_write_tokens, - cost: acc.cost + estimateCost(u), - }), - { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, - ); - - const { dailyTokens, dailyCost, modelDist } = aggregateByDate(recent); - - // Group by date for the table - const byDate = new Map(); - for (const u of recent) { - const existing = byDate.get(u.date) ?? []; - existing.push(u); - byDate.set(u.date, existing); - } - - return ( -
- {/* Summary cards */} -
- - - - -
- - {totals.cost > 0 && ( -
- - Estimated cost (30d):{" "} - - - ${totals.cost.toFixed(2)} - -
- )} - - {/* Heatmap + Hourly — 2-col on wide screens */} -
- - -
- - {/* Token & Cost charts — 2-col on wide screens */} -
- - -
- - - - {/* Daily breakdown table */} -
-
-
Date
-
Model
-
Input
-
Output
-
Cache R
-
Cache W
-
-
- {[...byDate.entries()].map(([date, rows]) => - rows.map((row, i) => ( -
-
{date}
-
{row.model}
-
- {formatTokens(row.input_tokens)} -
-
- {formatTokens(row.output_tokens)} -
-
- {formatTokens(row.cache_read_tokens)} -
-
- {formatTokens(row.cache_write_tokens)} -
-
- )), - )} -
-
-
- ); -} - -function TokenCard({ label, value }: { label: string; value: number }) { - return ( -
-
{label}
-
- {formatTokens(value)} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Connection Test (Ping) -// --------------------------------------------------------------------------- - -const pingStatusConfig: Record< - RuntimePingStatus, - { label: string; icon: typeof Loader2; color: string } -> = { - pending: { label: "Waiting for daemon...", icon: Loader2, color: "text-muted-foreground" }, - running: { label: "Running test...", icon: Loader2, color: "text-info" }, - completed: { label: "Connected", icon: CheckCircle2, color: "text-success" }, - failed: { label: "Failed", icon: XCircle, color: "text-destructive" }, - timeout: { label: "Timeout", icon: XCircle, color: "text-warning" }, -}; - -function PingSection({ runtimeId }: { runtimeId: string }) { - const [status, setStatus] = useState(null); - const [output, setOutput] = useState(""); - const [error, setError] = useState(""); - const [durationMs, setDurationMs] = useState(null); - const [testing, setTesting] = useState(false); - const pollRef = useRef | null>(null); - - const cleanup = useCallback(() => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - }, []); - - useEffect(() => cleanup, [cleanup]); - - const handleTest = async () => { - cleanup(); - setTesting(true); - setStatus("pending"); - setOutput(""); - setError(""); - setDurationMs(null); - - try { - const ping = await api.pingRuntime(runtimeId); - - // Poll for result every 2 seconds - pollRef.current = setInterval(async () => { - try { - const result = await api.getPingResult(runtimeId, ping.id); - setStatus(result.status as RuntimePingStatus); - - if (result.status === "completed") { - setOutput(result.output ?? ""); - setDurationMs(result.duration_ms ?? null); - setTesting(false); - cleanup(); - } else if (result.status === "failed" || result.status === "timeout") { - setError(result.error ?? "Unknown error"); - setDurationMs(result.duration_ms ?? null); - setTesting(false); - cleanup(); - } - } catch { - // ignore poll errors - } - }, 2000); - } catch { - setStatus("failed"); - setError("Failed to initiate test"); - setTesting(false); - } - }; - - const config = status ? pingStatusConfig[status] : null; - const Icon = config?.icon; - const isActive = status === "pending" || status === "running"; - - return ( -
-
- - - {config && Icon && ( - - - {config.label} - {durationMs != null && ( - - ({(durationMs / 1000).toFixed(1)}s) - - )} - - )} -
- - {status === "completed" && output && ( -
-
{output}
-
- )} - - {(status === "failed" || status === "timeout") && error && ( -
-

{error}

-
- )} -
- ); -} - -// --------------------------------------------------------------------------- -// Runtime Detail -// --------------------------------------------------------------------------- - -function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { - return ( -
- {/* Header */} -
-
-
- -
-
-

{runtime.name}

-
-
- -
- - {/* Content */} -
- {/* Info grid */} -
- - - - - {runtime.device_info && ( - - )} - {runtime.daemon_id && ( - - )} -
- - {/* Connection Test */} -
-

- Connection Test -

- -
- - {/* Usage */} -
-

- Token Usage (Last 30 Days) -

- -
- - {/* Metadata */} - {runtime.metadata && Object.keys(runtime.metadata).length > 0 && ( -
-

- Metadata -

-
-
-                {JSON.stringify(runtime.metadata, null, 2)}
-              
-
-
- )} - - {/* Timestamps */} -
- - -
-
-
- ); -} - -function InfoField({ - label, - value, - mono, -}: { - label: string; - value: string; - mono?: boolean; -}) { - return ( -
-
{label}
-
- {value} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Page -// --------------------------------------------------------------------------- +import { useRuntimeStore } from "../store"; +import { RuntimeList } from "./runtime-list"; +import { RuntimeDetail } from "./runtime-detail"; export default function RuntimesPage() { const isLoading = useAuthStore((s) => s.isLoading); const workspace = useWorkspaceStore((s) => s.workspace); - const [runtimes, setRuntimes] = useState([]); - const [selectedId, setSelectedId] = useState(""); - const [fetching, setFetching] = useState(true); + const runtimes = useRuntimeStore((s) => s.runtimes); + const selectedId = useRuntimeStore((s) => s.selectedId); + const fetching = useRuntimeStore((s) => s.fetching); + const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes); + const setSelectedId = useRuntimeStore((s) => s.setSelectedId); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_runtimes_layout", }); - const fetchRuntimes = useCallback(async () => { - if (!workspace) return; - try { - const data = await api.listRuntimes({ workspace_id: workspace.id }); - setRuntimes(data); - } finally { - setFetching(false); - } - }, [workspace]); - useEffect(() => { - fetchRuntimes(); - }, [fetchRuntimes]); + if (workspace) fetchRuntimes(); + }, [workspace, fetchRuntimes]); - // Auto-select first runtime - useEffect(() => { - if (runtimes.length > 0 && !selectedId) { - setSelectedId(runtimes[0]!.id); - } - }, [runtimes, selectedId]); - - // Real-time updates + // Re-fetch on daemon register/deregister events. + // Heartbeat events are not broadcast over WS, so no handler needed. const handleDaemonEvent = useCallback(() => { fetchRuntimes(); }, [fetchRuntimes]); useWSEvent("daemon:register", handleDaemonEvent); - useWSEvent("daemon:heartbeat", handleDaemonEvent); const selected = runtimes.find((r) => r.id === selectedId) ?? null; @@ -1132,49 +57,23 @@ export default function RuntimesPage() { defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged} > - - {/* Left column — runtime list */} -
-
-

Runtimes

- - {runtimes.filter((r) => r.status === "online").length}/ - {runtimes.length} online - -
- {runtimes.length === 0 ? ( -
- -

- No runtimes registered -

-

- Run{" "} - - multica daemon start - {" "} - to register a local runtime. -

-
- ) : ( -
- {runtimes.map((runtime) => ( - setSelectedId(runtime.id)} - /> - ))} -
- )} -
+ + - {/* Right column — runtime detail */} {selected ? ( ) : ( diff --git a/apps/web/features/runtimes/components/shared.tsx b/apps/web/features/runtimes/components/shared.tsx new file mode 100644 index 00000000..bbe2dbee --- /dev/null +++ b/apps/web/features/runtimes/components/shared.tsx @@ -0,0 +1,57 @@ +import { Monitor, Cloud, Wifi, WifiOff } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +export function RuntimeModeIcon({ mode }: { mode: string }) { + return mode === "cloud" ? ( + + ) : ( + + ); +} + +export function StatusBadge({ status }: { status: string }) { + const isOnline = status === "online"; + return ( + + {isOnline ? ( + + ) : ( + + )} + {isOnline ? "Online" : "Offline"} + + ); +} + +export function InfoField({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +export function TokenCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/features/runtimes/components/usage-section.tsx b/apps/web/features/runtimes/components/usage-section.tsx new file mode 100644 index 00000000..b8a26c34 --- /dev/null +++ b/apps/web/features/runtimes/components/usage-section.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { BarChart3 } from "lucide-react"; +import type { RuntimeUsage } from "@/shared/types"; +import { api } from "@/shared/api"; +import { formatTokens, estimateCost, aggregateByDate } from "../utils"; +import { TokenCard } from "./shared"; +import { + ActivityHeatmap, + HourlyActivityChart, + DailyTokenChart, + DailyCostChart, + ModelDistributionChart, +} from "./charts"; + +const TIME_RANGES = [ + { label: "7d", days: 7 }, + { label: "30d", days: 30 }, + { label: "90d", days: 90 }, +] as const; + +type TimeRange = (typeof TIME_RANGES)[number]["days"]; + +export function UsageSection({ runtimeId }: { runtimeId: string }) { + const [usage, setUsage] = useState([]); + const [loading, setLoading] = useState(true); + const [days, setDays] = useState(30); + + useEffect(() => { + setLoading(true); + api + .getRuntimeUsage(runtimeId, { days: 90 }) // always fetch 90d, filter client-side + .then(setUsage) + .catch(() => setUsage([])) + .finally(() => setLoading(false)); + }, [runtimeId]); + + if (loading) { + return ( +
Loading usage...
+ ); + } + + if (usage.length === 0) { + return ( +
+ +

No usage data yet

+
+ ); + } + + // Filter by selected time range + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + const cutoff = cutoffDate.toISOString().slice(0, 10); + const filtered = usage.filter((u) => u.date >= cutoff); + + // Compute totals + const totals = filtered.reduce( + (acc, u) => ({ + input: acc.input + u.input_tokens, + output: acc.output + u.output_tokens, + cacheRead: acc.cacheRead + u.cache_read_tokens, + cacheWrite: acc.cacheWrite + u.cache_write_tokens, + cost: acc.cost + estimateCost(u), + }), + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, + ); + + const { dailyTokens, dailyCost, modelDist } = aggregateByDate(filtered); + + // Group by date for the table + const byDate = new Map(); + for (const u of filtered) { + const existing = byDate.get(u.date) ?? []; + existing.push(u); + byDate.set(u.date, existing); + } + + return ( +
+ {/* Time range selector */} +
+ {TIME_RANGES.map((range) => ( + + ))} +
+ + {/* Summary cards */} +
+ + + + +
+ + {totals.cost > 0 && ( +
+ + Estimated cost ({days}d):{" "} + + + ${totals.cost.toFixed(2)} + +
+ )} + + {/* Heatmap + Hourly */} +
+ + +
+ + {/* Token & Cost charts */} +
+ + +
+ + + + {/* Daily breakdown table */} +
+
+
Date
+
Model
+
Input
+
Output
+
Cache R
+
Cache W
+
+
+ {[...byDate.entries()].map(([date, rows]) => + rows.map((row, i) => ( +
+
{date}
+
{row.model}
+
+ {formatTokens(row.input_tokens)} +
+
+ {formatTokens(row.output_tokens)} +
+
+ {formatTokens(row.cache_read_tokens)} +
+
+ {formatTokens(row.cache_write_tokens)} +
+
+ )), + )} +
+
+
+ ); +} diff --git a/apps/web/features/runtimes/index.ts b/apps/web/features/runtimes/index.ts index 5fa5b0bf..c24959ba 100644 --- a/apps/web/features/runtimes/index.ts +++ b/apps/web/features/runtimes/index.ts @@ -1 +1,2 @@ export { RuntimesPage } from "./components"; +export { useRuntimeStore } from "./store"; diff --git a/apps/web/features/runtimes/store.ts b/apps/web/features/runtimes/store.ts new file mode 100644 index 00000000..6256b30c --- /dev/null +++ b/apps/web/features/runtimes/store.ts @@ -0,0 +1,84 @@ +"use client"; + +import { create } from "zustand"; +import type { AgentRuntime } from "@/shared/types"; +import { api } from "@/shared/api"; +import { useWorkspaceStore } from "@/features/workspace"; + +interface RuntimeState { + runtimes: AgentRuntime[]; + selectedId: string; + fetching: boolean; +} + +interface RuntimeActions { + fetchRuntimes: () => Promise; + setSelectedId: (id: string) => void; + /** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */ + patchRuntime: (id: string, updates: Partial) => void; + /** Replace the full runtimes list (used on daemon:register events). */ + setRuntimes: (runtimes: AgentRuntime[]) => void; + /** Delete a runtime by ID (calls API). */ + deleteRuntime: (id: string) => Promise; +} + +type RuntimeStore = RuntimeState & RuntimeActions; + +export const useRuntimeStore = create((set, get) => ({ + // State + runtimes: [], + selectedId: "", + fetching: true, + + // Actions + fetchRuntimes: async () => { + const workspace = useWorkspaceStore.getState().workspace; + if (!workspace) return; + try { + const data = await api.listRuntimes({ workspace_id: workspace.id }); + const { selectedId } = get(); + set({ + runtimes: data, + fetching: false, + // Auto-select first if nothing selected + selectedId: selectedId && data.some((r) => r.id === selectedId) + ? selectedId + : data[0]?.id ?? "", + }); + } catch { + set({ fetching: false }); + } + }, + + setSelectedId: (id) => set({ selectedId: id }), + + patchRuntime: (id, updates) => { + set((state) => ({ + runtimes: state.runtimes.map((r) => + r.id === id ? { ...r, ...updates } : r, + ), + })); + }, + + setRuntimes: (runtimes) => { + const { selectedId } = get(); + set({ + runtimes, + selectedId: selectedId && runtimes.some((r) => r.id === selectedId) + ? selectedId + : runtimes[0]?.id ?? "", + }); + }, + + deleteRuntime: async (id) => { + await api.deleteRuntime(id); + const remaining = get().runtimes.filter((r) => r.id !== id); + const { selectedId } = get(); + set({ + runtimes: remaining, + selectedId: selectedId === id + ? remaining[0]?.id ?? "" + : selectedId, + }); + }, +})); diff --git a/apps/web/features/runtimes/utils.ts b/apps/web/features/runtimes/utils.ts new file mode 100644 index 00000000..10aa3b17 --- /dev/null +++ b/apps/web/features/runtimes/utils.ts @@ -0,0 +1,141 @@ +import type { RuntimeUsage } from "@/shared/types"; + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +export function formatLastSeen(lastSeenAt: string | null): string { + if (!lastSeenAt) return "Never"; + const diff = Date.now() - new Date(lastSeenAt).getTime(); + if (diff < 60_000) return "Just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / 86_400_000)}d ago`; +} + +export function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +} + +// --------------------------------------------------------------------------- +// Cost estimation +// --------------------------------------------------------------------------- + +// Pricing per million tokens (USD) +const MODEL_PRICING: Record< + string, + { input: number; output: number; cacheRead: number; cacheWrite: number } +> = { + "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + "claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, +}; + +export function estimateCost(usage: RuntimeUsage): number { + const model = usage.model; + let pricing = MODEL_PRICING[model]; + if (!pricing) { + for (const [key, p] of Object.entries(MODEL_PRICING)) { + if (model.startsWith(key)) { + pricing = p; + break; + } + } + } + if (!pricing) return 0; + + return ( + (usage.input_tokens * pricing.input + + usage.output_tokens * pricing.output + + usage.cache_read_tokens * pricing.cacheRead + + usage.cache_write_tokens * pricing.cacheWrite) / + 1_000_000 + ); +} + +// --------------------------------------------------------------------------- +// Data aggregation +// --------------------------------------------------------------------------- + +export interface DailyTokenData { + date: string; + label: string; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +export interface DailyCostData { + date: string; + label: string; + cost: number; +} + +export interface ModelDistribution { + model: string; + tokens: number; + cost: number; +} + +export function aggregateByDate(usage: RuntimeUsage[]): { + dailyTokens: DailyTokenData[]; + dailyCost: DailyCostData[]; + modelDist: ModelDistribution[]; +} { + const dateMap = new Map>(); + const costMap = new Map(); + const modelMap = new Map(); + + for (const u of usage) { + const existing = dateMap.get(u.date) ?? { + date: u.date, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }; + existing.input += u.input_tokens; + existing.output += u.output_tokens; + existing.cacheRead += u.cache_read_tokens; + existing.cacheWrite += u.cache_write_tokens; + dateMap.set(u.date, existing); + + const dayCost = (costMap.get(u.date) ?? 0) + estimateCost(u); + costMap.set(u.date, dayCost); + + const modelName = u.model || u.provider; + const m = modelMap.get(modelName) ?? { tokens: 0, cost: 0 }; + m.tokens += + u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens; + m.cost += estimateCost(u); + modelMap.set(modelName, m); + } + + const formatLabel = (d: string) => { + const date = new Date(d + "T00:00:00"); + return `${date.getMonth() + 1}/${date.getDate()}`; + }; + + const dailyTokens = [...dateMap.values()] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((d) => ({ ...d, label: formatLabel(d.date) })); + + const dailyCost = [...costMap.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, cost]) => ({ + date, + label: formatLabel(date), + cost: Math.round(cost * 100) / 100, + })); + + const modelDist = [...modelMap.entries()] + .map(([model, data]) => ({ model, ...data })) + .sort((a, b) => b.tokens - a.tokens); + + return { dailyTokens, dailyCost, modelDist }; +} diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index dbde3195..8f7a07f6 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -283,6 +283,10 @@ export class ApiClient { return this.fetch(`/api/runtimes/${runtimeId}/activity`); } + async deleteRuntime(runtimeId: string): Promise { + await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" }); + } + async pingRuntime(runtimeId: string): Promise { return this.fetch(`/api/runtimes/${runtimeId}/ping`, { method: "POST" }); } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index dc488e83..956b5535 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -162,6 +162,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Route("/api/runtimes", func(r chi.Router) { r.Get("/", h.ListAgentRuntimes) + r.Delete("/{runtimeId}", h.DeleteAgentRuntime) r.Get("/{runtimeId}/usage", h.GetRuntimeUsage) r.Get("/{runtimeId}/activity", h.GetRuntimeTaskActivity) r.Post("/{runtimeId}/ping", h.InitiatePing) diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go index bccf4051..40535e45 100644 --- a/server/internal/handler/runtime.go +++ b/server/internal/handler/runtime.go @@ -192,6 +192,26 @@ func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, resp) } +func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) { + runtimeID := chi.URLParam(r, "runtimeId") + workspaceID := resolveWorkspaceID(r) + + if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "runtime not found"); !ok { + return + } + + err := h.Queries.DeleteAgentRuntime(r.Context(), db.DeleteAgentRuntimeParams{ + ID: parseUUID(runtimeID), + WorkspaceID: parseUUID(workspaceID), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete runtime") + return + } + + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) { workspaceID := resolveWorkspaceID(r) if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok { diff --git a/server/pkg/db/generated/runtime.sql.go b/server/pkg/db/generated/runtime.sql.go index d871d72d..d213607f 100644 --- a/server/pkg/db/generated/runtime.sql.go +++ b/server/pkg/db/generated/runtime.sql.go @@ -11,6 +11,21 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const deleteAgentRuntime = `-- name: DeleteAgentRuntime :exec +DELETE FROM agent_runtime +WHERE id = $1 AND workspace_id = $2 +` + +type DeleteAgentRuntimeParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteAgentRuntime(ctx context.Context, arg DeleteAgentRuntimeParams) error { + _, err := q.db.Exec(ctx, deleteAgentRuntime, arg.ID, arg.WorkspaceID) + return err +} + const getAgentRuntime = `-- name: GetAgentRuntime :one SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at FROM agent_runtime WHERE id = $1 diff --git a/server/pkg/db/queries/runtime.sql b/server/pkg/db/queries/runtime.sql index 6aabb657..65e06f09 100644 --- a/server/pkg/db/queries/runtime.sql +++ b/server/pkg/db/queries/runtime.sql @@ -51,3 +51,7 @@ SET status = 'offline', updated_at = now() WHERE status = 'online' AND last_seen_at < now() - make_interval(secs => @stale_seconds::double precision) RETURNING id, workspace_id; + +-- name: DeleteAgentRuntime :exec +DELETE FROM agent_runtime +WHERE id = $1 AND workspace_id = $2; From 87e76078ce02605588e63403267d22526a9281c1 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sun, 29 Mar 2026 17:05:36 +0800 Subject: [PATCH 3/3] revert(runtimes): remove runtime delete functionality Remove the full-stack delete feature (SQL query, Go handler, route, API client method, and UI delete button with confirmation dialog). --- .../runtimes/components/runtime-detail.tsx | 70 +------------------ apps/web/features/runtimes/store.ts | 14 ---- apps/web/shared/api/client.ts | 4 -- server/cmd/server/router.go | 1 - server/internal/handler/runtime.go | 20 ------ server/pkg/db/generated/runtime.sql.go | 15 ---- server/pkg/db/queries/runtime.sql | 4 -- 7 files changed, 1 insertion(+), 127 deletions(-) diff --git a/apps/web/features/runtimes/components/runtime-detail.tsx b/apps/web/features/runtimes/components/runtime-detail.tsx index 4e66f1cc..9c0919ac 100644 --- a/apps/web/features/runtimes/components/runtime-detail.tsx +++ b/apps/web/features/runtimes/components/runtime-detail.tsx @@ -1,38 +1,10 @@ -"use client"; - -import { useState } from "react"; -import { Trash2 } from "lucide-react"; import type { AgentRuntime } from "@/shared/types"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; import { formatLastSeen } from "../utils"; -import { useRuntimeStore } from "../store"; import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared"; import { PingSection } from "./ping-section"; import { UsageSection } from "./usage-section"; export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { - const [showDelete, setShowDelete] = useState(false); - const [deleting, setDeleting] = useState(false); - const deleteRuntime = useRuntimeStore((s) => s.deleteRuntime); - - const handleDelete = async () => { - setDeleting(true); - try { - await deleteRuntime(runtime.id); - } finally { - setDeleting(false); - setShowDelete(false); - } - }; - return (
{/* Header */} @@ -49,17 +21,7 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {

{runtime.name}

-
- - -
+
{/* Content */} @@ -123,36 +85,6 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { />
- - {/* Delete confirmation dialog */} - - - - Delete Runtime - - Are you sure you want to delete "{runtime.name}"? This - will remove the runtime and its usage data. This action cannot be - undone. - - - - - - - - ); } diff --git a/apps/web/features/runtimes/store.ts b/apps/web/features/runtimes/store.ts index 6256b30c..03e9f716 100644 --- a/apps/web/features/runtimes/store.ts +++ b/apps/web/features/runtimes/store.ts @@ -18,8 +18,6 @@ interface RuntimeActions { patchRuntime: (id: string, updates: Partial) => void; /** Replace the full runtimes list (used on daemon:register events). */ setRuntimes: (runtimes: AgentRuntime[]) => void; - /** Delete a runtime by ID (calls API). */ - deleteRuntime: (id: string) => Promise; } type RuntimeStore = RuntimeState & RuntimeActions; @@ -69,16 +67,4 @@ export const useRuntimeStore = create((set, get) => ({ : runtimes[0]?.id ?? "", }); }, - - deleteRuntime: async (id) => { - await api.deleteRuntime(id); - const remaining = get().runtimes.filter((r) => r.id !== id); - const { selectedId } = get(); - set({ - runtimes: remaining, - selectedId: selectedId === id - ? remaining[0]?.id ?? "" - : selectedId, - }); - }, })); diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 8f7a07f6..dbde3195 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -283,10 +283,6 @@ export class ApiClient { return this.fetch(`/api/runtimes/${runtimeId}/activity`); } - async deleteRuntime(runtimeId: string): Promise { - await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" }); - } - async pingRuntime(runtimeId: string): Promise { return this.fetch(`/api/runtimes/${runtimeId}/ping`, { method: "POST" }); } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 956b5535..dc488e83 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -162,7 +162,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Route("/api/runtimes", func(r chi.Router) { r.Get("/", h.ListAgentRuntimes) - r.Delete("/{runtimeId}", h.DeleteAgentRuntime) r.Get("/{runtimeId}/usage", h.GetRuntimeUsage) r.Get("/{runtimeId}/activity", h.GetRuntimeTaskActivity) r.Post("/{runtimeId}/ping", h.InitiatePing) diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go index 40535e45..bccf4051 100644 --- a/server/internal/handler/runtime.go +++ b/server/internal/handler/runtime.go @@ -192,26 +192,6 @@ func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, resp) } -func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) { - runtimeID := chi.URLParam(r, "runtimeId") - workspaceID := resolveWorkspaceID(r) - - if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "runtime not found"); !ok { - return - } - - err := h.Queries.DeleteAgentRuntime(r.Context(), db.DeleteAgentRuntimeParams{ - ID: parseUUID(runtimeID), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to delete runtime") - return - } - - w.WriteHeader(http.StatusNoContent) -} - func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) { workspaceID := resolveWorkspaceID(r) if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok { diff --git a/server/pkg/db/generated/runtime.sql.go b/server/pkg/db/generated/runtime.sql.go index d213607f..d871d72d 100644 --- a/server/pkg/db/generated/runtime.sql.go +++ b/server/pkg/db/generated/runtime.sql.go @@ -11,21 +11,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const deleteAgentRuntime = `-- name: DeleteAgentRuntime :exec -DELETE FROM agent_runtime -WHERE id = $1 AND workspace_id = $2 -` - -type DeleteAgentRuntimeParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` -} - -func (q *Queries) DeleteAgentRuntime(ctx context.Context, arg DeleteAgentRuntimeParams) error { - _, err := q.db.Exec(ctx, deleteAgentRuntime, arg.ID, arg.WorkspaceID) - return err -} - const getAgentRuntime = `-- name: GetAgentRuntime :one SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at FROM agent_runtime WHERE id = $1 diff --git a/server/pkg/db/queries/runtime.sql b/server/pkg/db/queries/runtime.sql index 65e06f09..6aabb657 100644 --- a/server/pkg/db/queries/runtime.sql +++ b/server/pkg/db/queries/runtime.sql @@ -51,7 +51,3 @@ SET status = 'offline', updated_at = now() WHERE status = 'online' AND last_seen_at < now() - make_interval(secs => @stale_seconds::double precision) RETURNING id, workspace_id; - --- name: DeleteAgentRuntime :exec -DELETE FROM agent_runtime -WHERE id = $1 AND workspace_id = $2;