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
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:
@@ -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
134
routers/api/v2/runners.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user