Adds a new page to view all waiting/blocked jobs for a specific runner label. This helps administrators identify which jobs are queued for particular runner labels and diagnose runner capacity issues.
843 lines
23 KiB
Go
843 lines
23 KiB
Go
// Copyright 2022 The Gitea Authors and MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
actions_model "code.gitcaddy.com/server/v3/models/actions"
|
|
"code.gitcaddy.com/server/v3/models/db"
|
|
"code.gitcaddy.com/server/v3/modules/json"
|
|
"code.gitcaddy.com/server/v3/modules/log"
|
|
"code.gitcaddy.com/server/v3/modules/setting"
|
|
"code.gitcaddy.com/server/v3/modules/structs"
|
|
"code.gitcaddy.com/server/v3/modules/templates"
|
|
"code.gitcaddy.com/server/v3/modules/timeutil"
|
|
"code.gitcaddy.com/server/v3/modules/util"
|
|
"code.gitcaddy.com/server/v3/modules/web"
|
|
shared_user "code.gitcaddy.com/server/v3/routers/web/shared/user"
|
|
"code.gitcaddy.com/server/v3/services/context"
|
|
"code.gitcaddy.com/server/v3/services/forms"
|
|
)
|
|
|
|
const (
|
|
// TODO: Separate secrets from runners when layout is ready
|
|
tplRepoRunners templates.TplName = "repo/settings/actions"
|
|
tplOrgRunners templates.TplName = "org/settings/actions"
|
|
tplAdminRunners templates.TplName = "admin/actions"
|
|
tplUserRunners templates.TplName = "user/settings/actions"
|
|
tplRepoRunnerEdit templates.TplName = "repo/settings/runner_edit"
|
|
tplOrgRunnerEdit templates.TplName = "org/settings/runners_edit"
|
|
tplAdminRunnerEdit templates.TplName = "admin/runners/edit"
|
|
tplUserRunnerEdit templates.TplName = "user/settings/runner_edit"
|
|
tplAdminWaitingJobs templates.TplName = "admin/runners/waiting_jobs"
|
|
)
|
|
|
|
type runnersCtx struct {
|
|
OwnerID int64
|
|
RepoID int64
|
|
IsRepo bool
|
|
IsOrg bool
|
|
IsAdmin bool
|
|
IsUser bool
|
|
RunnersTemplate templates.TplName
|
|
RunnerEditTemplate templates.TplName
|
|
RedirectLink string
|
|
}
|
|
|
|
func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
|
|
if ctx.Data["PageIsRepoSettings"] == true {
|
|
return &runnersCtx{
|
|
RepoID: ctx.Repo.Repository.ID,
|
|
OwnerID: 0,
|
|
IsRepo: true,
|
|
RunnersTemplate: tplRepoRunners,
|
|
RunnerEditTemplate: tplRepoRunnerEdit,
|
|
RedirectLink: ctx.Repo.RepoLink + "/settings/actions/runners/",
|
|
}, nil
|
|
}
|
|
|
|
if ctx.Data["PageIsOrgSettings"] == true {
|
|
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
|
ctx.ServerError("RenderUserOrgHeader", err)
|
|
return nil, nil
|
|
}
|
|
return &runnersCtx{
|
|
RepoID: 0,
|
|
OwnerID: ctx.Org.Organization.ID,
|
|
IsOrg: true,
|
|
RunnersTemplate: tplOrgRunners,
|
|
RunnerEditTemplate: tplOrgRunnerEdit,
|
|
RedirectLink: ctx.Org.OrgLink + "/settings/actions/runners/",
|
|
}, nil
|
|
}
|
|
|
|
if ctx.Data["PageIsAdmin"] == true {
|
|
return &runnersCtx{
|
|
RepoID: 0,
|
|
OwnerID: 0,
|
|
IsAdmin: true,
|
|
RunnersTemplate: tplAdminRunners,
|
|
RunnerEditTemplate: tplAdminRunnerEdit,
|
|
RedirectLink: setting.AppSubURL + "/-/admin/actions/runners/",
|
|
}, nil
|
|
}
|
|
|
|
if ctx.Data["PageIsUserSettings"] == true {
|
|
return &runnersCtx{
|
|
OwnerID: ctx.Doer.ID,
|
|
RepoID: 0,
|
|
IsUser: true,
|
|
RunnersTemplate: tplUserRunners,
|
|
RunnerEditTemplate: tplUserRunnerEdit,
|
|
RedirectLink: setting.AppSubURL + "/user/settings/actions/runners/",
|
|
}, nil
|
|
}
|
|
|
|
return nil, errors.New("unable to set Runners context")
|
|
}
|
|
|
|
// Runners render settings/actions/runners page for repo level
|
|
func Runners(ctx *context.Context) {
|
|
ctx.Data["PageIsSharedSettingsRunners"] = true
|
|
ctx.Data["Title"] = ctx.Tr("actions.actions")
|
|
ctx.Data["PageType"] = "runners"
|
|
|
|
rCtx, err := getRunnersCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getRunnersCtx", err)
|
|
return
|
|
}
|
|
|
|
page := max(ctx.FormInt("page"), 1)
|
|
|
|
opts := actions_model.FindRunnerOptions{
|
|
ListOptions: db.ListOptions{
|
|
Page: page,
|
|
PageSize: 100,
|
|
},
|
|
Sort: ctx.Req.URL.Query().Get("sort"),
|
|
Filter: ctx.Req.URL.Query().Get("q"),
|
|
}
|
|
if rCtx.IsRepo {
|
|
opts.RepoID = rCtx.RepoID
|
|
opts.WithAvailable = true
|
|
} else if rCtx.IsOrg || rCtx.IsUser {
|
|
opts.OwnerID = rCtx.OwnerID
|
|
opts.WithAvailable = true
|
|
}
|
|
|
|
runners, count, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts)
|
|
if err != nil {
|
|
ctx.ServerError("CountRunners", err)
|
|
return
|
|
}
|
|
|
|
if err := actions_model.RunnerList(runners).LoadAttributes(ctx); err != nil {
|
|
ctx.ServerError("LoadAttributes", err)
|
|
return
|
|
}
|
|
|
|
// ownid=0,repo_id=0,means this token is used for global
|
|
var token *actions_model.ActionRunnerToken
|
|
token, err = actions_model.GetLatestRunnerToken(ctx, opts.OwnerID, opts.RepoID)
|
|
if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) {
|
|
token, err = actions_model.NewRunnerToken(ctx, opts.OwnerID, opts.RepoID)
|
|
if err != nil {
|
|
ctx.ServerError("CreateRunnerToken", err)
|
|
return
|
|
}
|
|
} else if err != nil {
|
|
ctx.ServerError("GetLatestRunnerToken", err)
|
|
return
|
|
}
|
|
|
|
ctx.Data["Keyword"] = opts.Filter
|
|
ctx.Data["Runners"] = runners
|
|
ctx.Data["Total"] = count
|
|
ctx.Data["RegistrationToken"] = token.Token
|
|
ctx.Data["RunnerOwnerID"] = opts.OwnerID
|
|
ctx.Data["RunnerRepoID"] = opts.RepoID
|
|
ctx.Data["SortType"] = opts.Sort
|
|
|
|
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
|
|
|
|
ctx.Data["Page"] = pager
|
|
|
|
ctx.HTML(http.StatusOK, rCtx.RunnersTemplate)
|
|
}
|
|
|
|
// RunnersEdit renders runner edit page for repository level
|
|
func RunnersEdit(ctx *context.Context) {
|
|
ctx.Data["PageIsSharedSettingsRunners"] = true
|
|
ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner")
|
|
rCtx, err := getRunnersCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getRunnersCtx", err)
|
|
return
|
|
}
|
|
|
|
page := max(ctx.FormInt("page"), 1)
|
|
|
|
runnerID := ctx.PathParamInt64("runnerid")
|
|
ownerID := rCtx.OwnerID
|
|
repoID := rCtx.RepoID
|
|
|
|
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
|
if err != nil {
|
|
ctx.ServerError("GetRunnerByID", err)
|
|
return
|
|
}
|
|
if err := runner.LoadAttributes(ctx); err != nil {
|
|
ctx.ServerError("LoadAttributes", err)
|
|
return
|
|
}
|
|
if !runner.EditableInContext(ownerID, repoID) {
|
|
err = errors.New("no permission to edit this runner")
|
|
ctx.NotFound(err)
|
|
return
|
|
}
|
|
|
|
ctx.Data["Runner"] = runner
|
|
|
|
// Parse runner capabilities if available
|
|
if runner.CapabilitiesJSON != "" {
|
|
var caps structs.RunnerCapability
|
|
if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps); err == nil {
|
|
ctx.Data["RunnerCapabilities"] = &caps
|
|
}
|
|
}
|
|
|
|
opts := actions_model.FindTaskOptions{
|
|
ListOptions: db.ListOptions{
|
|
Page: page,
|
|
PageSize: 30,
|
|
},
|
|
Status: actions_model.StatusUnknown, // Unknown means all
|
|
RunnerID: runner.ID,
|
|
}
|
|
|
|
tasks, count, err := db.FindAndCount[actions_model.ActionTask](ctx, opts)
|
|
if err != nil {
|
|
ctx.ServerError("CountTasks", err)
|
|
return
|
|
}
|
|
|
|
if err = actions_model.TaskList(tasks).LoadAttributes(ctx); err != nil {
|
|
ctx.ServerError("TasksLoadAttributes", err)
|
|
return
|
|
}
|
|
|
|
ctx.Data["Tasks"] = tasks
|
|
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
|
|
ctx.Data["Page"] = pager
|
|
|
|
ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate)
|
|
}
|
|
|
|
func RunnersEditPost(ctx *context.Context) {
|
|
rCtx, err := getRunnersCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getRunnersCtx", err)
|
|
return
|
|
}
|
|
|
|
runnerID := ctx.PathParamInt64("runnerid")
|
|
ownerID := rCtx.OwnerID
|
|
repoID := rCtx.RepoID
|
|
redirectTo := rCtx.RedirectLink
|
|
|
|
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
|
if err != nil {
|
|
log.Warn("RunnerDetailsEditPost.GetRunnerByID failed: %v, url: %s", err, ctx.Req.URL)
|
|
ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err)
|
|
return
|
|
}
|
|
if !runner.EditableInContext(ownerID, repoID) {
|
|
ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner"))
|
|
return
|
|
}
|
|
|
|
form := web.GetForm(ctx).(*forms.EditRunnerForm)
|
|
runner.Description = form.Description
|
|
|
|
err = actions_model.UpdateRunner(ctx, runner, "description")
|
|
if err != nil {
|
|
log.Warn("RunnerDetailsEditPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
|
|
ctx.Flash.Warning(ctx.Tr("actions.runners.update_runner_failed"))
|
|
ctx.Redirect(redirectTo)
|
|
return
|
|
}
|
|
|
|
log.Debug("RunnerDetailsEditPost success: %s", ctx.Req.URL)
|
|
|
|
ctx.Flash.Success(ctx.Tr("actions.runners.update_runner_success"))
|
|
ctx.Redirect(redirectTo)
|
|
}
|
|
|
|
func ResetRunnerRegistrationToken(ctx *context.Context) {
|
|
rCtx, err := getRunnersCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getRunnersCtx", err)
|
|
return
|
|
}
|
|
|
|
ownerID := rCtx.OwnerID
|
|
repoID := rCtx.RepoID
|
|
redirectTo := rCtx.RedirectLink
|
|
|
|
if _, err := actions_model.NewRunnerToken(ctx, ownerID, repoID); err != nil {
|
|
ctx.ServerError("ResetRunnerRegistrationToken", err)
|
|
return
|
|
}
|
|
ctx.Flash.Success(ctx.Tr("actions.runners.reset_registration_token_success"))
|
|
ctx.JSONRedirect(redirectTo)
|
|
}
|
|
|
|
// runnerRequestTimestamp is a helper for runner timestamp request operations
|
|
func runnerRequestTimestamp(ctx *context.Context, opName string, setField func(*actions_model.ActionRunner), fieldName, successMsg, failMsg string) {
|
|
rCtx, err := getRunnersCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getRunnersCtx", err)
|
|
return
|
|
}
|
|
|
|
runnerID := ctx.PathParamInt64("runnerid")
|
|
redirectTo := rCtx.RedirectLink + url.PathEscape(ctx.PathParam("runnerid"))
|
|
|
|
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
|
if err != nil {
|
|
log.Warn("%s.GetRunnerByID failed: %v, url: %s", opName, err, ctx.Req.URL)
|
|
ctx.ServerError(opName+".GetRunnerByID", err)
|
|
return
|
|
}
|
|
if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) {
|
|
ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner"))
|
|
return
|
|
}
|
|
|
|
setField(runner)
|
|
err = actions_model.UpdateRunner(ctx, runner, fieldName)
|
|
if err != nil {
|
|
log.Warn("%s.UpdateRunner failed: %v, url: %s", opName, err, ctx.Req.URL)
|
|
ctx.Flash.Warning(ctx.Tr(failMsg))
|
|
ctx.Redirect(redirectTo)
|
|
return
|
|
}
|
|
|
|
log.Debug("%s success: %s", opName, ctx.Req.URL)
|
|
ctx.Flash.Success(ctx.Tr(successMsg))
|
|
ctx.Redirect(redirectTo)
|
|
}
|
|
|
|
// RunnerRequestBandwidthTest handles admin request to trigger a bandwidth test
|
|
func RunnerRequestBandwidthTest(ctx *context.Context) {
|
|
runnerRequestTimestamp(ctx, "RunnerRequestBandwidthTest",
|
|
func(r *actions_model.ActionRunner) { r.BandwidthTestRequestedAt = timeutil.TimeStampNow() },
|
|
"bandwidth_test_requested_at",
|
|
"actions.runners.bandwidth_test_requested",
|
|
"actions.runners.bandwidth_test_request_failed")
|
|
}
|
|
|
|
// RunnerDeletePost response for deleting runner
|
|
func RunnerDeletePost(ctx *context.Context) {
|
|
rCtx, err := getRunnersCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getRunnersCtx", err)
|
|
return
|
|
}
|
|
|
|
runner := findActionsRunner(ctx, rCtx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) {
|
|
ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to delete this runner"))
|
|
return
|
|
}
|
|
|
|
successRedirectTo := rCtx.RedirectLink
|
|
failedRedirectTo := rCtx.RedirectLink + url.PathEscape(ctx.PathParam("runnerid"))
|
|
|
|
if err := actions_model.DeleteRunner(ctx, runner.ID); err != nil {
|
|
log.Warn("DeleteRunnerPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
|
|
ctx.Flash.Warning(ctx.Tr("actions.runners.delete_runner_failed"))
|
|
|
|
ctx.JSONRedirect(failedRedirectTo)
|
|
return
|
|
}
|
|
|
|
log.Info("DeleteRunnerPost success: %s", ctx.Req.URL)
|
|
|
|
ctx.Flash.Success(ctx.Tr("actions.runners.delete_runner_success"))
|
|
|
|
ctx.JSONRedirect(successRedirectTo)
|
|
}
|
|
|
|
func RedirectToDefaultSetting(ctx *context.Context) {
|
|
ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners")
|
|
}
|
|
|
|
func findActionsRunner(ctx *context.Context, rCtx *runnersCtx) *actions_model.ActionRunner {
|
|
runnerID := ctx.PathParamInt64("runnerid")
|
|
opts := &actions_model.FindRunnerOptions{
|
|
IDs: []int64{runnerID},
|
|
}
|
|
switch {
|
|
case rCtx.IsRepo:
|
|
opts.RepoID = rCtx.RepoID
|
|
if opts.RepoID == 0 {
|
|
panic("repoID is 0")
|
|
}
|
|
case rCtx.IsOrg, rCtx.IsUser:
|
|
opts.OwnerID = rCtx.OwnerID
|
|
if opts.OwnerID == 0 {
|
|
panic("ownerID is 0")
|
|
}
|
|
case rCtx.IsAdmin:
|
|
// do nothing
|
|
default:
|
|
panic("invalid actions runner context")
|
|
}
|
|
|
|
got, err := db.Find[actions_model.ActionRunner](ctx, opts)
|
|
if err != nil {
|
|
ctx.ServerError("FindRunner", err)
|
|
return nil
|
|
} else if len(got) == 0 {
|
|
ctx.NotFound(errors.New("runner not found"))
|
|
return nil
|
|
}
|
|
|
|
return got[0]
|
|
}
|
|
|
|
// RunnerRequestCleanup handles admin request to trigger a force cleanup on the runner
|
|
func RunnerRequestCleanup(ctx *context.Context) {
|
|
runnerRequestTimestamp(ctx, "RunnerRequestCleanup",
|
|
func(r *actions_model.ActionRunner) { r.CleanupRequestedAt = timeutil.TimeStampNow() },
|
|
"cleanup_requested_at",
|
|
"actions.runners.cleanup_requested",
|
|
"actions.runners.cleanup_request_failed")
|
|
}
|
|
|
|
// RunnerAddLabel adds a single label to a runner
|
|
func RunnerAddLabel(ctx *context.Context) {
|
|
rCtx, err := getRunnersCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getRunnersCtx", err)
|
|
return
|
|
}
|
|
|
|
runner := findActionsRunner(ctx, rCtx)
|
|
if runner == nil {
|
|
return
|
|
}
|
|
|
|
label := ctx.FormString("label")
|
|
if label == "" {
|
|
ctx.Flash.Warning("No label specified")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
// Check if label already exists
|
|
if slices.Contains(runner.AgentLabels, label) {
|
|
ctx.Flash.Info("Label already exists")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
// Add the label
|
|
runner.AgentLabels = append(runner.AgentLabels, label)
|
|
|
|
err = actions_model.UpdateRunner(ctx, runner, "agent_labels")
|
|
if err != nil {
|
|
log.Warn("RunnerAddLabel.UpdateRunner failed: %v", err)
|
|
ctx.Flash.Warning("Failed to add label")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
ctx.Flash.Success("Label added: " + label)
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
}
|
|
|
|
// RunnerRemoveLabel removes a single label from a runner
|
|
func RunnerRemoveLabel(ctx *context.Context) {
|
|
rCtx, err := getRunnersCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getRunnersCtx", err)
|
|
return
|
|
}
|
|
|
|
runner := findActionsRunner(ctx, rCtx)
|
|
if runner == nil {
|
|
return
|
|
}
|
|
|
|
label := ctx.FormString("label")
|
|
if label == "" {
|
|
ctx.Flash.Warning("No label specified")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
// Remove the label
|
|
newLabels := make([]string, 0, len(runner.AgentLabels))
|
|
found := false
|
|
for _, existing := range runner.AgentLabels {
|
|
if existing == label {
|
|
found = true
|
|
} else {
|
|
newLabels = append(newLabels, existing)
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
ctx.Flash.Info("Label not found")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
runner.AgentLabels = newLabels
|
|
|
|
err = actions_model.UpdateRunner(ctx, runner, "agent_labels")
|
|
if err != nil {
|
|
log.Warn("RunnerRemoveLabel.UpdateRunner failed: %v", err)
|
|
ctx.Flash.Warning("Failed to remove label")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
ctx.Flash.Success("Label removed: " + label)
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
}
|
|
|
|
// RunnerUseSuggestedLabels adds all suggested labels based on capabilities
|
|
func RunnerUseSuggestedLabels(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 to get suggested labels
|
|
if runner.CapabilitiesJSON == "" {
|
|
ctx.Flash.Warning("No capabilities data available")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
var caps structs.RunnerCapability
|
|
if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps); err != nil {
|
|
ctx.Flash.Warning("Failed to parse capabilities")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
// Build suggested labels
|
|
suggestedLabels := []string{}
|
|
existingSet := make(map[string]bool)
|
|
for _, label := range runner.AgentLabels {
|
|
existingSet[label] = true
|
|
}
|
|
|
|
// OS-based labels
|
|
switch caps.OS {
|
|
case "linux":
|
|
suggestedLabels = append(suggestedLabels, "linux", "linux-latest")
|
|
case "windows":
|
|
suggestedLabels = append(suggestedLabels, "windows", "windows-latest")
|
|
case "darwin":
|
|
suggestedLabels = append(suggestedLabels, "macos", "macos-latest")
|
|
}
|
|
|
|
// Distro-based labels
|
|
if caps.Distro != nil && caps.Distro.ID != "" {
|
|
suggestedLabels = append(suggestedLabels, caps.Distro.ID, caps.Distro.ID+"-latest")
|
|
}
|
|
|
|
// Add only new labels
|
|
added := []string{}
|
|
for _, label := range suggestedLabels {
|
|
if !existingSet[label] {
|
|
runner.AgentLabels = append(runner.AgentLabels, label)
|
|
added = append(added, label)
|
|
existingSet[label] = true
|
|
}
|
|
}
|
|
|
|
if len(added) == 0 {
|
|
ctx.Flash.Info("All suggested labels already exist")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
err = actions_model.UpdateRunner(ctx, runner, "agent_labels")
|
|
if err != nil {
|
|
log.Warn("RunnerUseSuggestedLabels.UpdateRunner failed: %v", err)
|
|
ctx.Flash.Warning("Failed to add labels")
|
|
ctx.Redirect(rCtx.RedirectLink + ctx.PathParam("runnerid"))
|
|
return
|
|
}
|
|
|
|
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(),
|
|
"is_healthy": runner.IsHealthy(),
|
|
"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.CPU != nil {
|
|
response["cpu"] = map[string]any{
|
|
"num_cpu": caps.CPU.NumCPU,
|
|
"load_avg_1m": caps.CPU.LoadAvg1m,
|
|
"load_avg_5m": caps.CPU.LoadAvg5m,
|
|
"load_avg_15m": caps.CPU.LoadAvg15m,
|
|
"load_percent": caps.CPU.LoadPercent,
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
// RunnersStatusJSON returns status for all runners as JSON for AJAX polling on the list page
|
|
func RunnersStatusJSON(ctx *context.Context) {
|
|
rCtx, err := getRunnersCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getRunnersCtx", err)
|
|
return
|
|
}
|
|
|
|
opts := actions_model.FindRunnerOptions{
|
|
ListOptions: db.ListOptions{
|
|
Page: 1,
|
|
PageSize: 100,
|
|
},
|
|
}
|
|
if rCtx.IsRepo {
|
|
opts.RepoID = rCtx.RepoID
|
|
opts.WithAvailable = true
|
|
} else if rCtx.IsOrg || rCtx.IsUser {
|
|
opts.OwnerID = rCtx.OwnerID
|
|
opts.WithAvailable = true
|
|
}
|
|
|
|
runners, _, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts)
|
|
if err != nil {
|
|
ctx.ServerError("FindRunners", err)
|
|
return
|
|
}
|
|
|
|
// Get current tasks for all runners
|
|
runnerIDs := make([]int64, 0, len(runners))
|
|
for _, r := range runners {
|
|
runnerIDs = append(runnerIDs, r.ID)
|
|
}
|
|
currentTasks, _ := actions_model.GetCurrentTasksForRunners(ctx, runnerIDs)
|
|
|
|
response := make([]map[string]any, 0, len(runners))
|
|
for _, runner := range runners {
|
|
item := map[string]any{
|
|
"id": runner.ID,
|
|
"is_online": runner.IsOnline(),
|
|
"is_healthy": runner.IsHealthy(),
|
|
"status": runner.StatusLocaleName(ctx.Locale),
|
|
"version": runner.Version,
|
|
}
|
|
|
|
// Add current task info if runner is busy
|
|
if task, ok := currentTasks[runner.ID]; ok && task != nil {
|
|
taskInfo := map[string]any{
|
|
"id": task.ID,
|
|
"job_id": task.JobID,
|
|
"repo_id": task.RepoID,
|
|
"started": task.Started.AsTime().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
// Calculate running duration
|
|
if task.Started > 0 {
|
|
duration := time.Since(task.Started.AsTime())
|
|
var durationStr string
|
|
if duration < time.Minute {
|
|
durationStr = fmt.Sprintf("%ds", int(duration.Seconds()))
|
|
} else if duration < time.Hour {
|
|
durationStr = fmt.Sprintf("%dm %ds", int(duration.Minutes()), int(duration.Seconds())%60)
|
|
} else {
|
|
durationStr = fmt.Sprintf("%dh %dm", int(duration.Hours()), int(duration.Minutes())%60)
|
|
}
|
|
taskInfo["duration"] = durationStr
|
|
}
|
|
item["current_task"] = taskInfo
|
|
}
|
|
if runner.LastOnline.AsTime().Unix() > 0 {
|
|
item["last_online"] = runner.LastOnline.AsTime().Format("2006-01-02T15:04:05Z")
|
|
// Calculate relative time
|
|
duration := time.Since(runner.LastOnline.AsTime())
|
|
var relativeTime string
|
|
if duration < time.Minute {
|
|
relativeTime = "just now"
|
|
} else if duration < time.Hour {
|
|
mins := int(duration.Minutes())
|
|
if mins == 1 {
|
|
relativeTime = "1 minute ago"
|
|
} else {
|
|
relativeTime = fmt.Sprintf("%d minutes ago", mins)
|
|
}
|
|
} else if duration < 24*time.Hour {
|
|
hours := int(duration.Hours())
|
|
if hours == 1 {
|
|
relativeTime = "1 hour ago"
|
|
} else {
|
|
relativeTime = fmt.Sprintf("%d hours ago", hours)
|
|
}
|
|
} else {
|
|
days := int(duration.Hours() / 24)
|
|
if days == 1 {
|
|
relativeTime = "1 day ago"
|
|
} else {
|
|
relativeTime = fmt.Sprintf("%d days ago", days)
|
|
}
|
|
}
|
|
item["last_online_relative"] = relativeTime
|
|
}
|
|
response = append(response, item)
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// QueueDepthJSON returns queue depth for all labels as JSON
|
|
func QueueDepthJSON(ctx *context.Context) {
|
|
queueDepth, err := actions_model.GetQueueDepthByLabels(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("GetQueueDepthByLabels", err)
|
|
return
|
|
}
|
|
|
|
response := make([]map[string]any, 0, len(queueDepth))
|
|
for _, item := range queueDepth {
|
|
entry := map[string]any{
|
|
"label": item.Label,
|
|
"job_count": item.JobCount,
|
|
"stuck_jobs": item.StuckJobs,
|
|
}
|
|
// Add wait time info
|
|
if item.OldestWait > 0 {
|
|
waitDuration := time.Since(item.OldestWait.AsTime())
|
|
var waitStr string
|
|
if waitDuration < time.Minute {
|
|
waitStr = fmt.Sprintf("%ds", int(waitDuration.Seconds()))
|
|
} else if waitDuration < time.Hour {
|
|
waitStr = fmt.Sprintf("%dm", int(waitDuration.Minutes()))
|
|
} else if waitDuration < 24*time.Hour {
|
|
waitStr = fmt.Sprintf("%dh %dm", int(waitDuration.Hours()), int(waitDuration.Minutes())%60)
|
|
} else {
|
|
days := int(waitDuration.Hours() / 24)
|
|
waitStr = fmt.Sprintf("%dd %dh", days, int(waitDuration.Hours())%24)
|
|
}
|
|
entry["oldest_wait"] = waitStr
|
|
}
|
|
response = append(response, entry)
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// WaitingJobs shows waiting jobs filtered by label
|
|
func WaitingJobs(ctx *context.Context) {
|
|
ctx.Data["Title"] = ctx.Tr("actions.runners.waiting_jobs")
|
|
ctx.Data["PageIsAdminRunners"] = true
|
|
|
|
label := ctx.FormString("label")
|
|
ctx.Data["Label"] = label
|
|
|
|
jobs, err := actions_model.GetWaitingJobsByLabel(ctx, label)
|
|
if err != nil {
|
|
ctx.ServerError("GetWaitingJobsByLabel", err)
|
|
return
|
|
}
|
|
|
|
// Load repo and run info for each job
|
|
for _, job := range jobs {
|
|
if err := job.LoadRepo(ctx); err != nil {
|
|
log.Error("LoadRepo for job %d: %v", job.ID, err)
|
|
}
|
|
if err := job.LoadRun(ctx); err != nil {
|
|
log.Error("LoadRun for job %d: %v", job.ID, err)
|
|
}
|
|
}
|
|
|
|
ctx.Data["Jobs"] = jobs
|
|
ctx.Data["Link"] = setting.AppSubURL + "/-/admin/actions/runners"
|
|
|
|
ctx.HTML(http.StatusOK, tplAdminWaitingJobs)
|
|
}
|