diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9500d44 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: gofmt + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "The following files are not gofmt'd:" + gofmt -l . + exit 1 + fi + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4bbb013 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,3 @@ +version: "2" +linters: + default: standard diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..59e638d --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,38 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - main: ./cmd/terphite + binary: terphite + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: [tar.gz] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^test:" + - "^chore:" diff --git a/Cakefile b/Cakefile deleted file mode 100644 index 34da08e..0000000 --- a/Cakefile +++ /dev/null @@ -1,22 +0,0 @@ -{print} = require 'util' -{spawn, exec} = require 'child_process' - -build = (watch, callback) -> - if typeof watch is 'function' - callback = watch - watch = false - options = ['-c', '-o', 'lib', 'src'] - options.unshift '-w' if watch - - coffee = spawn 'node_modules/.bin/coffee', options - coffee.stdout.on 'data', (data) -> print data.toString() - coffee.stderr.on 'data', (data) -> print data.toString() - coffee.on 'exit', (status) -> callback?() if status is 0 - - -task 'build', 'Compile CoffeeScript source files', -> - build() - -task 'watch', 'Recompile CoffeeScript source files when modified', -> - build true - diff --git a/bin/terphite b/bin/terphite deleted file mode 100755 index ec0f69a..0000000 --- a/bin/terphite +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -require('../'); - diff --git a/cmd/terphite/main.go b/cmd/terphite/main.go new file mode 100644 index 0000000..eac94b0 --- /dev/null +++ b/cmd/terphite/main.go @@ -0,0 +1,46 @@ +// Command terphite is a terminal browser for Graphite metrics, loosely +// modeled on Graphite's web Composer. +package main + +import ( + "fmt" + "os" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/benwtr/terphite/internal/tui" +) + +// version is set at build time via -ldflags "-X main.version=...". +var version = "dev" + +func main() { + if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") { + fmt.Println("terphite", version) + return + } + + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: %s https://user:pass@yourgraphite.net:4321\n", filepath.Base(os.Args[0])) + fmt.Fprintln(os.Stderr, " (credentials may also be supplied via GRAPHITE_USER / GRAPHITE_PASS env vars)") + os.Exit(1) + } + + cfg := tui.Config{ + GraphiteURI: os.Args[1], + Username: os.Getenv("GRAPHITE_USER"), + Password: os.Getenv("GRAPHITE_PASS"), + } + + m, err := tui.New(cfg) + if err != nil { + fmt.Fprintln(os.Stderr, "terphite:", err) + os.Exit(1) + } + + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { + fmt.Fprintln(os.Stderr, "terphite:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d92e3c8 --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/benwtr/terphite + +go 1.24.7 + +require ( + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e92ed90 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/index.js b/index.js deleted file mode 100644 index 9869b64..0000000 --- a/index.js +++ /dev/null @@ -1,16 +0,0 @@ -var process = require('process'); -var path = require('path'); -var Terphite = require("./lib/composer.js"); - -var node_binary = process.argv.shift(); -var script_name = path.basename(process.argv.shift()); - -if (process.argv.length < 1) { - console.error("Usage: " + script_name + " https://user:pass@yourgraphite.net:4321"); - process.exit(1); -} - -var graphite_uri = process.argv.shift(); - -t = new Terphite(graphite_uri); -t.composer(); diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go new file mode 100644 index 0000000..2455dbf --- /dev/null +++ b/internal/dashboard/dashboard.go @@ -0,0 +1,148 @@ +// Package dashboard persists named sets of saved graphs ("panels") so they +// can be viewed together as a grid instead of one at a time. +package dashboard + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strings" +) + +// Panel is a saved snapshot of a graph: the metrics it plots, the time +// range it was viewed over, and how it was drawn ("line", "area", or +// "stacked" — empty means "line", for dashboards saved before draw modes +// existed). +type Panel struct { + Title string `json:"title"` + Targets []string `json:"targets"` + TimeFrom string `json:"timeFrom"` + DrawMode string `json:"drawMode,omitempty"` +} + +// Dashboard is a named collection of panels. +type Dashboard struct { + Name string `json:"name"` + Panels []Panel `json:"panels"` +} + +// Store persists dashboards as one JSON file per dashboard under Dir. +type Store struct { + Dir string +} + +// DefaultDir returns the standard on-disk location for saved dashboards: +// $XDG_CONFIG_HOME/terphite/dashboards (or the OS equivalent). +func DefaultDir() (string, error) { + cfgDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(cfgDir, "terphite", "dashboards"), nil +} + +// NewStore returns a Store rooted at dir. +func NewStore(dir string) *Store { + return &Store{Dir: dir} +} + +// sanitizeName maps a dashboard name to a safe filename component, so a +// user-supplied name can never escape the store directory. +func sanitizeName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", errors.New("dashboard: name must not be empty") + } + var sb strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == ' ': + sb.WriteRune(r) + default: + sb.WriteRune('_') + } + } + return sb.String(), nil +} + +func (s *Store) filePath(name string) (string, error) { + safe, err := sanitizeName(name) + if err != nil { + return "", err + } + return filepath.Join(s.Dir, safe+".json"), nil +} + +func (s *Store) loadFile(path string) (*Dashboard, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var d Dashboard + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil +} + +// List returns the names of all saved dashboards, sorted alphabetically. An +// empty (not-yet-created) store directory returns an empty list, not an error. +func (s *Store) List() ([]string, error) { + entries, err := os.ReadDir(s.Dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var names []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + d, err := s.loadFile(filepath.Join(s.Dir, e.Name())) + if err != nil { + continue + } + names = append(names, d.Name) + } + sort.Strings(names) + return names, nil +} + +// Load loads the dashboard with the given name. +func (s *Store) Load(name string) (*Dashboard, error) { + path, err := s.filePath(name) + if err != nil { + return nil, err + } + return s.loadFile(path) +} + +// Save persists d, creating the store directory and/or overwriting an +// existing file for the same name as needed. +func (s *Store) Save(d *Dashboard) error { + path, err := s.filePath(d.Name) + if err != nil { + return err + } + if err := os.MkdirAll(s.Dir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(d, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +// Delete removes the dashboard with the given name. +func (s *Store) Delete(name string) error { + path, err := s.filePath(name) + if err != nil { + return err + } + return os.Remove(path) +} diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go new file mode 100644 index 0000000..adb290f --- /dev/null +++ b/internal/dashboard/dashboard_test.go @@ -0,0 +1,111 @@ +package dashboard + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + s := NewStore(t.TempDir()) + d := &Dashboard{ + Name: "prod overview", + Panels: []Panel{ + {Title: "cpu", Targets: []string{"stats.cpu"}, TimeFrom: "-1h", DrawMode: "stacked"}, + {Title: "mem", Targets: []string{"stats.mem"}, TimeFrom: "-1h"}, + }, + } + if err := s.Save(d); err != nil { + t.Fatal(err) + } + got, err := s.Load("prod overview") + if err != nil { + t.Fatal(err) + } + if got.Name != d.Name || len(got.Panels) != 2 { + t.Errorf("got %+v, want %+v", got, d) + } + if got.Panels[0].Title != "cpu" || got.Panels[1].Title != "mem" { + t.Errorf("panels not preserved: %+v", got.Panels) + } + if got.Panels[0].DrawMode != "stacked" { + t.Errorf("Panels[0].DrawMode = %q, want stacked", got.Panels[0].DrawMode) + } + if got.Panels[1].DrawMode != "" { + t.Errorf("Panels[1].DrawMode = %q, want empty (defaults to line)", got.Panels[1].DrawMode) + } +} + +func TestListEmptyDir(t *testing.T) { + s := NewStore(filepath.Join(t.TempDir(), "does-not-exist-yet")) + names, err := s.List() + if err != nil { + t.Fatal(err) + } + if len(names) != 0 { + t.Errorf("got %v, want empty", names) + } +} + +func TestListSorted(t *testing.T) { + s := NewStore(t.TempDir()) + for _, name := range []string{"zebra", "alpha", "mango"} { + if err := s.Save(&Dashboard{Name: name}); err != nil { + t.Fatal(err) + } + } + names, err := s.List() + if err != nil { + t.Fatal(err) + } + want := []string{"alpha", "mango", "zebra"} + if len(names) != len(want) { + t.Fatalf("got %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("got %v, want %v", names, want) + break + } + } +} + +func TestDelete(t *testing.T) { + s := NewStore(t.TempDir()) + if err := s.Save(&Dashboard{Name: "temp"}); err != nil { + t.Fatal(err) + } + if err := s.Delete("temp"); err != nil { + t.Fatal(err) + } + if _, err := s.Load("temp"); err == nil { + t.Error("expected error loading deleted dashboard") + } +} + +func TestSaveRejectsEmptyName(t *testing.T) { + s := NewStore(t.TempDir()) + if err := s.Save(&Dashboard{Name: " "}); err == nil { + t.Error("expected error for empty/whitespace name") + } +} + +func TestNameSanitizedAgainstPathTraversal(t *testing.T) { + dir := t.TempDir() + s := NewStore(dir) + if err := s.Save(&Dashboard{Name: "../../etc/passwd"}); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected exactly one file written inside store dir, got %d", len(entries)) + } + for _, e := range entries { + if filepath.Dir(filepath.Join(dir, e.Name())) != dir { + t.Errorf("file escaped store dir: %s", e.Name()) + } + } +} diff --git a/internal/graphite/client.go b/internal/graphite/client.go new file mode 100644 index 0000000..a5e6c85 --- /dev/null +++ b/internal/graphite/client.go @@ -0,0 +1,223 @@ +// Package graphite is a small HTTP client for the parts of the Graphite web +// API terphite needs: the metrics index and the render endpoint. +package graphite + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Client talks to a single Graphite server. +type Client struct { + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient builds a Client for the given Graphite base URL, e.g. +// "http://user:pass@graphite.example.com:8080". Credentials embedded in the +// URL are used for HTTP Basic Auth; call SetAuth to override them. +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("graphite: invalid URL %q: %w", rawURL, err) + } + return &Client{BaseURL: u, HTTPClient: http.DefaultClient}, nil +} + +// SetAuth sets (or overrides) the HTTP Basic Auth credentials used for +// requests, taking precedence over any userinfo embedded in the base URL. +func (c *Client) SetAuth(username, password string) { + u := *c.BaseURL + u.User = url.UserPassword(username, password) + c.BaseURL = &u +} + +func (c *Client) newRequest(ctx context.Context, path string, query url.Values) (*http.Request, error) { + u := *c.BaseURL + user := u.User + u.User = nil + u.Path = strings.TrimRight(u.Path, "/") + path + if query != nil { + u.RawQuery = query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + if user != nil { + pass, _ := user.Password() + req.SetBasicAuth(user.Username(), pass) + } + return req, nil +} + +// FetchMetricsIndex returns the flat list of all metric names known to the +// server, from GET /metrics/index.json. +func (c *Client) FetchMetricsIndex(ctx context.Context) ([]string, error) { + req, err := c.newRequest(ctx, "/metrics/index.json", nil) + if err != nil { + return nil, err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("graphite: fetching metrics index: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("graphite: metrics index returned status %d", resp.StatusCode) + } + var names []string + if err := json.NewDecoder(resp.Body).Decode(&names); err != nil { + return nil, fmt.Errorf("graphite: decoding metrics index: %w", err) + } + return names, nil +} + +// RenderQuery describes a request to the render endpoint. +type RenderQuery struct { + Targets []string + From string + MaxDataPoints int +} + +func (q RenderQuery) values() url.Values { + v := url.Values{} + v.Set("format", "json") + if q.From != "" { + v.Set("from", q.From) + } + for _, t := range q.Targets { + v.Add("target", t) + } + if q.MaxDataPoints > 0 { + v.Set("maxDataPoints", strconv.Itoa(q.MaxDataPoints)) + } + return v +} + +// RenderURL builds the render URL for q without embedding credentials, e.g. +// for "open in browser" or "copy to clipboard" actions. +func (c *Client) RenderURL(q RenderQuery) string { + u := *c.BaseURL + u.User = nil + u.Path = strings.TrimRight(u.Path, "/") + "/render" + u.RawQuery = q.values().Encode() + return u.String() +} + +// ImageQuery describes a request for Graphite's own rendered PNG, used for +// terminals that can display images inline. +type ImageQuery struct { + Targets []string + From string + Width int + Height int + AreaMode string // "none", "all", or "stacked" — Graphite's areaMode param +} + +func (q ImageQuery) values() url.Values { + v := url.Values{} + v.Set("format", "png") + if q.From != "" { + v.Set("from", q.From) + } + for _, t := range q.Targets { + v.Add("target", t) + } + if q.Width > 0 { + v.Set("width", strconv.Itoa(q.Width)) + } + if q.Height > 0 { + v.Set("height", strconv.Itoa(q.Height)) + } + if q.AreaMode != "" { + v.Set("areaMode", q.AreaMode) + } + return v +} + +// FetchRenderImage fetches a rendered PNG for q from GET /render. +func (c *Client) FetchRenderImage(ctx context.Context, q ImageQuery) ([]byte, error) { + req, err := c.newRequest(ctx, "/render", q.values()) + if err != nil { + return nil, err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("graphite: fetching render image: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("graphite: render image returned status %d", resp.StatusCode) + } + png, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("graphite: reading render image: %w", err) + } + return png, nil +} + +// Datapoint is a single (possibly missing) sample from a render response. +type Datapoint struct { + Value *float64 + Time time.Time +} + +// Series is one target's worth of render data. +type Series struct { + Target string + Datapoints []Datapoint +} + +// FetchRender fetches render data for q from GET /render. +func (c *Client) FetchRender(ctx context.Context, q RenderQuery) ([]Series, error) { + req, err := c.newRequest(ctx, "/render", q.values()) + if err != nil { + return nil, err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("graphite: fetching render data: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("graphite: render returned status %d", resp.StatusCode) + } + + var raw []struct { + Target string `json:"target"` + Datapoints [][2]*float64 `json:"datapoints"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("graphite: decoding render data: %w", err) + } + + series := make([]Series, 0, len(raw)) + for _, r := range raw { + title := r.Target + if title == "" { + title = "[unnamed_target]" + } + dps := make([]Datapoint, 0, len(r.Datapoints)) + for _, pair := range r.Datapoints { + if pair[1] == nil { + continue + } + dp := Datapoint{Time: time.Unix(int64(*pair[1]), 0).UTC()} + if pair[0] != nil { + v := *pair[0] + dp.Value = &v + } + dps = append(dps, dp) + } + series = append(series, Series{Target: title, Datapoints: dps}) + } + return series, nil +} diff --git a/internal/graphite/client_test.go b/internal/graphite/client_test.go new file mode 100644 index 0000000..0530048 --- /dev/null +++ b/internal/graphite/client_test.go @@ -0,0 +1,226 @@ +package graphite + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestFetchMetricsIndex(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metrics/index.json" { + t.Errorf("unexpected path %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`["stats.foo.bar","stats.foo.baz"]`)) + })) + defer srv.Close() + + c, err := NewClient(srv.URL) + if err != nil { + t.Fatal(err) + } + names, err := c.FetchMetricsIndex(context.Background()) + if err != nil { + t.Fatal(err) + } + want := []string{"stats.foo.bar", "stats.foo.baz"} + if len(names) != len(want) || names[0] != want[0] || names[1] != want[1] { + t.Errorf("got %v, want %v", names, want) + } +} + +func TestFetchMetricsIndexError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c, err := NewClient(srv.URL) + if err != nil { + t.Fatal(err) + } + if _, err := c.FetchMetricsIndex(context.Background()); err == nil { + t.Fatal("expected error for 500 response, got nil") + } +} + +func TestFetchRender(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"target":"stats.foo","datapoints":[[1.5,1000],[null,1060]]}]`)) + })) + defer srv.Close() + + c, err := NewClient(srv.URL) + if err != nil { + t.Fatal(err) + } + series, err := c.FetchRender(context.Background(), RenderQuery{ + Targets: []string{"stats.foo"}, + From: "-1h", + MaxDataPoints: 300, + }) + if err != nil { + t.Fatal(err) + } + if len(series) != 1 { + t.Fatalf("got %d series, want 1", len(series)) + } + if series[0].Target != "stats.foo" { + t.Errorf("target = %q, want stats.foo", series[0].Target) + } + if len(series[0].Datapoints) != 2 { + t.Fatalf("got %d datapoints, want 2", len(series[0].Datapoints)) + } + if series[0].Datapoints[0].Value == nil || *series[0].Datapoints[0].Value != 1.5 { + t.Errorf("datapoint 0 value = %v, want 1.5", series[0].Datapoints[0].Value) + } + if series[0].Datapoints[1].Value != nil { + t.Errorf("datapoint 1 value = %v, want nil", *series[0].Datapoints[1].Value) + } + if gotQuery == "" { + t.Error("expected non-empty query string") + } +} + +func TestFetchRenderUnnamedTarget(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"target":"","datapoints":[]}]`)) + })) + defer srv.Close() + + c, err := NewClient(srv.URL) + if err != nil { + t.Fatal(err) + } + series, err := c.FetchRender(context.Background(), RenderQuery{}) + if err != nil { + t.Fatal(err) + } + if series[0].Target != "[unnamed_target]" { + t.Errorf("target = %q, want [unnamed_target]", series[0].Target) + } +} + +func TestBasicAuthFromURL(t *testing.T) { + var gotUser, gotPass string + var hadAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, gotPass, hadAuth = r.BasicAuth() + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + u := srv.URL[len("http://"):] + c, err := NewClient("http://alice:s3cret@" + u) + if err != nil { + t.Fatal(err) + } + if _, err := c.FetchMetricsIndex(context.Background()); err != nil { + t.Fatal(err) + } + if !hadAuth || gotUser != "alice" || gotPass != "s3cret" { + t.Errorf("got auth (%v, %q, %q), want (true, alice, s3cret)", hadAuth, gotUser, gotPass) + } +} + +func TestSetAuthOverridesURL(t *testing.T) { + var gotUser, gotPass string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, gotPass, _ = r.BasicAuth() + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + c, err := NewClient(srv.URL) + if err != nil { + t.Fatal(err) + } + c.SetAuth("bob", "hunter2") + if _, err := c.FetchMetricsIndex(context.Background()); err != nil { + t.Fatal(err) + } + if gotUser != "bob" || gotPass != "hunter2" { + t.Errorf("got (%q, %q), want (bob, hunter2)", gotUser, gotPass) + } +} + +func TestRenderURLOmitsCredentials(t *testing.T) { + c, err := NewClient("http://alice:s3cret@graphite.example.com") + if err != nil { + t.Fatal(err) + } + url := c.RenderURL(RenderQuery{Targets: []string{"stats.foo"}, From: "-1h"}) + if strings.Contains(url, "alice") || strings.Contains(url, "s3cret") { + t.Errorf("RenderURL leaked credentials: %s", url) + } + if !strings.Contains(url, "/render") { + t.Errorf("RenderURL missing /render path: %s", url) + } +} + +func TestFetchRenderImage(t *testing.T) { + fakePNG := []byte("\x89PNG\r\n\x1a\nfake-png-data") + var gotQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNG) + })) + defer srv.Close() + + c, err := NewClient(srv.URL) + if err != nil { + t.Fatal(err) + } + png, err := c.FetchRenderImage(context.Background(), ImageQuery{ + Targets: []string{"stats.foo"}, + From: "-1h", + Width: 800, + Height: 400, + AreaMode: "stacked", + }) + if err != nil { + t.Fatal(err) + } + if string(png) != string(fakePNG) { + t.Errorf("got %q, want %q", png, fakePNG) + } + + if got := gotQuery.Get("format"); got != "png" { + t.Errorf("format = %q, want png", got) + } + if got := gotQuery.Get("areaMode"); got != "stacked" { + t.Errorf("areaMode = %q, want stacked", got) + } + if got := gotQuery.Get("width"); got != "800" { + t.Errorf("width = %q, want 800", got) + } + if got := gotQuery.Get("height"); got != "400" { + t.Errorf("height = %q, want 400", got) + } + if got := gotQuery.Get("target"); got != "stats.foo" { + t.Errorf("target = %q, want stats.foo", got) + } +} + +func TestFetchRenderImageError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c, err := NewClient(srv.URL) + if err != nil { + t.Fatal(err) + } + if _, err := c.FetchRenderImage(context.Background(), ImageQuery{}); err == nil { + t.Fatal("expected error for 500 response, got nil") + } +} diff --git a/internal/graphite/tree.go b/internal/graphite/tree.go new file mode 100644 index 0000000..add2a53 --- /dev/null +++ b/internal/graphite/tree.go @@ -0,0 +1,53 @@ +package graphite + +import ( + "sort" + "strings" +) + +// MetricNode is one node in the nested tree built from a flat list of +// dot-delimited metric names (e.g. "stats.foo.bar"). +type MetricNode struct { + Name string + Path string + Leaf bool + Children []*MetricNode +} + +// BuildTree builds a nested MetricNode tree from a flat list of metric +// names. The root node's Name is "metrics" and has no Path. +func BuildTree(names []string) *MetricNode { + root := &MetricNode{Name: "metrics"} + childIndex := make(map[*MetricNode]map[string]*MetricNode) + + sorted := append([]string(nil), names...) + sort.Strings(sorted) + + for _, name := range sorted { + if name == "" { + continue + } + parts := strings.Split(name, ".") + cur := root + var pathParts []string + for i, part := range parts { + pathParts = append(pathParts, part) + children, ok := childIndex[cur] + if !ok { + children = make(map[string]*MetricNode) + childIndex[cur] = children + } + child, ok := children[part] + if !ok { + child = &MetricNode{Name: part, Path: strings.Join(pathParts, ".")} + children[part] = child + cur.Children = append(cur.Children, child) + } + if i == len(parts)-1 { + child.Leaf = true + } + cur = child + } + } + return root +} diff --git a/internal/graphite/tree_test.go b/internal/graphite/tree_test.go new file mode 100644 index 0000000..032d7f2 --- /dev/null +++ b/internal/graphite/tree_test.go @@ -0,0 +1,81 @@ +package graphite + +import "testing" + +func findChild(n *MetricNode, name string) *MetricNode { + for _, c := range n.Children { + if c.Name == name { + return c + } + } + return nil +} + +func TestBuildTree(t *testing.T) { + root := BuildTree([]string{"stats.foo.bar", "stats.foo.baz", "stats.qux"}) + + stats := findChild(root, "stats") + if stats == nil { + t.Fatal("expected top-level 'stats' node") + } + if stats.Leaf { + t.Error("'stats' should not be a leaf") + } + if stats.Path != "stats" { + t.Errorf("stats.Path = %q, want %q", stats.Path, "stats") + } + + foo := findChild(stats, "foo") + if foo == nil { + t.Fatal("expected 'stats.foo' node") + } + if foo.Leaf { + t.Error("'stats.foo' should not be a leaf (it has children)") + } + if foo.Path != "stats.foo" { + t.Errorf("foo.Path = %q, want %q", foo.Path, "stats.foo") + } + + bar := findChild(foo, "bar") + if bar == nil || !bar.Leaf { + t.Fatal("expected leaf node 'stats.foo.bar'") + } + if bar.Path != "stats.foo.bar" { + t.Errorf("bar.Path = %q, want %q", bar.Path, "stats.foo.bar") + } + + baz := findChild(foo, "baz") + if baz == nil || !baz.Leaf { + t.Fatal("expected leaf node 'stats.foo.baz'") + } + + qux := findChild(stats, "qux") + if qux == nil || !qux.Leaf { + t.Fatal("expected leaf node 'stats.qux'") + } +} + +func TestBuildTreeSharedPrefixLeafAndBranch(t *testing.T) { + // A metric name can be both a leaf itself and a prefix of other metrics + // (e.g. Graphite tags/aggregation nodes) — the node should be marked as + // a leaf while still holding its children. + root := BuildTree([]string{"stats.foo", "stats.foo.bar"}) + stats := findChild(root, "stats") + foo := findChild(stats, "foo") + if foo == nil { + t.Fatal("expected 'stats.foo' node") + } + if !foo.Leaf { + t.Error("'stats.foo' should be marked as a leaf since it was seen as a full metric name") + } + if len(foo.Children) != 1 || foo.Children[0].Name != "bar" { + t.Errorf("expected 'stats.foo' to have child 'bar', got %+v", foo.Children) + } +} + +func TestBuildTreeEmpty(t *testing.T) { + root := BuildTree(nil) + if len(root.Children) != 0 { + t.Errorf("expected no children for empty input, got %d", len(root.Children)) + } +} diff --git a/internal/termimg/detect.go b/internal/termimg/detect.go new file mode 100644 index 0000000..9f536bb --- /dev/null +++ b/internal/termimg/detect.go @@ -0,0 +1,65 @@ +// Package termimg detects terminal support for inline image protocols and +// builds the escape sequences that display an image using them. +package termimg + +import "os" + +// Protocol identifies which inline-image escape-sequence protocol (if any) +// the terminal supports. +type Protocol int + +const ( + ProtocolNone Protocol = iota + ProtocolITerm2 + ProtocolKitty +) + +func (p Protocol) String() string { + switch p { + case ProtocolITerm2: + return "iterm2" + case ProtocolKitty: + return "kitty" + default: + return "off" + } +} + +// Next cycles Off -> iTerm2 -> Kitty -> Off, for a manual override key. +func (p Protocol) Next() Protocol { + return (p + 1) % 3 +} + +// Detect makes a best-effort guess at which inline-image protocol the +// current terminal supports, based on environment variables. It can guess +// wrong — tmux and many SSH setups don't relay these escape sequences to +// the outer terminal — so callers should offer a manual override rather +// than trusting this unconditionally. +func Detect() Protocol { + if isKitty() { + return ProtocolKitty + } + if isITerm2() { + return ProtocolITerm2 + } + return ProtocolNone +} + +func isKitty() bool { + if os.Getenv("KITTY_WINDOW_ID") != "" { + return true + } + return os.Getenv("TERM") == "xterm-kitty" +} + +func isITerm2() bool { + switch os.Getenv("TERM_PROGRAM") { + case "iTerm.app", "WezTerm": + return true + } + switch os.Getenv("LC_TERMINAL") { + case "iTerm2", "WezTerm": + return true + } + return false +} diff --git a/internal/termimg/detect_test.go b/internal/termimg/detect_test.go new file mode 100644 index 0000000..1e56ba6 --- /dev/null +++ b/internal/termimg/detect_test.go @@ -0,0 +1,89 @@ +package termimg + +import "testing" + +func clearDetectEnv(t *testing.T) { + t.Helper() + t.Setenv("TERM_PROGRAM", "") + t.Setenv("LC_TERMINAL", "") + t.Setenv("KITTY_WINDOW_ID", "") + t.Setenv("TERM", "xterm-256color") +} + +func TestDetectNone(t *testing.T) { + clearDetectEnv(t) + if got := Detect(); got != ProtocolNone { + t.Errorf("Detect() = %v, want ProtocolNone", got) + } +} + +func TestDetectITerm2(t *testing.T) { + clearDetectEnv(t) + t.Setenv("TERM_PROGRAM", "iTerm.app") + if got := Detect(); got != ProtocolITerm2 { + t.Errorf("Detect() = %v, want ProtocolITerm2", got) + } +} + +func TestDetectWezTermUsesITerm2Protocol(t *testing.T) { + clearDetectEnv(t) + t.Setenv("TERM_PROGRAM", "WezTerm") + if got := Detect(); got != ProtocolITerm2 { + t.Errorf("Detect() = %v, want ProtocolITerm2 (WezTerm supports it)", got) + } +} + +func TestDetectITerm2ViaLCTerminal(t *testing.T) { + clearDetectEnv(t) + t.Setenv("LC_TERMINAL", "iTerm2") + if got := Detect(); got != ProtocolITerm2 { + t.Errorf("Detect() = %v, want ProtocolITerm2", got) + } +} + +func TestDetectKittyViaWindowID(t *testing.T) { + clearDetectEnv(t) + t.Setenv("KITTY_WINDOW_ID", "1") + if got := Detect(); got != ProtocolKitty { + t.Errorf("Detect() = %v, want ProtocolKitty", got) + } +} + +func TestDetectKittyViaTerm(t *testing.T) { + clearDetectEnv(t) + t.Setenv("TERM", "xterm-kitty") + if got := Detect(); got != ProtocolKitty { + t.Errorf("Detect() = %v, want ProtocolKitty", got) + } +} + +func TestDetectPrefersKittyWhenBothMatch(t *testing.T) { + clearDetectEnv(t) + t.Setenv("TERM_PROGRAM", "WezTerm") + t.Setenv("KITTY_WINDOW_ID", "1") + if got := Detect(); got != ProtocolKitty { + t.Errorf("Detect() = %v, want ProtocolKitty (preferred when both match)", got) + } +} + +func TestProtocolNextCycles(t *testing.T) { + seq := []Protocol{ProtocolNone, ProtocolITerm2, ProtocolKitty, ProtocolNone} + for i := 0; i < len(seq)-1; i++ { + if got := seq[i].Next(); got != seq[i+1] { + t.Errorf("%v.Next() = %v, want %v", seq[i], got, seq[i+1]) + } + } +} + +func TestProtocolString(t *testing.T) { + cases := map[Protocol]string{ + ProtocolNone: "off", + ProtocolITerm2: "iterm2", + ProtocolKitty: "kitty", + } + for p, want := range cases { + if got := p.String(); got != want { + t.Errorf("%v.String() = %q, want %q", p, got, want) + } + } +} diff --git a/internal/termimg/iterm2.go b/internal/termimg/iterm2.go new file mode 100644 index 0000000..4463424 --- /dev/null +++ b/internal/termimg/iterm2.go @@ -0,0 +1,18 @@ +package termimg + +import ( + "encoding/base64" + "fmt" +) + +// ITerm2Escape builds the iTerm2 inline-image escape sequence (also +// supported by WezTerm) to display png sized to cols x rows terminal +// cells. Size is given in cells, so the terminal handles scaling — no +// pixel-per-cell math is needed on our end. +func ITerm2Escape(png []byte, cols, rows int) string { + encoded := base64.StdEncoding.EncodeToString(png) + return fmt.Sprintf( + "\x1b]1337;File=inline=1;width=%d;height=%d;preserveAspectRatio=0;size=%d:%s\a", + cols, rows, len(png), encoded, + ) +} diff --git a/internal/termimg/iterm2_test.go b/internal/termimg/iterm2_test.go new file mode 100644 index 0000000..10dfe5b --- /dev/null +++ b/internal/termimg/iterm2_test.go @@ -0,0 +1,39 @@ +package termimg + +import ( + "encoding/base64" + "strconv" + "strings" + "testing" +) + +func TestITerm2EscapeStructure(t *testing.T) { + png := []byte("fake-png-bytes") + seq := ITerm2Escape(png, 40, 20) + + if !strings.HasPrefix(seq, "\x1b]1337;File=") { + t.Fatalf("missing OSC 1337 prefix: %q", seq) + } + if !strings.HasSuffix(seq, "\a") { + t.Fatalf("missing BEL terminator: %q", seq) + } + if !strings.Contains(seq, "width="+strconv.Itoa(40)) { + t.Errorf("missing width=40: %q", seq) + } + if !strings.Contains(seq, "height="+strconv.Itoa(20)) { + t.Errorf("missing height=20: %q", seq) + } + if !strings.Contains(seq, "inline=1") { + t.Errorf("missing inline=1: %q", seq) + } + + idx := strings.LastIndex(seq, ":") + payload := seq[idx+1 : len(seq)-1] + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + t.Fatalf("payload isn't valid base64: %v", err) + } + if string(decoded) != string(png) { + t.Errorf("decoded payload = %q, want %q", decoded, png) + } +} diff --git a/internal/termimg/kitty.go b/internal/termimg/kitty.go new file mode 100644 index 0000000..f339ce0 --- /dev/null +++ b/internal/termimg/kitty.go @@ -0,0 +1,51 @@ +package termimg + +import ( + "encoding/base64" + "strconv" + "strings" +) + +// kittyChunkSize is the maximum base64 payload bytes the Kitty graphics +// protocol allows per escape command; larger images must be split across +// multiple chunks. +const kittyChunkSize = 4096 + +// KittyEscape builds the Kitty graphics protocol escape sequence (also +// supported by WezTerm and Ghostty) to transmit and display png sized to +// cols x rows terminal cells, chunking the base64 payload as the protocol +// requires. +func KittyEscape(png []byte, cols, rows int) string { + encoded := base64.StdEncoding.EncodeToString(png) + + var b strings.Builder + for len(encoded) > 0 { + chunk := encoded + if len(chunk) > kittyChunkSize { + chunk = encoded[:kittyChunkSize] + } + encoded = encoded[len(chunk):] + + more := 0 + if len(encoded) > 0 { + more = 1 + } + + if b.Len() == 0 { + b.WriteString("\x1b_Ga=T,f=100,c=") + b.WriteString(strconv.Itoa(cols)) + b.WriteString(",r=") + b.WriteString(strconv.Itoa(rows)) + b.WriteString(",m=") + b.WriteString(strconv.Itoa(more)) + b.WriteByte(';') + } else { + b.WriteString("\x1b_Gm=") + b.WriteString(strconv.Itoa(more)) + b.WriteByte(';') + } + b.WriteString(chunk) + b.WriteString("\x1b\\") + } + return b.String() +} diff --git a/internal/termimg/kitty_test.go b/internal/termimg/kitty_test.go new file mode 100644 index 0000000..447ea32 --- /dev/null +++ b/internal/termimg/kitty_test.go @@ -0,0 +1,72 @@ +package termimg + +import ( + "encoding/base64" + "strings" + "testing" +) + +func TestKittyEscapeSingleChunk(t *testing.T) { + png := []byte("small-png-bytes") + seq := KittyEscape(png, 40, 20) + + if !strings.HasPrefix(seq, "\x1b_Ga=T,f=100,c=40,r=20,m=0;") { + t.Fatalf("unexpected header: %q", seq) + } + if !strings.HasSuffix(seq, "\x1b\\") { + t.Fatalf("missing ST terminator: %q", seq) + } + + payload := strings.TrimPrefix(seq, "\x1b_Ga=T,f=100,c=40,r=20,m=0;") + payload = strings.TrimSuffix(payload, "\x1b\\") + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + t.Fatalf("payload isn't valid base64: %v", err) + } + if string(decoded) != string(png) { + t.Errorf("decoded payload = %q, want %q", decoded, png) + } +} + +func TestKittyEscapeChunking(t *testing.T) { + png := make([]byte, 10000) + for i := range png { + png[i] = byte(i % 256) + } + seq := KittyEscape(png, 10, 5) + + var chunks []string + for _, c := range strings.Split(seq, "\x1b\\") { + if c != "" { + chunks = append(chunks, c) + } + } + if len(chunks) < 2 { + t.Fatalf("expected multiple chunks for a large payload, got %d", len(chunks)) + } + + if !strings.HasPrefix(chunks[0], "\x1b_Ga=T,f=100,c=10,r=5,m=1;") { + t.Errorf("first chunk header wrong: %q", chunks[0]) + } + if !strings.HasPrefix(chunks[len(chunks)-1], "\x1b_Gm=0;") { + t.Errorf("last chunk should have m=0: %q", chunks[len(chunks)-1]) + } + for _, c := range chunks[1 : len(chunks)-1] { + if !strings.HasPrefix(c, "\x1b_Gm=1;") { + t.Errorf("middle chunk should have m=1: %q", c) + } + } + + var b strings.Builder + for _, c := range chunks { + idx := strings.Index(c, ";") + b.WriteString(c[idx+1:]) + } + decoded, err := base64.StdEncoding.DecodeString(b.String()) + if err != nil { + t.Fatalf("reassembled payload isn't valid base64: %v", err) + } + if string(decoded) != string(png) { + t.Errorf("reassembled payload mismatch: got %d bytes, want %d", len(decoded), len(png)) + } +} diff --git a/internal/timerange/timerange.go b/internal/timerange/timerange.go new file mode 100644 index 0000000..fb211bf --- /dev/null +++ b/internal/timerange/timerange.go @@ -0,0 +1,129 @@ +// Package timerange parses and formats Graphite-style relative time strings, +// e.g. "-1d12h", used as the "from" parameter of a render request. +package timerange + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// Unit sizes in seconds. Months and years are fixed-size approximations +// (30d and 365d respectively), matching Graphite's own relative-time semantics. +const ( + UnitSecond = 1 + UnitMinute = 60 * UnitSecond + UnitHour = 60 * UnitMinute + UnitDay = 24 * UnitHour + UnitWeek = 7 * UnitDay + UnitMonth = 30 * UnitDay + UnitYear = 365 * UnitDay +) + +// Default is the time range used when no other value has been set. +const Default = "-1min" + +// Components is a parsed relative time string broken into its constituent units. +type Components struct { + Years, Months, Weeks, Days, Hours, Minutes, Seconds int +} + +// TotalSeconds returns the total number of seconds represented by c. +func (c Components) TotalSeconds() int { + return c.Years*UnitYear + c.Months*UnitMonth + c.Weeks*UnitWeek + + c.Days*UnitDay + c.Hours*UnitHour + c.Minutes*UnitMinute + c.Seconds*UnitSecond +} + +var pattern = regexp.MustCompile(`^-?(?:(\d+)y)?(?:(\d+)mon)?(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)min)?(?:(\d+)s)?$`) + +// Parse parses a relative time string such as "-1y2mon3w4d5h6min7s" into its +// components. Any unit may be omitted; an empty/all-zero match is valid and +// parses to a zero Components. +func Parse(s string) (Components, error) { + m := pattern.FindStringSubmatch(s) + if m == nil { + return Components{}, fmt.Errorf("timerange: invalid time range %q", s) + } + vals := make([]int, 7) + for i := 1; i <= 7; i++ { + if m[i] == "" { + continue + } + n, err := strconv.Atoi(m[i]) + if err != nil { + return Components{}, fmt.Errorf("timerange: invalid time range %q: %w", s, err) + } + vals[i-1] = n + } + return Components{ + Years: vals[0], + Months: vals[1], + Weeks: vals[2], + Days: vals[3], + Hours: vals[4], + Minutes: vals[5], + Seconds: vals[6], + }, nil +} + +// ParseSeconds is a convenience wrapper around Parse that returns the total +// number of seconds a relative time string represents. +func ParseSeconds(s string) (int, error) { + c, err := Parse(s) + if err != nil { + return 0, err + } + return c.TotalSeconds(), nil +} + +// Format renders a total number of seconds back into a canonical relative +// time string, greedily decomposing into the largest units first (e.g. 5400 +// seconds formats as "-1h30min"). A non-positive input formats as Default. +func Format(totalSeconds int) string { + if totalSeconds <= 0 { + return Default + } + + units := []struct { + suffix string + size int + }{ + {"y", UnitYear}, + {"mon", UnitMonth}, + {"w", UnitWeek}, + {"d", UnitDay}, + {"h", UnitHour}, + {"min", UnitMinute}, + {"s", UnitSecond}, + } + + var sb strings.Builder + sb.WriteByte('-') + remaining := totalSeconds + wrote := false + for _, u := range units { + if remaining < u.size { + continue + } + n := remaining / u.size + remaining -= n * u.size + fmt.Fprintf(&sb, "%d%s", n, u.suffix) + wrote = true + } + if !wrote { + return Default + } + return sb.String() +} + +// Normalize parses s and reformats it canonically via Format, collapsing +// e.g. "-90min" to "-1h30min". Returns an error if s is not a valid relative +// time string. +func Normalize(s string) (string, error) { + seconds, err := ParseSeconds(s) + if err != nil { + return "", err + } + return Format(seconds), nil +} diff --git a/internal/timerange/timerange_test.go b/internal/timerange/timerange_test.go new file mode 100644 index 0000000..00bb020 --- /dev/null +++ b/internal/timerange/timerange_test.go @@ -0,0 +1,95 @@ +package timerange + +import "testing" + +func TestParse(t *testing.T) { + cases := []struct { + in string + want Components + }{ + {"-1min", Components{Minutes: 1}}, + {"-1d12h", Components{Days: 1, Hours: 12}}, + {"-1y2mon3w4d5h6min7s", Components{Years: 1, Months: 2, Weeks: 3, Days: 4, Hours: 5, Minutes: 6, Seconds: 7}}, + {"-90min", Components{Minutes: 90}}, + {"", Components{}}, + {"-", Components{}}, + } + for _, c := range cases { + got, err := Parse(c.in) + if err != nil { + t.Errorf("Parse(%q) unexpected error: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("Parse(%q) = %+v, want %+v", c.in, got, c.want) + } + } +} + +func TestParseInvalid(t *testing.T) { + for _, in := range []string{"bogus", "-1decade", "-1d1"} { + if _, err := Parse(in); err == nil { + t.Errorf("Parse(%q) expected error, got nil", in) + } + } +} + +func TestTotalSeconds(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"-1min", UnitMinute}, + {"-1h", UnitHour}, + {"-1d12h", UnitDay + 12*UnitHour}, + {"-1w", UnitWeek}, + {"-1mon", UnitMonth}, + {"-1y", UnitYear}, + } + for _, c := range cases { + got, err := ParseSeconds(c.in) + if err != nil { + t.Fatalf("ParseSeconds(%q) unexpected error: %v", c.in, err) + } + if got != c.want { + t.Errorf("ParseSeconds(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestFormat(t *testing.T) { + cases := []struct { + in int + want string + }{ + {0, "-1min"}, + {-5, "-1min"}, + {UnitMinute, "-1min"}, + {90 * UnitMinute, "-1h30min"}, + {UnitDay + 12*UnitHour, "-1d12h"}, + {UnitYear + 2*UnitMonth + 3*UnitWeek + 4*UnitDay + 5*UnitHour + 6*UnitMinute + 7*UnitSecond, "-1y2mon3w4d5h6min7s"}, + } + for _, c := range cases { + got := Format(c.in) + if got != c.want { + t.Errorf("Format(%d) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestNormalizeRoundTrip(t *testing.T) { + cases := []struct{ in, want string }{ + {"-90min", "-1h30min"}, + {"-1d12h", "-1d12h"}, + {"-1min", "-1min"}, + } + for _, c := range cases { + got, err := Normalize(c.in) + if err != nil { + t.Fatalf("Normalize(%q) unexpected error: %v", c.in, err) + } + if got != c.want { + t.Errorf("Normalize(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/tui/braille.go b/internal/tui/braille.go new file mode 100644 index 0000000..e201bd6 --- /dev/null +++ b/internal/tui/braille.go @@ -0,0 +1,140 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// brailleBits maps a sub-pixel's (column, row) position within a braille +// character cell to its dot bit, using the standard drawille layout: +// +// col0,row0=0x01 col1,row0=0x08 +// col0,row1=0x02 col1,row1=0x10 +// col0,row2=0x04 col1,row2=0x20 +// col0,row3=0x40 col1,row3=0x80 +var brailleBits = [2][4]byte{ + {0x01, 0x02, 0x04, 0x40}, + {0x08, 0x10, 0x20, 0x80}, +} + +// brailleBlank is the "all dots off" braille character. +const brailleBlank = 0x2800 + +// brailleCanvas is a plotting surface addressed in sub-pixels (2 columns x +// 4 rows per character cell), giving much finer resolution than plotting +// one point per cell. Each cell also tracks which series last touched it, +// since a terminal cell can only have one foreground color regardless of +// how many series' dots land in it. +type brailleCanvas struct { + cols, rows int // character-cell dimensions + subCols, subRows int // sub-pixel dimensions (cols*2, rows*4) + bits [][]byte + colorIdx [][]int +} + +func newBrailleCanvas(cols, rows int) *brailleCanvas { + if cols < 1 { + cols = 1 + } + if rows < 1 { + rows = 1 + } + c := &brailleCanvas{ + cols: cols, rows: rows, + subCols: cols * 2, subRows: rows * 4, + } + c.bits = make([][]byte, rows) + c.colorIdx = make([][]int, rows) + for r := 0; r < rows; r++ { + c.bits[r] = make([]byte, cols) + c.colorIdx[r] = make([]int, cols) + for cc := range c.colorIdx[r] { + c.colorIdx[r][cc] = -1 + } + } + return c +} + +// set lights the sub-pixel at (subX, subY), tagging its cell with +// seriesIdx. Out-of-bounds coordinates are silently ignored. +func (c *brailleCanvas) set(subX, subY, seriesIdx int) { + if subX < 0 || subY < 0 || subX >= c.subCols || subY >= c.subRows { + return + } + cellCol, cellRow := subX/2, subY/4 + bitCol, bitRow := subX%2, subY%4 + c.bits[cellRow][cellCol] |= brailleBits[bitCol][bitRow] + c.colorIdx[cellRow][cellCol] = seriesIdx +} + +// line draws a Bresenham line between two sub-pixel points, coloring every +// touched cell with seriesIdx. +func (c *brailleCanvas) line(x0, y0, x1, y1, seriesIdx int) { + dx := absInt(x1 - x0) + sx := -1 + if x0 < x1 { + sx = 1 + } + dy := -absInt(y1 - y0) + sy := -1 + if y0 < y1 { + sy = 1 + } + err := dx + dy + x, y := x0, y0 + for { + c.set(x, y, seriesIdx) + if x == x1 && y == y1 { + return + } + e2 := 2 * err + if e2 >= dy { + err += dy + x += sx + } + if e2 <= dx { + err += dx + y += sy + } + } +} + +// fillColumn lights every sub-pixel row between y0 and y1 (inclusive, order +// independent) in sub-column subX. +func (c *brailleCanvas) fillColumn(subX, y0, y1, seriesIdx int) { + if y0 > y1 { + y0, y1 = y1, y0 + } + for y := y0; y <= y1; y++ { + c.set(subX, y, seriesIdx) + } +} + +// render converts the canvas into one string per character row, with each +// non-blank cell colored by its tagged series. +func (c *brailleCanvas) render() []string { + lines := make([]string, c.rows) + for r := 0; r < c.rows; r++ { + var b strings.Builder + for cc := 0; cc < c.cols; cc++ { + bits := c.bits[r][cc] + if bits == 0 { + b.WriteByte(' ') + continue + } + ch := rune(brailleBlank + int(bits)) + style := lipgloss.NewStyle().Foreground(seriesColor(c.colorIdx[r][cc])) + b.WriteString(style.Render(string(ch))) + } + lines[r] = b.String() + } + return lines +} + +func absInt(n int) int { + if n < 0 { + return -n + } + return n +} diff --git a/internal/tui/braille_test.go b/internal/tui/braille_test.go new file mode 100644 index 0000000..da457ac --- /dev/null +++ b/internal/tui/braille_test.go @@ -0,0 +1,95 @@ +package tui + +import "testing" + +func TestBrailleCanvasSetSingleDot(t *testing.T) { + c := newBrailleCanvas(2, 1) // 4 sub-cols x 4 sub-rows + c.set(0, 0, 0) + if c.bits[0][0] != 0x01 { + t.Errorf("bits[0][0] = %#x, want 0x01 (top-left dot)", c.bits[0][0]) + } + if c.colorIdx[0][0] != 0 { + t.Errorf("colorIdx[0][0] = %d, want 0", c.colorIdx[0][0]) + } +} + +func TestBrailleCanvasSetAllDotsInCell(t *testing.T) { + c := newBrailleCanvas(1, 1) + for x := 0; x < 2; x++ { + for y := 0; y < 4; y++ { + c.set(x, y, 0) + } + } + if c.bits[0][0] != 0xFF { + t.Errorf("bits[0][0] = %#x, want 0xff (all 8 dots lit)", c.bits[0][0]) + } + // render() wraps non-blank cells in ANSI color codes, so just check the + // expected rune (all 8 dots) appears somewhere in the styled output. + lines := c.render() + if !containsRune(lines[0], rune(brailleBlank+0xFF)) { + t.Errorf("render()[0] = %q, want it to contain the all-dots-lit rune", lines[0]) + } +} + +func TestBrailleCanvasSetOutOfBoundsIgnored(t *testing.T) { + c := newBrailleCanvas(1, 1) + c.set(-1, 0, 0) + c.set(0, -1, 0) + c.set(100, 0, 0) + c.set(0, 100, 0) + if c.bits[0][0] != 0 { + t.Errorf("bits[0][0] = %#x, want 0 (all sets were out of bounds)", c.bits[0][0]) + } +} + +func TestBrailleCanvasBlankCellRendersSpace(t *testing.T) { + c := newBrailleCanvas(3, 1) + lines := c.render() + if lines[0] != " " { + t.Errorf("render()[0] = %q, want three spaces", lines[0]) + } +} + +func TestBrailleCanvasLineHorizontal(t *testing.T) { + c := newBrailleCanvas(4, 1) // 8 sub-cols + c.line(0, 0, 7, 0, 0) + for x := 0; x < 8; x++ { + col := x / 2 + if c.bits[0][col] == 0 { + t.Errorf("expected sub-col %d (cell col %d) to be lit by horizontal line", x, col) + } + } +} + +func TestBrailleCanvasLineColorTagging(t *testing.T) { + c := newBrailleCanvas(2, 1) + c.line(0, 0, 3, 3, 2) + if c.colorIdx[0][0] != 2 { + t.Errorf("colorIdx[0][0] = %d, want 2", c.colorIdx[0][0]) + } +} + +func TestFillColumn(t *testing.T) { + c := newBrailleCanvas(1, 1) // 2 sub-cols x 4 sub-rows + c.fillColumn(0, 3, 0, 0) // reversed order should still fill 0..3 + for y := 0; y < 4; y++ { + if c.bits[0][0]&brailleBits[0][y] == 0 { + t.Errorf("expected sub-row %d in sub-col 0 to be filled", y) + } + } + // sub-col 1 (the other column in this cell) should be untouched. + for y := 0; y < 4; y++ { + if c.bits[0][0]&brailleBits[1][y] != 0 { + t.Errorf("sub-col 1, row %d should not be filled", y) + } + } +} + +func containsRune(s string, r rune) bool { + for _, c := range s { + if c == r { + return true + } + } + return false +} diff --git a/internal/tui/chart.go b/internal/tui/chart.go new file mode 100644 index 0000000..499bacc --- /dev/null +++ b/internal/tui/chart.go @@ -0,0 +1,289 @@ +package tui + +import ( + "fmt" + "math" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/benwtr/terphite/internal/graphite" +) + +// seriesColors mirrors the original's 15-color cycle: reds/greens/etc. at +// standard intensity, then their "light" (bright) ANSI variants. +var seriesColors = []string{ + "1", "2", "3", "4", "5", "6", "7", + "8", "9", "10", "11", "12", "13", "14", "15", +} + +func seriesColor(i int) lipgloss.Color { + return lipgloss.Color(seriesColors[i%len(seriesColors)]) +} + +// renderChart renders series as a chart with a legend beneath it, scaled to +// fit width x height. mode selects independent lines, independent filled +// areas, or a cumulative stacked area. +func renderChart(series []graphite.Series, mode drawMode, width, height int) string { + if width < 4 || height < 3 { + return "" + } + + legendHeight := len(series) + if max := height / 2; legendHeight > max { + legendHeight = max + } + plotHeight := height - legendHeight - 1 + if plotHeight < 1 { + plotHeight = 1 + } + + hasData := false + var min, max float64 + for _, s := range series { + for _, dp := range s.Datapoints { + if dp.Value == nil { + continue + } + v := *dp.Value + if !hasData || v < min { + min = v + } + if !hasData || v > max { + max = v + } + hasData = true + } + } + if !hasData { + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, faintStyle.Render("no data")) + } + + if mode == drawStacked { + // Stacked bands are built from cumulative sums starting at 0, so 0 + // must be part of the visible range. + min, max = stackedRange(series) + } + if max == min { + max = min + 1 + } + + canvas := newBrailleCanvas(width, plotHeight) + subCols := canvas.subCols + + valueToSubY := func(v float64) int { + frac := (v - min) / (max - min) + y := canvas.subRows - 1 - int(math.Round(frac*float64(canvas.subRows-1))) + return clampInt(y, 0, canvas.subRows-1) + } + + switch mode { + case drawStacked: + renderStacked(canvas, series, valueToSubY, subCols) + case drawArea: + renderArea(canvas, series, valueToSubY, subCols) + default: + renderLines(canvas, series, valueToSubY, subCols) + } + + lines := canvas.render() + lines = append(lines, strings.Repeat("─", width)) + for i, s := range series { + if i >= legendHeight { + break + } + swatch := lipgloss.NewStyle().Foreground(seriesColor(i)).Render("●") + title := s.Target + if len(title) > width-4 && width > 4 { + title = title[:width-4] + } + lines = append(lines, fmt.Sprintf("%s %s", swatch, title)) + } + + return strings.Join(lines, "\n") +} + +// point is a plotted sample in sub-pixel canvas coordinates. +type point struct { + subX, subY int +} + +// subXFor maps data-index i (of n total) onto a sub-pixel column, spreading +// samples evenly across the available width. +func subXFor(i, n, subCols int) int { + if n <= 1 || subCols <= 1 { + return 0 + } + return i * (subCols - 1) / (n - 1) +} + +// seriesPoints converts s's non-nil datapoints into sub-pixel points. Nil +// datapoints are skipped (matching the original renderer's behavior), so a +// run of points on either side of a gap is connected directly across it. +func seriesPoints(s graphite.Series, valueToSubY func(float64) int, subCols int) []point { + n := len(s.Datapoints) + pts := make([]point, 0, n) + for i, dp := range s.Datapoints { + if dp.Value == nil { + continue + } + pts = append(pts, point{subX: subXFor(i, n, subCols), subY: valueToSubY(*dp.Value)}) + } + return pts +} + +func renderLines(canvas *brailleCanvas, series []graphite.Series, valueToSubY func(float64) int, subCols int) { + for si, s := range series { + pts := seriesPoints(s, valueToSubY, subCols) + for i, p := range pts { + if i == 0 { + canvas.set(p.subX, p.subY, si) + continue + } + prev := pts[i-1] + canvas.line(prev.subX, prev.subY, p.subX, p.subY, si) + } + } +} + +// renderArea fills each series independently down to the bottom of the +// visible plot (not to value-zero — the data may float well above or below +// zero, and filling to zero in that case would just paint the whole plot +// solid). Later series are drawn over earlier ones where they overlap. +func renderArea(canvas *brailleCanvas, series []graphite.Series, valueToSubY func(float64) int, subCols int) { + baseline := canvas.subRows - 1 + for si, s := range series { + pts := seriesPoints(s, valueToSubY, subCols) + fillUnderCurve(canvas, pts, baseline, si) + } +} + +// fillUnderCurve fills, for every sub-column spanned by pts, from baseline +// to the curve (linearly interpolated between samples where they're more +// than one sub-column apart). +func fillUnderCurve(canvas *brailleCanvas, pts []point, baseline, seriesIdx int) { + switch len(pts) { + case 0: + return + case 1: + canvas.fillColumn(pts[0].subX, baseline, pts[0].subY, seriesIdx) + return + } + for i := 1; i < len(pts); i++ { + a, b := pts[i-1], pts[i] + if b.subX == a.subX { + canvas.fillColumn(a.subX, baseline, a.subY, seriesIdx) + continue + } + for x := a.subX; x <= b.subX; x++ { + t := float64(x-a.subX) / float64(b.subX-a.subX) + y := a.subY + int(math.Round(t*float64(b.subY-a.subY))) + canvas.fillColumn(x, baseline, y, seriesIdx) + } + } +} + +// stackedCumulative returns, for each series in order, the running total of +// that series plus every series before it (nil datapoints count as 0, so +// stacking has a well-defined value at every index). All series are aligned +// by datapoint index and truncated to the shortest series' length — in +// practice they come from the same render request so lengths match. +func stackedCumulative(series []graphite.Series) [][]float64 { + if len(series) == 0 { + return nil + } + n := len(series[0].Datapoints) + for _, s := range series { + if len(s.Datapoints) < n { + n = len(s.Datapoints) + } + } + + cum := make([][]float64, len(series)) + running := make([]float64, n) + for si, s := range series { + cum[si] = make([]float64, n) + for i := 0; i < n; i++ { + v := 0.0 + if s.Datapoints[i].Value != nil { + v = *s.Datapoints[i].Value + } + running[i] += v + cum[si][i] = running[i] + } + } + return cum +} + +// stackedRange returns the value range a stacked chart needs: 0 to the +// tallest cumulative total. +func stackedRange(series []graphite.Series) (min, max float64) { + cum := stackedCumulative(series) + if len(cum) == 0 { + return 0, 1 + } + top := cum[len(cum)-1] + for _, v := range top { + if v > max { + max = v + } + } + return 0, max +} + +func renderStacked(canvas *brailleCanvas, series []graphite.Series, valueToSubY func(float64) int, subCols int) { + cum := stackedCumulative(series) + if len(cum) == 0 { + return + } + n := len(cum[0]) + + prevCum := make([]float64, n) + for si := range series { + lower := make([]point, n) + upper := make([]point, n) + for i := 0; i < n; i++ { + x := subXFor(i, n, subCols) + lower[i] = point{subX: x, subY: valueToSubY(prevCum[i])} + upper[i] = point{subX: x, subY: valueToSubY(cum[si][i])} + } + fillBand(canvas, lower, upper, si) + prevCum = cum[si] + } +} + +// fillBand fills the region between the lower and upper bound lines +// (interpolating both between samples), used for stacked areas. +func fillBand(canvas *brailleCanvas, lower, upper []point, seriesIdx int) { + n := len(lower) + switch n { + case 0: + return + case 1: + canvas.fillColumn(lower[0].subX, lower[0].subY, upper[0].subY, seriesIdx) + return + } + for i := 1; i < n; i++ { + x0, x1 := lower[i-1].subX, lower[i].subX + if x1 == x0 { + canvas.fillColumn(x0, lower[i-1].subY, upper[i-1].subY, seriesIdx) + continue + } + for x := x0; x <= x1; x++ { + t := float64(x-x0) / float64(x1-x0) + ly := lower[i-1].subY + int(math.Round(t*float64(lower[i].subY-lower[i-1].subY))) + uy := upper[i-1].subY + int(math.Round(t*float64(upper[i].subY-upper[i-1].subY))) + canvas.fillColumn(x, ly, uy, seriesIdx) + } + } +} + +func clampInt(n, lo, hi int) int { + if n < lo { + return lo + } + if n > hi { + return hi + } + return n +} diff --git a/internal/tui/chart_test.go b/internal/tui/chart_test.go new file mode 100644 index 0000000..c60dc3c --- /dev/null +++ b/internal/tui/chart_test.go @@ -0,0 +1,137 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/benwtr/terphite/internal/graphite" +) + +func val(v float64) *float64 { return &v } + +func seriesOf(target string, values ...float64) graphite.Series { + dps := make([]graphite.Datapoint, len(values)) + for i, v := range values { + dps[i] = graphite.Datapoint{Value: val(v)} + } + return graphite.Series{Target: target, Datapoints: dps} +} + +func TestSubXForSpreadsEvenly(t *testing.T) { + if got := subXFor(0, 5, 100); got != 0 { + t.Errorf("subXFor(0,5,100) = %d, want 0", got) + } + if got := subXFor(4, 5, 100); got != 99 { + t.Errorf("subXFor(4,5,100) = %d, want 99 (last sample maps to last column)", got) + } + if got := subXFor(0, 1, 100); got != 0 { + t.Errorf("subXFor(0,1,100) = %d, want 0 (single sample doesn't divide by zero)", got) + } +} + +func TestSeriesPointsSkipsNilDatapoints(t *testing.T) { + s := graphite.Series{Datapoints: []graphite.Datapoint{ + {Value: val(1)}, + {Value: nil}, + {Value: val(2)}, + }} + identity := func(v float64) int { return int(v) } + pts := seriesPoints(s, identity, 10) + if len(pts) != 2 { + t.Fatalf("got %d points, want 2 (nil skipped)", len(pts)) + } + if pts[0].subY != 1 || pts[1].subY != 2 { + t.Errorf("points = %+v, want subY 1 then 2", pts) + } +} + +func TestStackedCumulativeSum(t *testing.T) { + series := []graphite.Series{ + seriesOf("a", 1, 2, 3), + seriesOf("b", 10, 10, 10), + } + cum := stackedCumulative(series) + if len(cum) != 2 { + t.Fatalf("got %d series in cumulative, want 2", len(cum)) + } + wantA := []float64{1, 2, 3} + wantB := []float64{11, 12, 13} + for i := range wantA { + if cum[0][i] != wantA[i] { + t.Errorf("cum[0][%d] = %v, want %v", i, cum[0][i], wantA[i]) + } + if cum[1][i] != wantB[i] { + t.Errorf("cum[1][%d] = %v, want %v", i, cum[1][i], wantB[i]) + } + } +} + +func TestStackedCumulativeTreatsNilAsZero(t *testing.T) { + series := []graphite.Series{ + {Datapoints: []graphite.Datapoint{{Value: val(5)}, {Value: nil}}}, + {Datapoints: []graphite.Datapoint{{Value: val(1)}, {Value: val(1)}}}, + } + cum := stackedCumulative(series) + if cum[1][1] != 1 { + t.Errorf("cum[1][1] = %v, want 1 (nil in series 0 contributes 0)", cum[1][1]) + } +} + +func TestStackedRangeUsesTopOfCumulative(t *testing.T) { + series := []graphite.Series{ + seriesOf("a", 1, 5), + seriesOf("b", 2, 2), + } + min, max := stackedRange(series) + if min != 0 { + t.Errorf("min = %v, want 0", min) + } + if max != 7 { // max of cumulative totals: (1+2)=3, (5+2)=7 + t.Errorf("max = %v, want 7", max) + } +} + +func TestStackedRangeEmptySeries(t *testing.T) { + min, max := stackedRange(nil) + if min != 0 || max != 1 { + t.Errorf("got (%v, %v), want (0, 1) for empty input", min, max) + } +} + +func TestRenderChartNoData(t *testing.T) { + out := renderChart(nil, drawLine, 40, 10) + if !strings.Contains(out, "no data") { + t.Errorf("expected 'no data' placeholder, got %q", out) + } +} + +func TestRenderChartTooSmall(t *testing.T) { + series := []graphite.Series{seriesOf("a", 1, 2, 3)} + if out := renderChart(series, drawLine, 2, 1); out != "" { + t.Errorf("expected empty string for too-small dimensions, got %q", out) + } +} + +func TestRenderChartIncludesLegend(t *testing.T) { + series := []graphite.Series{seriesOf("stats.foo", 1, 2, 3)} + out := renderChart(series, drawLine, 40, 12) + if !strings.Contains(out, "stats.foo") { + t.Errorf("expected legend to contain target name, got:\n%s", out) + } +} + +func TestRenderChartAllDrawModesProduceOutput(t *testing.T) { + series := []graphite.Series{ + seriesOf("a", 1, 3, 2, 5, 4), + seriesOf("b", 2, 2, 3, 1, 2), + } + for _, mode := range []drawMode{drawLine, drawArea, drawStacked} { + out := renderChart(series, mode, 40, 12) + if out == "" { + t.Errorf("mode %v: expected non-empty chart output", mode) + } + if !strings.Contains(out, "a") || !strings.Contains(out, "b") { + t.Errorf("mode %v: expected legend entries for both series", mode) + } + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..82b0b4d --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,171 @@ +package tui + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/benwtr/terphite/internal/dashboard" + "github.com/benwtr/terphite/internal/graphite" +) + +const requestTimeout = 15 * time.Second + +// Pixel dimensions requested when fetching a rendered image. These don't +// need to precisely match the terminal display size — the image protocols +// scale to whatever cell box we tell them to display at — just be "high +// enough resolution" for a reasonably sized pane. +const ( + composerImageWidth = 1000 + composerImageHeight = 500 + panelImageWidth = 500 + panelImageHeight = 300 +) + +func fetchMetricsCmd(client *graphite.Client) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + names, err := client.FetchMetricsIndex(ctx) + if err != nil { + return metricsErrMsg{err: err} + } + return metricsLoadedMsg{tree: graphite.BuildTree(names)} + } +} + +func fetchRenderCmd(client *graphite.Client, targets []string, from string, maxDataPoints, gen int) tea.Cmd { + targets = append([]string(nil), targets...) + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + series, err := client.FetchRender(ctx, graphite.RenderQuery{ + Targets: targets, + From: from, + MaxDataPoints: maxDataPoints, + }) + if err != nil { + return renderErrMsg{err: err, gen: gen} + } + return renderLoadedMsg{series: series, gen: gen} + } +} + +func fetchImageCmd(client *graphite.Client, targets []string, from, areaMode string, gen int) tea.Cmd { + targets = append([]string(nil), targets...) + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + png, err := client.FetchRenderImage(ctx, graphite.ImageQuery{ + Targets: targets, + From: from, + Width: composerImageWidth, + Height: composerImageHeight, + AreaMode: areaMode, + }) + if err != nil { + return imageErrMsg{err: err, gen: gen} + } + return imageLoadedMsg{png: png, gen: gen} + } +} + +func autorefreshTickCmd(intervalSeconds int) tea.Cmd { + return tea.Tick(time.Duration(intervalSeconds)*time.Second, func(time.Time) tea.Msg { + return autorefreshTickMsg{} + }) +} + +func dashboardAutorefreshTickCmd() tea.Cmd { + return tea.Tick(defaultAutorefreshInterval*time.Second, func(time.Time) tea.Msg { + return dashboardAutorefreshTickMsg{} + }) +} + +func fetchDashboardListCmd(store *dashboard.Store) tea.Cmd { + return func() tea.Msg { + names, err := store.List() + if err != nil { + return dashboardErrMsg{err: err} + } + return dashboardListLoadedMsg{names: names} + } +} + +func loadDashboardCmd(store *dashboard.Store, name string) tea.Cmd { + return func() tea.Msg { + d, err := store.Load(name) + if err != nil { + return dashboardErrMsg{err: err} + } + return dashboardLoadedMsg{d: d} + } +} + +func saveDashboardCmd(store *dashboard.Store, d *dashboard.Dashboard) tea.Cmd { + return func() tea.Msg { + if err := store.Save(d); err != nil { + return dashboardErrMsg{err: err} + } + return dashboardSavedMsg{} + } +} + +func saveDashboardPanelCmd(store *dashboard.Store, name string, panel dashboard.Panel) tea.Cmd { + return func() tea.Msg { + d, err := store.Load(name) + if err != nil { + d = &dashboard.Dashboard{Name: name} + } + d.Panels = append(d.Panels, panel) + if err := store.Save(d); err != nil { + return dashboardErrMsg{err: err} + } + return dashboardSavedMsg{} + } +} + +func fetchAllPanelsCmd(client *graphite.Client, panels []dashboard.Panel, gen int) tea.Cmd { + cmds := make([]tea.Cmd, len(panels)) + for i, p := range panels { + i, p := i, p + cmds[i] = func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + series, err := client.FetchRender(ctx, graphite.RenderQuery{ + Targets: p.Targets, + From: p.TimeFrom, + MaxDataPoints: defaultMaxDataPoints, + }) + if err != nil { + return panelRenderErrMsg{panelIndex: i, err: err, gen: gen} + } + return panelRenderLoadedMsg{panelIndex: i, series: series, gen: gen} + } + } + return tea.Batch(cmds...) +} + +func fetchAllPanelImagesCmd(client *graphite.Client, panels []dashboard.Panel, gen int) tea.Cmd { + cmds := make([]tea.Cmd, len(panels)) + for i, p := range panels { + i, p := i, p + cmds[i] = func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + png, err := client.FetchRenderImage(ctx, graphite.ImageQuery{ + Targets: p.Targets, + From: p.TimeFrom, + Width: panelImageWidth, + Height: panelImageHeight, + AreaMode: parseDrawMode(p.DrawMode).graphiteAreaMode(), + }) + if err != nil { + return panelImageErrMsg{panelIndex: i, err: err, gen: gen} + } + return panelImageLoadedMsg{panelIndex: i, png: png, gen: gen} + } + } + return tea.Batch(cmds...) +} diff --git a/internal/tui/composer.go b/internal/tui/composer.go new file mode 100644 index 0000000..c73de18 --- /dev/null +++ b/internal/tui/composer.go @@ -0,0 +1,188 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/benwtr/terphite/internal/termimg" + "github.com/benwtr/terphite/internal/timerange" +) + +func (m *Model) handleComposerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + + case "up", "k": + if m.treeCursor > 0 { + m.treeCursor-- + } + return m, nil + case "down", "j": + if m.treeCursor < len(m.treeRows)-1 { + m.treeCursor++ + } + return m, nil + + case "enter", " ": + return m.activateTreeCursor() + + case "[": + return m.adjustTime(-timerange.UnitMinute, timerange.UnitMinute, timerange.Default) + case "]": + return m.adjustTime(timerange.UnitMinute, 0, "") + case "{": + return m.adjustTime(-timerange.UnitHour, timerange.UnitHour, "-1h") + case "}": + return m.adjustTime(timerange.UnitHour, 0, "") + + case "t": + m.popup = popupSetTimeFrom + m.input = newTextInput("from: ", m.timeFrom, 40) + return m, nil + + case "m": + m.popup = popupMetricsList + m.metricsCursor = 0 + return m, nil + + case "i": + m.popup = popupSetAutorefresh + m.input = newTextInput("autorefresh seconds: ", fmt.Sprintf("%d", m.autorefreshInterval), 10) + return m, nil + + case "a": + m.autorefreshOn = !m.autorefreshOn + if m.autorefreshOn { + return m, autorefreshTickCmd(m.autorefreshInterval) + } + return m, nil + + case "x": + m.popup = popupSetMaxDataPoints + m.input = newTextInput("max datapoints (0=unlimited): ", fmt.Sprintf("%d", m.maxDataPoints), 10) + return m, nil + + case "o": + _ = openInBrowser(m.client.RenderURL(m.renderQuery())) + return m, nil + + case "c": + _ = copyToClipboard(m.client.RenderURL(m.renderQuery())) + return m, nil + + case "S": + m.popup = popupSaveDashboardName + m.input = newTextInput("save to dashboard: ", "", 40) + return m, nil + + case "D": + m.popup = popupPickDashboard + m.pickerCursor = 0 + return m, fetchDashboardListCmd(m.store) + + case "g": + m.drawMode = m.drawMode.next() + if m.imageProtocol != termimg.ProtocolNone { + // areaMode is baked into the rendered image server-side, so a + // mode change needs a refetch; the ASCII chart re-renders the + // already-fetched series data instantly, no refetch needed. + return m, m.refreshCmd() + } + return m, nil + + case "I": + m.imageProtocol = nextImageProtocol(m.imageProtocol) + return m, tea.Batch(m.refreshCmd(), tea.ClearScreen) + + case "?": + m.helpCollapsed = !m.helpCollapsed + return m, nil + + case "l", "ctrl+l": + return m, tea.ClearScreen + } + return m, nil +} + +// nextImageProtocol cycles graphical mode. Turning it on from off jumps +// straight to whatever the terminal looks like it supports, so the common +// case is a single keypress; cycling past that lets you override a wrong +// guess (detection can't see through tmux or SSH). +func nextImageProtocol(cur termimg.Protocol) termimg.Protocol { + if cur == termimg.ProtocolNone { + if detected := termimg.Detect(); detected != termimg.ProtocolNone { + return detected + } + return termimg.ProtocolITerm2 + } + return cur.Next() +} + +// adjustTime shifts the current time_from by delta seconds. When decreasing +// (delta < 0) and the result would fall below floor, it resets to +// resetValue instead — mirroring the original's per-key minimum ("-1min" +// for the minute keys, "-1h" for the hour keys). +func (m *Model) adjustTime(delta, floor int, resetValue string) (tea.Model, tea.Cmd) { + cur, err := timerange.ParseSeconds(m.timeFrom) + if err != nil { + cur = 0 + } + next := cur + delta + if delta < 0 && next < floor { + m.timeFrom = resetValue + } else { + m.timeFrom = timerange.Format(next) + } + return m, m.refreshCmd() +} + +func (m *Model) activateTreeCursor() (tea.Model, tea.Cmd) { + if m.treeCursor < 0 || m.treeCursor >= len(m.treeRows) { + return m, nil + } + node := m.treeRows[m.treeCursor].node + + var cmd tea.Cmd + if node.Leaf { + m.toggleMetric(node.Path) + cmd = m.refreshCmd() + } + if len(node.Children) > 0 { + m.expanded[node.Path] = !m.expanded[node.Path] + m.refreshTreeRows() + } + return m, cmd +} + +func (m *Model) toggleMetric(path string) { + for i, p := range m.selectedMetrics { + if p == path { + m.selectedMetrics = append(m.selectedMetrics[:i], m.selectedMetrics[i+1:]...) + return + } + } + m.selectedMetrics = append(m.selectedMetrics, path) +} + +func (m *Model) refreshTreeRows() { + m.treeRows = flattenTree(m.tree, m.expanded) + if m.treeCursor >= len(m.treeRows) { + m.treeCursor = len(m.treeRows) - 1 + } + if m.treeCursor < 0 { + m.treeCursor = 0 + } +} + +func panelTitle(targets []string) string { + if len(targets) == 0 { + return "(no targets)" + } + if len(targets) == 1 { + return targets[0] + } + return fmt.Sprintf("%s +%d more", targets[0], len(targets)-1) +} diff --git a/internal/tui/dashboardkeys.go b/internal/tui/dashboardkeys.go new file mode 100644 index 0000000..8f5149a --- /dev/null +++ b/internal/tui/dashboardkeys.go @@ -0,0 +1,96 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.currentDashboard == nil { + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc": + m.viewMode = viewComposer + } + return m, nil + } + + n := len(m.currentDashboard.Panels) + cols := gridColumns(n) + + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + + case "esc": + m.viewMode = viewComposer + return m, nil + + case "left", "h": + if n > 0 && m.dashboardFocus%cols > 0 { + m.dashboardFocus-- + } + return m, nil + + case "right", "l": + if n > 0 && m.dashboardFocus%cols < cols-1 && m.dashboardFocus+1 < n { + m.dashboardFocus++ + } + return m, nil + + case "up", "k": + if m.dashboardFocus-cols >= 0 { + m.dashboardFocus -= cols + } + return m, nil + + case "down", "j": + if m.dashboardFocus+cols < n { + m.dashboardFocus += cols + } + return m, nil + + case "enter": + if n == 0 { + return m, nil + } + p := m.currentDashboard.Panels[m.dashboardFocus] + m.selectedMetrics = append([]string(nil), p.Targets...) + m.timeFrom = p.TimeFrom + m.drawMode = parseDrawMode(p.DrawMode) + m.viewMode = viewComposer + return m, m.refreshCmd() + + case "ctrl+d": + if n == 0 { + return m, nil + } + d := m.currentDashboard + d.Panels = append(d.Panels[:m.dashboardFocus], d.Panels[m.dashboardFocus+1:]...) + if m.dashboardFocus >= len(d.Panels) { + m.dashboardFocus = len(d.Panels) - 1 + } + if m.dashboardFocus < 0 { + m.dashboardFocus = 0 + } + return m, saveDashboardCmd(m.store, d) + + case "a": + m.dashboardAutorefreshOn = !m.dashboardAutorefreshOn + if m.dashboardAutorefreshOn { + return m, dashboardAutorefreshTickCmd() + } + return m, nil + + case "?": + m.helpCollapsed = !m.helpCollapsed + return m, nil + + // `l` is taken by vim-style pane movement here, so only ctrl+l redraws. + case "ctrl+l": + return m, tea.ClearScreen + } + return m, nil +} diff --git a/internal/tui/grid.go b/internal/tui/grid.go new file mode 100644 index 0000000..d547da0 --- /dev/null +++ b/internal/tui/grid.go @@ -0,0 +1,119 @@ +package tui + +import ( + "math" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/benwtr/terphite/internal/graphite" + "github.com/benwtr/terphite/internal/termimg" +) + +type dashboardPanelState struct { + Title string + Series []graphite.Series + Mode drawMode + Err error + + ImageProto termimg.Protocol + ImageBytes []byte + ImageVersion int + ImageCache *imageEscapeCache +} + +func gridColumns(n int) int { + if n <= 1 { + return 1 + } + return int(math.Ceil(math.Sqrt(float64(n)))) +} + +// renderDashboardGrid lays the panels out as a grid and returns it along +// with any inline-image overlay escapes, which the caller appends after the +// whole frame. topOffset is the 0-indexed frame line the grid starts on, +// used to place those overlays at absolute terminal coordinates. +func renderDashboardGrid(panels []dashboardPanelState, focus, width, height, topOffset int) (string, string) { + if len(panels) == 0 { + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, + faintStyle.Render("this dashboard has no panels yet — press S in composer view to add one")), "" + } + + cols := gridColumns(len(panels)) + rows := int(math.Ceil(float64(len(panels)) / float64(cols))) + tileWidth := clampMin(width/cols, 12) + tileHeight := clampMin(height/rows, 6) + + var overlays strings.Builder + gridLines := make([]string, 0, rows) + for r := 0; r < rows; r++ { + tiles := make([]string, 0, cols) + for c := 0; c < cols; c++ { + idx := r*cols + c + if idx >= len(panels) { + tiles = append(tiles, lipgloss.NewStyle().Width(tileWidth).Height(tileHeight).Render("")) + continue + } + tiles = append(tiles, renderPanelTile(panels[idx], idx == focus, tileWidth, tileHeight)) + overlays.WriteString(panelImageOverlay(panels[idx], r, c, tileWidth, tileHeight, topOffset)) + } + gridLines = append(gridLines, lipgloss.JoinHorizontal(lipgloss.Top, tiles...)) + } + return lipgloss.JoinVertical(lipgloss.Left, gridLines...), overlays.String() +} + +// panelImageOverlay places a tile's image over its reserved blank area. +// Inside a tile the border takes one row and the title another, so the +// image body starts two rows down and one column in (all 1-indexed). +func panelImageOverlay(p dashboardPanelState, r, c, tileWidth, tileHeight, topOffset int) string { + if !p.imageActive() { + return "" + } + imgCols := clampMin(tileWidth-2, 1) + imgRows := clampMin(tileHeight-3, 1) + row := topOffset + r*tileHeight + 3 + col := c*tileWidth + 2 + return overlayAt(row, col, p.ImageCache.escape(p.ImageProto, p.ImageBytes, p.ImageVersion, imgCols, imgRows)) +} + +// imageActive reports whether this panel should show a rendered image +// rather than the ASCII chart. +func (p dashboardPanelState) imageActive() bool { + return p.Err == nil && + p.ImageProto != termimg.ProtocolNone && + len(p.ImageBytes) > 0 && + p.ImageCache != nil +} + +func renderPanelTile(p dashboardPanelState, focused bool, width, height int) string { + tileBorderColor := lipgloss.Color("8") + if focused { + tileBorderColor = lipgloss.Color("4") + } + + innerWidth := clampMin(width-4, 1) + innerHeight := clampMin(height-5, 1) + + title := p.Title + if len(title) > innerWidth { + title = title[:innerWidth] + } + + style := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(tileBorderColor). + Width(clampMin(width-2, 1)). + Height(clampMin(height-2, 1)) + + var body string + switch { + case p.Err != nil: + body = errorStyle.Render("error: " + p.Err.Error()) + case p.imageActive(): + // Reserve blank space; panelImageOverlay paints the image over it. + body = "" + default: + body = renderChart(p.Series, p.Mode, innerWidth, innerHeight) + } + return style.Render(title + "\n" + body) +} diff --git a/internal/tui/grid_test.go b/internal/tui/grid_test.go new file mode 100644 index 0000000..7745a06 --- /dev/null +++ b/internal/tui/grid_test.go @@ -0,0 +1,25 @@ +package tui + +import "testing" + +func TestGridColumns(t *testing.T) { + cases := []struct { + n int + want int + }{ + {0, 1}, + {1, 1}, + {2, 2}, + {3, 2}, + {4, 2}, + {5, 3}, + {9, 3}, + {10, 4}, + } + for _, c := range cases { + got := gridColumns(c.n) + if got != c.want { + t.Errorf("gridColumns(%d) = %d, want %d", c.n, got, c.want) + } + } +} diff --git a/internal/tui/image.go b/internal/tui/image.go new file mode 100644 index 0000000..fa38d6f --- /dev/null +++ b/internal/tui/image.go @@ -0,0 +1,61 @@ +package tui + +import ( + "fmt" + + "github.com/benwtr/terphite/internal/termimg" +) + +// overlayAt returns esc wrapped so it draws at an absolute 1-indexed +// (row, col) and leaves the cursor exactly where it found it. +// +// Inline images can't just be embedded in the layout string: the escape +// sequence is a single "line" as far as lipgloss and bubbletea's line +// diffing are concerned, but the terminal draws it across many rows. Laying +// out around that mismatch makes every pane below the image get pushed off +// the bottom of the screen. Instead the layout reserves a correctly-sized +// blank region, and the image is painted over it afterwards via this +// overlay, so the frame's line count stays honest. +func overlayAt(row, col int, esc string) string { + if esc == "" { + return "" + } + return fmt.Sprintf("\x1b7\x1b[%d;%dH%s\x1b8", row, col, esc) +} + +// imageEscapeCache avoids rebuilding (and re-base64-encoding) an inline +// image's escape sequence on every View() call — only when the underlying +// image data actually changed (tracked by version, bumped when new image +// bytes are stored) or the display size changed do we rebuild it. +type imageEscapeCache struct { + version int + cols int + rows int + str string + built bool +} + +// escape returns the cached escape sequence for (version, cols, rows), +// rebuilding it if anything relevant changed since the last call. +func (c *imageEscapeCache) escape(proto termimg.Protocol, png []byte, version, cols, rows int) string { + if c.built && c.version == version && c.cols == cols && c.rows == rows { + return c.str + } + str := buildImageEscape(proto, png, cols, rows) + c.version, c.cols, c.rows, c.str, c.built = version, cols, rows, str, true + return str +} + +func buildImageEscape(proto termimg.Protocol, png []byte, cols, rows int) string { + if len(png) == 0 || cols <= 0 || rows <= 0 { + return "" + } + switch proto { + case termimg.ProtocolITerm2: + return termimg.ITerm2Escape(png, cols, rows) + case termimg.ProtocolKitty: + return termimg.KittyEscape(png, cols, rows) + default: + return "" + } +} diff --git a/internal/tui/image_test.go b/internal/tui/image_test.go new file mode 100644 index 0000000..3c7e77b --- /dev/null +++ b/internal/tui/image_test.go @@ -0,0 +1,59 @@ +package tui + +import ( + "testing" + + "github.com/benwtr/terphite/internal/termimg" +) + +func TestImageEscapeCacheHitsWhenNothingChanged(t *testing.T) { + var c imageEscapeCache + png := []byte("fake-png") + + first := c.escape(termimg.ProtocolITerm2, png, 1, 40, 20) + if first == "" { + t.Fatal("expected non-empty escape sequence") + } + second := c.escape(termimg.ProtocolITerm2, png, 1, 40, 20) + if second != first { + t.Errorf("expected cache hit to return identical string, got different output") + } +} + +func TestImageEscapeCacheRebuildsOnVersionChange(t *testing.T) { + var c imageEscapeCache + first := c.escape(termimg.ProtocolITerm2, []byte("png-v1"), 1, 40, 20) + second := c.escape(termimg.ProtocolITerm2, []byte("png-v2"), 2, 40, 20) + if first == second { + t.Error("expected different output after version bump with new bytes") + } +} + +func TestImageEscapeCacheRebuildsOnSizeChange(t *testing.T) { + var c imageEscapeCache + png := []byte("fake-png") + first := c.escape(termimg.ProtocolITerm2, png, 1, 40, 20) + second := c.escape(termimg.ProtocolITerm2, png, 1, 80, 40) + if first == second { + t.Error("expected different output after display size changed") + } +} + +func TestBuildImageEscapeNoneProtocol(t *testing.T) { + if got := buildImageEscape(termimg.ProtocolNone, []byte("png"), 40, 20); got != "" { + t.Errorf("expected empty string for ProtocolNone, got %q", got) + } +} + +func TestBuildImageEscapeEmptyPNG(t *testing.T) { + if got := buildImageEscape(termimg.ProtocolITerm2, nil, 40, 20); got != "" { + t.Errorf("expected empty string for empty png, got %q", got) + } +} + +func TestBuildImageEscapeKitty(t *testing.T) { + got := buildImageEscape(termimg.ProtocolKitty, []byte("png"), 40, 20) + if got == "" { + t.Error("expected non-empty escape sequence for Kitty protocol") + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..8f59b4e --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,156 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/benwtr/terphite/internal/termimg" +) + +type keyBinding struct { + Key string + Help string +} + +var composerKeys = []keyBinding{ + {"↑/↓", "move metrics tree cursor"}, + {"enter", "select metric / expand"}, + {"[ {", "decrease time 1min, 1h"}, + {"] }", "increase time 1min, 1h"}, + {"t", "set relative \"from\" time"}, + {"m", "metrics list popup"}, + {"i", "set autorefresh interval"}, + {"a", "autorefresh toggle"}, + {"x", "set max datapoints"}, + {"g", "cycle graph style"}, + {"I", "toggle graphical mode"}, + {"o", "open in browser"}, + {"c", "copy graphite URI to clipboard"}, + {"S", "save graph to a dashboard"}, + {"D", "open a saved dashboard"}, + {"l", "redraw screen"}, + {"?", "collapse/expand this help"}, + {"q", "quit"}, +} + +var metricsPopupKeys = []keyBinding{ + {"ctrl+a", "append new target"}, + {"ctrl+d", "delete selected"}, + {"enter", "edit selected"}, + {"esc", "close"}, +} + +var dashboardKeys = []keyBinding{ + {"←/→/↑/↓", "move between panels"}, + {"enter", "edit panel in composer"}, + {"ctrl+d", "remove panel"}, + {"a", "toggle autorefresh"}, + {"?", "collapse/expand this help"}, + {"esc", "back to composer"}, + {"q", "quit"}, +} + +func bindingsFor(mode viewMode, popup popupKind) []keyBinding { + switch { + case popup == popupMetricsList: + return metricsPopupKeys + case mode == viewDashboard: + return dashboardKeys + default: + return composerKeys + } +} + +// annotate appends the current value to the bindings that toggle between +// states, so the help doubles as a status readout. +func annotate(b keyBinding, graphMode drawMode, imageProtocol termimg.Protocol) string { + switch b.Key { + case "g": + return fmt.Sprintf("%s [%s]", b.Help, graphMode) + case "I": + return fmt.Sprintf("%s [%s]", b.Help, imageProtocol) + } + return b.Help +} + +// helpText lays the key bindings out in as many columns as fit, keeping +// the help bar short instead of spending a third of the screen on one +// binding per line. When collapsed it shrinks to a single hint line. +// +// The result is guaranteed to be at most maxRows lines and at most width +// columns wide: callers size the help box from what this returns, and +// lipgloss's Height() only pads, so anything larger than the declared box +// would silently overflow and push the rest of the frame off-screen. +func helpText(mode viewMode, popup popupKind, graphMode drawMode, imageProtocol termimg.Protocol, width, maxRows int, collapsed bool) string { + if collapsed { + return fmt.Sprintf("? help g %s I %s q quit", graphMode, imageProtocol) + } + + bindings := bindingsFor(mode, popup) + n := len(bindings) + if n == 0 { + return "" + } + maxRows = clampMin(maxRows, 1) + if width <= 0 { + width = 1 << 20 // unconstrained (popups size to their content) + } + + keyW, helpW := 0, 0 + for _, b := range bindings { + if r := len([]rune(b.Key)); r > keyW { + keyW = r + } + if r := len([]rune(annotate(b, graphMode, imageProtocol))); r > helpW { + helpW = r + } + } + + const gutter = 3 + // Enough columns to fit the row budget, widened further if there's room. + cols := (n + maxRows - 1) / maxRows + if byWidth := width / (keyW + 1 + helpW + gutter); byWidth > cols { + cols = byWidth + } + cols = clampMin(cols, 1) + if cols > n { + cols = n + } + rows := (n + cols - 1) / cols + + // Share the width across the columns, truncating labels if the row + // budget forced more columns than would naturally fit. + cellW := clampMin(width/cols-gutter, keyW+2) + labelW := clampMin(cellW-keyW-1, 1) + + cells := make([]string, n) + for i, b := range bindings { + label := []rune(annotate(b, graphMode, imageProtocol)) + if len(label) > labelW { + label = label[:labelW] + } + cells[i] = fmt.Sprintf("%-*s %-*s", keyW, b.Key, labelW, string(label)) + } + + // Column-major fill so each column reads top-to-bottom. + lines := make([]string, rows) + for r := 0; r < rows; r++ { + var b strings.Builder + for c := 0; c < cols; c++ { + i := c*rows + r + if i >= n { + break + } + if c > 0 { + b.WriteString(strings.Repeat(" ", gutter)) + } + b.WriteString(cells[i]) + } + line := []rune(strings.TrimRight(b.String(), " ")) + if len(line) > width { + line = line[:width] + } + lines[r] = string(line) + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go new file mode 100644 index 0000000..f837f1b --- /dev/null +++ b/internal/tui/keys_test.go @@ -0,0 +1,75 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/benwtr/terphite/internal/termimg" +) + +func composerHelp(width, maxRows int, collapsed bool) string { + return helpText(viewComposer, popupNone, drawLine, termimg.ProtocolNone, width, maxRows, collapsed) +} + +func helpRows(s string) int { return strings.Count(s, "\n") + 1 } + +func TestHelpTextCollapsedIsOneLine(t *testing.T) { + got := composerHelp(200, 6, true) + if strings.Contains(got, "\n") { + t.Errorf("collapsed help should be a single line, got:\n%s", got) + } +} + +func TestHelpTextUsesMultipleColumns(t *testing.T) { + got := composerHelp(200, 6, false) + if rows := helpRows(got); rows >= len(composerKeys) { + t.Errorf("expected multi-column layout to use fewer than %d rows, got %d:\n%s", + len(composerKeys), rows, got) + } +} + +// The row budget is what keeps the help box from overflowing the frame, so +// it must hold even at widths too narrow to lay the bindings out nicely. +func TestHelpTextRespectsRowBudget(t *testing.T) { + for _, width := range []int{20, 40, 80, 120, 200} { + for _, maxRows := range []int{1, 3, 6, 12} { + got := composerHelp(width, maxRows, false) + if rows := helpRows(got); rows > maxRows { + t.Errorf("width %d budget %d: got %d rows:\n%s", width, maxRows, rows, got) + } + } + } +} + +func TestHelpTextNeverExceedsWidth(t *testing.T) { + for _, width := range []int{20, 40, 80, 120, 200} { + for _, maxRows := range []int{1, 3, 6, 12} { + got := composerHelp(width, maxRows, false) + for _, line := range strings.Split(got, "\n") { + if n := len([]rune(line)); n > width { + t.Errorf("width %d budget %d: line of %d runes overflows: %q", + width, maxRows, n, line) + } + } + } + } +} + +func TestHelpTextIncludesEveryBindingKey(t *testing.T) { + got := composerHelp(200, 6, false) + for _, b := range composerKeys { + if !strings.Contains(got, b.Key) { + t.Errorf("help is missing binding %q:\n%s", b.Key, got) + } + } +} + +func TestHelpTextAnnotatesToggleState(t *testing.T) { + got := helpText(viewComposer, popupNone, drawStacked, termimg.ProtocolKitty, 200, 6, false) + if !strings.Contains(got, "[stacked]") { + t.Errorf("expected graph style annotation, got:\n%s", got) + } + if !strings.Contains(got, "[kitty]") { + t.Errorf("expected image protocol annotation, got:\n%s", got) + } +} diff --git a/internal/tui/messages.go b/internal/tui/messages.go new file mode 100644 index 0000000..2421be8 --- /dev/null +++ b/internal/tui/messages.go @@ -0,0 +1,58 @@ +package tui + +import ( + "github.com/benwtr/terphite/internal/dashboard" + "github.com/benwtr/terphite/internal/graphite" +) + +type metricsLoadedMsg struct{ tree *graphite.MetricNode } +type metricsErrMsg struct{ err error } + +type renderLoadedMsg struct { + series []graphite.Series + gen int +} +type renderErrMsg struct { + err error + gen int +} + +type imageLoadedMsg struct { + png []byte + gen int +} +type imageErrMsg struct { + err error + gen int +} + +type autorefreshTickMsg struct{} + +type dashboardListLoadedMsg struct{ names []string } +type dashboardLoadedMsg struct{ d *dashboard.Dashboard } +type dashboardSavedMsg struct{} +type dashboardErrMsg struct{ err error } + +type panelRenderLoadedMsg struct { + panelIndex int + series []graphite.Series + gen int +} +type panelRenderErrMsg struct { + panelIndex int + err error + gen int +} + +type panelImageLoadedMsg struct { + panelIndex int + png []byte + gen int +} +type panelImageErrMsg struct { + panelIndex int + err error + gen int +} + +type dashboardAutorefreshTickMsg struct{} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..7c8991b --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,362 @@ +// Package tui implements terphite's terminal UI: a composer view for +// building a single graph, and a dashboard view for viewing several saved +// graphs at once as a grid. +package tui + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/benwtr/terphite/internal/dashboard" + "github.com/benwtr/terphite/internal/graphite" + "github.com/benwtr/terphite/internal/termimg" + "github.com/benwtr/terphite/internal/timerange" +) + +type viewMode int + +const ( + viewComposer viewMode = iota + viewDashboard +) + +type popupKind int + +const ( + popupNone popupKind = iota + popupSetTimeFrom + popupSetAutorefresh + popupSetMaxDataPoints + popupMetricsList + popupEditTarget + popupAddTarget + popupSaveDashboardName + popupPickDashboard +) + +const ( + defaultAutorefreshInterval = 10 + defaultMaxDataPoints = 300 +) + +// drawMode selects how a chart's series are rendered: as independent +// lines, independent filled areas, or a cumulative stacked area. +type drawMode int + +const ( + drawLine drawMode = iota + drawArea + drawStacked +) + +// String returns the mode's name, used both for the help text label and as +// the value persisted in a saved dashboard.Panel's DrawMode field. +func (d drawMode) String() string { + switch d { + case drawArea: + return "area" + case drawStacked: + return "stacked" + default: + return "line" + } +} + +// parseDrawMode parses a dashboard.Panel's stored DrawMode string, defaulting +// to drawLine for an empty or unrecognized value (covers dashboards saved +// before draw modes existed). +func parseDrawMode(s string) drawMode { + switch s { + case "area": + return drawArea + case "stacked": + return drawStacked + default: + return drawLine + } +} + +func (d drawMode) next() drawMode { + return (d + 1) % 3 +} + +// graphiteAreaMode maps d to Graphite's own areaMode render param, so +// graphical mode's images use the same draw mode as the ASCII chart. +func (d drawMode) graphiteAreaMode() string { + switch d { + case drawArea: + return "all" + case drawStacked: + return "stacked" + default: + return "none" + } +} + +// Config configures a new Model. +type Config struct { + GraphiteURI string + Username string + Password string + DashboardDir string // empty uses dashboard.DefaultDir() +} + +// Model is terphite's root bubbletea model. +type Model struct { + client *graphite.Client + store *dashboard.Store + + width, height int + quitting bool + + viewMode viewMode + popup popupKind + input textinput.Model + errMsg string + helpCollapsed bool + + tree *graphite.MetricNode + treeRows []treeRow + treeCursor int + expanded map[string]bool + selectedMetrics []string + timeFrom string + drawMode drawMode + + autorefreshOn bool + autorefreshInterval int + maxDataPoints int + + series []graphite.Series + fetchGen int + + imageProtocol termimg.Protocol + imageBytes []byte + imageGen int // bumped on each fetch request, to discard stale responses + imageVersion int // bumped only when imageBytes actually changes, for escape-string caching + imageCache imageEscapeCache + + metricsCursor int + + dashboardNames []string + pickerCursor int + + currentDashboard *dashboard.Dashboard + panelSeries [][]graphite.Series + panelErrs []error + panelImages [][]byte + panelImageErrs []error + panelImageVersions []int + panelImageCaches []imageEscapeCache + dashboardFocus int + dashboardAutorefreshOn bool + dashboardFetchGen int +} + +// New builds a Model ready to run. +func New(cfg Config) (*Model, error) { + client, err := graphite.NewClient(cfg.GraphiteURI) + if err != nil { + return nil, err + } + if cfg.Username != "" || cfg.Password != "" { + client.SetAuth(cfg.Username, cfg.Password) + } + + dir := cfg.DashboardDir + if dir == "" { + dir, err = dashboard.DefaultDir() + if err != nil { + return nil, err + } + } + + return &Model{ + client: client, + store: dashboard.NewStore(dir), + viewMode: viewComposer, + timeFrom: timerange.Default, + autorefreshInterval: defaultAutorefreshInterval, + maxDataPoints: defaultMaxDataPoints, + expanded: make(map[string]bool), + // Graphical mode starts off: it depends on terminal support that + // can't be detected reliably (tmux and SSH in particular don't + // always relay the escape sequences), so it's opt-in via `I`. + imageProtocol: termimg.ProtocolNone, + }, nil +} + +func (m *Model) Init() tea.Cmd { + return tea.Batch( + fetchMetricsCmd(m.client), + m.refreshCmd(), + ) +} + +// refreshCmd returns the command to (re-)fetch the composer's current +// graph: a rendered image when graphical mode is on, JSON series data +// otherwise. Centralizing the choice here (rather than branching at every +// call site) means every action that changes what's plotted — time nav, +// metric selection, popups, autorefresh — automatically fetches the right +// thing. +func (m *Model) refreshCmd() tea.Cmd { + if m.imageProtocol != termimg.ProtocolNone { + m.imageGen++ + return fetchImageCmd(m.client, m.selectedMetrics, m.timeFrom, m.drawMode.graphiteAreaMode(), m.imageGen) + } + m.fetchGen++ + return fetchRenderCmd(m.client, m.selectedMetrics, m.timeFrom, m.maxDataPoints, m.fetchGen) +} + +// refreshAllPanelsCmd is refreshCmd's dashboard-grid equivalent, fetching +// every panel's image or JSON data depending on graphical mode. +func (m *Model) refreshAllPanelsCmd(panels []dashboard.Panel) tea.Cmd { + if m.imageProtocol != termimg.ProtocolNone { + m.dashboardFetchGen++ + return fetchAllPanelImagesCmd(m.client, panels, m.dashboardFetchGen) + } + m.dashboardFetchGen++ + return fetchAllPanelsCmd(m.client, panels, m.dashboardFetchGen) +} + +func (m *Model) autorefreshSeconds() int { + if m.autorefreshOn { + return m.autorefreshInterval + } + return 0 +} + +func (m *Model) renderQuery() graphite.RenderQuery { + return graphite.RenderQuery{ + Targets: m.selectedMetrics, + From: m.timeFrom, + MaxDataPoints: m.maxDataPoints, + } +} + +func (m *Model) selectedSet() map[string]bool { + set := make(map[string]bool, len(m.selectedMetrics)) + for _, p := range m.selectedMetrics { + set[p] = true + } + return set +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case metricsLoadedMsg: + m.tree = msg.tree + m.refreshTreeRows() + return m, nil + case metricsErrMsg: + m.errMsg = msg.err.Error() + return m, nil + + case renderLoadedMsg: + if msg.gen == m.fetchGen { + m.series = msg.series + m.errMsg = "" + } + return m, nil + case renderErrMsg: + if msg.gen == m.fetchGen { + m.errMsg = msg.err.Error() + } + return m, nil + + case imageLoadedMsg: + if msg.gen == m.imageGen { + m.imageBytes = msg.png + m.imageVersion++ + m.errMsg = "" + } + return m, nil + case imageErrMsg: + if msg.gen == m.imageGen { + m.errMsg = msg.err.Error() + } + return m, nil + + case autorefreshTickMsg: + if !m.autorefreshOn { + return m, nil + } + return m, tea.Batch(m.refreshCmd(), autorefreshTickCmd(m.autorefreshInterval)) + + case dashboardListLoadedMsg: + m.dashboardNames = msg.names + m.pickerCursor = 0 + return m, nil + case dashboardLoadedMsg: + m.currentDashboard = msg.d + m.panelSeries = make([][]graphite.Series, len(msg.d.Panels)) + m.panelErrs = make([]error, len(msg.d.Panels)) + m.panelImages = make([][]byte, len(msg.d.Panels)) + m.panelImageErrs = make([]error, len(msg.d.Panels)) + m.panelImageVersions = make([]int, len(msg.d.Panels)) + m.panelImageCaches = make([]imageEscapeCache, len(msg.d.Panels)) + m.dashboardFocus = 0 + m.viewMode = viewDashboard + m.popup = popupNone + return m, m.refreshAllPanelsCmd(msg.d.Panels) + case dashboardSavedMsg: + m.popup = popupNone + m.errMsg = "" + return m, nil + case dashboardErrMsg: + m.errMsg = msg.err.Error() + m.popup = popupNone + return m, nil + + case panelRenderLoadedMsg: + if msg.gen == m.dashboardFetchGen && msg.panelIndex < len(m.panelSeries) { + m.panelSeries[msg.panelIndex] = msg.series + m.panelErrs[msg.panelIndex] = nil + } + return m, nil + case panelRenderErrMsg: + if msg.gen == m.dashboardFetchGen && msg.panelIndex < len(m.panelErrs) { + m.panelErrs[msg.panelIndex] = msg.err + } + return m, nil + case panelImageLoadedMsg: + if msg.gen == m.dashboardFetchGen && msg.panelIndex < len(m.panelImages) { + m.panelImages[msg.panelIndex] = msg.png + m.panelImageErrs[msg.panelIndex] = nil + m.panelImageVersions[msg.panelIndex]++ + } + return m, nil + case panelImageErrMsg: + if msg.gen == m.dashboardFetchGen && msg.panelIndex < len(m.panelImageErrs) { + m.panelImageErrs[msg.panelIndex] = msg.err + } + return m, nil + case dashboardAutorefreshTickMsg: + if !m.dashboardAutorefreshOn || m.currentDashboard == nil { + return m, nil + } + return m, tea.Batch( + m.refreshAllPanelsCmd(m.currentDashboard.Panels), + dashboardAutorefreshTickCmd(), + ) + + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.popup != popupNone { + return m.handlePopupKey(msg) + } + if m.viewMode == viewDashboard { + return m.handleDashboardKey(msg) + } + return m.handleComposerKey(msg) +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..a6a8fc5 --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,86 @@ +package tui + +import ( + "testing" + + "github.com/benwtr/terphite/internal/graphite" + "github.com/benwtr/terphite/internal/timerange" +) + +func newTestModel(t *testing.T) *Model { + t.Helper() + m, err := New(Config{ + GraphiteURI: "http://example.com", + DashboardDir: t.TempDir(), + }) + if err != nil { + t.Fatal(err) + } + return m +} + +func TestToggleMetric(t *testing.T) { + m := newTestModel(t) + m.toggleMetric("stats.foo") + if len(m.selectedMetrics) != 1 || m.selectedMetrics[0] != "stats.foo" { + t.Fatalf("after add: %v", m.selectedMetrics) + } + m.toggleMetric("stats.foo") + if len(m.selectedMetrics) != 0 { + t.Fatalf("after remove: %v", m.selectedMetrics) + } +} + +func TestAdjustTimeMinuteClampsToDefault(t *testing.T) { + m := newTestModel(t) + m.timeFrom = timerange.Default // -1min, exactly at the floor + m.adjustTime(-timerange.UnitMinute, timerange.UnitMinute, timerange.Default) + if m.timeFrom != timerange.Default { + t.Errorf("timeFrom = %q, want %q", m.timeFrom, timerange.Default) + } +} + +func TestAdjustTimeMinuteIncreases(t *testing.T) { + m := newTestModel(t) + m.timeFrom = "-5min" + m.adjustTime(timerange.UnitMinute, 0, "") + if m.timeFrom != "-6min" { + t.Errorf("timeFrom = %q, want -6min", m.timeFrom) + } +} + +func TestAdjustTimeHourClampsToOneHour(t *testing.T) { + m := newTestModel(t) + m.timeFrom = "-30min" + m.adjustTime(-timerange.UnitHour, timerange.UnitHour, "-1h") + if m.timeFrom != "-1h" { + t.Errorf("timeFrom = %q, want -1h", m.timeFrom) + } +} + +func TestRefreshTreeRowsClampsCursor(t *testing.T) { + m := newTestModel(t) + m.tree = graphite.BuildTree([]string{"a", "b", "c"}) + m.treeCursor = 100 + m.refreshTreeRows() + if m.treeCursor != len(m.treeRows)-1 { + t.Errorf("treeCursor = %d, want %d", m.treeCursor, len(m.treeRows)-1) + } +} + +func TestPanelTitle(t *testing.T) { + cases := []struct { + targets []string + want string + }{ + {nil, "(no targets)"}, + {[]string{"stats.foo"}, "stats.foo"}, + {[]string{"stats.foo", "stats.bar"}, "stats.foo +1 more"}, + } + for _, c := range cases { + got := panelTitle(c.targets) + if got != c.want { + t.Errorf("panelTitle(%v) = %q, want %q", c.targets, got, c.want) + } + } +} diff --git a/internal/tui/platform.go b/internal/tui/platform.go new file mode 100644 index 0000000..3073ea9 --- /dev/null +++ b/internal/tui/platform.go @@ -0,0 +1,28 @@ +package tui + +import ( + "os/exec" + "runtime" + + "github.com/atotto/clipboard" +) + +// openInBrowser opens url in the user's default browser, cross-platform. +func openInBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} + +// copyToClipboard copies text to the system clipboard, cross-platform +// (replacing the original's iTerm2-only escape-sequence approach). +func copyToClipboard(text string) error { + return clipboard.WriteAll(text) +} diff --git a/internal/tui/popup.go b/internal/tui/popup.go new file mode 100644 index 0000000..7850ed4 --- /dev/null +++ b/internal/tui/popup.go @@ -0,0 +1,13 @@ +package tui + +import "github.com/charmbracelet/bubbles/textinput" + +func newTextInput(prompt, value string, width int) textinput.Model { + ti := textinput.New() + ti.Prompt = prompt + ti.SetValue(value) + ti.CursorEnd() + ti.Width = width + ti.Focus() + return ti +} diff --git a/internal/tui/popupkeys.go b/internal/tui/popupkeys.go new file mode 100644 index 0000000..7716912 --- /dev/null +++ b/internal/tui/popupkeys.go @@ -0,0 +1,161 @@ +package tui + +import ( + "strconv" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/benwtr/terphite/internal/dashboard" + "github.com/benwtr/terphite/internal/timerange" +) + +func (m *Model) handlePopupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.popup { + case popupMetricsList: + return m.handleMetricsPopupKey(msg) + case popupPickDashboard: + return m.handleDashboardPickerKey(msg) + default: + return m.handleTextInputPopupKey(msg) + } +} + +func (m *Model) handleTextInputPopupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.popup = popupNone + return m, nil + case "enter": + return m.submitTextInput() + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func (m *Model) submitTextInput() (tea.Model, tea.Cmd) { + value := m.input.Value() + + switch m.popup { + case popupSetTimeFrom: + m.popup = popupNone + normalized, err := timerange.Normalize(value) + if err != nil { + m.errMsg = err.Error() + return m, nil + } + m.timeFrom = normalized + return m, m.refreshCmd() + + case popupSetAutorefresh: + m.popup = popupNone + n, err := strconv.Atoi(value) + if err != nil || n <= 0 { + n = 1 + } + m.autorefreshInterval = n + m.autorefreshOn = true + return m, autorefreshTickCmd(m.autorefreshInterval) + + case popupSetMaxDataPoints: + m.popup = popupNone + n, err := strconv.Atoi(value) + if err != nil || n < 0 { + n = 0 + } + m.maxDataPoints = n + return m, m.refreshCmd() + + case popupAddTarget: + m.popup = popupMetricsList + if value != "" { + m.selectedMetrics = append(m.selectedMetrics, value) + } + return m, m.refreshCmd() + + case popupEditTarget: + m.popup = popupMetricsList + if m.metricsCursor >= 0 && m.metricsCursor < len(m.selectedMetrics) { + m.selectedMetrics[m.metricsCursor] = value + } + return m, m.refreshCmd() + + case popupSaveDashboardName: + m.popup = popupNone + if value == "" { + return m, nil + } + return m, saveDashboardPanelCmd(m.store, value, dashboard.Panel{ + Title: panelTitle(m.selectedMetrics), + Targets: append([]string(nil), m.selectedMetrics...), + TimeFrom: m.timeFrom, + DrawMode: m.drawMode.String(), + }) + } + return m, nil +} + +func (m *Model) handleMetricsPopupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.popup = popupNone + return m, m.refreshCmd() + + case "up", "k": + if m.metricsCursor > 0 { + m.metricsCursor-- + } + return m, nil + case "down", "j": + if m.metricsCursor < len(m.selectedMetrics)-1 { + m.metricsCursor++ + } + return m, nil + + case "ctrl+a": + m.popup = popupAddTarget + m.input = newTextInput("add target: ", "", 60) + return m, nil + + case "ctrl+d": + if m.metricsCursor >= 0 && m.metricsCursor < len(m.selectedMetrics) { + m.selectedMetrics = append(m.selectedMetrics[:m.metricsCursor], m.selectedMetrics[m.metricsCursor+1:]...) + if m.metricsCursor >= len(m.selectedMetrics) { + m.metricsCursor = len(m.selectedMetrics) - 1 + } + } + return m, m.refreshCmd() + + case "enter": + if m.metricsCursor >= 0 && m.metricsCursor < len(m.selectedMetrics) { + m.popup = popupEditTarget + m.input = newTextInput("edit target: ", m.selectedMetrics[m.metricsCursor], 60) + } + return m, nil + } + return m, nil +} + +func (m *Model) handleDashboardPickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.popup = popupNone + return m, nil + case "up", "k": + if m.pickerCursor > 0 { + m.pickerCursor-- + } + return m, nil + case "down", "j": + if m.pickerCursor < len(m.dashboardNames)-1 { + m.pickerCursor++ + } + return m, nil + case "enter": + if m.pickerCursor >= 0 && m.pickerCursor < len(m.dashboardNames) { + return m, loadDashboardCmd(m.store, m.dashboardNames[m.pickerCursor]) + } + return m, nil + } + return m, nil +} diff --git a/internal/tui/status.go b/internal/tui/status.go new file mode 100644 index 0000000..df32dbb --- /dev/null +++ b/internal/tui/status.go @@ -0,0 +1,40 @@ +package tui + +import "fmt" + +// renderStatus renders the status line, truncating the error message (if +// any) so the whole line fits within width — an untruncated long error +// would wrap inside its box and grow it past its declared height, pushing +// everything below it off screen. +func renderStatus(timeFrom string, autorefreshSeconds, maxDataPoints int, errMsg string, width int) string { + auto := "off" + if autorefreshSeconds > 0 { + auto = fmt.Sprintf("%ds", autorefreshSeconds) + } + maxdp := "unlimited" + if maxDataPoints > 0 { + maxdp = fmt.Sprintf("%d", maxDataPoints) + } + prefix := fmt.Sprintf("from: %s autorefresh: %s maxdatapoints: %s", timeFrom, auto, maxdp) + if errMsg == "" { + return truncateLine(prefix, width) + } + + errPart := " error: " + errMsg + avail := width - len(prefix) + if width > 0 && len(errPart) > avail { + if avail > 1 { + errPart = errPart[:avail-1] + "…" + } else { + errPart = "" + } + } + return prefix + errorStyle.Render(errPart) +} + +func truncateLine(s string, width int) string { + if width > 0 && len(s) > width { + return s[:width] + } + return s +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..492e174 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,16 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + borderStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("4")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) + faintStyle = lipgloss.NewStyle().Faint(true) +) + +func clampMin(n, min int) int { + if n < min { + return min + } + return n +} diff --git a/internal/tui/tree.go b/internal/tui/tree.go new file mode 100644 index 0000000..28efdd7 --- /dev/null +++ b/internal/tui/tree.go @@ -0,0 +1,88 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/benwtr/terphite/internal/graphite" +) + +type treeRow struct { + node *graphite.MetricNode + depth int +} + +func flattenTree(root *graphite.MetricNode, expanded map[string]bool) []treeRow { + var rows []treeRow + var walk func(n *graphite.MetricNode, depth int) + walk = func(n *graphite.MetricNode, depth int) { + for _, c := range n.Children { + rows = append(rows, treeRow{node: c, depth: depth}) + if len(c.Children) > 0 && expanded[c.Path] { + walk(c, depth+1) + } + } + } + if root != nil { + walk(root, 0) + } + return rows +} + +func renderTree(rows []treeRow, cursor int, expanded, selected map[string]bool, width, height int) string { + if width <= 0 || height <= 0 { + return "" + } + if len(rows) == 0 { + return faintStyle.Render("loading metrics…") + } + + start := 0 + if cursor >= height { + start = cursor - height + 1 + } + end := start + height + if end > len(rows) { + end = len(rows) + } + + lines := make([]string, 0, end-start) + for i := start; i < end; i++ { + row := rows[i] + indent := strings.Repeat(" ", row.depth) + + marker := " " + if len(row.node.Children) > 0 { + if expanded[row.node.Path] { + marker = "▾ " + } else { + marker = "▸ " + } + } + + label := row.node.Name + if row.node.Leaf { + if selected[row.node.Path] { + label = "[x] " + label + } else { + label = "[ ] " + label + } + } + + line := indent + marker + label + if len(line) > width { + line = line[:width] + } + + style := lipgloss.NewStyle().Width(width) + if row.node.Leaf && selected[row.node.Path] { + style = style.Bold(true) + } + if i == cursor { + style = style.Reverse(true) + } + lines = append(lines, style.Render(line)) + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/tree_test.go b/internal/tui/tree_test.go new file mode 100644 index 0000000..8c20744 --- /dev/null +++ b/internal/tui/tree_test.go @@ -0,0 +1,50 @@ +package tui + +import ( + "testing" + + "github.com/benwtr/terphite/internal/graphite" +) + +func TestFlattenTreeCollapsedByDefault(t *testing.T) { + root := graphite.BuildTree([]string{"stats.foo.bar", "stats.baz"}) + rows := flattenTree(root, map[string]bool{}) + if len(rows) != 1 { + t.Fatalf("got %d rows, want 1 (only top-level 'stats' visible when collapsed)", len(rows)) + } + if rows[0].node.Name != "stats" { + t.Errorf("rows[0].node.Name = %q, want stats", rows[0].node.Name) + } +} + +func TestFlattenTreeExpanded(t *testing.T) { + root := graphite.BuildTree([]string{"stats.foo.bar", "stats.baz"}) + expanded := map[string]bool{"stats": true} + rows := flattenTree(root, expanded) + + if len(rows) != 3 { + t.Fatalf("got %d rows, want 3 (stats, stats.foo, stats.baz), rows=%+v", len(rows), rows) + } + if rows[0].node.Name != "stats" { + t.Errorf("rows[0] = %q, want stats", rows[0].node.Name) + } + // order within a level follows BuildTree's sorted insertion order (baz < foo) + if rows[1].node.Name != "baz" || rows[2].node.Name != "foo" { + t.Errorf("children order = [%q, %q], want [baz, foo]", rows[1].node.Name, rows[2].node.Name) + } +} + +func TestFlattenTreeDeepExpansion(t *testing.T) { + root := graphite.BuildTree([]string{"a.b.c"}) + expanded := map[string]bool{"a": true, "a.b": true} + rows := flattenTree(root, expanded) + if len(rows) != 3 { + t.Fatalf("got %d rows, want 3, rows=%+v", len(rows), rows) + } + if rows[2].node.Name != "c" || !rows[2].node.Leaf { + t.Errorf("rows[2] = %+v, want leaf node 'c'", rows[2]) + } + if rows[2].depth != 2 { + t.Errorf("rows[2].depth = %d, want 2", rows[2].depth) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..e28d2c9 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,225 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/benwtr/terphite/internal/termimg" +) + +func (m *Model) View() string { + if m.quitting { + return "" + } + if m.width == 0 || m.height == 0 { + return "loading…" + } + if m.popup != popupNone { + return m.viewPopup() + } + if m.viewMode == viewDashboard { + return m.viewDashboardScreen() + } + return m.viewComposerScreen() +} + +func (m *Model) viewComposerScreen() string { + statusHeight := 3 + help := helpText(m.viewMode, m.popup, m.drawMode, m.imageProtocol, m.width-4, m.helpBudgetRows(), m.helpCollapsed) + helpHeight := helpBoxHeight(help) + bodyHeight := clampMin(m.height-statusHeight-helpHeight, 3) + treeWidth := clampMin(m.width/4, 10) + chartWidth := clampMin(m.width-treeWidth, 10) + + status := borderStyle.Width(clampMin(m.width-2, 1)).Height(clampMin(statusHeight-2, 1)). + Render(renderStatus(m.timeFrom, m.autorefreshSeconds(), m.maxDataPoints, m.errMsg, m.width-4)) + + tree := borderStyle.Width(clampMin(treeWidth-2, 1)).Height(clampMin(bodyHeight-2, 1)). + Render(renderTree(m.treeRows, m.treeCursor, m.expanded, m.selectedSet(), treeWidth-4, bodyHeight-4)) + + chart := m.renderChartPane(chartWidth, bodyHeight) + + body := lipgloss.JoinHorizontal(lipgloss.Top, tree, chart) + + helpBox := borderStyle.Width(clampMin(m.width-2, 1)).Height(clampMin(helpHeight-2, 1)).Render(help) + + frame := lipgloss.JoinVertical(lipgloss.Left, status, body, helpBox) + + // The chart pane's inner area starts one row below the status box and + // one column inside the chart pane's left border (both 1-indexed). + return frame + m.composerImageOverlay(statusHeight+2, treeWidth+2, chartWidth, bodyHeight) +} + +// renderChartPane renders the composer's chart pane at the given outer +// width x height (border included). In graphical mode it renders an +// empty box of the correct size; the image itself is painted over that +// region by composerImageOverlay (see overlayAt for why). +func (m *Model) renderChartPane(width, height int) string { + box := borderStyle.Width(clampMin(width-2, 1)).Height(clampMin(height-2, 1)) + if m.imageActive() { + return box.Render("") + } + return box.Render(renderChart(m.series, m.drawMode, width-4, height-4)) +} + +// imageActive reports whether the composer should show a rendered image +// rather than the ASCII chart. +func (m *Model) imageActive() bool { + return m.imageProtocol != termimg.ProtocolNone && len(m.imageBytes) > 0 +} + +func (m *Model) composerImageOverlay(row, col, chartWidth, bodyHeight int) string { + if !m.imageActive() { + return "" + } + cols := clampMin(chartWidth-2, 1) + rows := clampMin(bodyHeight-2, 1) + return overlayAt(row, col, m.imageCache.escape(m.imageProtocol, m.imageBytes, m.imageVersion, cols, rows)) +} + +func (m *Model) viewDashboardScreen() string { + if m.currentDashboard == nil { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "no dashboard loaded") + } + + statusHeight := 3 + help := helpText(viewDashboard, popupNone, drawLine, m.imageProtocol, m.width-4, m.helpBudgetRows(), m.helpCollapsed) + helpHeight := helpBoxHeight(help) + gridHeight := clampMin(m.height-statusHeight-helpHeight, 3) + + title := fmt.Sprintf("dashboard: %s autorefresh: %s", m.currentDashboard.Name, onOff(m.dashboardAutorefreshOn)) + status := borderStyle.Width(clampMin(m.width-2, 1)).Height(clampMin(statusHeight-2, 1)).Render(title) + + panels := make([]dashboardPanelState, len(m.currentDashboard.Panels)) + for i, p := range m.currentDashboard.Panels { + panels[i] = dashboardPanelState{ + Title: p.Title, + Mode: parseDrawMode(p.DrawMode), + ImageProto: m.imageProtocol, + } + if i < len(m.panelSeries) { + panels[i].Series = m.panelSeries[i] + } + if i < len(m.panelErrs) { + panels[i].Err = m.panelErrs[i] + } + if i < len(m.panelImages) { + panels[i].ImageBytes = m.panelImages[i] + } + if i < len(m.panelImageErrs) && m.panelImageErrs[i] != nil { + panels[i].Err = m.panelImageErrs[i] + } + if i < len(m.panelImageVersions) { + panels[i].ImageVersion = m.panelImageVersions[i] + } + if i < len(m.panelImageCaches) { + panels[i].ImageCache = &m.panelImageCaches[i] + } + } + // The grid starts directly below the status box; renderDashboardGrid + // returns the image overlays separately so the frame's line count stays + // honest (see overlayAt). + grid, overlays := renderDashboardGrid(panels, m.dashboardFocus, m.width, gridHeight, statusHeight) + + helpBox := borderStyle.Width(clampMin(m.width-2, 1)).Height(clampMin(helpHeight-2, 1)).Render(help) + + return lipgloss.JoinVertical(lipgloss.Left, status, grid, helpBox) + overlays +} + +func (m *Model) viewPopup() string { + var content string + switch m.popup { + case popupMetricsList: + content = m.viewMetricsPopup() + case popupPickDashboard: + content = m.viewDashboardPicker() + default: + content = m.viewTextInputPopup() + } + box := borderStyle.Padding(1, 2).Render(content) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) +} + +func (m *Model) viewTextInputPopup() string { + return fmt.Sprintf("%s\n\n%s\n\n[enter] submit [esc] cancel", popupTitle(m.popup), m.input.View()) +} + +func popupTitle(p popupKind) string { + switch p { + case popupSetTimeFrom: + return "Set relative \"from\" time (e.g. -1d12h)" + case popupSetAutorefresh: + return "Set autorefresh interval (seconds)" + case popupSetMaxDataPoints: + return "Set max datapoints (0 = unlimited)" + case popupAddTarget: + return "Add target" + case popupEditTarget: + return "Edit target" + case popupSaveDashboardName: + return "Save to dashboard (existing or new name)" + } + return "" +} + +func (m *Model) viewMetricsPopup() string { + var b strings.Builder + b.WriteString("Selected metrics\n\n") + if len(m.selectedMetrics) == 0 { + b.WriteString(faintStyle.Render("(none — press ctrl+a to add a target)")) + } + for i, target := range m.selectedMetrics { + style := lipgloss.NewStyle() + if i == m.metricsCursor { + style = style.Reverse(true) + } + b.WriteString(style.Render(target)) + b.WriteString("\n") + } + // The popup's help is always expanded — it's only four bindings, and + // the popup is sized to its content rather than the screen. + b.WriteString("\n" + helpText(viewComposer, popupMetricsList, m.drawMode, m.imageProtocol, 0, len(metricsPopupKeys), false)) + return b.String() +} + +func (m *Model) viewDashboardPicker() string { + var b strings.Builder + b.WriteString("Saved dashboards\n\n") + if len(m.dashboardNames) == 0 { + b.WriteString(faintStyle.Render("(none saved yet — press S in composer view to save one)")) + } + for i, name := range m.dashboardNames { + style := lipgloss.NewStyle() + if i == m.pickerCursor { + style = style.Reverse(true) + } + b.WriteString(style.Render(name)) + b.WriteString("\n") + } + b.WriteString("\n[enter] open [esc] cancel") + return b.String() +} + +func onOff(b bool) string { + if b { + return "on" + } + return "off" +} + +// helpBudgetRows is how many rows of help the layout is willing to spend, +// leaving the rest of the screen for the chart. helpText fits itself into +// this by adding columns. +func (m *Model) helpBudgetRows() int { + return clampMin(m.height/4, 1) +} + +// helpBoxHeight returns the border-inclusive height of the help box, sized +// to the text helpText actually produced. It must not be a guess: lipgloss +// pads to a declared height but never truncates, so a box declared shorter +// than its content silently overflows and pushes the frame off-screen. +func helpBoxHeight(help string) int { + return strings.Count(help, "\n") + 1 + 2 +} diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go new file mode 100644 index 0000000..fd5786d --- /dev/null +++ b/internal/tui/view_test.go @@ -0,0 +1,95 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/benwtr/terphite/internal/dashboard" + "github.com/benwtr/terphite/internal/termimg" +) + +// frameRows counts the rows a rendered frame occupies. Overlay escapes are +// appended after the last line and draw out-of-band, so they add no rows. +func frameRows(view string) int { + return strings.Count(view, "\n") + 1 +} + +func TestComposerFrameFitsTerminalHeight(t *testing.T) { + sizes := [][2]int{{80, 24}, {170, 40}, {200, 50}, {120, 30}} + for _, size := range sizes { + w, h := size[0], size[1] + for _, collapsed := range []bool{false, true} { + m := newTestModel(t) + m.width, m.height = w, h + m.helpCollapsed = collapsed + + if got := frameRows(m.viewComposerScreen()); got > h { + t.Errorf("%dx%d collapsed=%v: frame is %d rows, exceeds height %d", + w, h, collapsed, got, h) + } + } + } +} + +// The image-mode frame must be the same height as the ASCII one: the image +// is painted as an overlay rather than embedded, so it must not add rows. +// Getting this wrong pushed the layout off-screen and smeared old frames. +func TestComposerFrameHeightUnchangedByImageMode(t *testing.T) { + m := newTestModel(t) + m.width, m.height = 170, 40 + + ascii := frameRows(m.viewComposerScreen()) + + m.imageProtocol = termimg.ProtocolITerm2 + m.imageBytes = []byte("fake-png-bytes") + m.imageVersion = 1 + withImage := frameRows(m.viewComposerScreen()) + + if withImage != ascii { + t.Errorf("image mode changed frame height: %d rows vs %d in ascii mode", withImage, ascii) + } + if withImage > m.height { + t.Errorf("image-mode frame is %d rows, exceeds height %d", withImage, m.height) + } +} + +func TestComposerImageOverlayEmittedOnlyInImageMode(t *testing.T) { + m := newTestModel(t) + m.width, m.height = 170, 40 + + if strings.Contains(m.viewComposerScreen(), "\x1b7") { + t.Error("ascii mode should not emit an image overlay") + } + + m.imageProtocol = termimg.ProtocolITerm2 + m.imageBytes = []byte("fake-png-bytes") + m.imageVersion = 1 + if !strings.Contains(m.viewComposerScreen(), "\x1b7") { + t.Error("image mode should emit an image overlay") + } +} + +func TestDashboardFrameFitsTerminalHeight(t *testing.T) { + panels := []dashboard.Panel{ + {Title: "a", Targets: []string{"stats.a"}, TimeFrom: "-1h"}, + {Title: "b", Targets: []string{"stats.b"}, TimeFrom: "-1h"}, + {Title: "c", Targets: []string{"stats.c"}, TimeFrom: "-1h"}, + } + for _, size := range [][2]int{{80, 24}, {170, 40}, {200, 50}} { + w, h := size[0], size[1] + for _, proto := range []termimg.Protocol{termimg.ProtocolNone, termimg.ProtocolITerm2} { + m := newTestModel(t) + m.width, m.height = w, h + m.currentDashboard = &dashboard.Dashboard{Name: "d", Panels: panels} + m.panelImages = [][]byte{[]byte("png"), []byte("png"), []byte("png")} + m.panelImageVersions = []int{1, 1, 1} + m.panelImageCaches = make([]imageEscapeCache, len(panels)) + m.imageProtocol = proto + + if got := frameRows(m.viewDashboardScreen()); got > h { + t.Errorf("%dx%d proto=%v: dashboard frame is %d rows, exceeds height %d", + w, h, proto, got, h) + } + } + } +} diff --git a/lib/composer.js b/lib/composer.js deleted file mode 100644 index 451bcab..0000000 --- a/lib/composer.js +++ /dev/null @@ -1,470 +0,0 @@ -// Generated by CoffeeScript 1.10.0 -(function() { - var Terphite, _, blessed, contrib, k, path, ref, request, v, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; - - blessed = require('blessed'); - - contrib = require('blessed-contrib'); - - request = require('request'); - - path = require('path'); - - _ = require('lodash'); - - ref = require('./helpers'); - for (k in ref) { - v = ref[k]; - global[k] = v; - } - - module.exports = Terphite = (function() { - function Terphite(graphite_uri1) { - this.graphite_uri = graphite_uri1; - } - - Terphite.prototype.composer = function() { - var autorefresh, autorefresh_loop, autorefresh_time, fetchMetricData, functions_tree, graphite_uri, help, help_content, layout, line, loadMetricsTree, max_data_points, metricURI, metrics_popup, screen, selected_metrics, setStatus, status, statusString, target_popup, time_from, time_popup, toggleAutorefresh, tree; - selected_metrics = []; - time_from = '-1min'; - autorefresh_time = 10; - max_data_points = 300; - graphite_uri = this.graphite_uri; - autorefresh = 0; - screen = blessed.screen({ - smartCSR: true, - debug: true, - title: 'Graphite Browser', - warnings: true, - dockBorders: true, - ignoreDockContrast: true - }); - layout = blessed.layout({ - parent: screen, - width: '100%', - height: '100%', - border: 'line', - layout: 'grid', - style: { - bg: 'black', - border: { - fg: 'blue' - } - } - }); - tree = contrib.tree({ - parent: layout, - top: 0, - left: 0, - padding: 1, - width: '25%+1', - height: '80%-2', - template: { - lines: true - } - }); - functions_tree = contrib.tree({ - parent: screen, - top: 'center', - left: 'center', - padding: 1, - width: '80%', - height: '50%', - hidden: true, - template: { - lines: true - } - }); - statusString = function() { - return ["from: " + time_from, "autorefresh: " + (!autorefresh ? 'off' : autorefresh + 's'), "maxdatapoints: " + (!max_data_points ? 'unlimited' : max_data_points)].join(' '); - }; - setStatus = function() { - return status.setContent(statusString()); - }; - status = blessed.box({ - parent: layout, - top: 0, - left: 0, - height: 3, - width: '100%', - content: statusString(), - border: { - type: 'line', - fg: 'blue' - } - }); - screen.append(status); - help_content = ' [ decrease time 1min, { decrease time 1s\n'; - help_content += ' ] increase time 1min, } increase time 1s\n'; - help_content += ' t set relative "from" time, m metrics list popup, i set autorefresh interval\n'; - help_content += ' a autorefresh toggle, x set max datapoints\n'; - help_content += ' o open in browser, c copy graphite URI to clipboard (iTerm2 only)\n'; - help_content += 'Metrics List Keys:\n'; - help_content += ' C-a append new target, C-d delete selected, edit selected\n'; - help = blessed.box({ - parent: layout, - top: '80%-1', - left: 0, - height: '20%+2', - content: help_content, - border: { - type: 'line', - fg: 'blue' - } - }); - screen.append(help); - line = contrib.line({ - parent: layout, - left: '25%', - top: 2, - height: '80%-2', - width: '75%+1', - showLegend: true, - legend: { - width: 60 - }, - border: { - type: 'line', - fg: 'blue' - } - }); - screen.append(line); - time_popup = blessed.prompt({ - parent: layout, - left: 'center', - top: 'center', - width: '80%', - height: 8, - keys: true, - mouse: true, - style: { - fg: 'blue' - }, - border: { - type: 'line', - fg: 'gray' - } - }); - screen.append(time_popup); - metrics_popup = blessed.list({ - parent: layout, - hidden: true, - left: 'center', - top: 'center', - width: '90%', - height: 'half', - padding: 1, - interactive: true, - items: selected_metrics, - mouse: true, - keys: true, - tags: true, - style: { - bg: 'blue' - }, - border: { - type: 'line', - fg: 'gray' - } - }); - screen.append(metrics_popup); - target_popup = blessed.prompt({ - parent: layout, - left: 'center', - top: 'center', - width: '80%', - height: 8, - mouse: true, - keys: true, - style: { - fg: 'blue' - }, - border: { - type: 'line', - fg: 'gray' - } - }); - screen.append(target_popup); - loadMetricsTree = function() { - var createObject, options; - options = { - uri: graphite_uri + "/metrics/index.json", - json: true - }; - request(options, function(err, resp, body) { - var j, len, metric, metrics, obj; - metrics = {}; - for (j = 0, len = body.length; j < len; j++) { - metric = body[j]; - obj = createObject(metric); - _.merge(metrics, obj); - } - tree.setData({ - extended: true, - name: 'metrics', - path: 'metrics', - children: metrics - }); - return screen.render(); - }); - return createObject = function(key, original_key) { - var obj, original_parts, parts, remainingParts; - if (original_key == null) { - original_key = ''; - } - obj = {}; - parts = key.split('.'); - if (original_key === '') { - original_key = key; - } - original_parts = original_key.split('.'); - path = original_parts.slice(0, +(original_parts.length - parts.length) + 1 || 9e9).join('.'); - if (parts.length === 1) { - obj[parts[0]] = { - name: parts[0], - extended: false, - path: path, - leaf: true - }; - } else if (parts.length > 1) { - remainingParts = parts.slice(1, parts.length).join('.'); - obj[parts[0]] = { - name: parts[0], - extended: false, - path: path, - leaf: false, - children: createObject(remainingParts, original_key) - }; - } - return obj; - }; - }; - metricURI = function(metrics, from, params, opts) { - var extra, maxdp, target; - if (params == null) { - params = {}; - } - if (opts == null) { - opts = { - format: 'json' - }; - } - extra = ''; - if (opts.format === 'json') { - extra += '&format=json'; - } - maxdp = parseInt(opts.maxDataPoints, 10); - if (maxdp > 0) { - extra += "&maxDataPoints=" + maxdp; - } - target = ''; - if (metrics.length) { - target += '&target=' + metrics.join('&target='); - } - return graphite_uri + "/render?from=" + from + target + extra; - }; - fetchMetricData = function(metrics, from) { - var options; - options = { - uri: metricURI(metrics, from, {}, { - format: 'json', - maxDataPoints: max_data_points - }), - json: true - }; - return request(options, function(err, resp, body) { - var i, point, series, target, ts; - series = (function() { - var j, len, results; - results = []; - for (i = j = 0, len = body.length; j < len; i = ++j) { - target = body[i]; - results.push({ - title: target.target || '[unnamed_target]', - y: (function() { - var l, len1, ref1, results1; - ref1 = target.datapoints; - results1 = []; - for (l = 0, len1 = ref1.length; l < len1; l++) { - point = ref1[l]; - results1.push(point[0]); - } - return results1; - })(), - x: (function() { - var l, len1, ref1, results1; - ref1 = target.datapoints; - results1 = []; - for (l = 0, len1 = ref1.length; l < len1; l++) { - point = ref1[l]; - ts = new Date(point[1] * 1000); - results1.push((ts.getHours()) + ":" + (format_twodigit(ts.getUTCMinutes()))); - } - return results1; - })(), - style: { - line: colors[i % 15] - } - }); - } - return results; - })(); - if (series.length === 0) { - series = [ - { - title: 'no data', - x: [], - y: [] - } - ]; - } - line.setData(series); - return screen.render(); - }); - }; - tree.on('select', function(node) { - path = node.path; - if (indexOf.call(selected_metrics, path) >= 0) { - selected_metrics = selected_metrics.filter(function(metric) { - return metric !== path; - }); - } else if (node.leaf) { - selected_metrics.push(path); - } - return fetchMetricData(selected_metrics, time_from); - }); - screen.key(['['], function(ch, key) { - var t; - t = (parse_time_to_i(time_from)) - MIN; - time_from = t < MIN ? '-1min' : seconds_to_time_string(t); - setStatus(); - return fetchMetricData(selected_metrics, time_from); - }); - screen.key([']'], function(ch, key) { - var t; - t = (parse_time_to_i(time_from)) + MIN; - time_from = seconds_to_time_string(t); - setStatus(); - return fetchMetricData(selected_metrics, time_from); - }); - screen.key(['{'], function(ch, key) { - var t; - t = (parse_time_to_i(time_from)) - H; - time_from = t < H ? '-1h' : seconds_to_time_string(t); - setStatus(); - return fetchMetricData(selected_metrics, time_from); - }); - screen.key(['}'], function(ch, key) { - var t; - t = (parse_time_to_i(time_from)) + H; - time_from = seconds_to_time_string(t); - setStatus(); - return fetchMetricData(selected_metrics, time_from); - }); - screen.key(['t'], function(ch, key) { - return time_popup.input('set relative _from_ time (y, mon, w, d, h, min, s)\n eg: -1d12h', time_from, function(err, value) { - var d, h, min, mon, ref1, s, w, y; - ref1 = parse_time(value), y = ref1[0], mon = ref1[1], w = ref1[2], d = ref1[3], h = ref1[4], min = ref1[5], s = ref1[6]; - time_from = get_time_string(y, mon, w, d, h, min, s); - setStatus(); - return fetchMetricData(selected_metrics, time_from); - }); - }); - screen.key(['m'], function(ch, key) { - metrics_popup.setItems(selected_metrics); - metrics_popup.focus(); - metrics_popup.show(); - return screen.render(); - }); - metrics_popup.on('select', function(item, select) { - return target_popup.input('Edit target', item.getText(), function(err, value) { - selected_metrics[select] = value; - metrics_popup.setItems(selected_metrics); - return screen.render(); - }); - }); - metrics_popup.key('C-d', function(ch, key) { - selected_metrics.splice(metrics_popup.selected, 1); - metrics_popup.setItems(selected_metrics); - return fetchMetricData(selected_metrics, time_from); - }); - metrics_popup.key('C-a', function(ch, key) { - return target_popup.input('Add target', '', function(err, value) { - selected_metrics.push(value); - metrics_popup.setItems(selected_metrics); - return fetchMetricData(selected_metrics, time_from); - }); - }); - metrics_popup.key(['escape'], function(ch, key) { - metrics_popup.hide(); - tree.focus(); - return fetchMetricData(selected_metrics, time_from); - }); - screen.key(['c'], function(ch, key) { - screen.cursorReset(); - screen.copyToClipboard(metricURI(selected_metrics, time_from, {}, {})); - screen.realloc(); - return screen.render(); - }); - screen.key(['o'], function(ch, key) { - return screen.exec('open', [metricURI(selected_metrics, time_from, {}, {})]); - }); - autorefresh_loop = null; - toggleAutorefresh = function() { - if (!autorefresh) { - autorefresh = autorefresh_time; - setStatus(); - screen.render(); - return autorefresh_loop = setInterval(function() { - return fetchMetricData(selected_metrics, time_from); - }, autorefresh * 1000); - } else { - autorefresh = 0; - clearInterval(autorefresh_loop); - setStatus(); - screen.render(); - return autorefresh_loop = null; - } - }; - screen.key(['a'], function(ch, key) { - return toggleAutorefresh(); - }); - screen.key(['i'], function(ch, key) { - return time_popup.input('set autorefresh interval time (seconds)', autorefresh_time.toString(), function(e, v) { - var t; - t = parseInt(v); - autorefresh_time = t > 0 ? t : 1; - if (autorefresh) { - clearInterval(autorefresh_loop); - } - autorefresh = autorefresh_time; - setStatus(); - screen.render(); - return autorefresh_loop = setInterval(function() { - return fetchMetricData(selected_metrics, time_from); - }, autorefresh * 1000); - }); - }); - screen.key(['x'], function(ch, key) { - return time_popup.input('set max datapoints for graphite api to return in json response\n 0 = unlimited', max_data_points.toString(), function(e, v) { - var p; - p = parseInt(v); - max_data_points = p < 0 ? 0 : p; - setStatus(); - return fetchMetricData(selected_metrics, time_from); - }); - }); - screen.key(['q', 'C-c'], function(ch, key) { - return process.exit(0); - }); - tree.focus(); - loadMetricsTree(); - return fetchMetricData(selected_metrics, time_from); - }; - - return Terphite; - - })(); - -}).call(this); diff --git a/lib/helpers.js b/lib/helpers.js deleted file mode 100644 index 3fec6b3..0000000 --- a/lib/helpers.js +++ /dev/null @@ -1,120 +0,0 @@ -// Generated by CoffeeScript 1.10.0 -(function() { - module.exports = { - S: 1, - MIN: 60, - H: 60 * 60, - D: 60 * 60 * 24, - W: 60 * 60 * 24 * 7, - MON: 60 * 60 * 24 * 30, - Y: 60 * 60 * 24 * 365, - format_twodigit: function(n) { - if (n.toString().length < 2) { - return '0' + n.toString(); - } else { - return n; - } - }, - get_time_string: function(y, mon, w, d, h, min, s) { - var str; - str = "-"; - if (y) { - str += y + "y"; - } - if (mon) { - str += mon + "mon"; - } - if (w) { - str += w + "w"; - } - if (d) { - str += d + "d"; - } - if (h) { - str += h + "h"; - } - if (min) { - str += min + "min"; - } - if (s) { - str += s + "s"; - } - if (str === '-') { - str = '-1min'; - } - return str; - }, - parse_time: function(time) { - var d, h, min, mon, s, time_ary, w, y; - time_ary = /\-?(([0-9]+)y)?(([0-9]+)mon)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)min)?(([0-9]+)s)?/.exec(time); - y = time_ary[2] || 0; - mon = time_ary[4] || 0; - w = time_ary[6] || 0; - d = time_ary[8] || 0; - h = time_ary[10] || 0; - min = time_ary[12] || 0; - s = time_ary[14] || 0; - return [y, mon, w, d, h, min, s]; - }, - parse_time_to_i: function(time) { - var d, h, min, mon, s, seconds, time_ary, w, y; - time_ary = /\-?(([0-9]+)y)?(([0-9]+)mon)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)min)?(([0-9]+)s)?/.exec(time); - y = time_ary[2] || 0; - mon = time_ary[4] || 0; - w = time_ary[6] || 0; - d = time_ary[8] || 0; - h = time_ary[10] || 0; - min = time_ary[12] || 0; - s = time_ary[14] || 0; - seconds = s * S; - seconds += min * MIN; - seconds += h * H; - seconds += d * D; - seconds += w * W; - seconds += mon * MON; - seconds += y * Y; - return parseInt(seconds); - }, - seconds_to_time_string: function(seconds) { - var d, h, min, mon, s, str, w, y; - str = '-'; - if (seconds > Y) { - y = parseInt(seconds / Y); - seconds = seconds - y * Y; - str += y + "y"; - } - if (seconds > MON) { - mon = parseInt(seconds / MON); - seconds = seconds - mon * MON; - str += mon + "mon"; - } - if (seconds > W) { - w = parseInt(seconds / W); - seconds = seconds - w * W; - str += w + "w"; - } - if (seconds > D) { - d = parseInt(seconds / D); - seconds = seconds - d * D; - str += d + "d"; - } - if (seconds > H) { - h = parseInt(seconds / H); - seconds = seconds - h * H; - str += h + "h"; - } - if (seconds > MIN) { - min = parseInt(seconds / MIN); - seconds = seconds - min * MIN; - str += min + "min"; - } - if (seconds) { - s = seconds; - str += s + "s"; - } - return str; - }, - colors: ["red", "green", "yellow", "blue", "magenta", "cyan", "white", "lightblack", "lightred", "lightgreen", "lightyellow", "lightblue", "lightmagenta", "lightcyan", "lightwhite"] - }; - -}).call(this); diff --git a/package.json b/package.json deleted file mode 100644 index f42f800..0000000 --- a/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "terphite", - "version": "0.0.3", - "description": "Browse and display Graphite graphs in terminal", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "prepublish": "cake build" - }, - "bin": { - "terphite": "./bin/terphite" - }, - "repository": { - "type": "git", - "url": "https://github.com/benwtr/terphite.git" - }, - "keywords": [ - "graphite", - "graph", - "blessed-contrib" - ], - "author": "benwtr ", - "license": "MIT", - "bugs": { - "url": "https://github.com/benwtr/terphite/issues" - }, - "homepage": "https://github.com/benwtr/terphite#readme", - "dependencies": { - "blessed": "https://github.com/benwtr/blessed#e7a82bdc8593c6a9ef03ec18fafdc9e061aedc83", - "blessed-contrib": "2.5.x", - "lodash": "^4.5.0", - "request": "^2.69.0" - }, - "devDependencies": { - "coffee-script": "^1.10.0" - } -} diff --git a/readme.md b/readme.md index ac99f69..e9ec488 100644 --- a/readme.md +++ b/readme.md @@ -1,51 +1,104 @@ # Terphite -This is a toy/experimental Console [Graphite](http://graphite.readthedocs.org/) Browser loosely based on Graphite Composer. +A terminal [Graphite](http://graphite.readthedocs.org/) browser, loosely +based on Graphite Composer. Browse the metrics tree, build a graph, and +save sets of graphs as dashboards you can view together as a grid. -It uses [blessed](https://github.com/chjj/blessed) and [blessed-contrib](https://github.com/yaronn/blessed-contrib) to do all the heavy lifting. *blessed-contrib* is a library for building console dashboards, it provides the tree and graph widgets. *blessed* is the UI toolkit, it has a DOM-like API and is surprisingly easy to work with. +Written in Go using [bubbletea](https://github.com/charmbracelet/bubbletea), +[lipgloss](https://github.com/charmbracelet/lipgloss), and +[bubbles](https://github.com/charmbracelet/bubbles). -Next steps might be to add the _Graph Options_ and _Apply Function_ features from Composer. And make a dashboard view a la [blessed-graphite](https://github.com/lovehandle/blessed-graphite) that can display and save a grid of graphs. - -#### Demo Screencast -![](http://i.imgur.com/l8LbbrG.gif) - -##### Reactions to the Screencast :-) -> grubernaut [4:37 PM] -holy shit - -> obfuscurity [8:37 AM] -whoa wtf - ->obfuscurity [8:37 AM] -that’s better than the real thing lol +> **This is 100% vibe coded.** Every line of the Go rewrite was written by an +> LLM. It builds, the tests pass, and it's been smoke tested against a mock +> Graphite server — but it has never run against a real production Graphite +> instance, and no human has reviewed the code line by line. Treat it +> accordingly. ### Install - npm install -g terphite - -### Usage - - terphite http://user:pass@your.graphite.com:1234 - -### Install and run from source +``` +go install github.com/benwtr/terphite/cmd/terphite@latest +``` - git clone git@github.com:benwtr/terphite.git - cd terphite - npm install - ./bin/terphite http://your.graphite.com - -#### Getting started with this code (for people unfamiliar with CoffeeScript) - -The code in `src/` is CoffeeScript, it gets compiled to JavaScript and output to `lib/`. - -To compile the CoffeeScript source: - - cake build - -Or watch source for changes and compile when modified: - - cake watch - -Or, if you don't like CoffeeScript, just edit the JS directly. :-) +Or download a prebuilt binary from the +[releases page](https://github.com/benwtr/terphite/releases). +### Usage +``` +terphite http://your.graphite.com:1234 +``` + +Credentials can be embedded in the URL (`http://user:pass@host:1234`) or +supplied via the `GRAPHITE_USER` / `GRAPHITE_PASS` environment variables. + +### Keys + +Composer view: + +| Key | Action | +| --- | --- | +| `↑`/`↓`, `enter` | move the metrics tree cursor, select a metric or expand/collapse a branch | +| `[` / `]` | decrease / increase the time range by 1 minute | +| `{` / `}` | decrease / increase the time range by 1 hour | +| `t` | set a relative "from" time (e.g. `-1d12h`) | +| `m` | open the selected-metrics popup (`ctrl+a` add, `ctrl+d` delete, `enter` edit, `esc` close) | +| `i` | set the autorefresh interval, in seconds | +| `a` | toggle autorefresh | +| `x` | set max datapoints (`0` = unlimited) | +| `o` | open the current graph in a browser | +| `c` | copy the current graph's URL to the clipboard | +| `S` | save the current graph as a panel on a dashboard | +| `D` | open a saved dashboard | +| `g` | cycle graph style: line / area / stacked | +| `I` | toggle graphical mode: off / iterm2 / kitty (off by default) | +| `l` | redraw the screen | +| `?` | collapse/expand the help bar | +| `q` / `ctrl+c` | quit | + +Charts render with braille sub-character resolution for smooth connected +lines. `g` cycles between plain lines, independent filled areas per series, +and a cumulative stacked area (like Graphite's `areaMode=stacked`). Each +saved dashboard panel remembers its own graph style. + +Dashboard view (a grid of saved graphs): + +| Key | Action | +| --- | --- | +| `←`/`→`/`↑`/`↓` | move focus between panels | +| `enter` | load the focused panel back into the composer view for editing | +| `ctrl+d` | remove the focused panel | +| `a` | toggle autorefresh for all panels | +| `esc` | back to composer view | +| `q` / `ctrl+c` | quit | + +Dashboards are saved as JSON files under `$XDG_CONFIG_HOME/terphite/dashboards` +(typically `~/.config/terphite/dashboards` on Linux and +`~/Library/Application Support/terphite/dashboards` on macOS). + +### Graphical mode + +In terminals that support inline images — iTerm2, WezTerm, Kitty, Ghostty — +terphite can display Graphite's own rendered PNGs instead of ASCII charts, +giving you graphite-web's real axis labels, legends, and gridlines. + +It is **off by default**, since whether it works depends on terminal support +that can't be detected reliably (tmux and SSH in particular don't always +relay the escape sequences). Press `I` to turn it on: the first press picks +whichever protocol your terminal looks like it supports, and pressing again +cycles through the others in case that guess is wrong. + +The image is painted as an overlay on top of a blank region reserved in the +layout, rather than embedded in it — an image escape sequence is a single +line of text but many rows on screen, and embedding one directly makes +everything below it slide off the bottom of the terminal. + +### Developing + +``` +git clone https://github.com/benwtr/terphite.git +cd terphite +go build ./... +go test ./... +go run ./cmd/terphite http://your.graphite.com:1234 +``` diff --git a/src/composer.coffee b/src/composer.coffee deleted file mode 100644 index f7cced3..0000000 --- a/src/composer.coffee +++ /dev/null @@ -1,412 +0,0 @@ -blessed = require 'blessed' -contrib = require 'blessed-contrib' -request = require 'request' -path = require 'path' -_ = require 'lodash' - -global[k] = v for k,v of require './helpers' - - -module.exports = class Terphite - constructor: (@graphite_uri) -> - - composer: -> - - selected_metrics = [] - time_from = '-1min' - autorefresh_time = 10 # default autorefresh time in seconds - max_data_points = 300 - - graphite_uri = @graphite_uri - autorefresh = 0 - - screen = blessed.screen { - smartCSR: true - debug: true # F12 to open debug popup - title: 'Graphite Browser' - warnings: true - dockBorders: true - ignoreDockContrast: true - } - - layout = blessed.layout { - parent: screen - width: '100%' - height: '100%' - border: 'line' - layout: 'grid' - style: - bg: 'black', - border: - fg: 'blue' - } - - tree = contrib.tree { - parent: layout - #label: 'Metrics Browser' - top: 0 - left: 0 - padding: 1 - width: '25%+1' - height: '80%-2' - template: - lines: true - } - - functions_tree = contrib.tree { - parent: screen - top: 'center' - left: 'center' - padding: 1 - width: '80%' - height: '50%' - hidden: true - template: - lines: true - } - - statusString = -> - [ - "from: #{time_from}" - "autorefresh: #{if !autorefresh then 'off' else autorefresh + 's'}" - "maxdatapoints: #{if !max_data_points then 'unlimited' else max_data_points}" - ].join(' ') - - setStatus = -> status.setContent statusString() - - status = blessed.box { - parent: layout - top: 0 - left: 0 - height: 3 - width: '100%' - # padding: 1 - content: statusString() - border: - type: 'line' - fg: 'blue' - } - screen.append(status) - - help_content = ' [ decrease time 1min, { decrease time 1s\n' - help_content += ' ] increase time 1min, } increase time 1s\n' - help_content += ' t set relative "from" time, m metrics list popup, i set autorefresh interval\n' - help_content += ' a autorefresh toggle, x set max datapoints\n' - help_content += ' o open in browser, c copy graphite URI to clipboard (iTerm2 only)\n' - help_content += 'Metrics List Keys:\n' - help_content += ' C-a append new target, C-d delete selected, edit selected\n' - - help = blessed.box { - parent: layout - top: '80%-1' - left: 0 - height: '20%+2' - content: help_content - border: - type: 'line' - fg: 'blue' - } - screen.append(help) - - line = contrib.line { - parent: layout - #label: 'Graph' - left: '25%' - top: 2 - height: '80%-2' - width: '75%+1' - showLegend: true - legend: - width: 60 - border: - type: 'line' - fg: 'blue' - } - screen.append(line) - - time_popup = blessed.prompt { - parent: layout - left: 'center' - top: 'center' - width: '80%' - height: 8 - keys: true - mouse: true - style: - fg: 'blue' - border: - type: 'line' - fg: 'gray' - } - screen.append time_popup - - metrics_popup = blessed.list { - parent: layout - hidden: true - left: 'center' - top: 'center' - width: '90%' - height: 'half' - padding: 1 - interactive: true - items: selected_metrics - mouse: true - keys: true - tags: true - style: - #fg: 'blue' - bg: 'blue' - border: - type: 'line' - fg: 'gray' - } - screen.append metrics_popup - - target_popup = blessed.prompt { - parent: layout - left: 'center' - top: 'center' - width: '80%' - height: 8 - mouse: true - keys: true - style: - fg: 'blue' - border: - type: 'line' - fg: 'gray' - } - screen.append target_popup - - loadMetricsTree = -> - options = { - uri: "#{graphite_uri}/metrics/index.json" - json: true - } - request options, (err, resp, body) -> - metrics = {} - for metric in body - obj = createObject metric - _.merge metrics, obj - tree.setData( - extended: true - name: 'metrics' - path: 'metrics' - children: metrics - ) - screen.render() - - createObject = (key, original_key = '') -> - obj = {} - parts = key.split('.') - original_key = key if original_key == '' - original_parts = original_key.split('.') - path = original_parts[0..original_parts.length-parts.length].join('.') - if (parts.length == 1) - # leaf - obj[parts[0]] = - name: parts[0] - extended: false - path: path - leaf: true - else if(parts.length > 1) - remainingParts = parts.slice(1,parts.length).join('.') - obj[parts[0]] = - name: parts[0] - extended: false - path: path - leaf: false - children: createObject(remainingParts, original_key) - return obj - - metricURI = (metrics, from, params = {}, opts = {format:'json'}) -> - extra = '' - extra += '&format=json' if opts.format == 'json' - maxdp = parseInt(opts.maxDataPoints,10) - extra += "&maxDataPoints=#{maxdp}" if maxdp > 0 - target = '' - target += '&target=' + metrics.join('&target=') if metrics.length - "#{graphite_uri}/render?from=#{from}#{target}#{extra}" - - fetchMetricData = (metrics, from) -> - options = - uri: metricURI metrics, from, {}, { - format: 'json' - maxDataPoints: max_data_points - } - json: true - request options, (err, resp, body) -> - series = for target,i in body - title: target.target || '[unnamed_target]' - y: (point[0] for point in target.datapoints) - x: for point in target.datapoints - ts = new Date(point[1]*1000) - "#{ts.getHours()}:#{format_twodigit(ts.getUTCMinutes())}" - style: { line: colors[i%15] } - if series.length == 0 - series = [{ title: 'no data', x: [], y: [] }] - line.setData(series) - screen.render() - - tree.on 'select', (node) -> - path = node.path - if path in selected_metrics - selected_metrics = selected_metrics.filter (metric) -> metric isnt path - else if node.leaf - selected_metrics.push path - fetchMetricData selected_metrics, time_from - - screen.key ['['], (ch, key) -> - t = ( parse_time_to_i time_from ) - MIN - time_from = if t < MIN then '-1min' else seconds_to_time_string t - setStatus() - fetchMetricData(selected_metrics, time_from) - - screen.key [']'], (ch, key) -> - t = ( parse_time_to_i time_from ) + MIN - time_from = seconds_to_time_string t - setStatus() - fetchMetricData(selected_metrics, time_from) - - screen.key ['{'], (ch, key) -> - t = ( parse_time_to_i time_from ) - H - time_from = if t < H then '-1h' else seconds_to_time_string t - setStatus() - fetchMetricData(selected_metrics, time_from) - - screen.key ['}'], (ch, key) -> - t = ( parse_time_to_i time_from ) + H - time_from = seconds_to_time_string t - setStatus() - fetchMetricData(selected_metrics, time_from) - - screen.key ['t'], (ch, key) -> - time_popup.input( - 'set relative _from_ time (y, mon, w, d, h, min, s)\n eg: -1d12h', - time_from, - (err, value) -> - [ y, mon, w, d, h, min, s ] = parse_time value - time_from = get_time_string y, mon, w, d, h, min, s - setStatus() - fetchMetricData selected_metrics, time_from - ) - - screen.key ['m'], (ch, key) -> - metrics_popup.setItems selected_metrics - metrics_popup.focus() - metrics_popup.show() - screen.render() - - metrics_popup.on 'select', (item, select) -> - target_popup.input( - 'Edit target', - item.getText(), - (err, value) -> - selected_metrics[select] = value - metrics_popup.setItems selected_metrics - screen.render() - ) - - metrics_popup.key 'C-d', (ch, key) -> - selected_metrics.splice(metrics_popup.selected, 1) - metrics_popup.setItems selected_metrics - fetchMetricData selected_metrics, time_from - - metrics_popup.key 'C-a', (ch, key) -> - target_popup.input( - 'Add target', - '', - (err, value) -> - selected_metrics.push value - metrics_popup.setItems selected_metrics - fetchMetricData selected_metrics, time_from - ) - - metrics_popup.key ['escape'], (ch, key) -> - metrics_popup.hide() - tree.focus() - fetchMetricData selected_metrics, time_from - - # screen.key ['f'], (ch, key) -> - # functions_tree.setData( - # extended: true - # name: 'foo' - # children: - # 'bar': - # extended: false - # name: 'bar' - # 'stuff': - # extended: false - # name: 'stuff' - # ) - # functions_tree.focus() - # functions_tree.setFront() - # functions_tree.show() - # screen.render() - - screen.key ['c'], (ch, key) -> - screen.cursorReset() - screen.copyToClipboard(metricURI selected_metrics, time_from, {}, {}) - screen.realloc() - screen.render() - - screen.key ['o'], (ch, key) -> - screen.exec 'open', [(metricURI selected_metrics, time_from, {}, {})] - - autorefresh_loop = null - - toggleAutorefresh = -> - if !autorefresh - autorefresh = autorefresh_time - setStatus() - screen.render() - autorefresh_loop = setInterval( -> - fetchMetricData selected_metrics, time_from - , autorefresh * 1000) - else - autorefresh = 0 - clearInterval autorefresh_loop - setStatus() - screen.render() - autorefresh_loop = null - - screen.key ['a'], (ch, key) -> - toggleAutorefresh() - - screen.key ['i'], (ch, key) -> - time_popup.input( - 'set autorefresh interval time (seconds)', - autorefresh_time.toString(), - (e, v) -> - t = parseInt(v) - autorefresh_time = if t > 0 then t else 1 - if autorefresh - clearInterval autorefresh_loop - autorefresh = autorefresh_time - setStatus() - screen.render() - autorefresh_loop = setInterval( -> - fetchMetricData selected_metrics, time_from - , autorefresh * 1000) - ) - - screen.key ['x'], (ch, key) -> - time_popup.input( - 'set max datapoints for graphite api to return in json response\n 0 = unlimited', - max_data_points.toString(), - (e,v) -> - p = parseInt(v) - max_data_points = if p < 0 then 0 else p - setStatus() - fetchMetricData selected_metrics, time_from - ) - - screen.key(['q', 'C-c'], (ch, key) -> - process.exit(0) - ) - - - tree.focus() - loadMetricsTree() - fetchMetricData selected_metrics, time_from - diff --git a/src/helpers.coffee b/src/helpers.coffee deleted file mode 100644 index be8c436..0000000 --- a/src/helpers.coffee +++ /dev/null @@ -1,103 +0,0 @@ -module.exports = - - # for calculating time in seconds - S: 1 - MIN: 60 - H: 60 * 60 - D: 60 * 60 * 24 - W: 60 * 60 * 24 * 7 - MON: 60 * 60 * 24 * 30 - Y: 60 * 60 * 24 * 365 - - format_twodigit: (n) -> - if n.toString().length < 2 then '0' + n.toString() else n - - get_time_string: (y, mon, w, d, h, min, s) -> - str = "-" - str += "#{y}y" if y - str += "#{mon}mon" if mon - str += "#{w}w" if w - str += "#{d}d" if d - str += "#{h}h" if h - str += "#{min}min" if min - str += "#{s}s" if s - str = '-1min' if str == '-' - str - - parse_time: (time) -> - time_ary = /\-?(([0-9]+)y)?(([0-9]+)mon)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)min)?(([0-9]+)s)?/.exec time - y = time_ary[2] || 0 - mon = time_ary[4] || 0 - w = time_ary[6] || 0 - d = time_ary[8] || 0 - h = time_ary[10] || 0 - min = time_ary[12] || 0 - s = time_ary[14] || 0 - [y, mon, w, d, h, min, s] - - parse_time_to_i: (time) -> - time_ary = /\-?(([0-9]+)y)?(([0-9]+)mon)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)min)?(([0-9]+)s)?/.exec time - y = time_ary[2] || 0 - mon = time_ary[4] || 0 - w = time_ary[6] || 0 - d = time_ary[8] || 0 - h = time_ary[10] || 0 - min = time_ary[12] || 0 - s = time_ary[14] || 0 - seconds = s * S - seconds += min * MIN - seconds += h * H - seconds += d * D - seconds += w * W - seconds += mon * MON - seconds += y * Y - parseInt seconds - - seconds_to_time_string: (seconds) -> - str = '-' - if seconds > Y - y = parseInt(seconds / Y) - seconds = seconds - y * Y - str += "#{y}y" - if seconds > MON - mon = parseInt(seconds / MON) - seconds = seconds - mon * MON - str += "#{mon}mon" - if seconds > W - w = parseInt(seconds / W) - seconds = seconds - w * W - str += "#{w}w" - if seconds > D - d = parseInt(seconds / D) - seconds = seconds - d * D - str += "#{d}d" - if seconds > H - h = parseInt(seconds / H) - seconds = seconds - h * H - str += "#{h}h" - if seconds > MIN - min = parseInt(seconds / MIN) - seconds = seconds - min * MIN - str += "#{min}min" - if seconds - s = seconds - str += "#{s}s" - str - - colors: [ - "red", - "green", - "yellow", - "blue", - "magenta", - "cyan", - "white", - "lightblack", - "lightred", - "lightgreen", - "lightyellow", - "lightblue", - "lightmagenta", - "lightcyan", - "lightwhite" - ]