Skip to content

Commit 8a55caf

Browse files
committed
feat: Implement configurable refresh intervals for tabs, globally and per-tab.
1 parent e32364c commit 8a55caf

4 files changed

Lines changed: 106 additions & 48 deletions

File tree

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,24 @@ cmd = ["top", "-b", "-n", "1"]
5555
- Memory: `free -m` (Linux)
5656
- Net: `/proc/net/dev` (Linux) or `netstat -ib` (macOS)
5757

58-
If no configuration file is found, Perfmon falls back to a sensible set of defaults (uptime, vmstat, mpstat, iostat, free, sar, top, neofetch).
58+
If no configuration file is found, Perfmon falls back to a sensible set of defaults.
5959

60-
If a command is missing, the tab is disabled and a hint is shown.
60+
### Configuration
61+
You can customize tabs and refresh rates in `perfmon.toml`.
62+
63+
```toml
64+
# Global refresh rate (default: 5s)
65+
global_refresh_interval = "5s"
66+
67+
[[tab]]
68+
title = "uptime"
69+
cmd = ["uptime"]
70+
71+
[[tab]]
72+
title = "top (fast)"
73+
cmd = ["top", "-b", "-n", "1"]
74+
refresh_interval = "1s" # Override global rate
75+
```
6176

6277

6378
## Development

internal/config/config.go

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,58 @@ import (
77
"path/filepath"
88
"runtime"
99
"strings"
10+
"time"
1011

1112
"github.com/BurntSushi/toml"
1213
)
1314

1415
type Tab struct {
15-
Title string `toml:"title"`
16-
Cmd []string `toml:"cmd"`
17-
Disabled bool `toml:"-"`
18-
DisabledMsg string `toml:"-"`
16+
Title string `toml:"title"`
17+
Cmd []string `toml:"cmd"`
18+
Disabled bool `toml:"-"`
19+
DisabledMsg string `toml:"-"`
20+
RefreshInterval duration `toml:"refresh_interval"`
1921
}
2022

2123
type Config struct {
22-
Tabs []Tab `toml:"tab"`
24+
Tabs []Tab `toml:"tab"`
25+
GlobalRefreshInterval duration `toml:"global_refresh_interval"`
2326
}
2427

25-
func Load() []Tab {
26-
if cfgTabs, ok := loadFromConfig(); ok {
27-
validated := make([]Tab, 0, len(cfgTabs))
28-
for _, t := range cfgTabs {
28+
// Custom duration type for TOML parsing
29+
type duration struct {
30+
time.Duration
31+
}
32+
33+
func (d *duration) UnmarshalText(text []byte) error {
34+
var err error
35+
d.Duration, err = time.ParseDuration(string(text))
36+
return err
37+
}
38+
39+
func Load() (Config, []Tab) {
40+
if cfg, ok := loadFromConfig(); ok {
41+
validated := make([]Tab, 0, len(cfg.Tabs))
42+
for _, t := range cfg.Tabs {
2943
validated = append(validated, validateTab(t))
3044
}
3145
if len(validated) > 0 {
32-
return validated
46+
// Apply global refresh if tab refresh is missing
47+
if cfg.GlobalRefreshInterval.Duration <= 0 {
48+
cfg.GlobalRefreshInterval.Duration = 5 * time.Second
49+
}
50+
for i := range validated {
51+
if validated[i].RefreshInterval.Duration <= 0 {
52+
validated[i].RefreshInterval = cfg.GlobalRefreshInterval
53+
}
54+
}
55+
return cfg, validated
3356
}
3457
}
35-
return buildDefaultTabs()
58+
return Config{GlobalRefreshInterval: duration{5 * time.Second}}, buildDefaultTabs()
3659
}
3760

38-
func loadFromConfig() ([]Tab, bool) {
61+
func loadFromConfig() (Config, bool) {
3962
paths := configPaths()
4063
for _, path := range paths {
4164
data, err := os.ReadFile(path)
@@ -50,20 +73,21 @@ func loadFromConfig() ([]Tab, bool) {
5073
if len(cfg.Tabs) == 0 {
5174
continue
5275
}
53-
76+
5477
// Filter invalid tabs
5578
validTabs := make([]Tab, 0, len(cfg.Tabs))
5679
for _, t := range cfg.Tabs {
5780
if t.Title != "" && len(t.Cmd) > 0 {
5881
validTabs = append(validTabs, t)
5982
}
6083
}
61-
84+
6285
if len(validTabs) > 0 {
63-
return validTabs, true
86+
cfg.Tabs = validTabs
87+
return cfg, true
6488
}
6589
}
66-
return nil, false
90+
return Config{}, false
6791
}
6892

6993
func configPaths() []string {
@@ -134,17 +158,19 @@ func buildDefaultTabs() []Tab {
134158

135159
fetchTitle, fetchCmd := detectFetchCmd()
136160

161+
defaultInterval := duration{5 * time.Second}
162+
137163
tabs := []Tab{
138-
{Title: "uptime", Cmd: []string{"uptime"}},
139-
{Title: "vmstat", Cmd: []string{"vmstat"}},
140-
{Title: "mpstat -P ALL", Cmd: []string{"mpstat", "-P", "ALL"}},
141-
{Title: "pidstat -p ALL", Cmd: []string{"pidstat", "-p", "ALL"}},
142-
{Title: "iostat", Cmd: []string{"iostat"}},
143-
{Title: freeTitle, Cmd: freeCmd},
144-
{Title: "sar -n DEV", Cmd: []string{"sar", "-n", "DEV"}},
145-
{Title: "sar -n TCP,ETCP", Cmd: []string{"sar", "-n", "TCP,ETCP"}},
146-
{Title: topTitle, Cmd: topCmd},
147-
{Title: fetchTitle, Cmd: fetchCmd},
164+
{Title: "uptime", Cmd: []string{"uptime"}, RefreshInterval: defaultInterval},
165+
{Title: "vmstat", Cmd: []string{"vmstat"}, RefreshInterval: defaultInterval},
166+
{Title: "mpstat -P ALL", Cmd: []string{"mpstat", "-P", "ALL"}, RefreshInterval: defaultInterval},
167+
{Title: "pidstat -p ALL", Cmd: []string{"pidstat", "-p", "ALL"}, RefreshInterval: defaultInterval},
168+
{Title: "iostat", Cmd: []string{"iostat"}, RefreshInterval: defaultInterval},
169+
{Title: freeTitle, Cmd: freeCmd, RefreshInterval: defaultInterval},
170+
{Title: "sar -n DEV", Cmd: []string{"sar", "-n", "DEV"}, RefreshInterval: defaultInterval},
171+
{Title: "sar -n TCP,ETCP", Cmd: []string{"sar", "-n", "TCP,ETCP"}, RefreshInterval: defaultInterval},
172+
{Title: topTitle, Cmd: topCmd, RefreshInterval: defaultInterval},
173+
{Title: fetchTitle, Cmd: fetchCmd, RefreshInterval: defaultInterval},
148174
}
149175

150176
for i := range tabs {

internal/config/config_test.go

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,48 @@ import (
44
"os"
55
"path/filepath"
66
"testing"
7+
"time"
8+
79
"github.com/BurntSushi/toml"
810
)
911

1012
func TestParseTomlConfig(t *testing.T) {
1113
data := []byte(`
14+
global_refresh_interval = "10s"
15+
1216
[[tab]]
1317
title = "uptime"
1418
cmd = ["uptime"]
1519
1620
[[tab]]
1721
title = "top"
1822
cmd = ["top","-b","-n","1"]
23+
refresh_interval = "1s"
1924
`)
2025
var cfg Config
2126
if _, err := toml.Decode(string(data), &cfg); err != nil {
2227
t.Fatalf("parse error: %v", err)
2328
}
2429

30+
if cfg.GlobalRefreshInterval.Duration != 10*time.Second {
31+
t.Errorf("expected global 10s, got %v", cfg.GlobalRefreshInterval.Duration)
32+
}
33+
2534
if len(cfg.Tabs) != 2 {
2635
t.Fatalf("expected 2 tabs, got %d", len(cfg.Tabs))
2736
}
28-
if cfg.Tabs[0].Title != "uptime" || len(cfg.Tabs[0].Cmd) != 1 {
29-
t.Fatalf("unexpected first tab: %+v", cfg.Tabs[0])
30-
}
31-
if cfg.Tabs[1].Cmd[0] != "top" || cfg.Tabs[1].Cmd[3] != "1" {
32-
t.Fatalf("unexpected second tab cmd: %+v", cfg.Tabs[1].Cmd)
37+
38+
// Note: Load() handles applying global defaults to tabs, so we just check raw parsing here
39+
if cfg.Tabs[1].RefreshInterval.Duration != 1*time.Second {
40+
t.Errorf("expected tab 1s, got %v", cfg.Tabs[1].RefreshInterval.Duration)
3341
}
3442
}
3543

3644
func TestLoadTabsFromConfig(t *testing.T) {
3745
dir := t.TempDir()
3846
path := filepath.Join(dir, "perfmon.toml")
3947
err := os.WriteFile(path, []byte(`
48+
global_refresh_interval = "2s"
4049
[[tab]]
4150
title = "vmstat"
4251
cmd = ["vmstat"]
@@ -46,11 +55,13 @@ cmd = ["vmstat"]
4655
}
4756

4857
t.Setenv("PERFMON_CONFIG", path)
49-
tabs, ok := loadFromConfig()
50-
if !ok {
51-
t.Fatalf("expected config load")
58+
_, tabs := Load() // Load now returns (Config, []Tab)
59+
60+
if len(tabs) != 1 {
61+
t.Fatalf("expected 1 tab")
5262
}
53-
if len(tabs) != 1 || tabs[0].Title != "vmstat" || tabs[0].Cmd[0] != "vmstat" {
54-
t.Fatalf("unexpected tabs: %+v", tabs)
63+
64+
if tabs[0].RefreshInterval.Duration != 2*time.Second {
65+
t.Errorf("expected inherited 2s refresh, got %v", tabs[0].RefreshInterval.Duration)
5566
}
5667
}

internal/ui/model.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ type systemMsg struct {
3434
}
3535

3636
const (
37-
refreshInterval = 5 * time.Second
3837
spinnerInterval = 200 * time.Millisecond
3938
fixedRows = 9
4039
)
@@ -60,8 +59,12 @@ func NewModel() Model {
6059
vp := viewport.New(0, 0)
6160
vp.SetContent("Loading...")
6261

62+
// config.Load() now returns (Config, []Tab), we only need []Tab here
63+
// but the signature of Load changed in previous step, so we need to adapt
64+
_, tabs := config.Load()
65+
6366
return Model{
64-
tabs: config.Load(),
67+
tabs: tabs,
6568
active: 0,
6669
viewport: vp,
6770
themeIndex: 0,
@@ -70,15 +73,18 @@ func NewModel() Model {
7073
}
7174

7275
func (m Model) Init() tea.Cmd {
76+
interval := m.tabs[m.active].RefreshInterval.Duration
7377
if m.tabs[m.active].Disabled {
7478
m.content = m.tabs[m.active].DisabledMsg
7579
m.viewport.SetContent(m.content)
76-
return tea.Batch(tick(), spinnerTick(), sampleMetricsCmd(), sampleSystemCmd())
80+
return tea.Batch(tick(interval), spinnerTick(), sampleMetricsCmd(), sampleSystemCmd())
7781
}
78-
return tea.Batch(runCommandCmd(m.tabs[m.active]), tick(), spinnerTick(), sampleMetricsCmd(), sampleSystemCmd())
82+
return tea.Batch(runCommandCmd(m.tabs[m.active]), tick(interval), spinnerTick(), sampleMetricsCmd(), sampleSystemCmd())
7983
}
8084

8185
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
86+
interval := m.tabs[m.active].RefreshInterval.Duration
87+
8288
switch msg := msg.(type) {
8389
case tea.KeyMsg:
8490
if isQuitKey(msg) {
@@ -109,9 +115,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
109115
m.viewport.SetContent(m.content)
110116
case tickMsg:
111117
if m.tabs[m.active].Disabled {
112-
return m, tea.Batch(tick(), sampleMetricsCmd(), sampleSystemCmd())
118+
return m, tea.Batch(tick(interval), sampleMetricsCmd(), sampleSystemCmd())
113119
}
114-
return m, tea.Batch(runCommandCmd(m.tabs[m.active]), tick(), sampleMetricsCmd(), sampleSystemCmd())
120+
return m, tea.Batch(runCommandCmd(m.tabs[m.active]), tick(interval), sampleMetricsCmd(), sampleSystemCmd())
115121
case spinnerMsg:
116122
m.spinnerIdx = (m.spinnerIdx + 1) % len(spinnerFrames)
117123
return m, spinnerTick()
@@ -124,7 +130,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
124130
if msg.err != nil {
125131
m.statusLine = fmt.Sprintf("error: %v", msg.err)
126132
} else {
127-
m.statusLine = fmt.Sprintf("updated %s", time.Now().Format("15:04:05"))
133+
m.statusLine = fmt.Sprintf("updated %s (every %s)", time.Now().Format("15:04:05"), interval)
128134
}
129135
case metricsMsg:
130136
m.metrics = monitor.UpdateHistory(m.metrics, msg.metrics)
@@ -172,8 +178,8 @@ func (m Model) onTabSelected() tea.Cmd {
172178
return runCommandCmd(m.tabs[m.active])
173179
}
174180

175-
func tick() tea.Cmd {
176-
return tea.Tick(refreshInterval, func(t time.Time) tea.Msg { return tickMsg(t) })
181+
func tick(d time.Duration) tea.Cmd {
182+
return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) })
177183
}
178184

179185
func spinnerTick() tea.Cmd {
@@ -207,7 +213,7 @@ func runCommandCmd(t config.Tab) tea.Cmd {
207213
}
208214
}
209215

210-
// Rendering helpers
216+
// Rendering helpers (unchanged)
211217

212218
func (m Model) renderTabs(tabs []config.Tab, active, width int) string {
213219
if width <= 0 {

0 commit comments

Comments
 (0)