2
0

feat(actions): add Clear Running, workflow filter for disk usage, conditional buttons

- 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 <noreply@anthropic.com>
This commit is contained in:
GitCaddy
2026-01-15 12:15:52 +00:00
parent 010c47e6d8
commit 6ca11bc207
6 changed files with 184 additions and 52 deletions

View File

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

View File

@@ -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.",

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@
</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"
<button class="ui small grey 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"}}
@@ -41,20 +41,25 @@
data-modal-confirm="{{ctx.Locale.Tr "actions.runs.clear_failed_confirm"}}">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "actions.runs.clear_failed"}}
</button>
<button class="ui small button link-action tw-w-full" style="background-color: #2185d0; color: white;"
data-url="{{$.RepoLink}}/actions/clear-running"
data-modal-confirm="{{ctx.Locale.Tr "actions.runs.clear_running_confirm"}}">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "actions.runs.clear_running"}}
</button>
<div class="ui divider"></div>
<div class="tw-flex tw-items-center tw-gap-2" style="flex-wrap: nowrap;">
<select id="clear-old-success-days" class="ui compact dropdown" style="width: 100px; flex-shrink: 0;">
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="5" selected>5 days</option>
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30">30 days</option>
</select>
<button id="clear-old-success-btn" class="ui small green button" style="flex: 1;">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "actions.runs.clear_old_success"}}
</button>
</div>
<select id="clear-old-success-days" class="ui dropdown tw-w-full">
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="5" selected>5 days</option>
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30">30 days</option>
</select>
<button id="clear-old-success-btn" class="ui small green button link-action tw-w-full"
data-url="{{$.RepoLink}}/actions/clear-old-success?days=5"
data-modal-confirm="{{ctx.Locale.Tr "actions.runs.clear_old_success_confirm"}}">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "actions.runs.clear_old_success"}}
</button>
</div>
</div>
<script>
@@ -97,8 +102,12 @@
totalSize += item.total_size || 0;
const name = statusNames[item.status] || 'Unknown';
const color = statusColors[item.status] || 'grey';
let labelStyle = '';
if (item.status === 6) {
labelStyle = ' style="background-color: #2185d0 !important; color: white !important;"';
}
html += '<tr>';
html += '<td><span class="ui ' + color + ' label">' + name + '</span></td>';
html += '<td><span class="ui ' + color + ' label"' + labelStyle + '>' + 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>';
@@ -111,29 +120,11 @@
document.getElementById('actions-disk-usage').innerHTML = '<p class="tw-text-red">Error loading disk usage</p>';
});
// Clear old success button handler
document.getElementById('clear-old-success-btn').addEventListener('click', function() {
const days = document.getElementById('clear-old-success-days').value;
const confirmMsg = '{{ctx.Locale.Tr "actions.runs.clear_old_success_confirm"}}'.replace('%d', days);
if (confirm(confirmMsg)) {
fetch('{{$.RepoLink}}/actions/clear-old-success?days=' + days, {
method: 'POST',
headers: {
'X-Csrf-Token': document.querySelector('meta[name="_csrf"]').content
}
})
.then(r => r.json())
.then(data => {
if (data.redirect) {
window.location.href = data.redirect;
} else {
window.location.reload();
}
})
.catch(err => {
alert('Error: ' + err);
});
}
// Update clear-old-success URL when dropdown changes
document.getElementById('clear-old-success-days').addEventListener('change', function() {
const days = this.value;
const btn = document.getElementById('clear-old-success-btn');
btn.setAttribute('data-url', '{{$.RepoLink}}/actions/clear-old-success?days=' + days);
});
});
</script>