1e3d14bef2
Append app-side delays to logs/notify-timing.log, add a PowerShell baseline script (~773 ms), and track native WinRT toasts in ROADMAP. Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
4.3 KiB
Go
121 lines
4.3 KiB
Go
package ui
|
|
|
|
import (
|
|
"strconv"
|
|
"time"
|
|
|
|
"gitea.mixdep.ru/mix/gosentry/assets"
|
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/theme"
|
|
)
|
|
|
|
const runRecordTimeLayout = "2006-01-02 15:04:05"
|
|
|
|
// The UI package aliases domain types to keep widget callbacks short. The actual
|
|
// durable model still lives in src/domain, so UI code does not define a second
|
|
// copy of the scheduler data.
|
|
type job = domain.Job
|
|
type event = domain.RunRecord
|
|
|
|
func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|
svc.InstallDesktopIcon(appID, assets.IconBytes())
|
|
|
|
// Build the initial event history from the current runtime state. Jobs and
|
|
// runtimes are read here only for this one-time initialization; the jobs view
|
|
// owns all subsequent state via its own syncFromService closure.
|
|
initialJobs := svc.Jobs()
|
|
initialRuntimes := make(map[int]*domain.JobRuntime, len(initialJobs))
|
|
for _, j := range initialJobs {
|
|
if rt := svc.Runtime(j.ID); rt != nil {
|
|
initialRuntimes[j.ID] = rt
|
|
}
|
|
}
|
|
events := collectActivity(initialJobs, initialRuntimes)
|
|
|
|
jobsPanel, refreshJobsView := newJobsView(w, svc)
|
|
|
|
history, refreshHistory := newHistoryView(&events)
|
|
recordStartup := func(duration time.Duration, windowShown bool) {
|
|
// Startup is recorded as an in-memory History event instead of being
|
|
// persisted into jobs.json. It is session diagnostics, not durable job
|
|
// state, and keeping it ephemeral avoids polluting the human-editable JSON
|
|
// file with process-lifetime bookkeeping.
|
|
detail := "Window shown in " + duration.Round(time.Millisecond).String()
|
|
if !windowShown {
|
|
detail = "Started in tray in " + duration.Round(time.Millisecond).String()
|
|
}
|
|
events = append(events, newEvent(0, "Application", "Started", detail))
|
|
refreshHistory()
|
|
}
|
|
|
|
refresh := func() {
|
|
refreshJobsView()
|
|
refreshHistory()
|
|
}
|
|
|
|
// The Service announces every change through events. This single listener is
|
|
// where the UI reacts: it appends run/activity records to History and redraws.
|
|
// Events fire from two contexts — UI button handlers call into the Service
|
|
// synchronously (main goroutine), while scheduled and manual run completions
|
|
// emit from the run goroutine. fyne.Do marshals all of this widget work onto
|
|
// the main thread in both cases, so the engine never mutates Fyne state off
|
|
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
|
|
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
|
|
recorded, isRecorded := ev.(app.RunRecorded)
|
|
errOccurred, isError := ev.(app.ErrorOccurred)
|
|
jobsLoaded, isJobsLoaded := ev.(app.JobsLoaded)
|
|
fyne.Do(func() {
|
|
if isRecorded {
|
|
events = append(events, recorded.Record)
|
|
r := recorded.Record
|
|
if r.State == "Failed" &&
|
|
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
|
|
svc.ShouldNotifyOnFailure() {
|
|
timing := notificationTiming{
|
|
JobName: r.JobName,
|
|
EmittedAt: time.Now(),
|
|
}
|
|
if finished, err := time.ParseInLocation(runRecordTimeLayout, r.Time, time.Local); err == nil {
|
|
timing.RunFinished = finished
|
|
}
|
|
fyne.Do(func() {
|
|
timing.UIQueuedAt = time.Now()
|
|
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
|
Title: "GoSentry: Job Failed",
|
|
Content: r.JobName + ": " + r.Detail,
|
|
})
|
|
timing.AfterSendAt = time.Now()
|
|
if err := appendNotificationTimingLog(svc.Store().Paths.LogsDir, timing); err != nil {
|
|
fyne.LogError("Failed to write notification timing log", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
if isError {
|
|
events = append(events, newEvent(0, "Service", "Error", errOccurred.Err.Error()))
|
|
}
|
|
if isJobsLoaded {
|
|
// Selecting an existing jobs file replaces the job list without a
|
|
// prompt, so History carries the receipt: how many jobs, from where.
|
|
detail := strconv.Itoa(jobsLoaded.Count) + " jobs from " + jobsLoaded.Path
|
|
events = append(events, newEvent(0, "Service", "Jobs loaded", detail))
|
|
}
|
|
refresh()
|
|
})
|
|
}))
|
|
svc.Start()
|
|
|
|
tabs := container.NewAppTabs(
|
|
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
|
|
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
|
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)),
|
|
)
|
|
tabs.SetTabLocation(container.TabLocationTop)
|
|
|
|
return tabs, recordStartup
|
|
}
|