Implements items 4-5 of the whole-project review's suggested order (docs/PROJECT_REVIEW_PLAN.md): - Drop the three SaveJobs calls in the run lifecycle (startRunLocked, executeRun, SetGlobalPause): none of them change a durable Job field, everything they touch lives on JobRuntime, which is never persisted. Retire TestStartRunLockedRollbackOnSaveFailure with the rollback it guarded, since a run can no longer fail to start this way. - Clear PendingRuns (the "queue" overlap policy's backlog) when a job is disabled or the scheduler is globally paused, so resuming or re-enabling a job no longer replays a deferred run left over from before the pause/disable. Cap it at maxPendingRuns (10) so a job whose runs take longer than its own interval stops accumulating an unbounded backlog. Surface the queued count in the details pane via DisplayStats. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
148 lines
4.8 KiB
Go
148 lines
4.8 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
)
|
|
|
|
// StatusText formats a job's current state for display: "Paused" if disabled,
|
|
// else its runtime LastState (Ready, Running, Success, etc).
|
|
func StatusText(j domain.Job, runtime *domain.JobRuntime) string {
|
|
if !j.Enabled {
|
|
return "Paused"
|
|
}
|
|
if runtime == nil {
|
|
return ""
|
|
}
|
|
return runtime.LastState
|
|
}
|
|
|
|
// EventText formats a run record for the History table, showing time, trigger,
|
|
// job name, outcome state, detail, and log file (if any).
|
|
func EventText(e domain.RunRecord) string {
|
|
trigger := e.Trigger
|
|
if trigger == "" {
|
|
trigger = "Unknown"
|
|
}
|
|
if e.LogFile != "" {
|
|
return fmt.Sprintf("%s %s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail, e.LogFile)
|
|
}
|
|
return fmt.Sprintf("%s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail)
|
|
}
|
|
|
|
// EventLine formats a run record as a compact single line for the jobs log
|
|
// view, using only the base name of the log file instead of the full path.
|
|
func EventLine(e domain.RunRecord) string {
|
|
trigger := e.Trigger
|
|
if trigger == "" {
|
|
trigger = "Unknown"
|
|
}
|
|
if e.LogFile != "" {
|
|
return fmt.Sprintf("%s %s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail, filepath.Base(e.LogFile))
|
|
}
|
|
return fmt.Sprintf("%s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail)
|
|
}
|
|
|
|
// DisplayFolder formats a job's folder for display: "(No folder)" if empty,
|
|
// else the trimmed folder name.
|
|
func DisplayFolder(folder string) string {
|
|
if strings.TrimSpace(folder) == "" {
|
|
return "(No folder)"
|
|
}
|
|
return strings.TrimSpace(folder)
|
|
}
|
|
|
|
// DisplayArguments formats a job's arguments for display: "(none)" if empty,
|
|
// else the trimmed arguments.
|
|
func DisplayArguments(arguments string) string {
|
|
if strings.TrimSpace(arguments) == "" {
|
|
return "(none)"
|
|
}
|
|
return strings.TrimSpace(arguments)
|
|
}
|
|
|
|
// DisplayRunMode formats a job's execution mode: "Start only" or
|
|
// "Wait for completion".
|
|
func DisplayRunMode(job domain.Job) string {
|
|
if job.StartOnly {
|
|
return "Start only"
|
|
}
|
|
return "Wait for completion"
|
|
}
|
|
|
|
// DisplayInvocation formats a job's command and arguments for the jobs list,
|
|
// joining them with spacing and collapsing newlines in arguments to spaces.
|
|
func DisplayInvocation(job domain.Job) string {
|
|
if strings.TrimSpace(job.Arguments) == "" {
|
|
return job.Command
|
|
}
|
|
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
|
|
}
|
|
|
|
// DisplayStats returns a one-line execution-time summary for a job runtime,
|
|
// with the queued-run depth appended whenever the "queue" overlap policy has
|
|
// deferred runs waiting (see maxPendingRuns). Returns "No runs recorded" when
|
|
// no runs have been counted yet, still showing the queue depth if one exists.
|
|
func DisplayStats(rt *domain.JobRuntime) string {
|
|
if rt == nil || rt.RunCount == 0 {
|
|
if rt != nil && rt.PendingRuns > 0 {
|
|
return "No runs recorded" + pendingRunsSuffix(rt.PendingRuns)
|
|
}
|
|
return "No runs recorded"
|
|
}
|
|
return fmt.Sprintf("%d runs, %d failed, last %d ms, avg %d ms, max %d ms",
|
|
rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS) +
|
|
pendingRunsSuffix(rt.PendingRuns)
|
|
}
|
|
|
|
// pendingRunsSuffix formats the queued-run depth for DisplayStats, empty when
|
|
// nothing is queued.
|
|
func pendingRunsSuffix(pending int) string {
|
|
if pending <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(", %d queued", pending)
|
|
}
|
|
|
|
// DisplayOverlapPolicy formats a job's effective overlap policy for the details
|
|
// panel. When the job has its own policy it is shown as-is; when empty (inherit
|
|
// global), the global default is shown with "(global default)" appended.
|
|
func DisplayOverlapPolicy(job domain.Job, globalPolicy domain.OverlapPolicy) string {
|
|
if p := domain.OverlapPolicy(strings.TrimSpace(job.OverlapPolicy)); p != "" {
|
|
return string(p)
|
|
}
|
|
return string(globalPolicy) + " (global default)"
|
|
}
|
|
|
|
// DisplayTimeout formats a job's effective run timeout for the details panel.
|
|
// When the job sets its own TimeoutSeconds it is shown as-is, with an explicit 0
|
|
// rendered as "no timeout"; when unset (nil), the global default is shown with
|
|
// "(global default)" appended, mirroring DisplayOverlapPolicy. A non-positive
|
|
// global default means no timeout at all.
|
|
func DisplayTimeout(job domain.Job, globalDefault int) string {
|
|
if job.TimeoutSeconds != nil {
|
|
if *job.TimeoutSeconds <= 0 {
|
|
return "no timeout"
|
|
}
|
|
return fmt.Sprintf("%d s", *job.TimeoutSeconds)
|
|
}
|
|
if globalDefault <= 0 {
|
|
return "no timeout (global default)"
|
|
}
|
|
return fmt.Sprintf("%d s (global default)", globalDefault)
|
|
}
|
|
|
|
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
|
|
// or 0 if not found.
|
|
func DisplayIndex(indexes []int, jobIndex int) int {
|
|
for display, index := range indexes {
|
|
if index == jobIndex {
|
|
return display
|
|
}
|
|
}
|
|
return 0
|
|
}
|