From 55ee64530b5818325d5d6b3013e6ce64b0612321 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Thu, 21 May 2026 13:28:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Stage=201=20scaffold=20=E2=80=94=20conf?= =?UTF-8?q?ig,=20API=20client,=20placeholder=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets up the full project structure following the hactl/gokapi-tui architecture: strict Elm-pattern Bubbletea TUI, YAML config at ~/.config/terdut-tui/config.yaml, REST API client with Bearer auth, self-update via GitHub Releases, and a three-section tab placeholder (Alerts / Schedule / Users) that verifies server connectivity on startup. --- .gitignore | 2 + CLAUDE.md | 68 +++++++++++++++++ README.md | 53 +++++++++++++ go.mod | 30 ++++++++ go.sum | 51 +++++++++++++ internal/api/client.go | 76 +++++++++++++++++++ internal/config/config.go | 63 ++++++++++++++++ internal/tui/keys.go | 41 ++++++++++ internal/tui/model.go | 89 ++++++++++++++++++++++ internal/tui/styles.go | 41 ++++++++++ internal/tui/update.go | 54 ++++++++++++++ internal/tui/view.go | 87 ++++++++++++++++++++++ internal/updater/updater.go | 145 ++++++++++++++++++++++++++++++++++++ main.go | 49 ++++++++++++ 14 files changed, 849 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/client.go create mode 100644 internal/config/config.go create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/update.go create mode 100644 internal/tui/view.go create mode 100644 internal/updater/updater.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3f5f32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +terdut-tui +.graymatter/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..226c027 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# terdut-tui + +TUI client for [terdut-server](https://github.com/terdut-server), a Prometheus Alertmanager receiver and on-call scheduler. + +## Tech stack + +- Go 1.25+ +- [Bubbletea](https://github.com/charmbracelet/bubbletea) — TUI framework (strict Elm architecture) +- [Lipgloss](https://github.com/charmbracelet/lipgloss) — styles (all in `internal/tui/styles.go`, never inline) +- [Bubbles](https://github.com/charmbracelet/bubbles) — table, textinput, help components + +## Project layout + +``` +main.go CLI entry point: flags, config load, health check, start TUI +internal/api/client.go REST API client — one method per endpoint +internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml) +internal/tui/ Bubbletea UI + model.go Model struct, mode/section constants, Init(), tea.Cmd constructors + update.go Update() — dispatch only, no API calls inline + view.go View() — pure rendering + keys.go keyMap (bubbles/key pattern) + styles.go All lipgloss styles +internal/updater/updater.go Self-update via GitHub Releases +``` + +## Architecture rules + +1. **Never call API inside `Update()`** — return `tea.Cmd` instead; the runtime runs it async. +2. **`View()` is pure** — no side effects, no state mutations. +3. **All state in `Model`** — no globals. +4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`. + +## Config + +Location: `~/.config/terdut-tui/config.yaml` + +```yaml +server_url: https://terdut.example.com +api_key: <64-char hex key> +refresh_interval: 30 # seconds, optional, default 30 +``` + +The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`). + +## Running + +```bash +go run . +go run . --version +go run . --self-update +``` + +## Building + +```bash +go build -ldflags="-X main.version=v0.1.0" -o terdut-tui . +``` + +## Development stages + +| Stage | Feature | +|-------|---------| +| 1 | Scaffold, config, health check, placeholder TUI | +| 2 | Alert dashboard with auto-refresh and stats | +| 3 | Alert detail: acknowledge, comment, statistics charts | +| 4 | On-call schedule calendar view | +| 5 | User management and API key lifecycle | diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d8d65a --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# terdut-tui + +A terminal user interface for [terdut-server](https://github.com/terdut-server). Communicates with the server over its REST API. + +Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea). + +## Features + +- **Alert dashboard** — live view of firing and resolved alerts with auto-refresh +- **Alert actions** — acknowledge, comment, and view per-alert statistics +- **On-call schedule** — visual calendar of who is on duty, assign and remove entries +- **User management** — add and remove users, manage API keys + +## Installation + +Download the latest release binary for your platform from the [releases page](https://github.com/yeniklas/terdut-tui/releases), or build from source: + +```bash +go install github.com/yeniklas/terdut-tui@latest +``` + +## Configuration + +Create `~/.config/terdut-tui/config.yaml`: + +```yaml +server_url: https://terdut.example.com +api_key: +refresh_interval: 30 # seconds, optional +``` + +The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key. + +## Usage + +``` +terdut-tui start the TUI +terdut-tui --version print version +terdut-tui --self-update update to the latest release +``` + +### Keybindings + +| Key | Action | +|-----|--------| +| `j` / `↓` | Move down | +| `k` / `↑` | Move up | +| `tab` | Switch section (Alerts / Schedule / Users) | +| `enter` | Select / open detail | +| `esc` | Go back | +| `r` | Refresh | +| `f` | Filter / cycle filter | +| `q` | Quit | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e5fde00 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/yeniklas/terdut-tui + +go 1.25.9 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // 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 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dfc953c --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +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/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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..497152e --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,76 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type Client struct { + baseURL string + httpClient *http.Client + apiKey string +} + +func NewClient(baseURL, apiKey string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *Client) newRequest(method, path string) (*http.Request, error) { + req, err := http.NewRequest(method, c.baseURL+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + return req, nil +} + +func (c *Client) do(req *http.Request, out any) error { + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var e struct { + Error string `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&e) + if e.Error != "" { + return fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error) + } + return fmt.Errorf("server returned %d", resp.StatusCode) + } + + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway). +func (c *Client) HealthCheck() error { + req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil) + if err != nil { + return err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("health check failed: %s", resp.Status) + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..92273a1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,63 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +const defaultRefreshInterval = 30 * time.Second + +type Config struct { + ServerURL string + APIKey string + RefreshInterval time.Duration +} + +type rawConfig struct { + ServerURL string `yaml:"server_url"` + APIKey string `yaml:"api_key"` + RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds +} + +func Load() (*Config, error) { + dir, err := os.UserConfigDir() + if err != nil { + return nil, fmt.Errorf("cannot determine config directory: %w", err) + } + + path := filepath.Join(dir, "terdut-tui", "config.yaml") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: ", path) + } + return nil, fmt.Errorf("cannot read config file: %w", err) + } + + var raw rawConfig + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("invalid config file: %w", err) + } + + if raw.ServerURL == "" { + return nil, fmt.Errorf("config: 'server_url' is required") + } + if raw.APIKey == "" { + return nil, fmt.Errorf("config: 'api_key' is required") + } + + interval := defaultRefreshInterval + if raw.RefreshInterval > 0 { + interval = time.Duration(raw.RefreshInterval) * time.Second + } + + return &Config{ + ServerURL: raw.ServerURL, + APIKey: raw.APIKey, + RefreshInterval: interval, + }, nil +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..b92537a --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,41 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Tab key.Binding + Enter key.Binding + Escape key.Binding + Refresh key.Binding + Filter key.Binding + Quit key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("◀/h", "prev")), + Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("▶/l", "next")), + Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch section")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Tab, k.Refresh, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right}, + {k.Tab, k.Enter, k.Escape}, + {k.Refresh, k.Filter, k.Quit}, + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..785a5cf --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,89 @@ +package tui + +import ( + "time" + + "github.com/charmbracelet/bubbles/help" + tea "github.com/charmbracelet/bubbletea" + "github.com/yeniklas/terdut-tui/internal/api" +) + +type section int + +const ( + sectionAlerts section = iota + sectionSchedule + sectionUsers +) + +type mode int + +const ( + modeDashboard mode = iota + modeDetail + modeSchedule + modeUsers +) + +// tea.Msg types + +type connectedMsg struct{ serverURL string } +type connectErrMsg struct{ err error } +type tickMsg time.Time +type clearStatusMsg struct{} + +// Model holds all UI state. +type Model struct { + client *api.Client + serverURL string + refreshInterval time.Duration + + activeSection section + mode mode + width int + height int + + connected bool + err error + statusMsg string + + help help.Model + keys keyMap +} + +func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model { + return Model{ + client: client, + serverURL: serverURL, + refreshInterval: refreshInterval, + activeSection: sectionAlerts, + mode: modeDashboard, + help: help.New(), + keys: keys, + } +} + +func (m Model) Init() tea.Cmd { + return connectCmd(m.client) +} + +func connectCmd(client *api.Client) tea.Cmd { + return func() tea.Msg { + if err := client.HealthCheck(); err != nil { + return connectErrMsg{err} + } + return connectedMsg{} + } +} + +func tickCmd(interval time.Duration) tea.Cmd { + return tea.Tick(interval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func clearStatusCmd() tea.Cmd { + return tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return clearStatusMsg{} + }) +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..ccf5983 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,41 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + colorPrimary = lipgloss.Color("69") // blue + colorMuted = lipgloss.Color("240") // gray + colorFiring = lipgloss.Color("196") // red + colorResolved = lipgloss.Color("70") // green + colorAccent = lipgloss.Color("214") // orange + + styleHeader = lipgloss.NewStyle(). + Bold(true). + Foreground(colorPrimary). + Padding(0, 1) + + styleTabActive = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("0")). + Background(colorPrimary). + Padding(0, 2) + + styleTabInactive = lipgloss.NewStyle(). + Foreground(colorMuted). + Padding(0, 2) + + styleFooter = lipgloss.NewStyle(). + Foreground(colorMuted) + + styleStatus = lipgloss.NewStyle(). + Foreground(colorAccent). + Bold(true) + + styleError = lipgloss.NewStyle(). + Foreground(colorFiring). + Bold(true) + + styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true) + styleResolved = lipgloss.NewStyle().Foreground(colorResolved) + styleMuted = lipgloss.NewStyle().Foreground(colorMuted) +) diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..c47889a --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,54 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +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 connectedMsg: + m.connected = true + m.err = nil + m.statusMsg = "Connected to " + m.serverURL + return m, tea.Batch(tickCmd(m.refreshInterval), clearStatusCmd()) + + case connectErrMsg: + m.connected = false + m.err = msg.err + return m, nil + + case tickMsg: + return m, tickCmd(m.refreshInterval) + + case clearStatusMsg: + m.statusMsg = "" + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { + switch { + case msg.String() == "q" || msg.String() == "ctrl+c": + return m, tea.Quit + + case msg.String() == "tab": + m.activeSection = (m.activeSection + 1) % 3 + return m, nil + + case msg.String() == "r": + m.statusMsg = "Refreshing…" + return m, tea.Batch(connectCmd(m.client), clearStatusCmd()) + } + + return m, nil +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..94fbec9 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,87 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var sectionNames = []string{"Alerts", "Schedule", "Users"} + +func (m Model) View() string { + if m.width == 0 { + return "" + } + + header := m.renderHeader() + tabs := m.renderTabs() + body := m.renderBody() + footer := m.renderFooter() + + return lipgloss.JoinVertical(lipgloss.Left, header, tabs, body, footer) +} + +func (m Model) renderHeader() string { + title := styleHeader.Render("terdut-tui") + right := "" + if m.serverURL != "" { + right = styleMuted.Render(m.serverURL) + } + + gap := m.width - lipgloss.Width(title) - lipgloss.Width(right) + if gap < 0 { + gap = 0 + } + return title + strings.Repeat(" ", gap) + right +} + +func (m Model) renderTabs() string { + var tabs []string + for i, name := range sectionNames { + if section(i) == m.activeSection { + tabs = append(tabs, styleTabActive.Render(name)) + } else { + tabs = append(tabs, styleTabInactive.Render(name)) + } + } + line := strings.Repeat("─", m.width) + return strings.Join(tabs, "") + "\n" + styleMuted.Render(line) +} + +func (m Model) renderBody() string { + bodyHeight := m.height - 5 // header + tabs + separator + footer + help + if bodyHeight < 1 { + bodyHeight = 1 + } + + var content string + if m.err != nil { + content = styleError.Render(fmt.Sprintf("Error: %v", m.err)) + } else if !m.connected { + content = styleMuted.Render("Connecting…") + } else if m.statusMsg != "" { + content = styleStatus.Render(m.statusMsg) + } else { + switch m.activeSection { + case sectionAlerts: + content = styleMuted.Render("Alert dashboard — coming in Stage 2") + case sectionSchedule: + content = styleMuted.Render("On-call schedule — coming in Stage 4") + case sectionUsers: + content = styleMuted.Render("User management — coming in Stage 5") + } + } + + lines := strings.Split(content, "\n") + padding := (bodyHeight - len(lines)) / 2 + if padding < 0 { + padding = 0 + } + return strings.Repeat("\n", padding) + content +} + +func (m Model) renderFooter() string { + helpView := m.help.ShortHelpView(m.keys.ShortHelp()) + return "\n" + styleFooter.Render(helpView) +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..61979a4 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,145 @@ +package updater + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +const releaseAPI = "https://api.github.com/repos/yeniklas/terdut-tui/releases/latest" + +type release struct { + TagName string `json:"tag_name"` + Assets []asset `json:"assets"` +} + +type asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func Run(currentVersion string) error { + if currentVersion == "dev" { + fmt.Println("cannot self-update a dev build") + return nil + } + + fmt.Println("Checking for updates…") + rel, err := fetchLatest() + if err != nil { + return fmt.Errorf("fetch release info: %w", err) + } + + if rel.TagName == currentVersion { + fmt.Printf("terdut-tui is already up to date (%s)\n", currentVersion) + return nil + } + + assetName := fmt.Sprintf("terdut-tui-%s-%s-%s", rel.TagName, runtime.GOOS, runtime.GOARCH) + var downloadURL string + for _, a := range rel.Assets { + if a.Name == assetName { + downloadURL = a.BrowserDownloadURL + break + } + } + if downloadURL == "" { + var names []string + for _, a := range rel.Assets { + names = append(names, a.Name) + } + return fmt.Errorf("no binary found for %s/%s in release %s\navailable: %s", + runtime.GOOS, runtime.GOARCH, rel.TagName, strings.Join(names, ", ")) + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve binary path: %w", err) + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return fmt.Errorf("resolve symlink: %w", err) + } + dir := filepath.Dir(exe) + + probe, err := os.CreateTemp(dir, ".terdut-tui-update-*") + if err != nil { + return fmt.Errorf("cannot write to %s: %w\nre-run with appropriate permissions", dir, err) + } + probe.Close() + os.Remove(probe.Name()) + + fmt.Printf("Update terdut-tui %s → %s? [y/N] ", currentVersion, rel.TagName) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + if strings.ToLower(strings.TrimSpace(answer)) != "y" { + fmt.Println("Update cancelled.") + return nil + } + + fmt.Printf("Downloading %s…\n", assetName) + tmp, err := os.CreateTemp(dir, ".terdut-tui-update-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + defer func() { + tmp.Close() + os.Remove(tmpName) + }() + + resp, err := http.Get(downloadURL) //nolint:gosec + if err != nil { + return fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download: unexpected status %s", resp.Status) + } + + if _, err := io.Copy(tmp, resp.Body); err != nil { + return fmt.Errorf("write binary: %w", err) + } + tmp.Close() + + if err := os.Chmod(tmpName, 0755); err != nil { + return fmt.Errorf("chmod: %w", err) + } + + if err := os.Rename(tmpName, exe); err != nil { + return fmt.Errorf("replace binary: %w", err) + } + + fmt.Printf("Updated to %s.\n", rel.TagName) + return nil +} + +func fetchLatest() (*release, error) { + req, err := http.NewRequest(http.MethodGet, releaseAPI, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned %s", resp.Status) + } + + var rel release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, err + } + return &rel, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..81daee3 --- /dev/null +++ b/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "flag" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/yeniklas/terdut-tui/internal/api" + "github.com/yeniklas/terdut-tui/internal/config" + "github.com/yeniklas/terdut-tui/internal/tui" + "github.com/yeniklas/terdut-tui/internal/updater" +) + +var version = "dev" + +func main() { + versionFlag := flag.Bool("version", false, "print version and exit") + updateFlag := flag.Bool("self-update", false, "update terdut-tui to the latest release") + flag.Parse() + + if *versionFlag { + fmt.Println(version) + os.Exit(0) + } + + if *updateFlag { + if err := updater.Run(version); err != nil { + fmt.Fprintln(os.Stderr, "update:", err) + os.Exit(1) + } + os.Exit(0) + } + + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + client := api.NewClient(cfg.ServerURL, cfg.APIKey) + model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval) + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +}