Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The live indicator and CPU activity area chart are animated with [termkit-go](ht

Prometheus and load-test commands are optional integrations when you need application metrics or want to correlate a workload with system health.

When Docker is available, the dashboard also shows live container CPU, memory, and network data. An optional `observe.yaml` can set `refresh_interval` (milliseconds), `thresholds` (`cpu`, `memory`, `swap`, `disk`, and `io_wait`), and visible `panels` while the defaults remain zero-config.
When Docker is available, the dashboard also shows live container CPU, memory, and network data. An optional `observe.yaml` can set `refresh_interval` (milliseconds), `theme` (`default`, `dark`, `light`, or `mono`), `thresholds` (`cpu`, `memory`, `swap`, `disk`, and `io_wait`), visible `panels`, and custom `prometheus_presets` while the defaults remain zero-config. See [observe.yaml.example](observe.yaml.example).

## Install

Expand Down
10 changes: 8 additions & 2 deletions cmd/observe/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package observe
import (
"fmt"
"os"
"time"

"github.com/pol-cova/observe/internal/assistant"
"github.com/pol-cova/observe/internal/config"
"github.com/pol-cova/observe/internal/detect"
"github.com/pol-cova/observe/internal/metrics/local"
"github.com/pol-cova/observe/internal/prometheus"
"github.com/pol-cova/observe/internal/snapshot"
"github.com/pol-cova/observe/internal/tui"
Expand Down Expand Up @@ -53,11 +55,15 @@ var initCmd = &cobra.Command{
var askCmd = &cobra.Command{
Use: "ask <question>", Short: "Explain current system health in plain English", Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
snapshot, err := tui.Snapshot()
cfg, err := config.Load(configPath)
if err != nil {
return err
}
history, err := local.SampleHistory(3, 500*time.Millisecond)
if err != nil {
return err
}
fmt.Print(assistant.Answer(args, snapshot))
fmt.Print(assistant.Answer(args, history, cfg.Thresholds.Health()))
return nil
},
}
Expand Down
54 changes: 41 additions & 13 deletions docs/demo.tape
Original file line number Diff line number Diff line change
@@ -1,19 +1,47 @@
Output docs/observe.gif

Set FontSize 18
Set Width 1440
Set Height 820
Set Margin 24
Set MarginFill "#0b1118"
Set Theme "Dracula"
Set TypingSpeed 14ms
Set PlaybackSpeed 1.15
Set Shell bash
Set FontSize 20
Set Width 1280
Set Height 760
Set Margin 20
Set MarginFill "#0a0e14"
Set Theme "Catppuccin Mocha"
Set TypingSpeed 12ms
Set PlaybackSpeed 1

Hide
Type "clear"
Enter
Sleep 300ms
Type "cat docs/demo-output.txt"
Type "go build -o /tmp/observe-demo ."
Enter
Sleep 4s
Show
Sleep 5s

Type "OBSERVE_NO_ALT_SCREEN=1 /tmp/observe-demo"
Enter
Sleep 4s
Type "2"
Sleep 2.5s
Type "3"
Sleep 2.5s
Type "5"
Sleep 2.5s
Type "1"
Sleep 3s
Type "j"
Sleep 400ms
Type "j"
Sleep 400ms
Type "Enter"
Sleep 2.5s
Type "Esc"
Sleep 800ms
Type "?"
Sleep 2s
Type "?"
Sleep 800ms
Type "q"
Enter
Sleep 500ms
Type "/tmp/observe-demo ask is my server cpu bound"
Enter
Sleep 2.5s
Binary file modified docs/observe.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
84 changes: 66 additions & 18 deletions internal/assistant/assistant.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,80 @@ package assistant

import (
"fmt"
"github.com/pol-cova/observe/internal/metrics/local"
"sort"
"strings"

"github.com/pol-cova/observe/internal/health"
"github.com/pol-cova/observe/internal/metrics/local"
)

func Answer(parts []string, s local.Snapshot) string {
func Answer(parts []string, history []local.Snapshot, thresholds health.Thresholds) string {
if len(history) == 0 {
return "No samples collected yet.\n"
}
latest := history[len(history)-1]
q := strings.ToLower(strings.Join(parts, " "))

var b strings.Builder
fmt.Fprintf(&b, "%s\n\n", titleFor(q))
fmt.Fprintf(&b, "CPU %.1f%%, memory %.1f%%, disk %.1f%%.\n", latest.CPU, latest.Memory, latest.Disk)
if len(history) > 1 {
first := history[0]
fmt.Fprintf(&b, "Trend (%d samples): CPU %+.1f, memory %+.1f.\n",
len(history), latest.CPU-first.CPU, latest.Memory-first.Memory)
}
for _, process := range topProcesses(latest.Processes, 3) {
fmt.Fprintf(&b, " • %s (pid %d) — %.1f%% CPU\n", process.Name, process.PID, process.CPU)
}
for _, hint := range health.HintsWithThresholds(history, thresholds) {
fmt.Fprintf(&b, " • %s\n", hint)
}
fmt.Fprintf(&b, "\n%s\n", adviceFor(q, latest))
return b.String()
}

func titleFor(q string) string {
switch {
case strings.Contains(q, "prometheus") || strings.Contains(q, "5xx") || strings.Contains(q, "query"):
b.WriteString("For 5xx errors, use:\n\n sum(rate(http_requests_total{status=~\"5..\"}[1m]))\n\n")
case strings.Contains(q, "prometheus") || strings.Contains(q, "5xx"):
return "Prometheus diagnosis"
case strings.Contains(q, "latency"):
b.WriteString("Latency diagnosis\n\n")
return "Latency diagnosis"
case strings.Contains(q, "memory") || strings.Contains(q, "ram"):
return "Memory diagnosis"
case strings.Contains(q, "disk"):
return "Disk diagnosis"
case strings.Contains(q, "network"):
return "Network diagnosis"
case strings.Contains(q, "cpu"):
b.WriteString("CPU diagnosis\n\n")
return "CPU diagnosis"
default:
b.WriteString("Current system health\n\n")
}
fmt.Fprintf(&b, "CPU is %.1f%%, memory is %.1f%%, and disk is %.1f%%.\n", s.CPU, s.Memory, s.Disk)
if s.CPU >= 90 {
b.WriteString("\nThis machine appears CPU-bound. Profile the top process or reduce test concurrency.\n")
} else if s.Memory >= 85 {
b.WriteString("\nMemory pressure is the strongest signal. Check process growth and swapping.\n")
} else if s.Disk >= 90 {
b.WriteString("\nDisk capacity is critically high; free space before trusting results.\n")
} else {
b.WriteString("\nNo clear local resource bottleneck is present in this single sample. For latency, compare p95 with CPU and error rate over time.\n")
return "Current system health"
}
return b.String()
}

func adviceFor(q string, s local.Snapshot) string {
switch {
case strings.Contains(q, "prometheus") || strings.Contains(q, "5xx"):
return "For 5xx errors, use:\n\n sum(rate(http_requests_total{status=~\"5..\"}[1m]))"
case strings.Contains(q, "cpu") && s.CPU >= 90:
return "This machine appears CPU-bound. Profile the top process or reduce concurrency."
case strings.Contains(q, "memory") && (s.Memory >= 85 || s.Swap >= 25):
return "Memory pressure is the strongest signal. Check process growth and swapping."
case strings.Contains(q, "disk") && s.Disk >= 90:
return "Disk capacity is critically high; free space before trusting results."
default:
return "No clear local bottleneck in these samples. Compare metrics over time or run with --prometheus."
}
}

func topProcesses(processes []local.Process, limit int) []local.Process {
if limit <= 0 || len(processes) == 0 {
return nil
}
ranked := append([]local.Process(nil), processes...)
sort.Slice(ranked, func(i, j int) bool { return ranked[i].CPU > ranked[j].CPU })
if len(ranked) > limit {
return ranked[:limit]
}
return ranked
}
36 changes: 36 additions & 0 deletions internal/assistant/assistant_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package assistant

import (
"strings"
"testing"

"github.com/pol-cova/observe/internal/health"
"github.com/pol-cova/observe/internal/metrics/local"
)

func TestAnswerIncludesTrendAndHints(t *testing.T) {
history := []local.Snapshot{
{CPU: 40, Memory: 50, Disk: 60, Processes: []local.Process{{PID: 1, Name: "worker", CPU: 12}}},
{CPU: 55, Memory: 62, Disk: 61, Processes: []local.Process{{PID: 1, Name: "worker", CPU: 18}}},
}
answer := Answer([]string{"cpu"}, history, health.Thresholds{})
for _, phrase := range []string{"Trend (2 samples)", "worker", "No immediate resource bottleneck detected"} {
if !strings.Contains(answer, phrase) {
t.Fatalf("answer missing %q:\n%s", phrase, answer)
}
}
}

func TestAnswerCPUAdviceWhenSaturated(t *testing.T) {
answer := Answer([]string{"cpu"}, []local.Snapshot{{CPU: 95, Memory: 40, Disk: 50}}, health.Thresholds{})
if !strings.Contains(answer, "CPU-bound") {
t.Fatalf("expected CPU-bound advice: %s", answer)
}
}

func TestAnswerUsesThresholdHints(t *testing.T) {
answer := Answer([]string{"health"}, []local.Snapshot{{CPU: 96, Memory: 40, Disk: 50}}, health.Thresholds{CPU: 95})
if !strings.Contains(answer, "CPU saturated") {
t.Fatalf("expected threshold hint: %s", answer)
}
}
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"os"

"github.com/pol-cova/observe/internal/health"
"gopkg.in/yaml.v3"
)

Expand All @@ -23,6 +24,10 @@ type Thresholds struct {
IOWait float64 `yaml:"io_wait"`
}

func (t Thresholds) Health() health.Thresholds {
return health.Thresholds{CPU: t.CPU, Memory: t.Memory, Swap: t.Swap, Disk: t.Disk, IOWait: t.IOWait}
}

func Load(path string) (Config, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
Expand Down
7 changes: 5 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ import (

func TestLoadConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "observe.yaml")
if err := os.WriteFile(path, []byte("refresh_interval: 750\nthresholds:\n cpu: 80\npanels: [overview, docker]\n"), 0600); err != nil {
if err := os.WriteFile(path, []byte("refresh_interval: 750\ntheme: light\nthresholds:\n cpu: 80\npanels: [overview, docker]\nprometheus_presets:\n - name: Queue depth\n query: queue_depth\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.RefreshInterval != 750 || cfg.Thresholds.CPU != 80 || len(cfg.Panels) != 2 {
if cfg.RefreshInterval != 750 || cfg.Thresholds.CPU != 80 || len(cfg.Panels) != 2 || cfg.Theme != "light" {
t.Fatalf("unexpected config: %#v", cfg)
}
if len(cfg.Prometheus) != 1 || cfg.Prometheus[0].Name != "Queue depth" {
t.Fatalf("unexpected prometheus presets: %#v", cfg.Prometheus)
}
}

func TestMissingConfigUsesDefaults(t *testing.T) {
Expand Down
22 changes: 22 additions & 0 deletions internal/metrics/local/sample.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package local

import "time"

func SampleHistory(samples int, interval time.Duration) ([]Snapshot, error) {
if samples < 1 {
samples = 1
}
collector := New()
history := make([]Snapshot, 0, samples)
for i := 0; i < samples; i++ {
snapshot, err := collector.Collect()
if err != nil {
return history, err
}
history = append(history, snapshot)
if i < samples-1 {
time.Sleep(interval)
}
}
return history, nil
}
29 changes: 29 additions & 0 deletions internal/prometheus/presets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package prometheus

import "github.com/pol-cova/observe/internal/config"

func MergePresets(custom []config.PrometheusPreset) []Preset {
if len(custom) == 0 {
return append([]Preset(nil), Presets...)
}
byName := make(map[string]Preset, len(Presets)+len(custom))
order := make([]string, 0, len(Presets)+len(custom))
for _, preset := range Presets {
byName[preset.Name] = preset
order = append(order, preset.Name)
}
for _, preset := range custom {
if preset.Name == "" || preset.Query == "" {
continue
}
if _, ok := byName[preset.Name]; !ok {
order = append(order, preset.Name)
}
byName[preset.Name] = Preset{Name: preset.Name, Description: preset.Description, Query: preset.Query}
}
merged := make([]Preset, len(order))
for i, name := range order {
merged[i] = byName[name]
}
return merged
}
37 changes: 37 additions & 0 deletions internal/prometheus/presets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package prometheus

import (
"testing"

"github.com/pol-cova/observe/internal/config"
)

func TestMergePresetsUsesBuiltinsWhenEmpty(t *testing.T) {
merged := MergePresets(nil)
if len(merged) != len(Presets) {
t.Fatalf("len(MergePresets(nil)) = %d, want %d", len(merged), len(Presets))
}
}

func TestMergePresetsOverridesAndAddsCustom(t *testing.T) {
custom := []config.PrometheusPreset{
{Name: "5xx errors", Description: "custom 5xx", Query: "sum(rate(errors_total[1m]))"},
{Name: "Queue depth", Description: "pending jobs", Query: "queue_depth"},
}
merged := MergePresets(custom)
foundOverride, foundCustom := false, false
for _, preset := range merged {
if preset.Name == "5xx errors" {
foundOverride = true
if preset.Query != "sum(rate(errors_total[1m]))" {
t.Fatalf("override query = %q", preset.Query)
}
}
if preset.Name == "Queue depth" {
foundCustom = true
}
}
if !foundOverride || !foundCustom {
t.Fatalf("merged presets missing override/custom: %#v", merged)
}
}
Loading
Loading