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