fb2f9b6e30
Fyne 2.7.4 made Table.Select() reject cells with Row < 0, so OnSelected was never fired for header clicks and the Time sort toggle stopped working. Replace the plain Label header with a custom historyHeader widget that implements fyne.Tappable; UpdateHeader wires OnTapped for col 0 directly. Also swap "asc"/"desc" text for ▲/▼ glyphs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
224 lines
5.6 KiB
Go
224 lines
5.6 KiB
Go
package ui
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/theme"
|
|
"fyne.io/fyne/v2/widget"
|
|
)
|
|
|
|
func newEvent(jobID int, jobName string, state string, detail string) event {
|
|
// Use the same timestamp shape as command run records so the History tab is
|
|
// visually consistent across startup, UI actions, manual runs, and schedules.
|
|
return event{
|
|
Time: time.Now().Format("2006-01-02 15:04:05"),
|
|
JobID: jobID,
|
|
JobName: jobName,
|
|
Trigger: "UI",
|
|
State: state,
|
|
Detail: detail,
|
|
}
|
|
}
|
|
|
|
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
|
var events []event
|
|
for _, current := range jobs {
|
|
// At startup this is usually empty because jobs.yaml does not persist
|
|
// runtime logs. The function still centralizes the merge for future
|
|
// history loading from log metadata.
|
|
if rt := runtimes[current.ID]; rt != nil {
|
|
events = append(events, rt.Logs...)
|
|
}
|
|
}
|
|
sort.SliceStable(events, func(left int, right int) bool {
|
|
return events[left].Time < events[right].Time
|
|
})
|
|
return events
|
|
}
|
|
|
|
// logColumnMinWidth/logColumnMaxWidth bound the dynamically sized Log column.
|
|
// The minimum keeps the column readable when names are short or absent; the
|
|
// maximum stops a single very long file name from dominating the table (the
|
|
// table still scrolls horizontally past it).
|
|
const (
|
|
logColumnMinWidth = 240
|
|
logColumnMaxWidth = 520
|
|
logColumnPadding = 24
|
|
)
|
|
|
|
// logColumnWidth measures the widest Log cell value so the column can be sized
|
|
// to fit its content. Fyne tables do not auto-size columns, so without this the
|
|
// fixed width clips file names like "20260601-100000_SomeJobName.log".
|
|
func logColumnWidth(events []event) float32 {
|
|
width := float32(logColumnMinWidth)
|
|
for _, current := range events {
|
|
text := logFileName(current.LogFile)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
w := fyne.MeasureText(text, theme.TextSize(), fyne.TextStyle{}).Width + logColumnPadding
|
|
if w > width {
|
|
width = w
|
|
}
|
|
}
|
|
if width > logColumnMaxWidth {
|
|
width = logColumnMaxWidth
|
|
}
|
|
return width
|
|
}
|
|
|
|
// historyHeader is a bold tappable label used in the History table header row.
|
|
// In Fyne 2.7+ OnSelected is not fired for header cells (Row < 0), so the sort
|
|
// toggle is wired through the Tappable interface instead.
|
|
type historyHeader struct {
|
|
widget.BaseWidget
|
|
label *widget.Label
|
|
OnTapped func()
|
|
}
|
|
|
|
func newHistoryHeader() *historyHeader {
|
|
h := &historyHeader{label: widget.NewLabel("")}
|
|
h.label.TextStyle = fyne.TextStyle{Bold: true}
|
|
h.label.Wrapping = fyne.TextTruncate
|
|
h.ExtendBaseWidget(h)
|
|
return h
|
|
}
|
|
|
|
func (h *historyHeader) CreateRenderer() fyne.WidgetRenderer {
|
|
return widget.NewSimpleRenderer(h.label)
|
|
}
|
|
|
|
func (h *historyHeader) Tapped(*fyne.PointEvent) {
|
|
if h.OnTapped != nil {
|
|
h.OnTapped()
|
|
}
|
|
}
|
|
|
|
func (h *historyHeader) SetText(text string) {
|
|
h.label.SetText(text)
|
|
}
|
|
|
|
func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
|
descending := false
|
|
headerText := func(id widget.TableCellID) string {
|
|
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
|
if id.Row < 0 && id.Col == 0 {
|
|
if descending {
|
|
return "Time ▼"
|
|
}
|
|
return "Time ▲"
|
|
}
|
|
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
|
|
return headers[id.Col]
|
|
}
|
|
return ""
|
|
}
|
|
sortedEvents := func() []event {
|
|
result := append([]event(nil), (*events)...)
|
|
sort.SliceStable(result, func(left int, right int) bool {
|
|
if descending {
|
|
return result[left].Time > result[right].Time
|
|
}
|
|
return result[left].Time < result[right].Time
|
|
})
|
|
return result
|
|
}
|
|
|
|
table := widget.NewTable(
|
|
func() (int, int) {
|
|
return len(*events), 6
|
|
},
|
|
func() fyne.CanvasObject {
|
|
label := widget.NewLabel("")
|
|
label.Wrapping = fyne.TextTruncate
|
|
return label
|
|
},
|
|
func(id widget.TableCellID, item fyne.CanvasObject) {
|
|
label := item.(*widget.Label)
|
|
label.SetText(historyCellText(id, sortedEvents()))
|
|
label.TextStyle = fyne.TextStyle{}
|
|
label.Refresh()
|
|
},
|
|
)
|
|
table.ShowHeaderRow = true
|
|
table.CreateHeader = func() fyne.CanvasObject {
|
|
return newHistoryHeader()
|
|
}
|
|
table.UpdateHeader = func(id widget.TableCellID, item fyne.CanvasObject) {
|
|
h := item.(*historyHeader)
|
|
h.SetText(headerText(id))
|
|
if id.Row < 0 && id.Col == 0 {
|
|
h.OnTapped = func() {
|
|
descending = !descending
|
|
table.Refresh()
|
|
}
|
|
} else {
|
|
h.OnTapped = nil
|
|
}
|
|
h.Refresh()
|
|
}
|
|
table.OnSelected = func(id widget.TableCellID) {
|
|
table.Unselect(id)
|
|
}
|
|
table.SetColumnWidth(0, 150)
|
|
table.SetColumnWidth(1, 90)
|
|
table.SetColumnWidth(2, 170)
|
|
table.SetColumnWidth(3, 90)
|
|
table.SetColumnWidth(4, 260)
|
|
table.SetColumnWidth(5, logColumnWidth(*events))
|
|
|
|
// refresh recomputes the content-fit Log column width before redrawing, so
|
|
// newly recorded events with longer file names widen the column instead of
|
|
// being truncated.
|
|
refresh := func() {
|
|
table.SetColumnWidth(5, logColumnWidth(*events))
|
|
table.Refresh()
|
|
}
|
|
return container.NewPadded(table), refresh
|
|
}
|
|
|
|
func historyCellText(id widget.TableCellID, events []event) string {
|
|
if id.Row < 0 || id.Row >= len(events) {
|
|
return ""
|
|
}
|
|
current := events[id.Row]
|
|
trigger := current.Trigger
|
|
if trigger == "" {
|
|
trigger = "Unknown"
|
|
}
|
|
switch id.Col {
|
|
case 0:
|
|
return current.Time
|
|
case 1:
|
|
return trigger
|
|
case 2:
|
|
return current.JobName
|
|
case 3:
|
|
return current.State
|
|
case 4:
|
|
return current.Detail
|
|
case 5:
|
|
return logFileName(current.LogFile)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func logFileName(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
path = strings.ReplaceAll(path, "\\", "/")
|
|
if slash := strings.LastIndex(path, "/"); slash >= 0 {
|
|
return path[slash+1:]
|
|
}
|
|
return path
|
|
}
|