feat: Stage 1 scaffold — config, API client, placeholder TUI
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.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
terdut-tui
|
||||||
|
.graymatter/
|
||||||
@@ -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 |
|
||||||
@@ -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: <your-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 |
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: <your-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
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user