Skip to content

Commit 43e9245

Browse files
committed
feat: access-log telemetry, GPU metrics, filterable panels, trend charts
- Parse Ollama server.log for API latency, request counts and endpoint activity - Add GPU telemetry via agputop JSON on macOS; improve nvidia-smi parsing on Linux - Expose token throughput, request latency and active model insights in overview panel - Add log path and request timeout config options (OLLAMON_LOG_PATH, OLLAMON_REQUEST_TIMEOUT_MS) - Update README with screenshot placeholder, full config reference, GPU and log docs
1 parent 95952dc commit 43e9245

9 files changed

Lines changed: 264 additions & 54 deletions

File tree

README.md

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,117 @@
11
# ollamon
22

3-
Terminal monitor for Ollama nodes.
3+
`ollamon` is a terminal monitor for Ollama nodes.
44

5-
## install (one-line)
5+
It provides a TUI focused on operational visibility:
66

7+
- installed models
8+
- running models
9+
- CPU, memory, disk, and GPU activity
10+
- Ollama access-log telemetry
11+
- lightweight operational insights
12+
13+
## Screenshot
14+
15+
Add your screenshot file and update the path below if needed:
16+
17+
![ollamon screenshot](./docs/screenshot.png)
18+
19+
## Features
20+
21+
- Live overview for host and Ollama runtime state
22+
- Running and installed model panels
23+
- Access-log telemetry from Ollama `server.log`
24+
- Filterable model views
25+
- Mini trend charts for CPU, memory, disk, and GPU
26+
- GPU metrics on macOS via [`agputop`](https://github.com/hbasria/agputop)
27+
28+
## Install
29+
30+
One-line install:
31+
32+
```bash
733
curl -fsSL https://raw.githubusercontent.com/hbasria/ollamon/main/scripts/install.sh | sh
34+
```
835

9-
Sadece kurulum (çalıştırmadan):
36+
Install only, without launching:
1037

38+
```bash
1139
curl -fsSL https://raw.githubusercontent.com/hbasria/ollamon/main/scripts/install.sh | sh -s -- --no-run
40+
```
1241

13-
Sürüm vererek kurulum:
42+
Install a specific version:
1443

44+
```bash
1545
curl -fsSL https://raw.githubusercontent.com/hbasria/ollamon/main/scripts/install.sh | sh -s -- --version v0.1.0
46+
```
1647

1748
Supported targets:
1849

19-
- darwin/amd64
20-
- darwin/arm64 (Apple Silicon)
21-
- linux/amd64
22-
- linux/arm64
50+
- `darwin/amd64`
51+
- `darwin/arm64`
52+
- `linux/amd64`
53+
- `linux/arm64`
54+
55+
## Run
56+
57+
```bash
58+
make build
59+
./bin/ollamon
60+
```
61+
62+
For development:
63+
64+
```bash
65+
make run
66+
```
67+
68+
## Configuration
69+
70+
Environment variables:
71+
72+
- `OLLAMA_HOST`
73+
- `OLLAMON_INTERVAL_MS`
74+
- `OLLAMON_REQUEST_TIMEOUT_MS`
75+
- `OLLAMON_DISK_PATH`
76+
- `OLLAMON_LOG_PATH`
77+
- `OLLAMON_COMPACT`
78+
79+
Example:
80+
81+
```bash
82+
OLLAMA_HOST=http://127.0.0.1:11434 \
83+
OLLAMON_INTERVAL_MS=2000 \
84+
OLLAMON_REQUEST_TIMEOUT_MS=5000 \
85+
make run
86+
```
87+
88+
## Logs
89+
90+
By default, `ollamon` reads Ollama access logs from:
91+
92+
```bash
93+
~/.ollama/logs/server.log
94+
```
95+
96+
You can override this with:
97+
98+
```bash
99+
OLLAMON_LOG_PATH=/path/to/server.log
100+
```
101+
102+
## GPU Metrics
103+
104+
On macOS, GPU telemetry is collected from [`agputop`](https://github.com/hbasria/agputop) using its JSON output.
105+
106+
Example command:
107+
108+
```bash
109+
agputop --json
110+
```
111+
112+
If `agputop` is not available, `ollamon` falls back to basic GPU detection.
23113

24-
## run
114+
## Notes
25115

26-
go mod tidy
27-
go run ./cmd/ollamon
116+
- Access-log-derived telemetry can show API latency, request counts, and endpoint activity.
117+
- Token throughput is only shown when the underlying telemetry source exposes it.

docs/screenshot.png

853 KB
Loading

internal/app/model.go

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,19 @@ type Model struct {
3636
cpuHistory []float64
3737
memHistory []float64
3838
diskHistory []float64
39+
gpuHistory []float64
3940
}
4041

4142
func New(cfg config.Config) Model {
4243
return Model{
4344
cfg: cfg,
44-
client: ollama.NewClient(cfg.BaseURL),
45+
client: ollama.NewClientWithTimeout(cfg.BaseURL, cfg.RequestTimeout),
4546
width: 120,
4647
height: 40,
4748
cpuHistory: make([]float64, 0, 40),
4849
memHistory: make([]float64, 0, 40),
4950
diskHistory: make([]float64, 0, 40),
51+
gpuHistory: make([]float64, 0, 40),
5052
}
5153
}
5254

@@ -97,6 +99,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9799
m.cpuHistory = pushHistory(m.cpuHistory, msg.sample.Host.CPUPercent, 48)
98100
m.memHistory = pushHistory(m.memHistory, msg.sample.Host.MemoryUsedPercent, 48)
99101
m.diskHistory = pushHistory(m.diskHistory, msg.sample.Host.DiskUsedPercent, 48)
102+
m.gpuHistory = pushHistory(m.gpuHistory, msg.sample.Host.GPU.UtilizationPct, 48)
100103
case tickMsg:
101104
return m, tea.Batch(m.refreshCmd(), tickCmd(m.cfg.Interval))
102105
}
@@ -197,6 +200,7 @@ func (m Model) renderOverview() string {
197200
cpuBar := util.PercentBar(s.Host.CPUPercent, 18, "█", "░")
198201
memBar := util.PercentBar(s.Host.MemoryUsedPercent, 18, "█", "░")
199202
diskBar := util.PercentBar(s.Host.DiskUsedPercent, 18, "█", "░")
203+
gpuBar := util.PercentBar(s.Host.GPU.UtilizationPct, 18, "█", "░")
200204

201205
lines := []string{
202206
tui.Title.Render("Overview"),
@@ -208,9 +212,11 @@ func (m Model) renderOverview() string {
208212
fmt.Sprintf("%s %5.1f%% %s", tui.MetricLabel.Render("CPU"), s.Host.CPUPercent, colorizePercent(s.Host.CPUPercent, cpuBar)),
209213
fmt.Sprintf("%s %5.1f%% %s", tui.MetricLabel.Render("MEM"), s.Host.MemoryUsedPercent, colorizePercent(s.Host.MemoryUsedPercent, memBar)),
210214
fmt.Sprintf("%s %5.1f%% %s", tui.MetricLabel.Render("DSK"), s.Host.DiskUsedPercent, colorizePercent(s.Host.DiskUsedPercent, diskBar)),
215+
fmt.Sprintf("%s %5.1f%% %s", tui.MetricLabel.Render("GPU"), s.Host.GPU.UtilizationPct, colorizePercent(s.Host.GPU.UtilizationPct, gpuBar)),
211216
"",
212217
fmt.Sprintf("%s %.1f / %.1f GB", tui.MetricLabel.Render("Memory"), s.Host.MemoryUsedGB, s.Host.MemoryTotalGB),
213218
fmt.Sprintf("%s %.1f / %.1f GB", tui.MetricLabel.Render("Disk"), s.Host.DiskUsedGB, s.Host.DiskTotalGB),
219+
fmt.Sprintf("%s %.1fW @ %.0fMHz", tui.MetricLabel.Render("GPU Pwr"), s.Host.GPU.PowerW, s.Host.GPU.FrequencyMHz),
214220
fmt.Sprintf("%s %.2f / %.2f", tui.MetricLabel.Render("Load"), s.Host.Load1, s.Host.Load5),
215221
fmt.Sprintf("%s %d installed / %d running", tui.MetricLabel.Render("Models"), len(s.Installed), len(s.Running)),
216222
}
@@ -229,12 +235,14 @@ func (m Model) renderTelemetry() string {
229235
cpuSpark := util.Sparkline(m.cpuHistory, 32)
230236
memSpark := util.Sparkline(m.memHistory, 32)
231237
diskSpark := util.Sparkline(m.diskHistory, 32)
238+
gpuSpark := util.Sparkline(m.gpuHistory, 32)
232239

233240
lines := []string{
234241
tui.Title.Render("Trends"),
235242
fmt.Sprintf("%s %s", tui.MetricLabel.Render("CPU"), tui.Accent.Render(cpuSpark)),
236243
fmt.Sprintf("%s %s", tui.MetricLabel.Render("MEM"), tui.Accent.Render(memSpark)),
237244
fmt.Sprintf("%s %s", tui.MetricLabel.Render("DSK"), tui.Accent.Render(diskSpark)),
245+
fmt.Sprintf("%s %s", tui.MetricLabel.Render("GPU"), tui.Accent.Render(gpuSpark)),
238246
"",
239247
tui.Title.Render("Telemetry"),
240248
fmt.Sprintf("%s %s", tui.MetricLabel.Render("Token/s"), renderTokenRate(s)),
@@ -254,9 +262,9 @@ func (m Model) renderTelemetry() string {
254262
}
255263

256264
if s.Time.IsZero() {
257-
lines = append(lines, "", tui.Dim.Render("İlk örnek bekleniyor..."))
265+
lines = append(lines, "", tui.Dim.Render("Waiting for first sample..."))
258266
} else if s.LogStats.RequestCount == 0 {
259-
lines = append(lines, "", tui.Dim.Render("Log erişim kaydı bekleniyor."))
267+
lines = append(lines, "", tui.Dim.Render("Waiting for access log entries."))
260268
}
261269

262270
return tui.Box.Width(rightW).Render(strings.Join(lines, "\n"))
@@ -273,15 +281,15 @@ func (m Model) renderInsights() string {
273281
insights := make([]string, 0, 6)
274282

275283
if s.Host.MemoryUsedPercent > 85 {
276-
insights = append(insights, "• RAM kullanımı yüksek. Büyük model yüklemelerinde yavaşlama olabilir.")
284+
insights = append(insights, "• RAM usage is high. Large model loads may slow down.")
277285
} else if s.Host.MemoryUsedPercent > 70 {
278-
insights = append(insights, "• RAM kullanımı orta-yüksek. Keep-alive süreleri gözden geçirilebilir.")
286+
insights = append(insights, "• RAM usage is moderately high. Keep-alive settings may need review.")
279287
} else {
280-
insights = append(insights, "• RAM tarafı şu an rahat görünüyor.")
288+
insights = append(insights, "• RAM usage is currently in a comfortable range.")
281289
}
282290

283291
if s.Host.DiskUsedPercent > 80 {
284-
insights = append(insights, "• Disk doluluğu kritik eşiğe yaklaşıyor. Eski modeller temizlenebilir.")
292+
insights = append(insights, "• Disk usage is approaching a critical threshold. Old models may need cleanup.")
285293
}
286294

287295
if len(s.Installed) > 0 {
@@ -291,25 +299,25 @@ func (m Model) renderInsights() string {
291299
idx = i
292300
}
293301
}
294-
insights = append(insights, fmt.Sprintf("• En büyük model: %s (%s)", s.Installed[idx].Name, util.BytesToHuman(s.Installed[idx].Size)))
302+
insights = append(insights, fmt.Sprintf("• Largest installed model: %s (%s)", s.Installed[idx].Name, util.BytesToHuman(s.Installed[idx].Size)))
295303
}
296304

297305
if len(s.Running) > 0 {
298306
rm := s.Running[0]
299-
insights = append(insights, fmt.Sprintf("• Aktif örnek: %s, VRAM/RAM yükü yaklaşık %s", rm.Name, util.BytesToHuman(rm.SizeVRAM)))
307+
insights = append(insights, fmt.Sprintf("• Active runtime: %s, estimated VRAM/RAM load %s", rm.Name, util.BytesToHuman(rm.SizeVRAM)))
300308
} else {
301-
insights = append(insights, "• Şu an bellekte aktif model görünmüyor.")
309+
insights = append(insights, "• No active in-memory model is visible right now.")
302310
}
303311

304312
if s.LogStats.RequestCount > 0 {
305-
insights = append(insights, fmt.Sprintf("• Son %d log kaydında ortalama latency %s.", s.LogStats.RequestCount, renderLatency(s.LogStats.AvgLatency)))
313+
insights = append(insights, fmt.Sprintf("• Average latency across the last %d log entries is %s.", s.LogStats.RequestCount, renderLatency(s.LogStats.AvgLatency)))
306314
if len(s.LogStats.TopEndpoints) > 0 {
307-
insights = append(insights, fmt.Sprintf("• En yoğun endpoint: %s (%d istek).", s.LogStats.TopEndpoints[0].Endpoint, s.LogStats.TopEndpoints[0].Count))
315+
insights = append(insights, fmt.Sprintf("• Busiest endpoint: %s (%d requests).", s.LogStats.TopEndpoints[0].Endpoint, s.LogStats.TopEndpoints[0].Count))
308316
}
309317
}
310318

311319
if m.filter != "" {
312-
insights = append(insights, fmt.Sprintf("• Filtre etkin: %q", m.filter))
320+
insights = append(insights, fmt.Sprintf("• Active filter: %q", m.filter))
313321
}
314322

315323
return strings.Join(insights, "\n")
@@ -325,7 +333,7 @@ func (m Model) renderRunning() string {
325333
running = filterRunning(running, m.filter)
326334

327335
if len(running) == 0 {
328-
lines = append(lines, tui.Dim.Render("Aktif model yok veya filtre eşleşmedi."))
336+
lines = append(lines, tui.Dim.Render("No active model found or the filter did not match."))
329337
return tui.Box.Width(leftW).Render(strings.Join(lines, "\n"))
330338
}
331339

@@ -359,7 +367,7 @@ func (m Model) renderInstalled() string {
359367
models = filterInstalled(models, m.filter)
360368

361369
if len(models) == 0 {
362-
lines = append(lines, tui.Dim.Render("Yüklü model bulunamadı veya filtre eşleşmedi."))
370+
lines = append(lines, tui.Dim.Render("No installed model found or the filter did not match."))
363371
return tui.Box.Width(rightW).Render(strings.Join(lines, "\n"))
364372
}
365373

@@ -394,7 +402,7 @@ func (m Model) renderLogPanel() string {
394402
case m.last.LogError != "":
395403
lines = append(lines, tui.Warn.Render(m.last.LogError))
396404
case len(m.last.LogLines) == 0:
397-
lines = append(lines, tui.Dim.Render("Log bulunamadı. OLLAMON_LOG_PATH ile özel yol verebilirsin."))
405+
lines = append(lines, tui.Dim.Render("Log file not found. Use OLLAMON_LOG_PATH to provide a custom path."))
398406
default:
399407
logLines := m.last.LogLines
400408
if len(logLines) > 10 {

internal/app/sample.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package app
33
import (
44
"context"
55
"fmt"
6+
"sync"
67
"time"
78

89
"github.com/example/ollamon/internal/config"
@@ -26,8 +27,6 @@ type Sample struct {
2627

2728
func CollectSample(cfg config.Config, client *ollama.Client) (Sample, error) {
2829
s := Sample{Time: time.Now(), BaseURL: client.BaseURL}
29-
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
30-
defer cancel()
3130

3231
hs, _ := system.Collect(cfg.RootDiskPath)
3332
s.Host = hs
@@ -40,8 +39,36 @@ func CollectSample(cfg config.Config, client *ollama.Client) (Sample, error) {
4039
s.LogError = logErr.Error()
4140
}
4241

43-
installed, errTags := client.Tags(ctx)
44-
running, errPS := client.PS(ctx)
42+
timeout := cfg.RequestTimeout
43+
if timeout <= 0 {
44+
timeout = 5 * time.Second
45+
}
46+
47+
var (
48+
installed []ollama.Model
49+
running []ollama.RunningModel
50+
errTags error
51+
errPS error
52+
wg sync.WaitGroup
53+
)
54+
55+
wg.Add(2)
56+
57+
go func() {
58+
defer wg.Done()
59+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
60+
defer cancel()
61+
installed, errTags = client.Tags(ctx)
62+
}()
63+
64+
go func() {
65+
defer wg.Done()
66+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
67+
defer cancel()
68+
running, errPS = client.PS(ctx)
69+
}()
70+
71+
wg.Wait()
4572

4673
s.Installed = installed
4774
s.Running = running
@@ -52,7 +79,7 @@ func CollectSample(cfg config.Config, client *ollama.Client) (Sample, error) {
5279
return s, nil
5380
case errTags != nil && errPS != nil:
5481
s.HealthError = fmt.Sprintf("tags: %v | ps: %v", errTags, errPS)
55-
return s, fmt.Errorf("ollama erişilemedi: %s", s.HealthError)
82+
return s, fmt.Errorf("ollama unavailable: %s", s.HealthError)
5683
case errTags != nil:
5784
s.HealthError = errTags.Error()
5885
return s, nil

0 commit comments

Comments
 (0)