2
0

feat(actions): add bulk delete and disk usage display for repo admins

- Add disk usage statistics endpoint (GET /actions/disk-usage)
- Add Clear Cancelled button to delete all cancelled workflow runs
- Add Clear Failed button to delete all failed workflow runs
- Show disk usage breakdown by status on Actions page sidebar
- Repo admins only feature

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
GitCaddy
2026-01-14 23:07:38 +00:00
parent a9c04be5cc
commit c02e648a3e
6 changed files with 246 additions and 2 deletions

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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("").

View File

@@ -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
}

View File

@@ -23,6 +23,82 @@
</a>
{{end}}
</div>
{{if $.IsRepoAdmin}}
<div class="ui segment tw-mt-4">
<h4 class="ui header">{{ctx.Locale.Tr "actions.runs.disk_usage"}}</h4>
<div id="actions-disk-usage" class="tw-text-sm">
<div class="ui active centered inline loader"></div>
</div>
<div class="ui divider"></div>
<div class="tw-flex tw-flex-col tw-gap-2">
<button class="ui small red button link-action tw-w-full"
data-url="{{$.RepoLink}}/actions/clear-cancelled"
data-modal-confirm="{{ctx.Locale.Tr "actions.runs.clear_cancelled_confirm"}}">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "actions.runs.clear_cancelled"}}
</button>
<button class="ui small red button link-action tw-w-full"
data-url="{{$.RepoLink}}/actions/clear-failed"
data-modal-confirm="{{ctx.Locale.Tr "actions.runs.clear_failed_confirm"}}">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "actions.runs.clear_failed"}}
</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
fetch('{{$.RepoLink}}/actions/disk-usage')
.then(r => r.json())
.then(data => {
const container = document.getElementById('actions-disk-usage');
if (!data || data.length === 0) {
container.innerHTML = '<p class="tw-text-muted">{{ctx.Locale.Tr "actions.runs.no_disk_data"}}</p>';
return;
}
const statusNames = {
1: '{{ctx.Locale.Tr "actions.status.success"}}',
2: '{{ctx.Locale.Tr "actions.status.failure"}}',
3: '{{ctx.Locale.Tr "actions.status.cancelled"}}',
4: '{{ctx.Locale.Tr "actions.status.skipped"}}',
5: '{{ctx.Locale.Tr "actions.status.waiting"}}',
6: '{{ctx.Locale.Tr "actions.status.running"}}',
7: '{{ctx.Locale.Tr "actions.status.blocked"}}'
};
const statusColors = {
1: 'green',
2: 'red',
3: 'grey',
4: 'grey',
5: 'yellow',
6: 'blue',
7: 'orange'
};
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
if (bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(1) + ' MB';
return (bytes/1024/1024/1024).toFixed(2) + ' GB';
}
let html = '<table class="ui very basic small compact table"><tbody>';
let totalSize = 0;
for (const item of data) {
totalSize += item.total_size || 0;
const name = statusNames[item.status] || 'Unknown';
const color = statusColors[item.status] || 'grey';
html += '<tr>';
html += '<td><span class="ui ' + color + ' label">' + name + '</span></td>';
html += '<td class="right aligned">' + item.run_count + ' runs</td>';
html += '<td class="right aligned">' + formatSize(item.total_size || 0) + '</td>';
html += '</tr>';
}
html += '</tbody></table>';
html += '<div class="tw-mt-2 tw-font-bold">Total: ' + formatSize(totalSize) + '</div>';
container.innerHTML = html;
})
.catch(err => {
document.getElementById('actions-disk-usage').innerHTML = '<p class="tw-text-red">Error loading disk usage</p>';
});
});
</script>
{{end}}
</div>
<div class="twelve wide column content">
<div class="ui secondary filter menu tw-justify-end tw-flex tw-items-center">