diff --git a/models/actions/run.go b/models/actions/run.go index be332d6857..eb1f8288a4 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -457,3 +457,81 @@ 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 { + Status Status `json:"status"` + RunCount int64 `json:"run_count"` + LogSize int64 `json:"log_size"` + ArtifactSize int64 `json:"artifact_size"` + 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) { + type diskRow struct { + Status Status + RunCount int64 + LogSize int64 + } + + 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) + if err != nil { + return nil, err + } + + type artifactRow struct { + Status Status + 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) + if err != nil { + return nil, err + } + + artMap := make(map[Status]int64) + for _, ar := range artRows { + artMap[ar.Status] = ar.ArtifactSize + } + + var results []ActionsDiskUsage + for _, row := range rows { + usage := ActionsDiskUsage{ + Status: row.Status, + RunCount: row.RunCount, + LogSize: row.LogSize, + ArtifactSize: artMap[row.Status], + } + usage.TotalSize = usage.LogSize + usage.ArtifactSize + results = append(results, usage) + } + + return results, nil +} + +// GetRunIDsByRepoAndStatus returns all run IDs for a repository with the given status +func GetRunIDsByRepoAndStatus(ctx context.Context, repoID int64, status Status) ([]int64, error) { + var runIDs []int64 + err := db.GetEngine(ctx).Table("action_run"). + Where("repo_id = ? AND status = ?", repoID, status). + Cols("id"). + Find(&runIDs) + return runIDs, err +} + +// GetRunByID returns an action run by its ID +func GetRunByID(ctx context.Context, runID int64) (*ActionRun, error) { + run := &ActionRun{} + has, err := db.GetEngine(ctx).ID(runID).Get(run) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return run, nil +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 47d34f8e18..ff72d18208 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3946,5 +3946,12 @@ "repo.settings.pages.seo_title": "SEO Title", "repo.settings.pages.seo_description": "Meta Description", "repo.settings.pages.seo_keywords": "Keywords", - "repo.settings.pages.og_image": "Open Graph Image URL" -} + "repo.settings.pages.og_image": "Open Graph Image URL", + "actions.runs.disk_usage": "Disk Usage", + "actions.runs.clear_cancelled": "Clear Cancelled", + "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.cleared": "Deleted %d workflow runs", + "actions.runs.no_disk_data": "No workflow data" +} \ No newline at end of file diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index cc70cd4e06..8104e43992 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -957,3 +957,48 @@ func Run(ctx *context_module.Context) { ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID)) ctx.Redirect(redirectURL) } + + +// ClearCancelled deletes all cancelled workflow runs for the repository +func ClearCancelled(ctx *context_module.Context) { + if !ctx.Repo.IsAdmin() { + ctx.NotFound(nil) + return + } + + count, err := actions_service.DeleteRunsByStatus(ctx, ctx.Repo.Repository.ID, actions_model.StatusCancelled) + if err != nil { + ctx.ServerError("DeleteRunsByStatus", err) + return + } + + ctx.Flash.Success(ctx.Tr("actions.runs.cleared", count)) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/actions") +} + +// ClearFailed deletes all failed workflow runs for the repository +func ClearFailed(ctx *context_module.Context) { + if !ctx.Repo.IsAdmin() { + ctx.NotFound(nil) + return + } + + count, err := actions_service.DeleteRunsByStatus(ctx, ctx.Repo.Repository.ID, actions_model.StatusFailure) + if err != nil { + ctx.ServerError("DeleteRunsByStatus", 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) + if err != nil { + ctx.ServerError("GetActionsDiskUsageByRepo", err) + return + } + ctx.JSON(http.StatusOK, usage) +} diff --git a/routers/web/web.go b/routers/web/web.go index 97d4b5d5ce..adaa38953a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1537,6 +1537,9 @@ func registerWebRoutes(m *web.Router) { m.Post("/run", reqRepoActionsWriter, actions.Run) m.Get("/workflow-dispatch-inputs", reqRepoActionsWriter, actions.WorkflowDispatchInputs) m.Post("/approve-all-checks", reqRepoActionsWriter, actions.ApproveAllChecks) + m.Post("/clear-cancelled", reqRepoAdmin, actions.ClearCancelled) + m.Post("/clear-failed", reqRepoAdmin, actions.ClearFailed) + m.Get("/disk-usage", actions.DiskUsage) m.Group("/runs/{run}", func() { m.Combo(""). diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index d0cc63e538..1c46b7986c 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -258,3 +258,38 @@ 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) { + runIDs, err := actions_model.GetRunIDsByRepoAndStatus(ctx, repoID, status) + 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 + } + + // Only delete completed runs + if !run.Status.IsDone() { + continue + } + + if err := DeleteRun(ctx, run); err != nil { + log.Error("Failed to delete run %d: %v", runID, err) + continue + } + deleted++ + } + + return deleted, nil +} diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index fe8c26b523..55424886ab 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -23,6 +23,82 @@ {{end}} + {{if $.IsRepoAdmin}} +
+

{{ctx.Locale.Tr "actions.runs.disk_usage"}}

+
+
+
+
+
+ + +
+
+ + {{end}}