feat(api): Add v2 runner status API with AJAX polling
Some checks failed
Build and Release / Lint (push) Failing after 2m13s
Build and Release / Unit Tests (push) Successful in 2m48s
Build and Release / Create Release (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin) (push) Has been skipped
Build and Release / Build Binaries (arm64, linux) (push) Has been skipped
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 1m0s

- Add /api/v2/repos/{owner}/{repo}/actions/runners/status endpoint
- Add /api/v2/repos/{owner}/{repo}/actions/runners/{id}/status endpoint
- Add internal status JSON endpoint for admin panel polling
- Add JavaScript polling (10s interval) to runner edit page
- Status tiles now auto-update: online/offline, disk, bandwidth

🤖 Generated with Claude Code
This commit is contained in:
GitCaddy
2026-01-11 17:36:44 +00:00
parent b569c3f8a8
commit 15bd1d61c4
5 changed files with 288 additions and 0 deletions

View File

@@ -140,6 +140,8 @@ func Routes() *web.Router {
// Actions v2 API - AI-friendly runner capability discovery
m.Group("/repos/{owner}/{repo}/actions", func() {
m.Get("/runners/capabilities", repoAssignment(), GetActionsCapabilities)
m.Get("/runners/status", repoAssignment(), ListRunnersStatus)
m.Get("/runners/{runner_id}/status", repoAssignment(), GetRunnerStatus)
m.Post("/workflows/validate", repoAssignment(), reqToken(), web.Bind(api.WorkflowValidationRequest{}), ValidateWorkflow)
})

134
routers/api/v2/runners.go Normal file
View File

@@ -0,0 +1,134 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v2
import (
"net/http"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/json"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/context"
)
// RunnerStatusResponse represents the runner status for API/polling
type RunnerStatusResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
IsOnline bool `json:"is_online"`
Status string `json:"status"`
Version string `json:"version"`
Labels []string `json:"labels"`
LastOnline *time.Time `json:"last_online,omitempty"`
Capabilities *api.RunnerCapability `json:"capabilities,omitempty"`
}
// GetRunnerStatus returns the current status of a runner
// @Summary Get runner status
// @Description Returns current runner status including online state, capabilities, disk, and bandwidth
// @Tags actions
// @Accept json
// @Produce json
// @Param owner path string true "owner of the repo"
// @Param repo path string true "name of the repo"
// @Param runner_id path int64 true "runner ID"
// @Success 200 {object} RunnerStatusResponse
// @Router /repos/{owner}/{repo}/actions/runners/{runner_id}/status [get]
func GetRunnerStatus(ctx *context.APIContext) {
runnerID := ctx.PathParamInt64("runner_id")
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
// Check access - runner must belong to this repo or be global
repo := ctx.Repo.Repository
if runner.RepoID != 0 && runner.RepoID != repo.ID {
ctx.APIErrorNotFound(nil)
return
}
response := buildRunnerStatusResponse(runner)
ctx.JSON(http.StatusOK, response)
}
// GetAdminRunnerStatus returns the current status of a runner (admin endpoint)
// @Summary Get runner status (admin)
// @Description Returns current runner status for admin panel AJAX polling
// @Tags admin
// @Accept json
// @Produce json
// @Param runner_id path int64 true "runner ID"
// @Success 200 {object} RunnerStatusResponse
// @Router /admin/actions/runners/{runner_id}/status [get]
func GetAdminRunnerStatus(ctx *context.APIContext) {
runnerID := ctx.PathParamInt64("runner_id")
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
response := buildRunnerStatusResponse(runner)
ctx.JSON(http.StatusOK, response)
}
// buildRunnerStatusResponse creates a status response from a runner
func buildRunnerStatusResponse(runner *actions_model.ActionRunner) *RunnerStatusResponse {
response := &RunnerStatusResponse{
ID: runner.ID,
Name: runner.Name,
IsOnline: runner.IsOnline(),
Status: runner.Status().String(),
Version: runner.Version,
Labels: runner.AgentLabels,
}
// Add last online time if available
if runner.LastOnline.AsTime().Unix() > 0 {
lastOnline := runner.LastOnline.AsTime()
response.LastOnline = &lastOnline
}
// Parse capabilities JSON if available
if runner.CapabilitiesJSON != "" {
var caps api.RunnerCapability
if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps); err == nil {
response.Capabilities = &caps
}
}
return response
}
// ListRunnersStatus returns status for all runners accessible to the repo
// @Summary List runner statuses
// @Description Returns status for all runners available to the repository
// @Tags actions
// @Accept json
// @Produce json
// @Param owner path string true "owner of the repo"
// @Param repo path string true "name of the repo"
// @Success 200 {array} RunnerStatusResponse
// @Router /repos/{owner}/{repo}/actions/runners/status [get]
func ListRunnersStatus(ctx *context.APIContext) {
repo := ctx.Repo.Repository
runners, err := actions_model.GetRunnersOfRepo(ctx, repo.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
responses := make([]*RunnerStatusResponse, 0, len(runners))
for _, runner := range runners {
responses = append(responses, buildRunnerStatusResponse(runner))
}
ctx.JSON(http.StatusOK, responses)
}

View File

@@ -580,3 +580,63 @@ func RunnerUseSuggestedLabels(ctx *context.Context) {
ctx.Flash.Success("Added labels: " + strings.Join(added, ", "))
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
}
// RunnerStatusJSON returns runner status as JSON for AJAX polling
func RunnerStatusJSON(ctx *context.Context) {
rCtx, err := getRunnersCtx(ctx)
if err != nil {
ctx.ServerError("getRunnersCtx", err)
return
}
runner := findActionsRunner(ctx, rCtx)
if runner == nil {
return
}
// Parse capabilities
var caps *structs.RunnerCapability
if runner.CapabilitiesJSON != "" {
caps = &structs.RunnerCapability{}
if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), caps); err != nil {
caps = nil
}
}
// Build response matching the tile structure
response := map[string]any{
"id": runner.ID,
"name": runner.Name,
"is_online": runner.IsOnline(),
"status": runner.StatusLocaleName(ctx.Locale),
"version": runner.Version,
"labels": runner.AgentLabels,
}
if runner.LastOnline.AsTime().Unix() > 0 {
response["last_online"] = runner.LastOnline.AsTime().Format("2006-01-02T15:04:05Z")
}
if caps != nil {
if caps.Disk != nil {
response["disk"] = map[string]any{
"total_bytes": caps.Disk.Total,
"free_bytes": caps.Disk.Free,
"used_bytes": caps.Disk.Used,
"used_percent": caps.Disk.UsedPercent,
}
}
if caps.Bandwidth != nil {
bw := map[string]any{
"download_mbps": caps.Bandwidth.DownloadMbps,
"latency_ms": caps.Bandwidth.Latency,
}
if !caps.Bandwidth.TestedAt.IsZero() {
bw["tested_at"] = caps.Bandwidth.TestedAt.Format("2006-01-02T15:04:05Z")
}
response["bandwidth"] = bw
}
}
ctx.JSON(http.StatusOK, response)
}

View File

@@ -514,6 +514,7 @@ func registerWebRoutes(m *web.Router) {
m.Post("/{runnerid}/add-label", shared_actions.RunnerAddLabel)
m.Post("/{runnerid}/remove-label", shared_actions.RunnerRemoveLabel)
m.Post("/{runnerid}/use-suggested-labels", shared_actions.RunnerUseSuggestedLabels)
m.Get("/{runnerid}/status", shared_actions.RunnerStatusJSON)
m.Post("/reset_registration_token", shared_actions.ResetRunnerRegistrationToken)
})
}

View File

@@ -288,3 +288,94 @@
{{template "base/modal_actions_confirm" .}}
</div>
</div>
<script>
(function() {
const statusUrl = '{{.Link}}/status';
const pollInterval = 10000; // 10 seconds
function formatBytes(bytes) {
const gb = bytes / 1073741824;
return gb.toFixed(1) + ' GB';
}
function updateStatus() {
fetch(statusUrl, {
headers: {'Accept': 'application/json'}
})
.then(response => response.json())
.then(data => {
// Update status tile
const statusTile = document.querySelector('.runner-container .column:first-child .segment');
if (statusTile) {
const statusLabel = statusTile.querySelector('.label');
const statusText = statusTile.querySelector('.tw-text-xs');
if (statusLabel) {
statusLabel.className = 'ui ' + (data.is_online ? 'green' : 'red') + ' large label';
statusLabel.innerHTML = (data.is_online ?
'<svg class="svg octicon-check-circle" width="16" height="16"><use xlink:href="#octicon-check-circle"></use></svg>' :
'<svg class="svg octicon-x-circle" width="16" height="16"><use xlink:href="#octicon-x-circle"></use></svg>') +
' ' + data.status;
}
if (statusText) {
statusText.textContent = data.is_online ? 'Connected' :
(data.last_online ? 'Last seen ' + new Date(data.last_online).toLocaleString() : 'Never connected');
}
}
// Update disk tile
if (data.disk) {
const diskTile = document.querySelector('.runner-container .column:nth-child(2) .segment');
if (diskTile) {
const diskLabel = diskTile.querySelector('.label');
const diskText = diskTile.querySelector('.tw-text-xs');
const usedPct = data.disk.used_percent;
if (diskLabel) {
const color = usedPct >= 95 ? 'red' : (usedPct >= 85 ? 'yellow' : 'green');
const icon = usedPct >= 85 ? 'octicon-alert' : 'octicon-database';
diskLabel.className = 'ui ' + color + ' large label';
diskLabel.innerHTML = '<svg class="svg ' + icon + '" width="16" height="16"><use xlink:href="#' + icon + '"></use></svg> ' +
Math.round(usedPct) + '% used';
}
if (diskText) {
diskText.textContent = formatBytes(data.disk.free_bytes) + ' free of ' + formatBytes(data.disk.total_bytes);
}
}
}
// Update bandwidth tile
if (data.bandwidth) {
const bwTile = document.querySelector('.runner-container .column:nth-child(3) .segment');
if (bwTile) {
const bwLabel = bwTile.querySelector('.label');
const bwText = bwTile.querySelector('.tw-text-xs');
const mbps = data.bandwidth.download_mbps;
if (bwLabel) {
const color = mbps >= 100 ? 'green' : (mbps >= 10 ? 'blue' : 'yellow');
bwLabel.className = 'ui ' + color + ' large label';
bwLabel.innerHTML = '<svg class="svg octicon-arrow-down" width="16" height="16"><use xlink:href="#octicon-arrow-down"></use></svg> ' +
Math.round(mbps) + ' Mbps';
}
if (bwText && data.bandwidth.latency_ms) {
let text = Math.round(data.bandwidth.latency_ms) + ' ms latency';
if (data.bandwidth.tested_at) {
text += ' - tested ' + new Date(data.bandwidth.tested_at).toLocaleString();
}
bwText.textContent = text;
}
}
}
})
.catch(err => console.log('Status poll error:', err));
}
// Start polling
setInterval(updateStatus, pollInterval);
})();
</script>