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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("").
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user