Show human readable information in queue info (#5516)

Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
This commit is contained in:
Robert Kaussow
2025-09-23 09:35:49 +02:00
committed by GitHub
parent 92ebabe568
commit 7707e843f2
15 changed files with 302 additions and 42 deletions

View File

@@ -1777,7 +1777,7 @@ const docTemplate = `{
},
"/queue/info": {
"get": {
"description": "TODO: link the InfoT response object - this is blocked, until the ` + "`" + `swaggo/swag` + "`" + ` tool dependency is v1.18.12 or newer",
"description": "Returns pipeline queue information with agent details",
"produces": [
"application/json"
],
@@ -1799,10 +1799,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
"$ref": "#/definitions/QueueInfo"
}
}
}
@@ -5019,6 +5016,49 @@ const docTemplate = `{
}
}
},
"QueueInfo": {
"type": "object",
"properties": {
"paused": {
"type": "boolean"
},
"pending": {
"type": "array",
"items": {
"$ref": "#/definitions/model.QueueTask"
}
},
"running": {
"type": "array",
"items": {
"$ref": "#/definitions/model.QueueTask"
}
},
"stats": {
"type": "object",
"properties": {
"pending_count": {
"type": "integer"
},
"running_count": {
"type": "integer"
},
"waiting_on_deps_count": {
"type": "integer"
},
"worker_count": {
"type": "integer"
}
}
},
"waiting_on_deps": {
"type": "array",
"items": {
"$ref": "#/definitions/model.QueueTask"
}
}
}
},
"Registry": {
"type": "object",
"properties": {
@@ -5453,6 +5493,18 @@ const docTemplate = `{
"type": "string"
}
},
"name": {
"type": "string"
},
"pid": {
"type": "integer"
},
"pipeline_id": {
"type": "integer"
},
"repo_id": {
"type": "integer"
},
"run_on": {
"type": "array",
"items": {
@@ -5803,6 +5855,59 @@ const docTemplate = `{
"ForgeTypeAddon"
]
},
"model.QueueTask": {
"type": "object",
"properties": {
"agent_id": {
"type": "integer"
},
"agent_name": {
"type": "string"
},
"dep_status": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/StatusValue"
}
},
"dependencies": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"name": {
"type": "string"
},
"pid": {
"type": "integer"
},
"pipeline_id": {
"type": "integer"
},
"pipeline_number": {
"type": "integer"
},
"repo_id": {
"type": "integer"
},
"run_on": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"model.TrustedConfiguration": {
"type": "object",
"properties": {

View File

@@ -2,7 +2,8 @@
FROM docker.io/golang:1.25-alpine AS golang_image
FROM docker.io/node:23-alpine
RUN apk add --no-cache --update make gcc binutils-gold musl-dev protoc && \
RUN apk add --no-cache --update make gcc binutils-gold musl-dev && \
apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main protoc && \
corepack enable
# Build packages.

View File

@@ -16,4 +16,4 @@ package proto
// Version is the version of the woodpecker.proto file,
// IMPORTANT: increased by 1 each time it get changed.
const Version int32 = 13
const Version int32 = 14

View File

@@ -15,7 +15,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc-gen-go v1.36.9
// protoc v6.31.1
// source: woodpecker.proto

View File

@@ -37,16 +37,115 @@ import (
// GetQueueInfo
//
// @Summary Get pipeline queue information
// @Description TODO: link the InfoT response object - this is blocked, until the `swaggo/swag` tool dependency is v1.18.12 or newer
// @Description Returns pipeline queue information with agent details
// @Router /queue/info [get]
// @Produce json
// @Success 200 {object} map[string]string
// @Success 200 {object} QueueInfo
// @Tags Pipeline queues
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
func GetQueueInfo(c *gin.Context) {
c.IndentedJSON(http.StatusOK,
server.Config.Services.Queue.Info(c),
)
info := server.Config.Services.Queue.Info(c)
_store := store.FromContext(c)
// Create a map to store agent names by ID
agentNameMap := make(map[int64]string)
// Process tasks and add agent names
pendingWithAgents, err := processQueueTasks(_store, info.Pending, agentNameMap)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
}
waitingWithAgents, err := processQueueTasks(_store, info.WaitingOnDeps, agentNameMap)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
}
runningWithAgents, err := processQueueTasks(_store, info.Running, agentNameMap)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
}
// Create response with agent-enhanced tasks
response := model.QueueInfo{
Pending: pendingWithAgents,
WaitingOnDeps: waitingWithAgents,
Running: runningWithAgents,
Stats: struct {
WorkerCount int `json:"worker_count"`
PendingCount int `json:"pending_count"`
WaitingOnDepsCount int `json:"waiting_on_deps_count"`
RunningCount int `json:"running_count"`
}{
WorkerCount: info.Stats.Workers,
PendingCount: info.Stats.Pending,
WaitingOnDepsCount: info.Stats.WaitingOnDeps,
RunningCount: info.Stats.Running,
},
Paused: info.Paused,
}
c.IndentedJSON(http.StatusOK, response)
}
// getAgentName finds an agent's name, utilizing a map as a cache.
func getAgentName(store store.Store, agentNameMap map[int64]string, agentID int64) (string, bool) {
// 1. Check the cache first.
name, exists := agentNameMap[agentID]
if exists {
return name, true
}
// 2. If not in cache, query the store.
agent, err := store.AgentFind(agentID)
if err != nil || agent == nil {
// Agent not found or an error occurred.
return "", false
}
// 3. Found the agent, update the cache and return the name.
if agent.Name != "" {
agentNameMap[agentID] = agent.Name
return agent.Name, true
}
return "", false
}
// processQueueTasks converts tasks to QueueTask structs and adds agent names.
func processQueueTasks(store store.Store, tasks []*model.Task, agentNameMap map[int64]string) ([]model.QueueTask, error) {
result := make([]model.QueueTask, 0, len(tasks))
for _, task := range tasks {
taskResponse := model.QueueTask{
Task: *task,
}
if task.AgentID == 0 {
result = append(result, taskResponse)
continue
}
name, ok := getAgentName(store, agentNameMap, task.AgentID)
if !ok {
return nil, fmt.Errorf("agent not found for task %s", task.ID)
}
taskResponse.AgentName = name
p, err := store.GetPipeline(task.PipelineID)
if err != nil {
return nil, err
}
taskResponse.PipelineNumber = p.Number
result = append(result, taskResponse)
}
return result, nil
}
// PauseQueue

View File

@@ -15,6 +15,7 @@
package grpc
import (
"maps"
"strings"
pipelineConsts "go.woodpecker-ci.org/woodpecker/v3/pipeline"
@@ -25,15 +26,18 @@ import (
func createFilterFunc(agentFilter rpc.Filter) queue.FilterFn {
return func(task *model.Task) (bool, int) {
// Create a copy of the labels for filtering to avoid modifying the original task
labels := maps.Clone(task.Labels)
// ignore internal labels for filtering
for k := range task.Labels {
for k := range labels {
if strings.HasPrefix(k, pipelineConsts.InternalLabelPrefix) {
delete(task.Labels, k)
delete(labels, k)
}
}
score := 0
for taskLabel, taskLabelValue := range task.Labels {
for taskLabel, taskLabelValue := range labels {
// if a task label is empty it will be ignored
if taskLabelValue == "" {
continue

22
server/model/queue.go Normal file
View File

@@ -0,0 +1,22 @@
package model
// QueueTask represents a task in the queue with additional API-specific fields.
type QueueTask struct {
Task
PipelineNumber int64 `json:"pipeline_number"`
AgentName string `json:"agent_name"`
}
// QueueInfo represents the response structure for queue information API.
type QueueInfo struct {
Pending []QueueTask `json:"pending"`
WaitingOnDeps []QueueTask `json:"waiting_on_deps"`
Running []QueueTask `json:"running"`
Stats struct {
WorkerCount int `json:"worker_count"`
PendingCount int `json:"pending_count"`
WaitingOnDepsCount int `json:"waiting_on_deps_count"`
RunningCount int `json:"running_count"`
} `json:"stats"`
Paused bool `json:"paused"`
} // @name QueueInfo

View File

@@ -25,12 +25,16 @@ import (
// Task defines scheduled pipeline Task.
type Task struct {
ID string `json:"id" xorm:"PK UNIQUE 'id'"`
PID int `json:"pid" xorm:"'pid'"`
Name string `json:"name" xorm:"'name'"`
Data []byte `json:"-" xorm:"LONGBLOB 'data'"`
Labels map[string]string `json:"labels" xorm:"json 'labels'"`
Dependencies []string `json:"dependencies" xorm:"json 'dependencies'"`
RunOn []string `json:"run_on" xorm:"json 'run_on'"`
DepStatus map[string]StatusValue `json:"dep_status" xorm:"json 'dependencies_status'"`
AgentID int64 `json:"agent_id" xorm:"'agent_id'"`
PipelineID int64 `json:"pipeline_id" xorm:"'pipeline_id'"`
RepoID int64 `json:"repo_id" xorm:"'repo_id'"`
} // @name Task
// TableName return database table name for xorm.

View File

@@ -33,8 +33,12 @@ func queuePipeline(ctx context.Context, repo *model.Repo, pipelineItems []*stepb
continue
}
task := &model.Task{
ID: fmt.Sprint(item.Workflow.ID),
Labels: make(map[string]string),
ID: fmt.Sprint(item.Workflow.ID),
PID: item.Workflow.PID,
Name: item.Workflow.Name,
Labels: make(map[string]string),
PipelineID: item.Workflow.PipelineID,
RepoID: repo.ID,
}
maps.Copy(task.Labels, item.Labels)
err := task.ApplyLabelsFromRepo(repo)

View File

@@ -271,7 +271,8 @@
"no_permission": "You are not allowed to access the debug information",
"metadata_exec_title": "Re-run pipeline locally",
"metadata_exec_desc": "Download the metadata of this pipeline to run it locally. This allows you to fix problems and test changes before committing them. The Woodpecker CLI must be installed locally in the same version as the server."
}
},
"view": "View pipeline"
}
},
"org": {
@@ -341,7 +342,10 @@
"badge": "org"
},
"version": "Version",
"last_contact": "Last contact",
"last_contact": {
"last_contact": "Last contact",
"badge": "last contact"
},
"never": "Never",
"delete_confirm": "Do you really want to delete this agent? It will no longer be able to connect to the server.",
"edit_agent": "Edit agent",

View File

@@ -51,7 +51,7 @@
<TextField :id="id" :model-value="agent.version" disabled />
</InputField>
<InputField v-slot="{ id }" :label="$t('admin.settings.agents.last_contact')">
<InputField v-slot="{ id }" :label="$t('admin.settings.agents.last_contact.last_contact')">
<TextField
:id="id"
:model-value="

View File

@@ -3,25 +3,24 @@
<ListItem
v-for="agent in agents"
:key="agent.id"
class="bg-wp-background-200! dark:bg-wp-background-100! items-center"
class="bg-wp-background-200! dark:bg-wp-background-100! items-center gap-2"
>
<span>{{ agent.name || `Agent ${agent.id}` }}</span>
<span class="ml-auto">
<span class="hidden space-x-2 md:inline-block">
<Badge
v-if="isAdmin === true && agent.org_id !== -1"
:label="$t('admin.settings.agents.org.badge')"
:value="agent.org_id"
/>
<Badge v-if="agent.platform" :label="$t('admin.settings.agents.platform.badge')" :value="agent.platform" />
<Badge v-if="agent.backend" :label="$t('admin.settings.agents.backend.badge')" :value="agent.backend" />
<Badge v-if="agent.capacity" :label="$t('admin.settings.agents.capacity.badge')" :value="agent.capacity" />
</span>
<span class="ml-2">{{
agent.last_contact ? date.timeAgo(agent.last_contact * 1000) : $t('admin.settings.agents.never')
}}</span>
<span class="ml-auto flex gap-2">
<Badge
v-if="isAdmin === true && agent.org_id !== -1"
:label="$t('admin.settings.agents.org.badge')"
:value="agent.org_id"
/>
<Badge v-if="agent.platform" :label="$t('admin.settings.agents.platform.badge')" :value="agent.platform" />
<Badge v-if="agent.backend" :label="$t('admin.settings.agents.backend.badge')" :value="agent.backend" />
<Badge v-if="agent.capacity" :label="$t('admin.settings.agents.capacity.badge')" :value="agent.capacity" />
<Badge
:label="$t('admin.settings.agents.last_contact.badge')"
:value="agent.last_contact ? date.timeAgo(agent.last_contact * 1000) : $t('admin.settings.agents.never')"
/>
</span>
<div class="ml-auto flex items-center gap-2">
<div class="ml-2 flex items-center gap-2">
<IconButton
icon="edit"
:title="$t('admin.settings.agents.edit_agent')"

View File

@@ -1,10 +1,16 @@
export interface Task {
id: number;
pid: number;
name: string;
labels: Record<string, string>;
dependencies: string[];
dep_status: Record<string, string>;
run_on: string[];
agent_id: number;
agent_name: string;
pipeline_id: number;
pipeline_number: number;
repo_id: number;
}
export interface QueueStats {

View File

@@ -32,7 +32,7 @@
:key="task.id"
class="bg-wp-background-200! dark:bg-wp-background-200! mb-2 flex-col items-center gap-4"
>
<div class="flex w-full items-center justify-between border-b pb-2">
<div class="flex w-full items-center justify-between gap-2 border-b pb-2">
<div
class="flex items-center gap-2"
:title="
@@ -57,11 +57,11 @@
'text-wp-state-neutral-100': task.status === 'pending',
}"
/>
<span>{{ task.id }}</span>
<span>{{ task.name }}</span>
</div>
<div class="flex items-center">
<span class="ml-auto flex gap-2">
<Badge v-if="task.agent_id !== 0" :label="$t('admin.settings.queue.agent')" :value="task.agent_id" />
<div class="ml-auto flex items-center gap-2">
<span class="flex gap-2">
<Badge v-if="task.agent_name" :label="$t('admin.settings.queue.agent')" :value="task.agent_name" />
<Badge
v-if="task.dependencies"
:label="$t('admin.settings.queue.waiting_for')"
@@ -69,6 +69,17 @@
/>
</span>
</div>
<div class="ml-2 flex items-center gap-2">
<IconButton
icon="chevron-right"
:title="$t('repo.pipeline.view')"
class="h-8 w-8"
:to="{
name: 'repo-pipeline',
params: { repoId: task.repo_id, pipelineId: task.pipeline_number, stepId: task.pid },
}"
/>
</div>
</div>
<div class="flex w-full flex-wrap gap-2">
<template v-for="(value, label) in task.labels">
@@ -89,6 +100,7 @@ import AdminQueueStats from '~/components/admin/settings/queue/AdminQueueStats.v
import Badge from '~/components/atomic/Badge.vue';
import Button from '~/components/atomic/Button.vue';
import Icon from '~/components/atomic/Icon.vue';
import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import Settings from '~/components/layout/Settings.vue';
import useApiClient from '~/compositions/useApiClient';