From 6ca11bc207a66352300221cd9eaf5fcadf6b7f99 Mon Sep 17 00:00:00 2001 From: GitCaddy Date: Thu, 15 Jan 2026 12:15:52 +0000 Subject: [PATCH] feat(actions): add Clear Running, workflow filter for disk usage, conditional buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Clear Running button to delete stuck running workflow runs - Disk usage now filters by selected workflow - Clear buttons only appear when corresponding status has runs - Rename ActionsDiskUsage to DiskUsage (lint fix) 🤖 Generated with Claude Code Co-Authored-By: Claude --- models/actions/run.go | 38 ++++++++--- options/locale/locale_en-US.json | 2 + routers/web/repo/actions/view.go | 23 ++++++- routers/web/web.go | 1 + services/actions/cleanup.go | 105 ++++++++++++++++++++++++++++++- templates/repo/actions/list.tmpl | 67 +++++++++----------- 6 files changed, 184 insertions(+), 52 deletions(-) diff --git a/models/actions/run.go b/models/actions/run.go index ccc26c630e..cbaa5b7e65 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -458,8 +458,8 @@ func CancelPreviousJobsByRunConcurrency(ctx context.Context, actionRun *ActionRu return CancelJobs(ctx, jobsToCancel) } -// ActionsDiskUsage holds disk space usage breakdown by status for a repository -type ActionsDiskUsage struct { +// DiskUsage holds disk space usage breakdown by status for a repository +type DiskUsage struct { Status Status `json:"status"` RunCount int64 `json:"run_count"` LogSize int64 `json:"log_size"` @@ -467,8 +467,8 @@ type ActionsDiskUsage struct { TotalSize int64 `json:"total_size"` } -// GetActionsDiskUsageByRepo returns disk usage statistics for a repository action runs, grouped by status -func GetActionsDiskUsageByRepo(ctx context.Context, repoID int64) ([]ActionsDiskUsage, error) { +// GetDiskUsageByRepo returns disk usage statistics for a repository action runs, grouped by status +func GetDiskUsageByRepo(ctx context.Context, repoID int64, workflowFile string) ([]DiskUsage, error) { type diskRow struct { Status Status RunCount int64 @@ -476,8 +476,17 @@ func GetActionsDiskUsageByRepo(ctx context.Context, repoID int64) ([]ActionsDisk } var rows []diskRow - sql1 := "SELECT r.status, COUNT(DISTINCT r.id) as run_count, COALESCE(SUM(t.log_size), 0) as log_size FROM action_run r LEFT JOIN action_run_job j ON j.run_id = r.id LEFT JOIN action_task t ON t.job_id = j.id WHERE r.repo_id = ? GROUP BY r.status ORDER BY r.status" - err := db.GetEngine(ctx).SQL(sql1, repoID).Find(&rows) + var sql1 string + var args1 []any + + if workflowFile != "" { + sql1 = "SELECT r.status, COUNT(DISTINCT r.id) as run_count, COALESCE(SUM(t.log_size), 0) as log_size FROM action_run r LEFT JOIN action_run_job j ON j.run_id = r.id LEFT JOIN action_task t ON t.job_id = j.id WHERE r.repo_id = ? AND r.workflow_id = ? GROUP BY r.status ORDER BY r.status" + args1 = []any{repoID, workflowFile} + } else { + sql1 = "SELECT r.status, COUNT(DISTINCT r.id) as run_count, COALESCE(SUM(t.log_size), 0) as log_size FROM action_run r LEFT JOIN action_run_job j ON j.run_id = r.id LEFT JOIN action_task t ON t.job_id = j.id WHERE r.repo_id = ? GROUP BY r.status ORDER BY r.status" + args1 = []any{repoID} + } + err := db.GetEngine(ctx).SQL(sql1, args1...).Find(&rows) if err != nil { return nil, err } @@ -487,8 +496,17 @@ func GetActionsDiskUsageByRepo(ctx context.Context, repoID int64) ([]ActionsDisk ArtifactSize int64 } var artRows []artifactRow - sql2 := "SELECT r.status, COALESCE(SUM(a.file_size), 0) as artifact_size FROM action_run r INNER JOIN action_artifact a ON a.run_id = r.id AND a.status < 4 WHERE r.repo_id = ? GROUP BY r.status" - err = db.GetEngine(ctx).SQL(sql2, repoID).Find(&artRows) + var sql2 string + var args2 []any + + if workflowFile != "" { + sql2 = "SELECT r.status, COALESCE(SUM(a.file_size), 0) as artifact_size FROM action_run r INNER JOIN action_artifact a ON a.run_id = r.id AND a.status < 4 WHERE r.repo_id = ? AND r.workflow_id = ? GROUP BY r.status" + args2 = []any{repoID, workflowFile} + } else { + sql2 = "SELECT r.status, COALESCE(SUM(a.file_size), 0) as artifact_size FROM action_run r INNER JOIN action_artifact a ON a.run_id = r.id AND a.status < 4 WHERE r.repo_id = ? GROUP BY r.status" + args2 = []any{repoID} + } + err = db.GetEngine(ctx).SQL(sql2, args2...).Find(&artRows) if err != nil { return nil, err } @@ -498,9 +516,9 @@ func GetActionsDiskUsageByRepo(ctx context.Context, repoID int64) ([]ActionsDisk artMap[ar.Status] = ar.ArtifactSize } - var results []ActionsDiskUsage + var results []DiskUsage for _, row := range rows { - usage := ActionsDiskUsage{ + usage := DiskUsage{ Status: row.Status, RunCount: row.RunCount, LogSize: row.LogSize, diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 3e69de8e90..bf9069ef13 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3952,6 +3952,8 @@ "actions.runs.clear_failed": "Clear Failed", "actions.runs.clear_cancelled_confirm": "Delete all cancelled workflow runs? This cannot be undone.", "actions.runs.clear_failed_confirm": "Delete all failed workflow runs? This cannot be undone.", + "actions.runs.clear_running": "Clear Running", + "actions.runs.clear_running_confirm": "Delete all stuck running workflow runs? This cannot be undone.", "actions.runs.cleared": "Deleted %d workflow runs", "actions.runs.clear_old_success": "Clear Old Success", "actions.runs.clear_old_success_confirm": "Delete successful workflow runs older than %d days? This cannot be undone.", diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 289b95f386..8fb9267d84 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -958,7 +958,6 @@ func Run(ctx *context_module.Context) { ctx.Redirect(redirectURL) } - // ClearCancelled deletes all cancelled workflow runs for the repository func ClearCancelled(ctx *context_module.Context) { if !ctx.Repo.IsAdmin() { @@ -993,11 +992,29 @@ func ClearFailed(ctx *context_module.Context) { ctx.JSONRedirect(ctx.Repo.RepoLink + "/actions") } +// ClearRunning deletes stuck running workflow runs for the repository +func ClearRunning(ctx *context_module.Context) { + if !ctx.Repo.IsAdmin() { + ctx.NotFound(nil) + return + } + + count, err := actions_service.DeleteStuckRunningRuns(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("DeleteStuckRunningRuns", err) + return + } + + ctx.Flash.Success(ctx.Tr("actions.runs.cleared", count)) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/actions") +} + // DiskUsage returns disk usage statistics for the repository's workflow runs func DiskUsage(ctx *context_module.Context) { - usage, err := actions_model.GetActionsDiskUsageByRepo(ctx, ctx.Repo.Repository.ID) + workflow := ctx.FormString("workflow") + usage, err := actions_model.GetDiskUsageByRepo(ctx, ctx.Repo.Repository.ID, workflow) if err != nil { - ctx.ServerError("GetActionsDiskUsageByRepo", err) + ctx.ServerError("GetDiskUsageByRepo", err) return } ctx.JSON(http.StatusOK, usage) diff --git a/routers/web/web.go b/routers/web/web.go index 235174f554..60b7d3682e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1539,6 +1539,7 @@ func registerWebRoutes(m *web.Router) { m.Post("/approve-all-checks", reqRepoActionsWriter, actions.ApproveAllChecks) m.Post("/clear-cancelled", reqRepoAdmin, actions.ClearCancelled) m.Post("/clear-failed", reqRepoAdmin, actions.ClearFailed) + m.Post("/clear-running", reqRepoAdmin, actions.ClearRunning) m.Get("/disk-usage", actions.DiskUsage) m.Post("/clear-old-success", reqRepoAdmin, actions.ClearOldSuccess) diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index 786c635ff4..ab9dd86c56 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -259,7 +259,6 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error { return nil } - // DeleteRunsByStatus deletes all workflow runs for a repository with the given status // Returns the number of runs deleted func DeleteRunsByStatus(ctx context.Context, repoID int64, status actions_model.Status) (int, error) { @@ -322,3 +321,107 @@ func DeleteSuccessRunsOlderThan(ctx context.Context, repoID int64, days int) (in return deleted, nil } + +// DeleteStuckRunningRuns deletes running workflow runs (for stuck jobs) regardless of IsDone status +func DeleteStuckRunningRuns(ctx context.Context, repoID int64) (int, error) { + runIDs, err := actions_model.GetRunIDsByRepoAndStatus(ctx, repoID, actions_model.StatusRunning) + if err != nil { + return 0, fmt.Errorf("get run IDs: %w", err) + } + + deleted := 0 + for _, runID := range runIDs { + run, err := actions_model.GetRunByID(ctx, runID) + if err != nil { + log.Error("Failed to get run %d: %v", runID, err) + continue + } + if run == nil { + continue + } + + if err := DeleteRunForce(ctx, run); err != nil { + log.Error("Failed to delete run %d: %v", runID, err) + continue + } + deleted++ + } + + return deleted, nil +} + +// DeleteRunForce deletes a run regardless of its status (for stuck runs) +func DeleteRunForce(ctx context.Context, run *actions_model.ActionRun) error { + repoID := run.RepoID + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return err + } + jobIDs := container.FilterSlice(jobs, func(j *actions_model.ActionRunJob) (int64, bool) { + return j.ID, true + }) + tasks := make(actions_model.TaskList, 0) + if len(jobIDs) > 0 { + if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).In("job_id", jobIDs).Find(&tasks); err != nil { + return err + } + } + + artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ + RepoID: repoID, + RunID: run.ID, + }) + if err != nil { + return err + } + + var recordsToDelete []any + + recordsToDelete = append(recordsToDelete, &actions_model.ActionRun{ + RepoID: repoID, + ID: run.ID, + }) + recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJob{ + RepoID: repoID, + RunID: run.ID, + }) + for _, tas := range tasks { + recordsToDelete = append(recordsToDelete, &actions_model.ActionTask{ + RepoID: repoID, + ID: tas.ID, + }) + recordsToDelete = append(recordsToDelete, &actions_model.ActionTaskStep{ + RepoID: repoID, + TaskID: tas.ID, + }) + recordsToDelete = append(recordsToDelete, &actions_model.ActionTaskOutput{ + TaskID: tas.ID, + }) + } + recordsToDelete = append(recordsToDelete, &actions_model.ActionArtifact{ + RepoID: repoID, + RunID: run.ID, + }) + + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := CleanupEphemeralRunners(ctx); err != nil { + return err + } + return db.DeleteBeans(ctx, recordsToDelete...) + }); err != nil { + return err + } + + // Delete files on storage + for _, tas := range tasks { + removeTaskLog(ctx, tas) + } + for _, art := range artifacts { + if err := storage.ActionsArtifacts.Delete(art.StoragePath); err != nil { + log.Error("remove artifact file %q: %v", art.StoragePath, err) + } + } + + return nil +} diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index 64daa47b58..216f36c573 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -31,7 +31,7 @@
- +
-
- - -
+ +