Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 886d0d9caa | |||
| 6c69f323bd |
@@ -93,8 +93,8 @@ Done first because both share a compact, single-line record formatter.
|
|||||||
- [x] T2.3 — `duration` log header
|
- [x] T2.3 — `duration` log header
|
||||||
- [x] T2.4 — runtime aggregate + `executeRun` update
|
- [x] T2.4 — runtime aggregate + `executeRun` update
|
||||||
- [ ] T2.5 — seed stats from log files
|
- [ ] T2.5 — seed stats from log files
|
||||||
- [ ] T2.6 — `DisplayStats` + Statistics row
|
- [x] T2.6 — `DisplayStats` + Statistics row
|
||||||
- [ ] T2.7 — stats tests
|
- [x] T2.7 — stats tests
|
||||||
|
|
||||||
### Phase 3 — Per-job run policy
|
### Phase 3 — Per-job run policy
|
||||||
- [ ] T3.1 — `Job.OverlapPolicy` field
|
- [ ] T3.1 — `Job.OverlapPolicy` field
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ func DisplayInvocation(job domain.Job) string {
|
|||||||
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
|
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisplayStats returns a one-line execution-time summary for a job runtime.
|
||||||
|
// Returns "No runs recorded" when no runs have been counted yet.
|
||||||
|
func DisplayStats(rt *domain.JobRuntime) string {
|
||||||
|
if rt == nil || rt.RunCount == 0 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
|
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
|
||||||
// or 0 if not found.
|
// or 0 if not found.
|
||||||
func DisplayIndex(indexes []int, jobIndex int) int {
|
func DisplayIndex(indexes []int, jobIndex int) int {
|
||||||
|
|||||||
@@ -99,6 +99,35 @@ func TestDisplayIndex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisplayStats(t *testing.T) {
|
||||||
|
// Zero RunCount → sentinel string.
|
||||||
|
if got := DisplayStats(nil); got != "No runs recorded" {
|
||||||
|
t.Errorf("nil runtime = %q, want %q", got, "No runs recorded")
|
||||||
|
}
|
||||||
|
if got := DisplayStats(&domain.JobRuntime{}); got != "No runs recorded" {
|
||||||
|
t.Errorf("zero runtime = %q, want %q", got, "No runs recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
rt := &domain.JobRuntime{
|
||||||
|
RunCount: 5,
|
||||||
|
FailCount: 2,
|
||||||
|
LastDurationMS: 450,
|
||||||
|
AvgDurationMS: 380,
|
||||||
|
MaxDurationMS: 520,
|
||||||
|
}
|
||||||
|
want := "5 runs, 2 failed, last 450 ms, avg 380 ms, max 520 ms"
|
||||||
|
if got := DisplayStats(rt); got != want {
|
||||||
|
t.Errorf("DisplayStats = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero failures are included in the output (not hidden).
|
||||||
|
rtNoFail := &domain.JobRuntime{RunCount: 3, FailCount: 0, LastDurationMS: 100, AvgDurationMS: 90, MaxDurationMS: 110}
|
||||||
|
wantNoFail := "3 runs, 0 failed, last 100 ms, avg 90 ms, max 110 ms"
|
||||||
|
if got := DisplayStats(rtNoFail); got != wantNoFail {
|
||||||
|
t.Errorf("DisplayStats no-fail = %q, want %q", got, wantNoFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEventLine(t *testing.T) {
|
func TestEventLine(t *testing.T) {
|
||||||
withLog := domain.RunRecord{
|
withLog := domain.RunRecord{
|
||||||
Time: "2026-06-19 12:00:00", Trigger: "Schedule", JobName: "Build",
|
Time: "2026-06-19 12:00:00", Trigger: "Schedule", JobName: "Build",
|
||||||
|
|||||||
@@ -68,6 +68,43 @@ func expectNoEntry(t *testing.T, entered <-chan int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUpdateStats verifies that aggregate statistics are folded correctly after
|
||||||
|
// a sequence of fake runs with varying durations and states.
|
||||||
|
func TestUpdateStats(t *testing.T) {
|
||||||
|
rt := &domain.JobRuntime{}
|
||||||
|
|
||||||
|
// First run: success, 200 ms.
|
||||||
|
updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 200})
|
||||||
|
if rt.RunCount != 1 || rt.FailCount != 0 {
|
||||||
|
t.Fatalf("after run 1: RunCount=%d FailCount=%d, want 1/0", rt.RunCount, rt.FailCount)
|
||||||
|
}
|
||||||
|
if rt.LastDurationMS != 200 || rt.MaxDurationMS != 200 || rt.AvgDurationMS != 200 {
|
||||||
|
t.Errorf("after run 1: last=%d max=%d avg=%d, want 200/200/200",
|
||||||
|
rt.LastDurationMS, rt.MaxDurationMS, rt.AvgDurationMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second run: failure, 400 ms.
|
||||||
|
updateStats(rt, domain.RunRecord{State: "Failed", DurationMS: 400})
|
||||||
|
if rt.RunCount != 2 || rt.FailCount != 1 {
|
||||||
|
t.Fatalf("after run 2: RunCount=%d FailCount=%d, want 2/1", rt.RunCount, rt.FailCount)
|
||||||
|
}
|
||||||
|
if rt.LastDurationMS != 400 || rt.MaxDurationMS != 400 {
|
||||||
|
t.Errorf("after run 2: last=%d max=%d, want 400/400", rt.LastDurationMS, rt.MaxDurationMS)
|
||||||
|
}
|
||||||
|
if rt.AvgDurationMS != 300 {
|
||||||
|
t.Errorf("after run 2: avg=%d, want 300", rt.AvgDurationMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third run: success, 100 ms — avg should be (200+400+100)/3 = 233.
|
||||||
|
updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 100})
|
||||||
|
if rt.LastDurationMS != 100 || rt.MaxDurationMS != 400 {
|
||||||
|
t.Errorf("after run 3: last=%d max=%d, want 100/400", rt.LastDurationMS, rt.MaxDurationMS)
|
||||||
|
}
|
||||||
|
if rt.AvgDurationMS != 233 {
|
||||||
|
t.Errorf("after run 3: avg=%d, want 233", rt.AvgDurationMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRunDueParallelStartsAllDueJobs verifies that in parallel mode every due job
|
// TestRunDueParallelStartsAllDueJobs verifies that in parallel mode every due job
|
||||||
// starts at once: both runs are in flight (blocked in the runner) before either
|
// starts at once: both runs are in flight (blocked in the runner) before either
|
||||||
// is released.
|
// is released.
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
|||||||
s.parseScheduleLocked(job)
|
s.parseScheduleLocked(job)
|
||||||
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
||||||
}
|
}
|
||||||
|
// Seed execution-time statistics from existing log files so the details panel
|
||||||
|
// shows accumulated run history immediately after a restart, not just runs
|
||||||
|
// since this process started.
|
||||||
|
for id, seed := range runner.SeedStats(store.Paths.LogsDir, jobs, store.Config.MaxLogFiles) {
|
||||||
|
runtime := s.runtimes[id]
|
||||||
|
if runtime == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runtime.RunCount = seed.RunCount
|
||||||
|
runtime.FailCount = seed.FailCount
|
||||||
|
runtime.LastDurationMS = seed.LastDurationMS
|
||||||
|
runtime.AvgDurationMS = seed.AvgDurationMS
|
||||||
|
runtime.MaxDurationMS = seed.MaxDurationMS
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeededStats are the aggregate execution-time statistics reconstructed from a
|
||||||
|
// job's existing log files at startup. The fields mirror the run-time counters
|
||||||
|
// on domain.JobRuntime so the caller can fold them in directly.
|
||||||
|
type SeededStats struct {
|
||||||
|
RunCount int
|
||||||
|
FailCount int
|
||||||
|
LastDurationMS int64
|
||||||
|
AvgDurationMS int64
|
||||||
|
MaxDurationMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedStats scans logsDir once and reconstructs per-job execution-time
|
||||||
|
// statistics from the log files written by previous runs, keyed by Job.ID.
|
||||||
|
//
|
||||||
|
// Log files are matched to a job by the sanitized job-name suffix of the file
|
||||||
|
// name (the same name writeRunLog uses), so no per-file header read is needed to
|
||||||
|
// associate a log with its job. For each job only the newest maxFiles matching
|
||||||
|
// logs are parsed, mirroring the retention policy that CleanupLogs enforces; a
|
||||||
|
// maxFiles of zero or less means "no bound". The duration and state are read
|
||||||
|
// from each log's header. Logs written before duration tracking existed carry no
|
||||||
|
// duration line: those are tolerated — they still count toward RunCount and
|
||||||
|
// FailCount but are left out of the duration aggregates (last/avg/max) so a
|
||||||
|
// missing duration cannot masquerade as a zero-millisecond run.
|
||||||
|
//
|
||||||
|
// A missing or unreadable logs directory yields an empty map rather than an
|
||||||
|
// error: seeding is best-effort and must never block startup.
|
||||||
|
func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededStats {
|
||||||
|
result := make(map[int]SeededStats, len(jobs))
|
||||||
|
entries, err := os.ReadDir(logsDir)
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group log file names by the sanitized job-name portion of the file name.
|
||||||
|
// File names are "<timestamp>_<sanitizedName>.log"; the timestamp has no
|
||||||
|
// underscore, so everything after the first underscore is the name part.
|
||||||
|
byName := make(map[string][]string)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".log") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := name[:len(name)-len(".log")]
|
||||||
|
idx := strings.Index(base, "_")
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jobPart := base[idx+1:]
|
||||||
|
byName[jobPart] = append(byName[jobPart], name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
files := byName[sanitizeFileName(job.Name)]
|
||||||
|
if len(files) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The timestamp prefix sorts chronologically, so a lexical sort puts the
|
||||||
|
// oldest first; keep the newest maxFiles to honor the retention bound.
|
||||||
|
sort.Strings(files)
|
||||||
|
if maxFiles > 0 && len(files) > maxFiles {
|
||||||
|
files = files[len(files)-maxFiles:]
|
||||||
|
}
|
||||||
|
result[job.ID] = aggregateLogStats(logsDir, files)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateLogStats folds the header of each log file (oldest first) into one
|
||||||
|
// SeededStats. Files lacking a duration line contribute to the run/fail counts
|
||||||
|
// but not to the duration aggregates.
|
||||||
|
func aggregateLogStats(logsDir string, files []string) SeededStats {
|
||||||
|
var stats SeededStats
|
||||||
|
var durationSum int64
|
||||||
|
var durationCount int
|
||||||
|
for _, file := range files {
|
||||||
|
state, durationMS, hasDuration := readLogHeader(filepath.Join(logsDir, file))
|
||||||
|
stats.RunCount++
|
||||||
|
if state == "Failed" {
|
||||||
|
stats.FailCount++
|
||||||
|
}
|
||||||
|
if hasDuration {
|
||||||
|
// Files are oldest first, so the last assignment is the newest run.
|
||||||
|
stats.LastDurationMS = durationMS
|
||||||
|
if durationMS > stats.MaxDurationMS {
|
||||||
|
stats.MaxDurationMS = durationMS
|
||||||
|
}
|
||||||
|
durationSum += durationMS
|
||||||
|
durationCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if durationCount > 0 {
|
||||||
|
stats.AvgDurationMS = durationSum / int64(durationCount)
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLogHeader reads the "state" and "duration" fields from a log file's
|
||||||
|
// header (the lines before the first blank line). hasDuration reports whether a
|
||||||
|
// well-formed duration line was present, distinguishing a legacy duration-less
|
||||||
|
// log from one that genuinely recorded a zero-millisecond run.
|
||||||
|
func readLogHeader(path string) (state string, durationMS int64, hasDuration bool) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
break // end of header
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(line, "state: "); ok {
|
||||||
|
state = strings.TrimSpace(rest)
|
||||||
|
} else if rest, ok := strings.CutPrefix(line, "duration: "); ok {
|
||||||
|
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
|
||||||
|
durationMS = value
|
||||||
|
hasDuration = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state, durationMS, hasDuration
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTestLog writes a minimal log file in the format writeRunLog produces.
|
||||||
|
// Pass durationMS < 0 to omit the duration line (legacy log simulation).
|
||||||
|
func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64) {
|
||||||
|
t.Helper()
|
||||||
|
var content string
|
||||||
|
if durationMS >= 0 {
|
||||||
|
content = "state: " + state + "\nduration: " + itoa(durationMS) + "\n\n"
|
||||||
|
} else {
|
||||||
|
content = "state: " + state + "\n\n"
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int64) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
buf := make([]byte, 0, 20)
|
||||||
|
for n > 0 {
|
||||||
|
buf = append([]byte{byte('0' + n%10)}, buf...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
buf = append([]byte{'-'}, buf...)
|
||||||
|
}
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedStatsBasic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
job := domain.Job{ID: 1, Name: "Build"}
|
||||||
|
name := sanitizeFileName(job.Name)
|
||||||
|
|
||||||
|
writeTestLog(t, dir, "20260601-100000_"+name+".log", "OK", 200)
|
||||||
|
writeTestLog(t, dir, "20260601-110000_"+name+".log", "Failed", 400)
|
||||||
|
writeTestLog(t, dir, "20260601-120000_"+name+".log", "OK", 600)
|
||||||
|
|
||||||
|
result := SeedStats(dir, []domain.Job{job}, 0)
|
||||||
|
s, ok := result[job.ID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected stats for job 1")
|
||||||
|
}
|
||||||
|
if s.RunCount != 3 {
|
||||||
|
t.Errorf("RunCount = %d, want 3", s.RunCount)
|
||||||
|
}
|
||||||
|
if s.FailCount != 1 {
|
||||||
|
t.Errorf("FailCount = %d, want 1", s.FailCount)
|
||||||
|
}
|
||||||
|
if s.LastDurationMS != 600 {
|
||||||
|
t.Errorf("LastDurationMS = %d, want 600", s.LastDurationMS)
|
||||||
|
}
|
||||||
|
if s.MaxDurationMS != 600 {
|
||||||
|
t.Errorf("MaxDurationMS = %d, want 600", s.MaxDurationMS)
|
||||||
|
}
|
||||||
|
// avg = (200+400+600)/3 = 400
|
||||||
|
if s.AvgDurationMS != 400 {
|
||||||
|
t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedStatsDurationLessLegacyLog verifies that a log without a duration
|
||||||
|
// line still contributes to RunCount/FailCount but is excluded from duration
|
||||||
|
// aggregates, so a missing duration cannot masquerade as a 0 ms run.
|
||||||
|
func TestSeedStatsDurationLessLegacyLog(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
job := domain.Job{ID: 2, Name: "Deploy"}
|
||||||
|
name := sanitizeFileName(job.Name)
|
||||||
|
|
||||||
|
// Legacy log (no duration line).
|
||||||
|
writeTestLog(t, dir, "20260601-080000_"+name+".log", "OK", -1)
|
||||||
|
// Modern log with duration.
|
||||||
|
writeTestLog(t, dir, "20260601-090000_"+name+".log", "OK", 300)
|
||||||
|
|
||||||
|
result := SeedStats(dir, []domain.Job{job}, 0)
|
||||||
|
s := result[job.ID]
|
||||||
|
|
||||||
|
if s.RunCount != 2 {
|
||||||
|
t.Errorf("RunCount = %d, want 2", s.RunCount)
|
||||||
|
}
|
||||||
|
if s.FailCount != 0 {
|
||||||
|
t.Errorf("FailCount = %d, want 0", s.FailCount)
|
||||||
|
}
|
||||||
|
// Only the modern log has a duration — avg/last/max must reflect that single entry.
|
||||||
|
if s.LastDurationMS != 300 {
|
||||||
|
t.Errorf("LastDurationMS = %d, want 300", s.LastDurationMS)
|
||||||
|
}
|
||||||
|
if s.AvgDurationMS != 300 {
|
||||||
|
t.Errorf("AvgDurationMS = %d, want 300", s.AvgDurationMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedStatsMaxFilesHonoured verifies that only the newest N logs are
|
||||||
|
// parsed when maxFiles is positive.
|
||||||
|
func TestSeedStatsMaxFilesHonoured(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
job := domain.Job{ID: 3, Name: "Cleanup"}
|
||||||
|
name := sanitizeFileName(job.Name)
|
||||||
|
|
||||||
|
// Write 3 logs; only the 2 newest should be counted (maxFiles=2).
|
||||||
|
writeTestLog(t, dir, "20260601-060000_"+name+".log", "OK", 100)
|
||||||
|
writeTestLog(t, dir, "20260601-070000_"+name+".log", "OK", 200)
|
||||||
|
writeTestLog(t, dir, "20260601-080000_"+name+".log", "Failed", 300)
|
||||||
|
|
||||||
|
result := SeedStats(dir, []domain.Job{job}, 2)
|
||||||
|
s := result[job.ID]
|
||||||
|
|
||||||
|
if s.RunCount != 2 {
|
||||||
|
t.Errorf("RunCount = %d, want 2 (maxFiles=2)", s.RunCount)
|
||||||
|
}
|
||||||
|
if s.FailCount != 1 {
|
||||||
|
t.Errorf("FailCount = %d, want 1", s.FailCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedStatsMissingDir yields an empty map and does not panic.
|
||||||
|
func TestSeedStatsMissingDir(t *testing.T) {
|
||||||
|
result := SeedStats(filepath.Join(t.TempDir(), "no-such-dir"), []domain.Job{{ID: 1, Name: "J"}}, 0)
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Errorf("expected empty result for missing dir, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedStatsUnknownJobProducesNoEntry verifies that log files not matching
|
||||||
|
// any known job are silently ignored.
|
||||||
|
func TestSeedStatsUnknownJobProducesNoEntry(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestLog(t, dir, "20260601-100000_UnknownJob.log", "OK", 100)
|
||||||
|
|
||||||
|
result := SeedStats(dir, []domain.Job{{ID: 1, Name: "KnownJob"}}, 0)
|
||||||
|
if _, ok := result[1]; ok {
|
||||||
|
t.Error("expected no entry for a job with no matching log files")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|||||||
lastRunLabel := newJobDetailLabel(selectedRuntime.LastRun)
|
lastRunLabel := newJobDetailLabel(selectedRuntime.LastRun)
|
||||||
nextRunLabel := newJobDetailLabel(selectedRuntime.NextRun)
|
nextRunLabel := newJobDetailLabel(selectedRuntime.NextRun)
|
||||||
stateLabel := newJobDetailLabel(selectedRuntime.LastState)
|
stateLabel := newJobDetailLabel(selectedRuntime.LastState)
|
||||||
|
statsLabel := newJobDetailLabel(app.DisplayStats(selectedRuntime))
|
||||||
schedulerState := widget.NewLabel("Scheduler running")
|
schedulerState := widget.NewLabel("Scheduler running")
|
||||||
commandOutput := widget.NewTextGrid()
|
commandOutput := widget.NewTextGrid()
|
||||||
commandOutput.SetText(selectedRuntime.Output)
|
commandOutput.SetText(selectedRuntime.Output)
|
||||||
@@ -100,6 +101,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|||||||
lastRunLabel.SetText("")
|
lastRunLabel.SetText("")
|
||||||
nextRunLabel.SetText("")
|
nextRunLabel.SetText("")
|
||||||
stateLabel.SetText("")
|
stateLabel.SetText("")
|
||||||
|
statsLabel.SetText("")
|
||||||
commandOutput.SetText("")
|
commandOutput.SetText("")
|
||||||
selectedLogs = nil
|
selectedLogs = nil
|
||||||
return
|
return
|
||||||
@@ -116,6 +118,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|||||||
lastRunLabel.SetText(rt.LastRun)
|
lastRunLabel.SetText(rt.LastRun)
|
||||||
nextRunLabel.SetText(rt.NextRun)
|
nextRunLabel.SetText(rt.NextRun)
|
||||||
stateLabel.SetText(rt.LastState)
|
stateLabel.SetText(rt.LastState)
|
||||||
|
statsLabel.SetText(app.DisplayStats(rt))
|
||||||
commandOutput.SetText(rt.Output)
|
commandOutput.SetText(rt.Output)
|
||||||
selectedLogs = append(selectedLogs[:0], rt.Logs...)
|
selectedLogs = append(selectedLogs[:0], rt.Logs...)
|
||||||
}
|
}
|
||||||
@@ -343,6 +346,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|||||||
detailRow("Last run", lastRunLabel),
|
detailRow("Last run", lastRunLabel),
|
||||||
detailRow("Next run", nextRunLabel),
|
detailRow("Next run", nextRunLabel),
|
||||||
detailRow("State", stateLabel),
|
detailRow("State", stateLabel),
|
||||||
|
detailRow("Statistics", statsLabel),
|
||||||
widget.NewSeparator(),
|
widget.NewSeparator(),
|
||||||
widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
commandOutputScroll,
|
commandOutputScroll,
|
||||||
|
|||||||
Reference in New Issue
Block a user