From 5468b8c31e67d7b303be6a368230e154dfe4a2e2 Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Fri, 16 Jan 2026 23:05:22 +0900 Subject: [PATCH 01/10] add pagination to queue --- api/v2/api.gen.go | 582 ++++++++++++------ api/v2/api.yaml | 84 ++- internal/core/exec/queue.go | 2 + internal/persis/filequeue/store.go | 61 ++ internal/service/frontend/api/v2/queues.go | 157 +++-- ui/src/api/v2/schema.ts | 378 +++++++++++- .../features/queues/components/QueueCard.tsx | 100 ++- ui/src/pages/queues/index.tsx | 8 +- 8 files changed, 1100 insertions(+), 272 deletions(-) diff --git a/api/v2/api.gen.go b/api/v2/api.gen.go index 707142eac..64ce4962d 100644 --- a/api/v2/api.gen.go +++ b/api/v2/api.gen.go @@ -177,6 +177,12 @@ const ( ListDAGsParamsOrderDesc ListDAGsParamsOrder = "desc" ) +// Defines values for ListQueueItemsParamsType. +const ( + ListQueueItemsParamsTypeQueued ListQueueItemsParamsType = "queued" + ListQueueItemsParamsTypeRunning ListQueueItemsParamsType = "running" +) + // APIKey API key information type APIKey struct { // CreatedAt Creation timestamp @@ -913,7 +919,7 @@ type Pagination struct { TotalRecords int `json:"totalRecords"` } -// Queue A queue/process group with its active DAG-runs +// Queue A queue/process group with summary statistics type Queue struct { // MaxConcurrency Maximum number of concurrent runs allowed. For 'global' queues, this is the configured maxConcurrency. For 'dag-based' queues, this is the DAG's maxActiveRuns (default 1) MaxConcurrency *int `json:"maxConcurrency,omitempty"` @@ -921,12 +927,15 @@ type Queue struct { // Name Name of the queue (global queue name or DAG name if no queue specified) Name string `json:"name"` - // Queued List of DAG-runs waiting to execute - Queued []DAGRunSummary `json:"queued"` + // QueuedCount Number of queued DAG-runs waiting to execute + QueuedCount int `json:"queuedCount"` - // Running List of currently running DAG-runs + // Running List of currently running DAG-runs (bounded by maxConcurrency) Running []DAGRunSummary `json:"running"` + // RunningCount Number of currently running DAG-runs + RunningCount int `json:"runningCount"` + // Type Type of queue - 'global' if explicitly defined, 'dag-based' if using DAG name Type QueueType `json:"type"` } @@ -934,6 +943,13 @@ type Queue struct { // QueueType Type of queue - 'global' if explicitly defined, 'dag-based' if using DAG name type QueueType string +// QueueItemsResponse Paginated queue items response +type QueueItemsResponse struct { + // Items List of DAG-run summaries + Items []DAGRunSummary `json:"items"` + Pagination Pagination `json:"pagination"` +} + // QueuesResponse Response containing all queues with their active DAG-runs type QueuesResponse struct { // Queues List of all queues with their running and queued DAG-runs @@ -2098,6 +2114,24 @@ type ListQueuesParams struct { RemoteNode *RemoteNode `form:"remoteNode,omitempty" json:"remoteNode,omitempty"` } +// ListQueueItemsParams defines parameters for ListQueueItems. +type ListQueueItemsParams struct { + // RemoteNode name of the remote node + RemoteNode *RemoteNode `form:"remoteNode,omitempty" json:"remoteNode,omitempty"` + + // Page page number of items to fetch (default is 1) + Page *Page `form:"page,omitempty" json:"page,omitempty"` + + // PerPage number of items per page (default is 30, max is 100) + PerPage *PerPage `form:"perPage,omitempty" json:"perPage,omitempty"` + + // Type Item type to fetch + Type *ListQueueItemsParamsType `form:"type,omitempty" json:"type,omitempty"` +} + +// ListQueueItemsParamsType defines parameters for ListQueueItems. +type ListQueueItemsParamsType string + // GetCoordinatorStatusParams defines parameters for GetCoordinatorStatus. type GetCoordinatorStatusParams struct { // RemoteNode name of the remote node @@ -2533,9 +2567,12 @@ type ServerInterface interface { // Get Prometheus metrics // (GET /metrics) GetMetrics(w http.ResponseWriter, r *http.Request) - // List all execution queues with active DAG-runs + // List all execution queues with summary statistics // (GET /queues) ListQueues(w http.ResponseWriter, r *http.Request, params ListQueuesParams) + // Get paginated items for a specific queue + // (GET /queues/{name}/items) + ListQueueItems(w http.ResponseWriter, r *http.Request, name string, params ListQueueItemsParams) // Get coordinator service status // (GET /services/coordinator) GetCoordinatorStatus(w http.ResponseWriter, r *http.Request, params GetCoordinatorStatusParams) @@ -2950,12 +2987,18 @@ func (_ Unimplemented) GetMetrics(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// List all execution queues with active DAG-runs +// List all execution queues with summary statistics // (GET /queues) func (_ Unimplemented) ListQueues(w http.ResponseWriter, r *http.Request, params ListQueuesParams) { w.WriteHeader(http.StatusNotImplemented) } +// Get paginated items for a specific queue +// (GET /queues/{name}/items) +func (_ Unimplemented) ListQueueItems(w http.ResponseWriter, r *http.Request, name string, params ListQueueItemsParams) { + w.WriteHeader(http.StatusNotImplemented) +} + // Get coordinator service status // (GET /services/coordinator) func (_ Unimplemented) GetCoordinatorStatus(w http.ResponseWriter, r *http.Request, params GetCoordinatorStatusParams) { @@ -6306,6 +6349,74 @@ func (siw *ServerInterfaceWrapper) ListQueues(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } +// ListQueueItems operation middleware +func (siw *ServerInterfaceWrapper) ListQueueItems(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "name" ------------- + var name string + + err = runtime.BindStyledParameterWithOptions("simple", "name", chi.URLParam(r, "name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, ApiTokenScopes, []string{}) + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params ListQueueItemsParams + + // ------------- Optional query parameter "remoteNode" ------------- + + err = runtime.BindQueryParameter("form", true, false, "remoteNode", r.URL.Query(), ¶ms.RemoteNode) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "remoteNode", Err: err}) + return + } + + // ------------- Optional query parameter "page" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page", r.URL.Query(), ¶ms.Page) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err}) + return + } + + // ------------- Optional query parameter "perPage" ------------- + + err = runtime.BindQueryParameter("form", true, false, "perPage", r.URL.Query(), ¶ms.PerPage) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "perPage", Err: err}) + return + } + + // ------------- Optional query parameter "type" ------------- + + err = runtime.BindQueryParameter("form", true, false, "type", r.URL.Query(), ¶ms.Type) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "type", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListQueueItems(w, r, name, params) + })) + + for i := len(siw.HandlerMiddlewares) - 1; i >= 0; i-- { + handler = siw.HandlerMiddlewares[i](handler) + } + + handler.ServeHTTP(w, r) +} + // GetCoordinatorStatus operation middleware func (siw *ServerInterfaceWrapper) GetCoordinatorStatus(w http.ResponseWriter, r *http.Request) { @@ -7024,6 +7135,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/queues", wrapper.ListQueues) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/queues/{name}/items", wrapper.ListQueueItems) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/services/coordinator", wrapper.GetCoordinatorStatus) }) @@ -9780,6 +9894,45 @@ func (response ListQueuesdefaultJSONResponse) VisitListQueuesResponse(w http.Res return json.NewEncoder(w).Encode(response.Body) } +type ListQueueItemsRequestObject struct { + Name string `json:"name"` + Params ListQueueItemsParams +} + +type ListQueueItemsResponseObject interface { + VisitListQueueItemsResponse(w http.ResponseWriter) error +} + +type ListQueueItems200JSONResponse QueueItemsResponse + +func (response ListQueueItems200JSONResponse) VisitListQueueItemsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ListQueueItems404JSONResponse Error + +func (response ListQueueItems404JSONResponse) VisitListQueueItemsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type ListQueueItemsdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ListQueueItemsdefaultJSONResponse) VisitListQueueItemsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type GetCoordinatorStatusRequestObject struct { Params GetCoordinatorStatusParams } @@ -10561,9 +10714,12 @@ type StrictServerInterface interface { // Get Prometheus metrics // (GET /metrics) GetMetrics(ctx context.Context, request GetMetricsRequestObject) (GetMetricsResponseObject, error) - // List all execution queues with active DAG-runs + // List all execution queues with summary statistics // (GET /queues) ListQueues(ctx context.Context, request ListQueuesRequestObject) (ListQueuesResponseObject, error) + // Get paginated items for a specific queue + // (GET /queues/{name}/items) + ListQueueItems(ctx context.Context, request ListQueueItemsRequestObject) (ListQueueItemsResponseObject, error) // Get coordinator service status // (GET /services/coordinator) GetCoordinatorStatus(ctx context.Context, request GetCoordinatorStatusRequestObject) (GetCoordinatorStatusResponseObject, error) @@ -12506,6 +12662,33 @@ func (sh *strictHandler) ListQueues(w http.ResponseWriter, r *http.Request, para } } +// ListQueueItems operation middleware +func (sh *strictHandler) ListQueueItems(w http.ResponseWriter, r *http.Request, name string, params ListQueueItemsParams) { + var request ListQueueItemsRequestObject + + request.Name = name + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListQueueItems(ctx, request.(ListQueueItemsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListQueueItems") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListQueueItemsResponseObject); ok { + if err := validResponse.VisitListQueueItemsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetCoordinatorStatus operation middleware func (sh *strictHandler) GetCoordinatorStatus(w http.ResponseWriter, r *http.Request, params GetCoordinatorStatusParams) { var request GetCoordinatorStatusRequestObject @@ -12935,197 +13118,200 @@ var swaggerSpec = []string{ "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v2/api.yaml b/api/v2/api.yaml index ec6116fc1..388715986 100644 --- a/api/v2/api.yaml +++ b/api/v2/api.yaml @@ -2688,8 +2688,8 @@ paths: /queues: get: - summary: "List all execution queues with active DAG-runs" - description: "Retrieves all queues showing both running and queued DAG-runs, organized by queue/process group" + summary: "List all execution queues with summary statistics" + description: "Returns queue list with running/queued counts. Use /queues/{name}/items for paginated item details." operationId: "listQueues" tags: - "queues" @@ -2709,6 +2709,51 @@ paths: schema: $ref: "#/components/schemas/Error" + /queues/{name}/items: + get: + summary: "Get paginated items for a specific queue" + description: "Returns paginated list of running or queued DAG-runs for the specified queue" + operationId: "listQueueItems" + tags: + - "queues" + parameters: + - $ref: "#/components/parameters/RemoteNode" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" + - name: name + in: path + description: "Queue name" + required: true + schema: + type: string + - name: type + in: query + description: "Item type to fetch" + required: false + schema: + type: string + enum: ["running", "queued"] + default: "queued" + responses: + "200": + description: "A successful response" + content: + application/json: + schema: + $ref: "#/components/schemas/QueueItemsResponse" + "404": + description: "Queue not found" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + default: + description: "Generic error response" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /services/resources/history: get: summary: "Get resource usage history" @@ -4686,7 +4731,7 @@ components: Queue: type: object - description: "A queue/process group with its active DAG-runs" + description: "A queue/process group with summary statistics" properties: name: type: string @@ -4699,21 +4744,40 @@ components: type: integer description: "Maximum number of concurrent runs allowed. For 'global' queues, this is the configured maxConcurrency. For 'dag-based' queues, this is the DAG's maxActiveRuns (default 1)" minimum: 1 + runningCount: + type: integer + description: "Number of currently running DAG-runs" + minimum: 0 + queuedCount: + type: integer + description: "Number of queued DAG-runs waiting to execute" + minimum: 0 running: type: array - description: "List of currently running DAG-runs" - items: - $ref: "#/components/schemas/DAGRunSummary" - queued: - type: array - description: "List of DAG-runs waiting to execute" + description: "List of currently running DAG-runs (bounded by maxConcurrency)" items: $ref: "#/components/schemas/DAGRunSummary" required: - name - type + - runningCount + - queuedCount - running - - queued + + QueueItemsResponse: + type: object + description: "Paginated queue items response" + properties: + items: + type: array + description: "List of DAG-run summaries" + items: + $ref: "#/components/schemas/DAGRunSummary" + pagination: + $ref: "#/components/schemas/Pagination" + required: + - items + - pagination QueuesSummary: type: object diff --git a/internal/core/exec/queue.go b/internal/core/exec/queue.go index 600f140a0..617604a35 100644 --- a/internal/core/exec/queue.go +++ b/internal/core/exec/queue.go @@ -24,6 +24,8 @@ type QueueStore interface { Len(ctx context.Context, name string) (int, error) // List returns all items in the queue with the given name List(ctx context.Context, name string) ([]QueuedItemData, error) + // ListPaginated returns paginated items for a specific queue + ListPaginated(ctx context.Context, name string, pg Paginator) (PaginatedResult[QueuedItemData], error) // All returns all items in the queue All(ctx context.Context) ([]QueuedItemData, error) // ListByDAGName returns all items that has a specific DAG name diff --git a/internal/persis/filequeue/store.go b/internal/persis/filequeue/store.go index dba19ad6c..044c88d46 100644 --- a/internal/persis/filequeue/store.go +++ b/internal/persis/filequeue/store.go @@ -175,6 +175,67 @@ func (s *Store) List(ctx context.Context, name string) ([]exec.QueuedItemData, e return items, nil } +// ListPaginated returns paginated items for a specific queue. +// This implementation paginates at the file-path level BEFORE creating any +// QueuedFile objects, ensuring O(1) memory for the paginated items regardless +// of total queue size. +func (s *Store) ListPaginated(ctx context.Context, name string, pg exec.Paginator) (exec.PaginatedResult[exec.QueuedItemData], error) { + ctx = logger.WithValues(ctx, tag.Queue(name)) + s.mu.Lock() + defer s.mu.Unlock() + + limit := pg.Limit() + offset := pg.Offset() + + // Build queue directory path + queueDir := filepath.Join(s.baseDir, name) + if _, err := os.Stat(queueDir); os.IsNotExist(err) { + return exec.NewPaginatedResult([]exec.QueuedItemData{}, 0, pg), nil + } + + // Collect file paths ONLY (no parsing, no object creation) + // High priority first, then low - maintains proper queue ordering + patterns := []string{ + filepath.Join(queueDir, "item_high_*.json"), + filepath.Join(queueDir, "item_low_*.json"), + } + + var allFiles []string + for _, pattern := range patterns { + files, err := filepath.Glob(pattern) + if err != nil { + logger.Error(ctx, "Failed to glob queue files", tag.Error(err)) + return exec.PaginatedResult[exec.QueuedItemData]{}, fmt.Errorf("failed to list queue files: %w", err) + } + // Lexicographic sort = chronological (timestamp encoded in filename) + sort.Strings(files) + allFiles = append(allFiles, files...) + } + + total := len(allFiles) + + // Handle offset beyond total + if offset >= total { + return exec.NewPaginatedResult([]exec.QueuedItemData{}, total, pg), nil + } + + // Apply pagination TO FILE PATHS (efficient - just string slicing) + endIndex := offset + limit + if endIndex > total { + endIndex = total + } + paginatedFiles := allFiles[offset:endIndex] + + // Create QueuedFile objects ONLY for paginated portion + // QueuedFile is lazy-loaded - JSON not read until Data() called + items := make([]exec.QueuedItemData, 0, len(paginatedFiles)) + for _, file := range paginatedFiles { + items = append(items, NewQueuedFile(file)) + } + + return exec.NewPaginatedResult(items, total, pg), nil +} + func (s *Store) ListByDAGName(ctx context.Context, name, dagName string) ([]exec.QueuedItemData, error) { items, err := s.List(ctx, name) if err != nil { diff --git a/internal/service/frontend/api/v2/queues.go b/internal/service/frontend/api/v2/queues.go index 9b04b94e6..6152e1a6e 100644 --- a/internal/service/frontend/api/v2/queues.go +++ b/internal/service/frontend/api/v2/queues.go @@ -5,7 +5,10 @@ import ( "github.com/dagu-org/dagu/api/v2" "github.com/dagu-org/dagu/internal/cmn/config" + "github.com/dagu-org/dagu/internal/cmn/logger" + "github.com/dagu-org/dagu/internal/cmn/logger/tag" "github.com/dagu-org/dagu/internal/core" + "github.com/dagu-org/dagu/internal/core/exec" ) // ListQueues implements api.StrictServerInterface. @@ -25,7 +28,7 @@ func (a *API) ListQueues(ctx context.Context, _ api.ListQueuesRequestObject) (ap queueType: "global", maxConcurrency: queueCfg.MaxActiveRuns, running: []api.DAGRunSummary{}, - queued: []api.DAGRunSummary{}, + queuedCount: 0, } queueMap[queueCfg.Name] = queue } @@ -76,57 +79,39 @@ func (a *API) ListQueues(ctx context.Context, _ api.ListQueuesRequestObject) (ap } } - // 3. Get all queued items from QueueStore - allQueued, err := a.queueStore.All(ctx) + // 3. Get queued COUNTS only (NOT full items) using QueueList + Len + queueNames, err := a.queueStore.QueueList(ctx) if err != nil { return nil, &Error{ Code: api.ErrorCodeInternalError, - Message: "Failed to list queued items", + Message: "Failed to list queue names", HTTPStatus: 500, } } - // Process queued DAG runs - for _, queuedItem := range allQueued { - dagRunRef, err := queuedItem.Data() - if err != nil { - continue // Skip if we can't get data - } - - // Get the DAG run status to convert to summary - attempt, err := a.dagRunStore.FindAttempt(ctx, *dagRunRef) - if err != nil { - continue // Skip if we can't find the attempt - } - - dag, err := attempt.ReadDAG(ctx) - if err != nil { - continue // Skip if we can't read DAG - } - - queue := getOrCreateQueue(queueMap, dag.ProcGroup(), a.config, dag) - - runStatus, err := attempt.ReadStatus(ctx) + for _, queueName := range queueNames { + count, err := a.queueStore.Len(ctx, queueName) if err != nil { - continue // Skip if we can't read status + logger.Warn(ctx, "Failed to get queue length", + tag.Queue(queueName), + tag.Error(err)) + continue } - // Only include if status is actually queued - if runStatus.Status == core.Queued { - runSummary := toDAGRunSummary(*runStatus) - queue.queued = append(queue.queued, runSummary) - totalQueued++ - } + queue := getOrCreateQueue(queueMap, queueName, a.config, nil) + queue.queuedCount = count + totalQueued += count } // Convert map to slice and calculate total capacity queues := make([]api.Queue, 0, len(queueMap)) for _, q := range queueMap { queue := api.Queue{ - Name: q.name, - Type: api.QueueType(q.queueType), - Running: q.running, - Queued: q.queued, + Name: q.name, + Type: api.QueueType(q.queueType), + Running: q.running, + RunningCount: len(q.running), + QueuedCount: q.queuedCount, } // Include maxConcurrency for both global and DAG-based queues @@ -159,13 +144,103 @@ func (a *API) ListQueues(ctx context.Context, _ api.ListQueuesRequestObject) (ap return api.ListQueues200JSONResponse(response), nil } +// ListQueueItems implements api.StrictServerInterface. +// Returns paginated items for a specific queue. +func (a *API) ListQueueItems(ctx context.Context, req api.ListQueueItemsRequestObject) (api.ListQueueItemsResponseObject, error) { + queueName := req.Name + itemType := api.ListQueueItemsParamsTypeQueued + if req.Params.Type != nil { + itemType = *req.Params.Type + } + + // Create Paginator from page/perPage params + pg := exec.NewPaginator(valueOf(req.Params.Page), valueOf(req.Params.PerPage)) + + var items []api.DAGRunSummary + + if itemType == api.ListQueueItemsParamsTypeRunning { + // Get running items from proc store (bounded by maxConcurrency) + runningByGroup, err := a.procStore.ListAllAlive(ctx) + if err != nil { + return nil, &Error{ + Code: api.ErrorCodeInternalError, + Message: "Failed to list running processes", + HTTPStatus: 500, + } + } + + runningRefs := runningByGroup[queueName] + total := len(runningRefs) + + // Apply pagination manually for running items + offset := pg.Offset() + limit := pg.Limit() + if offset < total { + endIndex := offset + limit + if endIndex > total { + endIndex = total + } + for _, dagRun := range runningRefs[offset:endIndex] { + attempt, err := a.dagRunStore.FindAttempt(ctx, dagRun) + if err != nil { + continue + } + runStatus, err := attempt.ReadStatus(ctx) + if err != nil { + continue + } + items = append(items, toDAGRunSummary(*runStatus)) + } + } + + // Create paginated result for response + paginatedResult := exec.NewPaginatedResult(items, total, pg) + return api.ListQueueItems200JSONResponse{ + Items: items, + Pagination: toPagination(paginatedResult), + }, nil + } + + // Get queued items with pagination using ListPaginated + paginatedResult, err := a.queueStore.ListPaginated(ctx, queueName, pg) + if err != nil { + return nil, &Error{ + Code: api.ErrorCodeInternalError, + Message: "Failed to list queued items", + HTTPStatus: 500, + } + } + + // Convert QueuedItemData to DAGRunSummary + for _, queuedItem := range paginatedResult.Items { + dagRunRef, err := queuedItem.Data() + if err != nil { + continue + } + attempt, err := a.dagRunStore.FindAttempt(ctx, *dagRunRef) + if err != nil { + continue + } + runStatus, err := attempt.ReadStatus(ctx) + if err != nil { + continue + } + items = append(items, toDAGRunSummary(*runStatus)) + } + + return api.ListQueueItems200JSONResponse{ + Items: items, + Pagination: toPagination(paginatedResult), + }, nil +} + // Helper struct to build queue information type queueInfo struct { name string queueType string maxConcurrency int running []api.DAGRunSummary - queued []api.DAGRunSummary + queuedCount int } // Helper function to get or create queue in the map @@ -173,10 +248,10 @@ func getOrCreateQueue(queueMap map[string]*queueInfo, queueName string, config * queue, exists := queueMap[queueName] if !exists { queue = &queueInfo{ - name: queueName, - queueType: "dag-based", // Default to dag-based - running: []api.DAGRunSummary{}, - queued: []api.DAGRunSummary{}, + name: queueName, + queueType: "dag-based", // Default to dag-based + running: []api.DAGRunSummary{}, + queuedCount: 0, } // Check if this is a global queue from config diff --git a/ui/src/api/v2/schema.ts b/ui/src/api/v2/schema.ts index 548e256d5..2f337c982 100644 --- a/ui/src/api/v2/schema.ts +++ b/ui/src/api/v2/schema.ts @@ -721,6 +721,26 @@ export interface paths { patch?: never; trace?: never; }; + "/dag-runs/{name}/{dagRunId}/log/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download full execution log of a DAG-run + * @description Downloads the entire execution log file for a DAG-run + */ + get: operations["downloadDAGRunLog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/dag-runs/{name}/{dagRunId}/outputs": { parameters: { query?: never; @@ -801,6 +821,26 @@ export interface paths { patch?: never; trace?: never; }; + "/dag-runs/{name}/{dagRunId}/steps/{stepName}/log/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download log for a specific step in a DAG-run + * @description Downloads the entire log file for an individual step in a DAG-run + */ + get: operations["downloadDAGRunStepLog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/dag-runs/{name}/{dagRunId}/steps/{stepName}/messages": { parameters: { query?: never; @@ -921,6 +961,26 @@ export interface paths { patch?: never; trace?: never; }; + "/dag-runs/{name}/{dagRunId}/sub-dag-runs/{subDAGRunId}/log/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download log for a specific sub DAG-run + * @description Downloads the entire log file for an individual sub DAG-run + */ + get: operations["downloadSubDAGRunLog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/dag-runs/{name}/{dagRunId}/sub-dag-runs/{subDAGRunId}/steps/{stepName}/log": { parameters: { query?: never; @@ -941,6 +1001,26 @@ export interface paths { patch?: never; trace?: never; }; + "/dag-runs/{name}/{dagRunId}/sub-dag-runs/{subDAGRunId}/steps/{stepName}/log/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download log for a specific step in a sub DAG-run + * @description Downloads the entire log file for an individual step in a sub DAG-run + */ + get: operations["downloadSubDAGRunStepLog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/dag-runs/{name}/{dagRunId}/sub-dag-runs/{subDAGRunId}/steps/{stepName}/messages": { parameters: { query?: never; @@ -1029,8 +1109,8 @@ export interface paths { cookie?: never; }; /** - * List all execution queues with active DAG-runs - * @description Retrieves all queues showing both running and queued DAG-runs, organized by queue/process group + * List all execution queues with summary statistics + * @description Returns queue list with running/queued counts. Use /queues/{name}/items for paginated item details. */ get: operations["listQueues"]; put?: never; @@ -1041,6 +1121,26 @@ export interface paths { patch?: never; trace?: never; }; + "/queues/{name}/items": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get paginated items for a specific queue + * @description Returns paginated list of running or queued DAG-runs for the specified queue + */ + get: operations["listQueueItems"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/services/resources/history": { parameters: { query?: never; @@ -1967,7 +2067,7 @@ export interface components { queues: components["schemas"]["Queue"][]; summary: components["schemas"]["QueuesSummary"]; }; - /** @description A queue/process group with its active DAG-runs */ + /** @description A queue/process group with summary statistics */ Queue: { /** @description Name of the queue (global queue name or DAG name if no queue specified) */ name: string; @@ -1978,10 +2078,18 @@ export interface components { type: QueueType; /** @description Maximum number of concurrent runs allowed. For 'global' queues, this is the configured maxConcurrency. For 'dag-based' queues, this is the DAG's maxActiveRuns (default 1) */ maxConcurrency?: number; - /** @description List of currently running DAG-runs */ + /** @description Number of currently running DAG-runs */ + runningCount: number; + /** @description Number of queued DAG-runs waiting to execute */ + queuedCount: number; + /** @description List of currently running DAG-runs (bounded by maxConcurrency) */ running: components["schemas"]["DAGRunSummary"][]; - /** @description List of DAG-runs waiting to execute */ - queued: components["schemas"]["DAGRunSummary"][]; + }; + /** @description Paginated queue items response */ + QueueItemsResponse: { + /** @description List of DAG-run summaries */ + items: components["schemas"]["DAGRunSummary"][]; + pagination: components["schemas"]["Pagination"]; }; /** @description Summary statistics across all queues */ QueuesSummary: { @@ -4480,6 +4588,54 @@ export interface operations { }; }; }; + downloadDAGRunLog: { + parameters: { + query?: { + /** @description name of the remote node */ + remoteNode?: components["parameters"]["RemoteNode"]; + }; + header?: never; + path: { + /** @description name of the DAG */ + name: components["parameters"]["DAGName"]; + /** @description ID of the DAG-run or 'latest' to get the most recent DAG-run */ + dagRunId: components["parameters"]["DAGRunId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Log file content */ + 200: { + headers: { + /** @description Attachment filename */ + "Content-Disposition"?: string; + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Log file not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Generic error response */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getDAGRunOutputs: { parameters: { query?: { @@ -4662,6 +4818,58 @@ export interface operations { }; }; }; + downloadDAGRunStepLog: { + parameters: { + query?: { + /** @description name of the remote node */ + remoteNode?: components["parameters"]["RemoteNode"]; + /** @description Whether to return stdout or stderr logs */ + stream?: components["parameters"]["Stream"]; + }; + header?: never; + path: { + /** @description name of the DAG */ + name: components["parameters"]["DAGName"]; + /** @description ID of the DAG-run or 'latest' to get the most recent DAG-run */ + dagRunId: components["parameters"]["DAGRunId"]; + /** @description name of the step */ + stepName: components["parameters"]["StepName"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Log file content */ + 200: { + headers: { + /** @description Attachment filename */ + "Content-Disposition"?: string; + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Log file not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Generic error response */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getDAGRunStepMessages: { parameters: { query?: { @@ -4999,6 +5207,56 @@ export interface operations { }; }; }; + downloadSubDAGRunLog: { + parameters: { + query?: { + /** @description name of the remote node */ + remoteNode?: components["parameters"]["RemoteNode"]; + }; + header?: never; + path: { + /** @description name of the DAG */ + name: components["parameters"]["DAGName"]; + /** @description ID of the DAG-run or 'latest' to get the most recent DAG-run */ + dagRunId: components["parameters"]["DAGRunId"]; + /** @description ID of the sub DAG-run to download the log for */ + subDAGRunId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Log file content */ + 200: { + headers: { + /** @description Attachment filename */ + "Content-Disposition"?: string; + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Log file not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Generic error response */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getSubDAGRunStepLog: { parameters: { query?: { @@ -5059,6 +5317,60 @@ export interface operations { }; }; }; + downloadSubDAGRunStepLog: { + parameters: { + query?: { + /** @description name of the remote node */ + remoteNode?: components["parameters"]["RemoteNode"]; + /** @description Whether to return stdout or stderr logs */ + stream?: components["parameters"]["Stream"]; + }; + header?: never; + path: { + /** @description name of the DAG */ + name: components["parameters"]["DAGName"]; + /** @description ID of the DAG-run or 'latest' to get the most recent DAG-run */ + dagRunId: components["parameters"]["DAGRunId"]; + /** @description ID of the sub DAG-run to download the step log for */ + subDAGRunId: string; + /** @description name of the step */ + stepName: components["parameters"]["StepName"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Log file content */ + 200: { + headers: { + /** @description Attachment filename */ + "Content-Disposition"?: string; + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Log file not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Generic error response */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getSubDAGRunStepMessages: { parameters: { query?: { @@ -5330,6 +5642,56 @@ export interface operations { }; }; }; + listQueueItems: { + parameters: { + query?: { + /** @description name of the remote node */ + remoteNode?: components["parameters"]["RemoteNode"]; + /** @description page number of items to fetch (default is 1) */ + page?: components["parameters"]["Page"]; + /** @description number of items per page (default is 30, max is 100) */ + perPage?: components["parameters"]["PerPage"]; + /** @description Item type to fetch */ + type?: PathsQueuesNameItemsGetParametersQueryType; + }; + header?: never; + path: { + /** @description Queue name */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QueueItemsResponse"]; + }; + }; + /** @description Queue not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Generic error response */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getResourceHistory: { parameters: { query?: { @@ -5869,6 +6231,10 @@ export enum PathsDagsGetParametersQueryOrder { asc = "asc", desc = "desc" } +export enum PathsQueuesNameItemsGetParametersQueryType { + running = "running", + queued = "queued" +} export enum ChatMessageRole { system = "system", user = "user", diff --git a/ui/src/features/queues/components/QueueCard.tsx b/ui/src/features/queues/components/QueueCard.tsx index 3e8630ea7..b5616a0f9 100644 --- a/ui/src/features/queues/components/QueueCard.tsx +++ b/ui/src/features/queues/components/QueueCard.tsx @@ -1,10 +1,13 @@ import { ChevronDown, + ChevronLeft, ChevronRight, Trash2, } from 'lucide-react'; import React from 'react'; +import useSWR from 'swr'; import type { components } from '../../../api/v2/schema'; +import { PathsQueuesNameItemsGetParametersQueryType } from '../../../api/v2/schema'; import { Button } from '../../../components/ui/button'; import { Tooltip, @@ -38,15 +41,50 @@ function QueueCard({ const [isExpanded, setIsExpanded] = React.useState(true); const [isClearing, setIsClearing] = React.useState(false); const [showClearConfirm, setShowClearConfirm] = React.useState(false); + const [queuedPage, setQueuedPage] = React.useState(1); + const perPage = 50; + + // Reset page when remote node changes + const remoteNode = appBarContext?.selectedRemoteNode || 'local'; + React.useEffect(() => { + setQueuedPage(1); + }, [remoteNode, queue.name]); const toggleExpanded = () => setIsExpanded(!isExpanded); + // Fetch paginated queued items when expanded and there are queued items + const shouldFetchQueued = isExpanded && queue.queuedCount > 0; + const { data: queuedResponse, mutate: mutateQueuedData } = useSWR( + shouldFetchQueued + ? ['listQueueItems', queue.name, queuedPage, perPage, remoteNode] + : null, + async () => { + const response = await client.GET('/queues/{name}/items', { + params: { + path: { name: queue.name }, + query: { + type: PathsQueuesNameItemsGetParametersQueryType.queued, + page: queuedPage, + perPage: perPage, + remoteNode: remoteNode, + }, + }, + }); + return response.data; + }, + { refreshInterval: 3000 } + ); + + const queuedItems = queuedResponse?.items ?? []; + const pagination = queuedResponse?.pagination; + const handleClearQueue = async () => { setIsClearing(true); try { - const queuedRuns = queue.queued || []; + // Clear all queued items by dequeuing them one by one + // Note: This will only clear items on the current page, but triggers a refresh await Promise.all( - queuedRuns.map(async (dagRun) => { + queuedItems.map(async (dagRun) => { try { await client.GET('/dag-runs/{name}/{dagRunId}/dequeue', { params: { @@ -55,7 +93,7 @@ function QueueCard({ dagRunId: dagRun.dagRunId, }, query: { - remoteNode: appBarContext?.selectedRemoteNode || 'local', + remoteNode: remoteNode, }, }, }); @@ -67,6 +105,8 @@ function QueueCard({ } }) ); + // Refresh the queued items + mutateQueuedData(); if (onQueueCleared) { onQueueCleared(); } @@ -80,7 +120,7 @@ function QueueCard({ const utilization = React.useMemo(() => { if (queue.type !== 'global' || !queue.maxConcurrency) return null; - const running = queue.running?.length || 0; + const running = queue.runningCount || 0; return Math.round((running / queue.maxConcurrency) * 100); }, [queue]); @@ -159,7 +199,7 @@ function QueueCard({ /> - {queue.running?.length || 0}/{queue.maxConcurrency} + {queue.runningCount || 0}/{queue.maxConcurrency} )} @@ -169,13 +209,13 @@ function QueueCard({
- {queue.running?.length || 0} + {queue.runningCount || 0} running
- 0 ? 'text-foreground' : 'text-muted-foreground/50'}`}> - {queue.queued?.length || 0} + 0 ? 'text-foreground' : 'text-muted-foreground/50'}`}> + {queue.queuedCount || 0} queued
@@ -232,12 +272,12 @@ function QueueCard({ )} {/* Queued DAGs */} - {queue.queued && queue.queued.length > 0 && ( + {queue.queuedCount > 0 && (
0 ? 'border-t' : ''}>
- Queued ({queue.queued.length}) + Queued ({queue.queuedCount}) @@ -281,7 +321,7 @@ function QueueCard({ - {queue.queued.map((dagRun) => ( + {queuedItems.map((dagRun) => (
+ {/* Pagination controls */} + {pagination && pagination.totalPages > 1 && ( +
+ + Page {pagination.currentPage} of {pagination.totalPages} + +
+ + +
+
+ )}
)} {/* Empty state */} {(!queue.running || queue.running.length === 0) && - (!queue.queued || queue.queued.length === 0) && ( + queue.queuedCount === 0 && (
No DAGs running or queued
@@ -318,7 +392,7 @@ function QueueCard({ Remove all queued DAG runs from "{queue.name}"?

- {queue.queued?.length || 0} DAG runs will be removed. This cannot be undone. + {queue.queuedCount || 0} DAG runs will be removed. This cannot be undone.

diff --git a/ui/src/pages/queues/index.tsx b/ui/src/pages/queues/index.tsx index ae8fa7c57..2620e72f3 100644 --- a/ui/src/pages/queues/index.tsx +++ b/ui/src/pages/queues/index.tsx @@ -153,15 +153,15 @@ function Queues() { // Count active queues (those with running or queued items) const activeQueues = queues.filter( - (q) => (q.running?.length || 0) > 0 || (q.queued?.length || 0) > 0 + (q) => (q.runningCount || 0) > 0 || (q.queuedCount || 0) > 0 ).length; const totalRunning = queues.reduce( - (sum, q) => sum + (q.running?.length || 0), + (sum, q) => sum + (q.runningCount || 0), 0 ); const totalQueued = queues.reduce( - (sum, q) => sum + (q.queued?.length || 0), + (sum, q) => sum + (q.queuedCount || 0), 0 ); const totalActive = totalRunning + totalQueued; @@ -169,7 +169,7 @@ function Queues() { // Calculate utilization for global queues only (DAG-based queues are isolated and don't compete for shared capacity) const globalQueuesList = queues.filter((q) => q.type === 'global'); const globalRunning = globalQueuesList.reduce( - (sum, q) => sum + (q.running?.length || 0), + (sum, q) => sum + (q.runningCount || 0), 0 ); const globalCapacity = globalQueuesList From 3e797109a59c994800dc5fc14b59d977f0b27b54 Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sat, 17 Jan 2026 13:31:31 +0900 Subject: [PATCH 02/10] perPage to 10 --- ui/src/features/queues/components/QueueCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/features/queues/components/QueueCard.tsx b/ui/src/features/queues/components/QueueCard.tsx index b5616a0f9..0840460fc 100644 --- a/ui/src/features/queues/components/QueueCard.tsx +++ b/ui/src/features/queues/components/QueueCard.tsx @@ -42,7 +42,7 @@ function QueueCard({ const [isClearing, setIsClearing] = React.useState(false); const [showClearConfirm, setShowClearConfirm] = React.useState(false); const [queuedPage, setQueuedPage] = React.useState(1); - const perPage = 50; + const perPage = 10; // Reset page when remote node changes const remoteNode = appBarContext?.selectedRemoteNode || 'local'; From f4cae0f90f5af1f3a907c6436ca3fc32ad9b280d Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sat, 17 Jan 2026 13:36:02 +0900 Subject: [PATCH 03/10] refactoring --- internal/persis/filequeue/store.go | 19 ++-- internal/service/frontend/api/v2/queues.go | 98 ++++++++----------- .../features/queues/components/QueueCard.tsx | 17 +--- 3 files changed, 51 insertions(+), 83 deletions(-) diff --git a/internal/persis/filequeue/store.go b/internal/persis/filequeue/store.go index 044c88d46..4dc8514bd 100644 --- a/internal/persis/filequeue/store.go +++ b/internal/persis/filequeue/store.go @@ -214,20 +214,13 @@ func (s *Store) ListPaginated(ctx context.Context, name string, pg exec.Paginato total := len(allFiles) - // Handle offset beyond total - if offset >= total { - return exec.NewPaginatedResult([]exec.QueuedItemData{}, total, pg), nil - } - - // Apply pagination TO FILE PATHS (efficient - just string slicing) - endIndex := offset + limit - if endIndex > total { - endIndex = total - } - paginatedFiles := allFiles[offset:endIndex] + // Apply pagination to file paths (efficient string slicing) + startIndex := min(offset, total) + endIndex := min(offset+limit, total) + paginatedFiles := allFiles[startIndex:endIndex] - // Create QueuedFile objects ONLY for paginated portion - // QueuedFile is lazy-loaded - JSON not read until Data() called + // Create QueuedFile objects only for the paginated portion. + // QueuedFile is lazy-loaded - JSON is not read until Data() is called. items := make([]exec.QueuedItemData, 0, len(paginatedFiles)) for _, file := range paginatedFiles { items = append(items, NewQueuedFile(file)) diff --git a/internal/service/frontend/api/v2/queues.go b/internal/service/frontend/api/v2/queues.go index 6152e1a6e..95ff1eecc 100644 --- a/internal/service/frontend/api/v2/queues.go +++ b/internal/service/frontend/api/v2/queues.go @@ -172,28 +172,21 @@ func (a *API) ListQueueItems(ctx context.Context, req api.ListQueueItemsRequestO runningRefs := runningByGroup[queueName] total := len(runningRefs) - // Apply pagination manually for running items - offset := pg.Offset() - limit := pg.Limit() - if offset < total { - endIndex := offset + limit - if endIndex > total { - endIndex = total + // Apply pagination to running items + startIndex := min(pg.Offset(), total) + endIndex := min(pg.Offset()+pg.Limit(), total) + for _, dagRun := range runningRefs[startIndex:endIndex] { + attempt, err := a.dagRunStore.FindAttempt(ctx, dagRun) + if err != nil { + continue } - for _, dagRun := range runningRefs[offset:endIndex] { - attempt, err := a.dagRunStore.FindAttempt(ctx, dagRun) - if err != nil { - continue - } - runStatus, err := attempt.ReadStatus(ctx) - if err != nil { - continue - } - items = append(items, toDAGRunSummary(*runStatus)) + runStatus, err := attempt.ReadStatus(ctx) + if err != nil { + continue } + items = append(items, toDAGRunSummary(*runStatus)) } - // Create paginated result for response paginatedResult := exec.NewPaginatedResult(items, total, pg) return api.ListQueueItems200JSONResponse{ Items: items, @@ -243,53 +236,42 @@ type queueInfo struct { queuedCount int } -// Helper function to get or create queue in the map -func getOrCreateQueue(queueMap map[string]*queueInfo, queueName string, config *config.Config, dag *core.DAG) *queueInfo { - queue, exists := queueMap[queueName] - if !exists { - queue = &queueInfo{ - name: queueName, - queueType: "dag-based", // Default to dag-based - running: []api.DAGRunSummary{}, - queuedCount: 0, - } +// getOrCreateQueue returns an existing queue from the map or creates a new one. +func getOrCreateQueue(queueMap map[string]*queueInfo, queueName string, cfg *config.Config, dag *core.DAG) *queueInfo { + if queue, exists := queueMap[queueName]; exists { + return queue + } - // Check if this is a global queue from config - if isGlobalQueue(queueName, config) { - queue.queueType = "global" - queue.maxConcurrency = getQueueMaxConcurrency(queueName, config) - } else if dag != nil { - // For DAG-based queues, use the DAG's MaxActiveRuns - queue.maxConcurrency = dag.MaxActiveRuns - } + queue := &queueInfo{ + name: queueName, + queueType: "dag-based", + running: []api.DAGRunSummary{}, + queuedCount: 0, + } - queueMap[queueName] = queue + // Check if this is a global queue from config + if globalCfg := findGlobalQueueConfig(queueName, cfg); globalCfg != nil { + queue.queueType = "global" + queue.maxConcurrency = globalCfg.MaxActiveRuns + } else if dag != nil { + // For DAG-based queues, use the DAG's MaxActiveRuns + queue.maxConcurrency = dag.MaxActiveRuns } + + queueMap[queueName] = queue return queue } -// Helper function to check if a queue is global (defined in config) -func isGlobalQueue(queueName string, config *config.Config) bool { - if config.Queues.Enabled && config.Queues.Config != nil { - for _, queueCfg := range config.Queues.Config { - if queueCfg.Name == queueName { - return true - } - } +// findGlobalQueueConfig returns the queue config if this is a global queue defined in config. +// Returns nil if not found or queues are disabled. +func findGlobalQueueConfig(queueName string, cfg *config.Config) *config.QueueConfig { + if !cfg.Queues.Enabled || cfg.Queues.Config == nil { + return nil } - return false -} - -// Helper function to get queue max concurrency from config -func getQueueMaxConcurrency(queueName string, config *config.Config) int { - if config.Queues.Enabled && config.Queues.Config != nil { - for _, queueCfg := range config.Queues.Config { - if queueCfg.Name == queueName { - return queueCfg.MaxActiveRuns - } + for i := range cfg.Queues.Config { + if cfg.Queues.Config[i].Name == queueName { + return &cfg.Queues.Config[i] } } - - // Default to 1 if not found - return 1 + return nil } diff --git a/ui/src/features/queues/components/QueueCard.tsx b/ui/src/features/queues/components/QueueCard.tsx index 0840460fc..5411ca8b8 100644 --- a/ui/src/features/queues/components/QueueCard.tsx +++ b/ui/src/features/queues/components/QueueCard.tsx @@ -124,14 +124,13 @@ function QueueCard({ return Math.round((running / queue.maxConcurrency) * 100); }, [queue]); - const formatDateTime = (datetime: string) => { + const formatDateTime = (datetime: string | undefined): string => { if (!datetime) return 'N/A'; + const date = dayjs(datetime); if (config.tzOffsetInSec !== undefined) { - return dayjs(datetime) - .utcOffset(config.tzOffsetInSec / 60) - .format('MMM D, HH:mm:ss'); + return date.utcOffset(config.tzOffsetInSec / 60).format('MMM D, HH:mm:ss'); } - return dayjs(datetime).format('MMM D, HH:mm:ss'); + return date.format('MMM D, HH:mm:ss'); }; const DAGRunRow: React.FC<{ @@ -149,13 +148,7 @@ function QueueCard({ - {showQueuedAt - ? dagRun.queuedAt - ? formatDateTime(dagRun.queuedAt) - : 'N/A' - : dagRun.startedAt - ? formatDateTime(dagRun.startedAt) - : 'N/A'} + {formatDateTime(showQueuedAt ? dagRun.queuedAt : dagRun.startedAt)} {dagRun.dagRunId} From c7f90acdc627c73581fbc3ee56f26c20ac074b89 Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sat, 17 Jan 2026 14:08:38 +0900 Subject: [PATCH 04/10] fix bug --- .../features/queues/components/QueueCard.tsx | 58 +++++++------------ 1 file changed, 21 insertions(+), 37 deletions(-) diff --git a/ui/src/features/queues/components/QueueCard.tsx b/ui/src/features/queues/components/QueueCard.tsx index 5411ca8b8..acfe4c8db 100644 --- a/ui/src/features/queues/components/QueueCard.tsx +++ b/ui/src/features/queues/components/QueueCard.tsx @@ -1,6 +1,5 @@ import { ChevronDown, - ChevronLeft, ChevronRight, Trash2, } from 'lucide-react'; @@ -21,6 +20,7 @@ import dayjs from '../../../lib/dayjs'; import { cn } from '../../../lib/utils'; import ConfirmModal from '../../../ui/ConfirmModal'; import StatusChip from '../../../ui/StatusChip'; +import DAGPagination from '../../dags/components/common/DAGPagination'; interface QueueCardProps { queue: components['schemas']['Queue']; @@ -42,19 +42,19 @@ function QueueCard({ const [isClearing, setIsClearing] = React.useState(false); const [showClearConfirm, setShowClearConfirm] = React.useState(false); const [queuedPage, setQueuedPage] = React.useState(1); - const perPage = 10; + const [perPage, setPerPage] = React.useState(10); - // Reset page when remote node changes + // Reset page when remote node, queue name, or items per page changes const remoteNode = appBarContext?.selectedRemoteNode || 'local'; React.useEffect(() => { setQueuedPage(1); - }, [remoteNode, queue.name]); + }, [remoteNode, queue.name, perPage]); const toggleExpanded = () => setIsExpanded(!isExpanded); // Fetch paginated queued items when expanded and there are queued items const shouldFetchQueued = isExpanded && queue.queuedCount > 0; - const { data: queuedResponse, mutate: mutateQueuedData } = useSWR( + const { data: queuedResponse, mutate: mutateQueuedData, isLoading } = useSWR( shouldFetchQueued ? ['listQueueItems', queue.name, queuedPage, perPage, remoteNode] : null, @@ -72,7 +72,13 @@ function QueueCard({ }); return response.data; }, - { refreshInterval: 3000 } + { + refreshInterval: 3000, + keepPreviousData: true, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + } ); const queuedItems = queuedResponse?.items ?? []; @@ -295,7 +301,7 @@ function QueueCard({ -
+
@@ -325,37 +331,15 @@ function QueueCard({
{/* Pagination controls */} - {pagination && pagination.totalPages > 1 && ( + {pagination && pagination.totalRecords > 0 && (
- - Page {pagination.currentPage} of {pagination.totalPages} - -
- - -
+
)}
From fb54fe737c3fd9ddd298fbc7eb71ce5dad069ffe Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sat, 17 Jan 2026 14:15:39 +0900 Subject: [PATCH 05/10] simplify code --- internal/service/frontend/api/v2/queues.go | 39 ++++---- .../features/queues/components/QueueCard.tsx | 92 +++++++++++-------- ui/src/pages/queues/index.tsx | 82 ++++++++--------- 3 files changed, 115 insertions(+), 98 deletions(-) diff --git a/internal/service/frontend/api/v2/queues.go b/internal/service/frontend/api/v2/queues.go index 95ff1eecc..15e7ea34c 100644 --- a/internal/service/frontend/api/v2/queues.go +++ b/internal/service/frontend/api/v2/queues.go @@ -144,6 +144,19 @@ func (a *API) ListQueues(ctx context.Context, _ api.ListQueuesRequestObject) (ap return api.ListQueues200JSONResponse(response), nil } +// fetchDAGRunSummary fetches the status and converts it to a summary for a given DAG-run reference. +func (a *API) fetchDAGRunSummary(ctx context.Context, dagRun exec.DAGRunRef) (api.DAGRunSummary, error) { + attempt, err := a.dagRunStore.FindAttempt(ctx, dagRun) + if err != nil { + return api.DAGRunSummary{}, err + } + runStatus, err := attempt.ReadStatus(ctx) + if err != nil { + return api.DAGRunSummary{}, err + } + return toDAGRunSummary(*runStatus), nil +} + // ListQueueItems implements api.StrictServerInterface. // Returns paginated items for a specific queue. func (a *API) ListQueueItems(ctx context.Context, req api.ListQueueItemsRequestObject) (api.ListQueueItemsResponseObject, error) { @@ -153,13 +166,12 @@ func (a *API) ListQueueItems(ctx context.Context, req api.ListQueueItemsRequestO itemType = *req.Params.Type } - // Create Paginator from page/perPage params pg := exec.NewPaginator(valueOf(req.Params.Page), valueOf(req.Params.PerPage)) - var items []api.DAGRunSummary + var total int if itemType == api.ListQueueItemsParamsTypeRunning { - // Get running items from proc store (bounded by maxConcurrency) + // Get running items from proc store runningByGroup, err := a.procStore.ListAllAlive(ctx) if err != nil { return nil, &Error{ @@ -170,21 +182,17 @@ func (a *API) ListQueueItems(ctx context.Context, req api.ListQueueItemsRequestO } runningRefs := runningByGroup[queueName] - total := len(runningRefs) + total = len(runningRefs) - // Apply pagination to running items + // Apply pagination startIndex := min(pg.Offset(), total) endIndex := min(pg.Offset()+pg.Limit(), total) for _, dagRun := range runningRefs[startIndex:endIndex] { - attempt, err := a.dagRunStore.FindAttempt(ctx, dagRun) + summary, err := a.fetchDAGRunSummary(ctx, dagRun) if err != nil { continue } - runStatus, err := attempt.ReadStatus(ctx) - if err != nil { - continue - } - items = append(items, toDAGRunSummary(*runStatus)) + items = append(items, summary) } paginatedResult := exec.NewPaginatedResult(items, total, pg) @@ -204,21 +212,16 @@ func (a *API) ListQueueItems(ctx context.Context, req api.ListQueueItemsRequestO } } - // Convert QueuedItemData to DAGRunSummary for _, queuedItem := range paginatedResult.Items { dagRunRef, err := queuedItem.Data() if err != nil { continue } - attempt, err := a.dagRunStore.FindAttempt(ctx, *dagRunRef) - if err != nil { - continue - } - runStatus, err := attempt.ReadStatus(ctx) + summary, err := a.fetchDAGRunSummary(ctx, *dagRunRef) if err != nil { continue } - items = append(items, toDAGRunSummary(*runStatus)) + items = append(items, summary) } return api.ListQueueItems200JSONResponse{ diff --git a/ui/src/features/queues/components/QueueCard.tsx b/ui/src/features/queues/components/QueueCard.tsx index acfe4c8db..acfdd5dc8 100644 --- a/ui/src/features/queues/components/QueueCard.tsx +++ b/ui/src/features/queues/components/QueueCard.tsx @@ -1,8 +1,4 @@ -import { - ChevronDown, - ChevronRight, - Trash2, -} from 'lucide-react'; +import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'; import React from 'react'; import useSWR from 'swr'; import type { components } from '../../../api/v2/schema'; @@ -54,7 +50,11 @@ function QueueCard({ // Fetch paginated queued items when expanded and there are queued items const shouldFetchQueued = isExpanded && queue.queuedCount > 0; - const { data: queuedResponse, mutate: mutateQueuedData, isLoading } = useSWR( + const { + data: queuedResponse, + mutate: mutateQueuedData, + isLoading, + } = useSWR( shouldFetchQueued ? ['listQueueItems', queue.name, queuedPage, perPage, remoteNode] : null, @@ -130,37 +130,43 @@ function QueueCard({ return Math.round((running / queue.maxConcurrency) * 100); }, [queue]); - const formatDateTime = (datetime: string | undefined): string => { + function formatDateTime(datetime: string | undefined): string { if (!datetime) return 'N/A'; const date = dayjs(datetime); - if (config.tzOffsetInSec !== undefined) { - return date.utcOffset(config.tzOffsetInSec / 60).format('MMM D, HH:mm:ss'); - } - return date.format('MMM D, HH:mm:ss'); - }; + const offset = config.tzOffsetInSec; + const format = 'MMM D, HH:mm:ss'; + return offset !== undefined + ? date.utcOffset(offset / 60).format(format) + : date.format(format); + } - const DAGRunRow: React.FC<{ + function DAGRunRow({ + dagRun, + showQueuedAt = false, + }: { dagRun: components['schemas']['DAGRunSummary']; showQueuedAt?: boolean; - }> = ({ dagRun, showQueuedAt = false }) => ( - onDAGRunClick(dagRun)} - className="cursor-pointer hover:bg-muted/30 transition-colors" - > - {dagRun.name} - - - {dagRun.statusLabel} - - - - {formatDateTime(showQueuedAt ? dagRun.queuedAt : dagRun.startedAt)} - - - {dagRun.dagRunId} - - - ); + }): React.JSX.Element { + return ( + onDAGRunClick(dagRun)} + className="cursor-pointer hover:bg-muted/30 transition-colors" + > + {dagRun.name} + + + {dagRun.statusLabel} + + + + {formatDateTime(showQueuedAt ? dagRun.queuedAt : dagRun.startedAt)} + + + {dagRun.dagRunId} + + + ); + } return (
running
- 0 ? 'text-foreground' : 'text-muted-foreground/50'}`}> + 0 ? 'text-foreground' : 'text-muted-foreground/50'}`} + > {queue.queuedCount || 0} queued @@ -272,7 +280,11 @@ function QueueCard({ {/* Queued DAGs */} {queue.queuedCount > 0 && ( -
0 ? 'border-t' : ''}> +
0 ? 'border-t' : '' + } + >
@@ -291,7 +303,10 @@ function QueueCard({ className="h-6 px-2 text-muted-foreground hover:text-foreground" > Clear @@ -301,7 +316,9 @@ function QueueCard({
-
+
@@ -369,7 +386,8 @@ function QueueCard({ Remove all queued DAG runs from "{queue.name}"?

- {queue.queuedCount || 0} DAG runs will be removed. This cannot be undone. + {queue.queuedCount || 0} DAG runs will be removed. This cannot be + undone.

diff --git a/ui/src/pages/queues/index.tsx b/ui/src/pages/queues/index.tsx index 2620e72f3..68c61fb94 100644 --- a/ui/src/pages/queues/index.tsx +++ b/ui/src/pages/queues/index.tsx @@ -1,19 +1,14 @@ -import React from 'react'; import { Layers, Search } from 'lucide-react'; +import React from 'react'; +import type { components } from '../../api/v2/schema'; import { Input } from '../../components/ui/input'; import { RefreshButton } from '../../components/ui/refresh-button'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '../../components/ui/tooltip'; import { AppBarContext } from '../../contexts/AppBarContext'; import { useSearchState } from '../../contexts/SearchStateContext'; -import { useQuery } from '../../hooks/api'; -import type { components } from '../../api/v2/schema'; -import QueueMetrics from '../../features/queues/components/QueueMetrics'; -import QueueList from '../../features/queues/components/QueueList'; import { DAGRunDetailsModal } from '../../features/dag-runs/components/dag-run-details'; +import QueueList from '../../features/queues/components/QueueList'; +import QueueMetrics from '../../features/queues/components/QueueMetrics'; +import { useQuery } from '../../hooks/api'; import Title from '../../ui/Title'; function Queues() { @@ -26,8 +21,9 @@ function Queues() { queueType: string; }; - const areQueueFiltersEqual = (a: QueueFilters, b: QueueFilters) => - a.searchText === b.searchText && a.queueType === b.queueType; + function areQueueFiltersEqual(a: QueueFilters, b: QueueFilters): boolean { + return a.searchText === b.searchText && a.queueType === b.queueType; + } const defaultFilters = React.useMemo( () => ({ @@ -116,9 +112,9 @@ function Queues() { } ); - const handleRefresh = async () => { + async function handleRefresh(): Promise { await mutate(); - }; + } // Filter queues based on search text and type const filteredQueues = React.useMemo(() => { @@ -221,37 +217,37 @@ function Queues() { Queues {/* Header with search and refresh */}
-
-
-
- - setSearchText(e.target.value)} - className="h-7 w-[200px] pl-7 text-xs" - /> -
- -
-
+
- {filteredQueues.length !== data?.queues?.length && ( - - ({filteredQueues.length} of {data?.queues?.length}) - - )} - +
+ + setSearchText(e.target.value)} + className="h-7 w-[200px] pl-7 text-xs" + /> +
+
+
+
+ {filteredQueues.length !== data?.queues?.length && ( + + ({filteredQueues.length} of {data?.queues?.length}) + + )} + +
{/* Metrics */} From 91eecfdbbe480eef8ebb0442e3130e55809beeaf Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sat, 17 Jan 2026 14:35:22 +0900 Subject: [PATCH 06/10] update status border color in dag vis graph --- .../dags/components/visualization/Graph.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/ui/src/features/dags/components/visualization/Graph.tsx b/ui/src/features/dags/components/visualization/Graph.tsx index 7056d432f..6d8354b81 100644 --- a/ui/src/features/dags/components/visualization/Graph.tsx +++ b/ui/src/features/dags/components/visualization/Graph.tsx @@ -65,7 +65,7 @@ declare global { * Graph component for visualizing DAG dagRuns * Renders a Mermaid.js flowchart with nodes and connections */ -const Graph: React.FC = ({ +function Graph({ steps, flowchart = 'TD', onChangeFlowchart, @@ -74,7 +74,7 @@ const Graph: React.FC = ({ onRightClickNode, showIcons = true, isExpandedView = false, -}) => { +}: Props): React.JSX.Element { const [scale, setScale] = useState(isExpandedView ? 0.8 : 1); const [isModalOpen, setIsModalOpen] = useState(false); const containerRef = React.useRef(null); @@ -155,11 +155,11 @@ const Graph: React.FC = ({ // Track node classes for separate application const nodeClasses = new Map(); - const addNodeFn = ( + function addNodeFn( step: components['schemas']['Step'], status: NodeStatus, node?: components['schemas']['Node'] - ) => { + ): void { const id = toMermaidNodeId(step.name); const c = graphStatusMap[status] || ''; @@ -229,7 +229,7 @@ const Graph: React.FC = ({ // We no longer add the standard Mermaid click handler // Double-click will be handled by our custom implementation - }; + } // Process nodes based on type if (type === 'status') { @@ -247,31 +247,31 @@ const Graph: React.FC = ({ const nodeColor = '#3d3833'; // foreground dat.push( - `classDef none color:${nodeColor},fill:${nodeFill},stroke:#c8bfb0,stroke-width:1.2px` + `classDef none color:${nodeColor},fill:${nodeFill},stroke:#c8bfb0,stroke-width:2.5px` ); dat.push( - `classDef running color:${nodeColor},fill:${nodeFill},stroke:#7da87d,stroke-width:1.2px` + `classDef running color:${nodeColor},fill:${nodeFill},stroke:#c0ebab,stroke-width:2.5px` ); dat.push( - `classDef error color:${nodeColor},fill:${nodeFill},stroke:#c4726a,stroke-width:1.2px` + `classDef error color:${nodeColor},fill:${nodeFill},stroke:#c4726a,stroke-width:2.5px` ); dat.push( - `classDef cancel color:${nodeColor},fill:${nodeFill},stroke:#d4a574,stroke-width:1.2px` + `classDef cancel color:${nodeColor},fill:${nodeFill},stroke:#e396b3,stroke-width:2.5px` ); dat.push( - `classDef done color:${nodeColor},fill:${nodeFill},stroke:#7da87d,stroke-width:1.2px` + `classDef done color:${nodeColor},fill:${nodeFill},stroke:#7da87d,stroke-width:2.5px` ); dat.push( - `classDef skipped color:${nodeColor},fill:${nodeFill},stroke:#6b635a,stroke-width:1.2px` + `classDef skipped color:${nodeColor},fill:${nodeFill},stroke:#6b635a,stroke-width:2.5px` ); dat.push( - `classDef partial color:${nodeColor},fill:${nodeFill},stroke:#c4956a,stroke-width:1.2px` + `classDef partial color:${nodeColor},fill:${nodeFill},stroke:#c4956a,stroke-width:2.5px` ); dat.push( - `classDef waiting color:${nodeColor},fill:${nodeFill},stroke:#f59e0b,stroke-width:1.2px` + `classDef waiting color:${nodeColor},fill:${nodeFill},stroke:#f59e0b,stroke-width:2.5px` ); dat.push( - `classDef rejected color:${nodeColor},fill:${nodeFill},stroke:#dc2626,stroke-width:1.2px` + `classDef rejected color:${nodeColor},fill:${nodeFill},stroke:#dc2626,stroke-width:2.5px` ); // Add custom link styles @@ -408,13 +408,13 @@ const Graph: React.FC = ({ )} ); -}; +} /** * Calculate the maximum breadth of the graph * This helps determine the appropriate width for the graph container */ -const calculateGraphBreadth = (steps: Steps) => { +function calculateGraphBreadth(steps: Steps): number { // Create a map of nodes and their dependencies const nodeMap = new Map(); const parentMap = new Map(); @@ -435,7 +435,7 @@ const calculateGraphBreadth = (steps: Steps) => { const nodeLevels = new Map(); const visited = new Set(); - const calculateLevel = (nodeName: string, level = 0) => { + function calculateLevel(nodeName: string, level = 0): void { if (visited.has(nodeName)) return; visited.add(nodeName); @@ -444,7 +444,7 @@ const calculateGraphBreadth = (steps: Steps) => { // Process children const children = parentMap.get(nodeName) || []; children.forEach((child) => calculateLevel(child, level + 1)); - }; + } // Start from nodes with no dependencies steps.forEach((node) => { @@ -467,7 +467,7 @@ const calculateGraphBreadth = (steps: Steps) => { }); return maxBreadth; -}; +} export default Graph; From 9c19bb91d169fedb72f22aca8531b273eceff236 Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sat, 17 Jan 2026 14:46:46 +0900 Subject: [PATCH 07/10] fxi test --- internal/cmn/telemetry/collector_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/cmn/telemetry/collector_test.go b/internal/cmn/telemetry/collector_test.go index d750e29cf..5258d9ddb 100644 --- a/internal/cmn/telemetry/collector_test.go +++ b/internal/cmn/telemetry/collector_test.go @@ -223,6 +223,11 @@ func (m *mockQueueStore) List(ctx context.Context, name string) ([]exec.QueuedIt return args.Get(0).([]exec.QueuedItemData), args.Error(1) } +func (m *mockQueueStore) ListPaginated(ctx context.Context, name string, pg exec.Paginator) (exec.PaginatedResult[exec.QueuedItemData], error) { + args := m.Called(ctx, name, pg) + return args.Get(0).(exec.PaginatedResult[exec.QueuedItemData]), args.Error(1) +} + func (m *mockQueueStore) All(ctx context.Context) ([]exec.QueuedItemData, error) { args := m.Called(ctx) if args.Get(0) == nil { From e1ba8f0001d983bb0aacc68ebe7eef80089ad79c Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sat, 17 Jan 2026 14:53:19 +0900 Subject: [PATCH 08/10] add static type check --- internal/cmn/telemetry/collector.go | 2 ++ internal/cmn/telemetry/collector_test.go | 8 ++++++++ internal/llm/providers/anthropic/anthropic.go | 2 ++ internal/llm/providers/gemini/gemini.go | 2 ++ internal/llm/providers/local/local.go | 2 ++ internal/llm/providers/openai/openai.go | 2 ++ internal/llm/providers/openrouter/openrouter.go | 2 ++ 7 files changed, 20 insertions(+) diff --git a/internal/cmn/telemetry/collector.go b/internal/cmn/telemetry/collector.go index e3232d32b..455b34e00 100644 --- a/internal/cmn/telemetry/collector.go +++ b/internal/cmn/telemetry/collector.go @@ -25,6 +25,8 @@ var ( ) // Collector implements prometheus.Collector interface +var _ prometheus.Collector = (*Collector)(nil) + type Collector struct { startTime time.Time version string diff --git a/internal/cmn/telemetry/collector_test.go b/internal/cmn/telemetry/collector_test.go index 5258d9ddb..6a30a3791 100644 --- a/internal/cmn/telemetry/collector_test.go +++ b/internal/cmn/telemetry/collector_test.go @@ -23,6 +23,8 @@ type mockDAGStore struct { mock.Mock } +var _ exec.DAGStore = (*mockDAGStore)(nil) + func (m *mockDAGStore) Create(ctx context.Context, fileName string, spec []byte) error { args := m.Called(ctx, fileName, spec) return args.Error(0) @@ -103,6 +105,8 @@ type mockDAGRunStore struct { mock.Mock } +var _ exec.DAGRunStore = (*mockDAGRunStore)(nil) + // RemoveDAGRun implements models.DAGRunStore. func (m *mockDAGRunStore) RemoveDAGRun(_ context.Context, _ exec.DAGRunRef) error { panic("unimplemented") @@ -180,6 +184,8 @@ type mockQueueStore struct { mock.Mock } +var _ exec.QueueStore = (*mockQueueStore)(nil) + // QueueWatcher implements execution.QueueStore. func (m *mockQueueStore) QueueWatcher(_ context.Context) exec.QueueWatcher { panic("unimplemented") @@ -242,6 +248,8 @@ type mockServiceRegistry struct { mock.Mock } +var _ exec.ServiceRegistry = (*mockServiceRegistry)(nil) + func (m *mockServiceRegistry) Register(ctx context.Context, serviceName exec.ServiceName, hostInfo exec.HostInfo) error { args := m.Called(ctx, serviceName, hostInfo) return args.Error(0) diff --git a/internal/llm/providers/anthropic/anthropic.go b/internal/llm/providers/anthropic/anthropic.go index 53feb6fd3..6b2e4dd83 100644 --- a/internal/llm/providers/anthropic/anthropic.go +++ b/internal/llm/providers/anthropic/anthropic.go @@ -32,6 +32,8 @@ func init() { } // Provider implements the llm.Provider interface for Anthropic Claude. +var _ llm.Provider = (*Provider)(nil) + type Provider struct { config llm.Config httpClient *llm.HTTPClient diff --git a/internal/llm/providers/gemini/gemini.go b/internal/llm/providers/gemini/gemini.go index 30905db55..29e1391f6 100644 --- a/internal/llm/providers/gemini/gemini.go +++ b/internal/llm/providers/gemini/gemini.go @@ -25,6 +25,8 @@ func init() { } // Provider implements the llm.Provider interface for Google Gemini. +var _ llm.Provider = (*Provider)(nil) + type Provider struct { config llm.Config httpClient *llm.HTTPClient diff --git a/internal/llm/providers/local/local.go b/internal/llm/providers/local/local.go index 1e18513a6..f3794f731 100644 --- a/internal/llm/providers/local/local.go +++ b/internal/llm/providers/local/local.go @@ -25,6 +25,8 @@ func init() { } // Provider implements the llm.Provider interface for local OpenAI-compatible servers. +var _ llm.Provider = (*Provider)(nil) + type Provider struct { config llm.Config httpClient *llm.HTTPClient diff --git a/internal/llm/providers/openai/openai.go b/internal/llm/providers/openai/openai.go index 58d3c30ee..391c8e449 100644 --- a/internal/llm/providers/openai/openai.go +++ b/internal/llm/providers/openai/openai.go @@ -24,6 +24,8 @@ func init() { } // Provider implements the llm.Provider interface for OpenAI. +var _ llm.Provider = (*Provider)(nil) + type Provider struct { config llm.Config httpClient *llm.HTTPClient diff --git a/internal/llm/providers/openrouter/openrouter.go b/internal/llm/providers/openrouter/openrouter.go index 3f2de87a3..5bf58455a 100644 --- a/internal/llm/providers/openrouter/openrouter.go +++ b/internal/llm/providers/openrouter/openrouter.go @@ -25,6 +25,8 @@ func init() { } // Provider implements the llm.Provider interface for OpenRouter. +var _ llm.Provider = (*Provider)(nil) + type Provider struct { config llm.Config httpClient *llm.HTTPClient From 94e0f9e03e6ea9ad4fb07558a5784fd210bda2cd Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sat, 17 Jan 2026 15:21:00 +0900 Subject: [PATCH 09/10] apply feedbacks --- internal/cmn/telemetry/collector_test.go | 2 -- internal/service/frontend/api/v2/queues.go | 20 ++++++++++++++++++- .../features/queues/components/QueueCard.tsx | 10 +++++++++- ui/src/pages/queues/index.tsx | 4 ++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/internal/cmn/telemetry/collector_test.go b/internal/cmn/telemetry/collector_test.go index 6a30a3791..ca8989c63 100644 --- a/internal/cmn/telemetry/collector_test.go +++ b/internal/cmn/telemetry/collector_test.go @@ -178,8 +178,6 @@ func (m *mockDAGRunStore) RenameDAGRuns(ctx context.Context, oldName, newName st return args.Error(0) } -var _ exec.QueueStore = (*mockQueueStore)(nil) - type mockQueueStore struct { mock.Mock } diff --git a/internal/service/frontend/api/v2/queues.go b/internal/service/frontend/api/v2/queues.go index 15e7ea34c..0eacc1ba3 100644 --- a/internal/service/frontend/api/v2/queues.go +++ b/internal/service/frontend/api/v2/queues.go @@ -98,7 +98,25 @@ func (a *API) ListQueues(ctx context.Context, _ api.ListQueuesRequestObject) (ap continue } - queue := getOrCreateQueue(queueMap, queueName, a.config, nil) + // Try to load DAG metadata for DAG-based queues that aren't in the map yet. + // This ensures their MaxActiveRuns is used for totalCapacity. + var dag *core.DAG + if _, exists := queueMap[queueName]; !exists && findGlobalQueueConfig(queueName, a.config) == nil { + // Not a global queue and not yet in map (no running items found). + // Peek at the first queued item to find out which DAG it belongs to. + res, err := a.queueStore.ListPaginated(ctx, queueName, exec.NewPaginator(1, 1)) + if err == nil && len(res.Items) > 0 { + ref, err := res.Items[0].Data() + if err == nil { + attempt, err := a.dagRunStore.FindAttempt(ctx, *ref) + if err == nil { + dag, _ = attempt.ReadDAG(ctx) + } + } + } + } + + queue := getOrCreateQueue(queueMap, queueName, a.config, dag) queue.queuedCount = count totalQueued += count } diff --git a/ui/src/features/queues/components/QueueCard.tsx b/ui/src/features/queues/components/QueueCard.tsx index acfdd5dc8..1b4a3f6e0 100644 --- a/ui/src/features/queues/components/QueueCard.tsx +++ b/ui/src/features/queues/components/QueueCard.tsx @@ -150,7 +150,15 @@ function QueueCard({ return (
onDAGRunClick(dagRun)} - className="cursor-pointer hover:bg-muted/30 transition-colors" + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onDAGRunClick(dagRun); + } + }} + role="button" + tabIndex={0} + className="cursor-pointer hover:bg-muted/30 transition-colors focus:bg-muted/50 focus:outline-none" >
{dagRun.name} diff --git a/ui/src/pages/queues/index.tsx b/ui/src/pages/queues/index.tsx index 68c61fb94..037bd2984 100644 --- a/ui/src/pages/queues/index.tsx +++ b/ui/src/pages/queues/index.tsx @@ -241,9 +241,9 @@ function Queues() {
- {filteredQueues.length !== data?.queues?.length && ( + {data?.queues && filteredQueues.length !== data.queues.length && ( - ({filteredQueues.length} of {data?.queues?.length}) + ({filteredQueues.length} of {data.queues.length}) )} From 69a704ba172fe885eb4dc64574cf8314e2f67b86 Mon Sep 17 00:00:00 2001 From: Yota Hamada Date: Sat, 17 Jan 2026 16:24:59 +0900 Subject: [PATCH 10/10] fix --- internal/service/frontend/api/v2/queues.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/service/frontend/api/v2/queues.go b/internal/service/frontend/api/v2/queues.go index 0eacc1ba3..0824d9055 100644 --- a/internal/service/frontend/api/v2/queues.go +++ b/internal/service/frontend/api/v2/queues.go @@ -239,6 +239,10 @@ func (a *API) ListQueueItems(ctx context.Context, req api.ListQueueItemsRequestO if err != nil { continue } + // Filter out running items from the "queued" list to avoid duplication + if summary.Status == api.StatusRunning { + continue + } items = append(items, summary) }