diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..81e5a9f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run + +```bash +go build -o ruok . # build binary +go run . # run without building +go test ./... # run tests +go vet ./... # lint +``` + +## Architecture + +Single-package (`main`) Go CLI that queries [Atlassian Statuspage](https://www.atlassian.com/software/statuspage) APIs to show service health from the terminal. Uses `urfave/cli/v3` for command routing. + +**Key files:** + +- `main.go` — entrypoint, builds and runs the root command +- `cli.go` — CLI command definitions and action handlers (`check`, `list`, `add`, `table`) +- `tui.go` — Bubbletea-based interactive dashboard (`table` command); fetches all services concurrently, renders a `bubbles/table` sorted by severity, supports drill-down detail view +- `statuspage.go` — `StatusPage` type with API calls (`/api/v2/components.json`, `/api/v2/incidents/unresolved.json`), response types (`Component`, `Incident`, etc.), config loading/saving (YAML at `~/.config/ruok/config.yaml`), and service resolution logic +- `status.go` — `Status` enum type (operational → critical) with JSON unmarshaling, emoji `String()`, and severity ordering +- `impact.go` — `Impact` enum type (operational → critical) with JSON unmarshaling and emoji `String()` +- `registry.go` — built-in service name→URL map (github, cloudflare, datadog, etc.) + +**Dependencies:** `urfave/cli/v3` for command routing; `charmbracelet/bubbletea`, `charmbracelet/bubbles`, `charmbracelet/lipgloss` for the interactive TUI. + +**Service resolution order** (in `resolveStatusPage`): CLI arg → config `default` → falls back to GitHub. Arguments can be a registry name (case-insensitive) or a raw URL. + +**Config** (`~/.config/ruok/config.yaml`): `pages` map merges into the built-in registry (user entries override); `default` sets the service used when no arg is given. `ruok add` validates and writes to this file. diff --git a/README.md b/README.md index 6355a32..b29cff0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ go build -o ruok . ``` ruok [service] # check a status page (default: github) +ruok table # interactive dashboard of all services ruok list # list known services +ruok add -a name # add a service to your config ruok --help # show help ruok --version # show version ``` @@ -48,13 +50,16 @@ Link: https://stspg.io/4g7zvr319njb Last Updated: 07 Feb 26 04:15 UTC ``` +### Interactive dashboard + +Use `ruok table` (or `ruok t` / `ruok dashboard`) to see all services at once in an interactive table. It fetches every service concurrently and sorts by severity (worst first). Press enter or space to drill into a service's components and incidents. + ### Built-in services Use `ruok list` (or `ruok ls`) to see all known services: ``` $ ruok list -atlassian https://status.atlassian.com bitbucket https://bitbucket.status.atlassian.com cloudflare https://www.cloudflarestatus.com datadog https://status.datadoghq.com @@ -81,9 +86,20 @@ Pass any Statuspage URL directly: $ ruok https://status.render.com ``` +### Adding services + +Use `ruok add` to register a new Statuspage URL (it validates the URL first): + +``` +$ ruok add https://status.render.com --alias render +added https://status.render.com as render +``` + +The `--alias` (`-a`) flag is repeatable to register multiple names for the same URL. + ## Config file -Create `~/.config/ruok/config.yaml` to add custom services or set a default: +Create `~/.config/ruok/config.yaml` to add custom services or set a default (or use `ruok add`): ```yaml default: mycompany diff --git a/cli.go b/cli.go index 9e895b3..58d064e 100644 --- a/cli.go +++ b/cli.go @@ -3,12 +3,12 @@ package main import ( "context" "fmt" - "net/http" "os" "strings" "text/tabwriter" "time" + tea "github.com/charmbracelet/bubbletea" cli "github.com/urfave/cli/v3" ) @@ -18,7 +18,7 @@ func checkAction(_ context.Context, cmd *cli.Command) error { return err } - page.Client = &http.Client{} + page.Client = newClient() cmps, err := page.Components() if err != nil { @@ -28,11 +28,8 @@ func checkAction(_ context.Context, cmd *cli.Command) error { writer := tabwriter.NewWriter(os.Stdout, 4, 4, 1, ' ', 0) fTime := cmps.Page.UpdatedAt.Format(time.RFC822) fmt.Fprintf(writer, "=== %s Components as of %s === \n", cmps.Page.Name, fTime) - for _, c := range cmps.Components { - if c.Group || (c.OnlyShowIfDegraded && c.Status == "operational") { - continue - } - fmt.Fprintf(writer, "%s\t%s\n", c.Name, toIcon(c.Status)) + for _, c := range visibleComponents(cmps.Components) { + fmt.Fprintf(writer, "%s\t%s\n", c.Name, c.Status) } incs, err := page.Incidents() @@ -40,28 +37,12 @@ func checkAction(_ context.Context, cmd *cli.Command) error { return fmt.Errorf("failed to fetch incidents: %w", err) } - if len(incs.Incidents) > 0 { - fmt.Fprint(writer, "\n=== Incidents ===") - for _, i := range incs.Incidents { - fTime := i.UpdatedAt.Format(time.RFC822) - fmt.Fprintf(writer, "\nName:\t%s\n", i.Name) - fmt.Fprintf(writer, "Impact:\t%s %s\n", toIcon(i.Impact), i.Impact) - fmt.Fprintf(writer, "Status:\t%s\n", i.Status) - fmt.Fprintf(writer, "Details:\t%s\n", i.Updates[0].Body) - fmt.Fprintf(writer, "Link:\t%s\n", i.ShortLink) - fmt.Fprintf(writer, "Last Updated:\t%s\n", fTime) - } - } + formatIncidents(writer, incs.Incidents) return writer.Flush() } func listAction(_ context.Context, cmd *cli.Command) error { - cfg := loadConfig(cmd.Root().String("config")) - if cfg != nil { - for name, page := range cfg.Pages { - registry[strings.ToLower(name)] = page - } - } + mergeConfig(cmd.Root().String("config")) writer := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) for _, name := range knownServices() { fmt.Fprintf(writer, "%s\t%s\n", name, registry[name].URL) @@ -83,7 +64,7 @@ func addAction(_ context.Context, cmd *cli.Command) error { } // Validate that the URL points to an actual Statuspage - sp := StatusPage{URL: url, Client: &http.Client{}} + sp := StatusPage{URL: url, Client: newClient()} if _, err := sp.Components(); err != nil { return fmt.Errorf("URL does not appear to be a valid Statuspage: %w", err) } @@ -106,6 +87,12 @@ func addAction(_ context.Context, cmd *cli.Command) error { return nil } +func tableAction(_ context.Context, cmd *cli.Command) error { + m := buildModel(cmd.Root().String("config")) + _, err := tea.NewProgram(m).Run() + return err +} + func buildRootCommand() *cli.Command { return &cli.Command{ Name: "ruok", @@ -139,6 +126,12 @@ func buildRootCommand() *cli.Command { }, }, }, + { + Name: "table", + Aliases: []string{"t", "dashboard"}, + Usage: "Show status of all services in an interactive table", + Action: tableAction, + }, }, } } diff --git a/go.mod b/go.mod index 9fb5807..5d00097 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,32 @@ module github.com/abennett/ruok go 1.25 require ( + github.com/charmbracelet/bubbles v0.21.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/urfave/cli/v3 v3.6.2 gopkg.in/yaml.v3 v3.0.1 ) + +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.40.0 // indirect + golang.org/x/text v0.33.0 // indirect +) diff --git a/go.sum b/go.sum index a948dde..5a8d95d 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,65 @@ +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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= +github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= +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/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/impact.go b/impact.go new file mode 100644 index 0000000..97ec536 --- /dev/null +++ b/impact.go @@ -0,0 +1,63 @@ +package main + +import ( + "strings" +) + +type Impact int + +const ( + ImpactOperational Impact = iota + ImpactMinor + ImpactMajor + ImpactCritical + ImpactUnknown +) + +func (i *Impact) UnmarshalJSON(data []byte) error { + impact := strings.ToLower(string(data)) + impact = strings.Trim(impact, `"`) + switch impact { + case "operational": + *i = ImpactOperational + case "minor": + *i = ImpactMinor + case "major": + *i = ImpactMajor + case "critical": + *i = ImpactCritical + default: + *i = ImpactUnknown + } + return nil +} + +func (i Impact) String() string { + switch i { + case ImpactOperational: + return "✅" + case ImpactMinor: + return "🟡" + case ImpactMajor: + return "🟠" + case ImpactCritical: + return "🔴" + default: + return "❔" + } +} + +func (i Impact) Name() string { + switch i { + case ImpactOperational: + return "operational" + case ImpactMinor: + return "minor" + case ImpactMajor: + return "major" + case ImpactCritical: + return "critical" + default: + return "unknown" + } +} diff --git a/impact_test.go b/impact_test.go new file mode 100644 index 0000000..71c5478 --- /dev/null +++ b/impact_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestImpactUnmarshalJSON(t *testing.T) { + tests := []struct { + input string + want Impact + }{ + {`"operational"`, ImpactOperational}, + {`"minor"`, ImpactMinor}, + {`"major"`, ImpactMajor}, + {`"critical"`, ImpactCritical}, + {`"MINOR"`, ImpactMinor}, + {`"Critical"`, ImpactCritical}, + {`"something_else"`, ImpactUnknown}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + var i Impact + if err := json.Unmarshal([]byte(tt.input), &i); err != nil { + t.Fatalf("Unmarshal(%s): %v", tt.input, err) + } + if i != tt.want { + t.Errorf("Unmarshal(%s) = %d, want %d", tt.input, i, tt.want) + } + }) + } +} + +func TestImpactString(t *testing.T) { + tests := []struct { + i Impact + want string + }{ + {ImpactOperational, "✅"}, + {ImpactMinor, "🟡"}, + {ImpactMajor, "🟠"}, + {ImpactCritical, "🔴"}, + {ImpactUnknown, "❔"}, + } + for _, tt := range tests { + got := tt.i.String() + if got != tt.want { + t.Errorf("Impact(%d).String() = %q, want %q", tt.i, got, tt.want) + } + } +} + +func TestImpactName(t *testing.T) { + tests := []struct { + i Impact + want string + }{ + {ImpactOperational, "operational"}, + {ImpactMinor, "minor"}, + {ImpactMajor, "major"}, + {ImpactCritical, "critical"}, + {ImpactUnknown, "unknown"}, + } + for _, tt := range tests { + got := tt.i.Name() + if got != tt.want { + t.Errorf("Impact(%d).Name() = %q, want %q", tt.i, got, tt.want) + } + } +} + +func TestImpactSeverityOrdering(t *testing.T) { + ordered := []Impact{ + ImpactOperational, + ImpactMinor, + ImpactMajor, + ImpactCritical, + ImpactUnknown, + } + for i := 1; i < len(ordered); i++ { + if ordered[i] <= ordered[i-1] { + t.Errorf("expected %d > %d in severity ordering", ordered[i], ordered[i-1]) + } + } +} diff --git a/registry.go b/registry.go index 847393f..083c3d9 100644 --- a/registry.go +++ b/registry.go @@ -7,7 +7,6 @@ var registry = map[string]StatusPage{ "twilio": {URL: "https://status.twilio.com"}, "bitbucket": {URL: "https://bitbucket.status.atlassian.com"}, "hashicorp": {URL: "https://status.hashicorp.com"}, - "atlassian": {URL: "https://status.atlassian.com"}, "reddit": {URL: "https://www.redditstatus.com"}, "digitalocean": {URL: "https://status.digitalocean.com"}, } diff --git a/status.go b/status.go new file mode 100644 index 0000000..c5c9421 --- /dev/null +++ b/status.go @@ -0,0 +1,75 @@ +package main + +import ( + "log/slog" + "strings" +) + +type Status int + +const ( + StatusOperational Status = iota + StatusMaintenance + StatusDegraded + StatusPartial + StatusMajor + StatusCritical + StatusUnknown +) + +func (s *Status) UnmarshalJSON(data []byte) error { + status := strings.ToLower(string(data)) + status = strings.Trim(status, `"`) + switch status { + case "operational": + *s = StatusOperational + case "under_maintenance": + *s = StatusMaintenance + case "degraded_performance": + *s = StatusDegraded + case "partial_outage": + *s = StatusPartial + case "major_outage": + *s = StatusMajor + case "critical": + *s = StatusCritical + default: + slog.Error("unknown status", "status", status) + *s = StatusUnknown + } + return nil +} + +func (s Status) String() string { + switch s { + case StatusOperational: + return "✅" + case StatusMaintenance: + return "🔧" + case StatusDegraded, StatusPartial: + return "🟡" + case StatusMajor: + return "🟠" + case StatusCritical: + return "🔴" + default: + return "❔" + } +} + +func (s Status) Name() string { + switch s { + case StatusOperational: + return "operational" + case StatusDegraded: + return "degraded_performance" + case StatusPartial: + return "partial_outage" + case StatusMajor: + return "major_outage" + case StatusCritical: + return "critical" + default: + return "unknown" + } +} diff --git a/status_test.go b/status_test.go new file mode 100644 index 0000000..0604aef --- /dev/null +++ b/status_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestStatusUnmarshalJSON(t *testing.T) { + tests := []struct { + input string + want Status + }{ + {`"operational"`, StatusOperational}, + {`"under_maintenance"`, StatusMaintenance}, + {`"degraded_performance"`, StatusDegraded}, + {`"partial_outage"`, StatusPartial}, + {`"major_outage"`, StatusCritical}, + {`"critical"`, StatusCritical}, + {`"OPERATIONAL"`, StatusOperational}, + {`"Degraded_Performance"`, StatusDegraded}, + {`"something_else"`, StatusUnknown}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + var s Status + if err := json.Unmarshal([]byte(tt.input), &s); err != nil { + t.Fatalf("Unmarshal(%s): %v", tt.input, err) + } + if s != tt.want { + t.Errorf("Unmarshal(%s) = %d, want %d", tt.input, s, tt.want) + } + }) + } +} + +func TestStatusString(t *testing.T) { + tests := []struct { + s Status + want string + }{ + {StatusOperational, "✅"}, + {StatusMaintenance, "🔧"}, + {StatusDegraded, "🟡"}, + {StatusPartial, "🟡"}, + {StatusMajor, "🟠"}, + {StatusCritical, "🔴"}, + {StatusUnknown, "❔"}, + } + for _, tt := range tests { + got := tt.s.String() + if got != tt.want { + t.Errorf("Status(%d).String() = %q, want %q", tt.s, got, tt.want) + } + } +} + +func TestStatusName(t *testing.T) { + tests := []struct { + s Status + want string + }{ + {StatusOperational, "operational"}, + {StatusDegraded, "degraded_performance"}, + {StatusPartial, "partial_outage"}, + {StatusMajor, "major_outage"}, + {StatusCritical, "critical"}, + {StatusUnknown, "unknown"}, + } + for _, tt := range tests { + got := tt.s.Name() + if got != tt.want { + t.Errorf("Status(%d).Name() = %q, want %q", tt.s, got, tt.want) + } + } +} + +func TestStatusSeverityOrdering(t *testing.T) { + // Verify that severity increases from operational to unknown + ordered := []Status{ + StatusOperational, + StatusMaintenance, + StatusDegraded, + StatusPartial, + StatusMajor, + StatusCritical, + StatusUnknown, + } + for i := 1; i < len(ordered); i++ { + if ordered[i] <= ordered[i-1] { + t.Errorf("expected %d > %d in severity ordering", ordered[i], ordered[i-1]) + } + } +} diff --git a/statuspage.go b/statuspage.go index 280b8f3..b989462 100644 --- a/statuspage.go +++ b/statuspage.go @@ -3,25 +3,19 @@ package main import ( "encoding/json" "fmt" + "io" "log/slog" "net/http" "os" "path/filepath" "sort" "strings" + "text/tabwriter" "time" "gopkg.in/yaml.v3" ) -const ( - NONE = "✅" - MINOR = "🟡" - MAJOR = "🟠" - CRITICAL = "🔴" - UNKNOWN = "❔" -) - type StatusPage struct { URL string Client *http.Client @@ -32,7 +26,7 @@ func (sp *StatusPage) UnmarshalYAML(value *yaml.Node) error { return nil } -func (sp StatusPage) MarshalYAML() (interface{}, error) { +func (sp StatusPage) MarshalYAML() (any, error) { return sp.URL, nil } @@ -114,15 +108,60 @@ func knownServices() []string { return names } -func resolveStatusPage(arg string, cfgPath string) (StatusPage, error) { +// mergeConfig loads the config file and merges user-defined pages into the +// built-in registry. It is safe to call even when no config file exists. +func mergeConfig(cfgPath string) *Config { cfg := loadConfig(cfgPath) - - // Merge config pages into registry (user entries override built-ins) if cfg != nil { for name, page := range cfg.Pages { registry[strings.ToLower(name)] = page } } + return cfg +} + +// newClient returns an *http.Client with a sensible default timeout. +func newClient() *http.Client { + return &http.Client{Timeout: 10 * time.Second} +} + +// visibleComponents filters out group headers and components that are +// only shown when degraded but are currently operational. +func visibleComponents(cmps []Component) []Component { + result := make([]Component, 0, len(cmps)) + for _, c := range cmps { + if c.Group || (c.OnlyShowIfDegraded && c.Status == StatusOperational) { + continue + } + result = append(result, c) + } + return result +} + +// formatIncidents writes a human-readable incident list to w using tabwriter +// for aligned columns. +func formatIncidents(w io.Writer, incidents []Incident) { + if len(incidents) == 0 { + return + } + fmt.Fprint(w, "\n=== Incidents ===") + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, i := range incidents { + fTime := i.UpdatedAt.Format(time.RFC822) + fmt.Fprintf(tw, "\nName:\t%s\n", i.Name) + fmt.Fprintf(tw, "Impact:\t%s %s\n", i.Impact, i.Impact.Name()) + fmt.Fprintf(tw, "Status:\t%s\n", i.Status) + if len(i.Updates) > 0 { + fmt.Fprintf(tw, "Details:\t%s\n", i.Updates[0].Body) + } + fmt.Fprintf(tw, "Link:\t%s\n", i.ShortLink) + fmt.Fprintf(tw, "Last Updated:\t%s\n", fTime) + } + tw.Flush() +} + +func resolveStatusPage(arg string, cfgPath string) (StatusPage, error) { + cfg := mergeConfig(cfgPath) // 1. CLI arg (highest priority) if arg != "" { @@ -153,21 +192,6 @@ func resolveStatusPage(arg string, cfgPath string) (StatusPage, error) { return registry["github"], nil } -func toIcon(s string) string { - switch s := strings.ToLower(s); s { - case "none", "operational": - return NONE - case "minor", "degraded_performance": - return MINOR - case "major", "partial_outage": - return MAJOR - case "critical", "major_outage": - return CRITICAL - default: - return UNKNOWN - } -} - type ComponentsResponse struct { Page Page Components []Component @@ -197,14 +221,14 @@ type Component struct { Position int `json:"position"` Showcase bool `json:"showcase"` StartDate string `json:"start_date"` - Status string `json:"status"` + Status Status `json:"status"` UpdatedAt string `json:"updated_at"` } type Incident struct { CreatedAt string `json:"created_at"` ID string `json:"id"` - Impact string `json:"impact"` + Impact Impact `json:"impact"` Updates []Update `json:"incident_updates"` Name string `json:"name"` ShortLink string `json:"shortlink"` diff --git a/statuspage_test.go b/statuspage_test.go new file mode 100644 index 0000000..5dae4b2 --- /dev/null +++ b/statuspage_test.go @@ -0,0 +1,319 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// fakeComponentsJSON returns a minimal valid /api/v2/components.json response. +func fakeComponentsJSON() []byte { + data, _ := json.Marshal(map[string]any{ + "page": map[string]any{ + "id": "abc123", + "name": "TestPage", + "url": "https://example.com", + "updated_at": "2025-01-01T00:00:00Z", + }, + "components": []map[string]any{ + {"id": "1", "name": "API", "status": "operational", "group": false, "only_show_if_degraded": false}, + {"id": "2", "name": "Web", "status": "degraded_performance", "group": false, "only_show_if_degraded": false}, + }, + }) + return data +} + +// fakeIncidentsJSON returns a minimal valid /api/v2/incidents/unresolved.json response. +func fakeIncidentsJSON() []byte { + data, _ := json.Marshal(map[string]any{ + "page": map[string]any{ + "id": "abc123", + "name": "TestPage", + "url": "https://example.com", + "updated_at": "2025-01-01T00:00:00Z", + }, + "incidents": []map[string]any{ + { + "id": "inc1", + "name": "Test Incident", + "status": "investigating", + "impact": "minor", + "shortlink": "https://stspg.io/test", + "updated_at": "2025-01-01T01:00:00Z", + "created_at": "2025-01-01T00:00:00Z", + "incident_updates": []map[string]any{ + {"id": "u1", "body": "Looking into it", "status": "investigating", + "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z", + "display_at": "2025-01-01T00:00:00Z", "incident_id": "inc1"}, + }, + }, + }, + }) + return data +} + +func newTestServer() *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/components.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(fakeComponentsJSON()) + }) + mux.HandleFunc("/api/v2/incidents/unresolved.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(fakeIncidentsJSON()) + }) + return httptest.NewServer(mux) +} + +func TestStatusPageComponents(t *testing.T) { + srv := newTestServer() + defer srv.Close() + + sp := StatusPage{URL: srv.URL, Client: srv.Client()} + resp, err := sp.Components() + if err != nil { + t.Fatalf("Components() error: %v", err) + } + if resp.Page.Name != "TestPage" { + t.Errorf("Page.Name = %q, want %q", resp.Page.Name, "TestPage") + } + if len(resp.Components) != 2 { + t.Fatalf("got %d components, want 2", len(resp.Components)) + } + if resp.Components[0].Status != StatusOperational { + t.Errorf("component[0].Status = %d, want StatusOperational", resp.Components[0].Status) + } + if resp.Components[1].Status != StatusDegraded { + t.Errorf("component[1].Status = %d, want StatusDegraded", resp.Components[1].Status) + } +} + +func TestStatusPageIncidents(t *testing.T) { + srv := newTestServer() + defer srv.Close() + + sp := StatusPage{URL: srv.URL, Client: srv.Client()} + resp, err := sp.Incidents() + if err != nil { + t.Fatalf("Incidents() error: %v", err) + } + if len(resp.Incidents) != 1 { + t.Fatalf("got %d incidents, want 1", len(resp.Incidents)) + } + inc := resp.Incidents[0] + if inc.Name != "Test Incident" { + t.Errorf("incident.Name = %q, want %q", inc.Name, "Test Incident") + } + if inc.Impact != ImpactMinor { + t.Errorf("incident.Impact = %d, want ImpactMinor", inc.Impact) + } + if inc.Status != "investigating" { + t.Errorf("incident.Status = %q, want %q", inc.Status, "investigating") + } + if len(inc.Updates) != 1 { + t.Fatalf("got %d updates, want 1", len(inc.Updates)) + } + if inc.Updates[0].Body != "Looking into it" { + t.Errorf("update.Body = %q, want %q", inc.Updates[0].Body, "Looking into it") + } +} + +func TestStatusPageComponentsHTTPError(t *testing.T) { + sp := StatusPage{URL: "http://127.0.0.1:1", Client: &http.Client{}} + _, err := sp.Components() + if err == nil { + t.Error("expected error for unreachable server, got nil") + } +} + +func TestStatusPageIncidentsHTTPError(t *testing.T) { + sp := StatusPage{URL: "http://127.0.0.1:1", Client: &http.Client{}} + _, err := sp.Incidents() + if err == nil { + t.Error("expected error for unreachable server, got nil") + } +} + +func TestKnownServices(t *testing.T) { + names := knownServices() + if len(names) == 0 { + t.Fatal("knownServices() returned empty list") + } + // Verify sorted order + for i := 1; i < len(names); i++ { + if names[i] < names[i-1] { + t.Errorf("knownServices() not sorted: %q comes after %q", names[i], names[i-1]) + } + } + // Verify github is present + found := false + for _, n := range names { + if n == "github" { + found = true + break + } + } + if !found { + t.Error("expected 'github' in knownServices()") + } +} + +func TestConfigLoadSaveRoundtrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + cfg := &Config{ + Default: "myservice", + Pages: map[string]StatusPage{ + "myservice": {URL: "https://status.example.com"}, + }, + } + if err := saveConfig(path, cfg); err != nil { + t.Fatalf("saveConfig: %v", err) + } + + loaded := loadConfig(path) + if loaded == nil { + t.Fatal("loadConfig returned nil") + } + if loaded.Default != "myservice" { + t.Errorf("Default = %q, want %q", loaded.Default, "myservice") + } + sp, ok := loaded.Pages["myservice"] + if !ok { + t.Fatal("missing 'myservice' in loaded pages") + } + if sp.URL != "https://status.example.com" { + t.Errorf("Pages[myservice].URL = %q, want %q", sp.URL, "https://status.example.com") + } +} + +func TestConfigLoadMissing(t *testing.T) { + cfg := loadConfig(filepath.Join(t.TempDir(), "nonexistent.yaml")) + if cfg != nil { + t.Error("expected nil for missing config file") + } +} + +func TestConfigLoadInvalid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + os.WriteFile(path, []byte(":::not yaml"), 0o644) + cfg := loadConfig(path) + if cfg != nil { + t.Error("expected nil for invalid config file") + } +} + +func TestSaveConfigCreatesDir(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "dir", "config.yaml") + cfg := &Config{Default: "test"} + if err := saveConfig(path, cfg); err != nil { + t.Fatalf("saveConfig to nested dir: %v", err) + } + loaded := loadConfig(path) + if loaded == nil || loaded.Default != "test" { + t.Error("failed to load config from nested dir") + } +} + +func TestResolveStatusPageCLIArg(t *testing.T) { + sp, err := resolveStatusPage("github", "") + if err != nil { + t.Fatalf("resolveStatusPage(github): %v", err) + } + if sp.URL != "https://www.githubstatus.com" { + t.Errorf("URL = %q, want github URL", sp.URL) + } +} + +func TestResolveStatusPageCLIArgCaseInsensitive(t *testing.T) { + sp, err := resolveStatusPage("GitHub", "") + if err != nil { + t.Fatalf("resolveStatusPage(GitHub): %v", err) + } + if sp.URL != "https://www.githubstatus.com" { + t.Errorf("URL = %q, want github URL", sp.URL) + } +} + +func TestResolveStatusPageRawURL(t *testing.T) { + sp, err := resolveStatusPage("https://status.example.com", "") + if err != nil { + t.Fatalf("resolveStatusPage(raw URL): %v", err) + } + if sp.URL != "https://status.example.com" { + t.Errorf("URL = %q, want %q", sp.URL, "https://status.example.com") + } +} + +func TestResolveStatusPageUnknownService(t *testing.T) { + _, err := resolveStatusPage("nonexistent_service_xyz", "") + if err == nil { + t.Error("expected error for unknown service, got nil") + } +} + +func TestResolveStatusPageDefault(t *testing.T) { + // No arg, no config → should fall back to github + sp, err := resolveStatusPage("", "") + if err != nil { + t.Fatalf("resolveStatusPage(''): %v", err) + } + if sp.URL != "https://www.githubstatus.com" { + t.Errorf("URL = %q, want github URL", sp.URL) + } +} + +func TestResolveStatusPageConfigDefault(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + cfg := &Config{Default: "cloudflare"} + saveConfig(path, cfg) + + sp, err := resolveStatusPage("", path) + if err != nil { + t.Fatalf("resolveStatusPage with config default: %v", err) + } + if sp.URL != "https://www.cloudflarestatus.com" { + t.Errorf("URL = %q, want cloudflare URL", sp.URL) + } +} + +func TestResolveStatusPageConfigDefaultURL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + cfg := &Config{Default: "https://status.custom.com"} + saveConfig(path, cfg) + + sp, err := resolveStatusPage("", path) + if err != nil { + t.Fatalf("resolveStatusPage with URL default: %v", err) + } + if sp.URL != "https://status.custom.com" { + t.Errorf("URL = %q, want %q", sp.URL, "https://status.custom.com") + } +} + +func TestResolveStatusPageConfigPages(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + cfg := &Config{ + Pages: map[string]StatusPage{ + "myco": {URL: "https://status.myco.com"}, + }, + } + saveConfig(path, cfg) + + sp, err := resolveStatusPage("myco", path) + if err != nil { + t.Fatalf("resolveStatusPage(myco): %v", err) + } + if sp.URL != "https://status.myco.com" { + t.Errorf("URL = %q, want %q", sp.URL, "https://status.myco.com") + } +} diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..8434f9f --- /dev/null +++ b/tui.go @@ -0,0 +1,332 @@ +package main + +import ( + "fmt" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) + +// serviceResult is returned when a components fetch completes for a service. +type serviceResult struct { + name string + components *ComponentsResponse + err error +} + +// detailResult is returned when a detail fetch completes for drill-down. +type detailResult struct { + name string + components *ComponentsResponse + incidents *IncidentsResponse + err error +} + +type serviceRow struct { + name string + url string + status string // icon once loaded + statusVal Status // numeric severity for sorting + failed int // -1 while loading, then count of non-operational + total int + err error + done bool +} + +type model struct { + rows []serviceRow + table table.Model + spinner spinner.Model + done int + quitting bool + + detail *detailResult + detailLoading bool +} + +func buildModel(cfgPath string) model { + mergeConfig(cfgPath) + + names := knownServices() + rows := make([]serviceRow, len(names)) + for i, name := range names { + rows[i] = serviceRow{ + name: name, + url: registry[name].URL, + failed: -1, + } + } + + cols := []table.Column{ + {Title: "Service", Width: 16}, + {Title: "Status", Width: 10}, + {Title: "Components", Width: 20}, + } + + // Remove "space" from PageDown bindings so it can be used for detail view + km := table.DefaultKeyMap() + km.PageDown.SetKeys("f", "pgdown") + + s := spinner.New(spinner.WithSpinner(spinner.Dot)) + + tableRows := make([]table.Row, len(rows)) + for i, r := range rows { + tableRows[i] = table.Row{r.name, s.View(), s.View()} + } + + t := table.New( + table.WithColumns(cols), + table.WithRows(tableRows), + table.WithHeight(len(rows)+1), + table.WithFocused(true), + table.WithKeyMap(km), + ) + st := table.DefaultStyles() + st.Header = st.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + st.Selected = st.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(st) + + m := model{ + rows: rows, + table: t, + spinner: s, + } + return m +} + +func (m model) Init() tea.Cmd { + cmds := make([]tea.Cmd, 0, len(m.rows)+1) + cmds = append(cmds, m.spinner.Tick) + for _, r := range m.rows { + cmds = append(cmds, func() tea.Msg { + sp := StatusPage{ + URL: r.url, + Client: newClient(), + } + cmps, err := sp.Components() + return serviceResult{name: r.name, components: cmps, err: err} + }) + } + return tea.Batch(cmds...) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Detail view: any key returns to table (except ctrl+c which quits) + if m.detail != nil && !m.detailLoading { + if k, ok := msg.(tea.KeyMsg); ok { + if k.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + m.detail = nil + return m, nil + } + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + m.quitting = true + return m, tea.Quit + case "enter", " ": + idx := m.table.Cursor() + if idx >= 0 && idx < len(m.rows) { + m.detailLoading = true + r := m.rows[idx] + return m, func() tea.Msg { + sp := StatusPage{ + URL: r.url, + Client: newClient(), + } + cmps, err := sp.Components() + if err != nil { + return detailResult{name: r.name, err: err} + } + incs, err := sp.Incidents() + return detailResult{ + name: r.name, + components: cmps, + incidents: incs, + err: err, + } + } + } + } + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + m.rebuildTableRows() + return m, cmd + + case serviceResult: + for i := range m.rows { + if m.rows[i].name == msg.name { + m.rows[i].done = true + if msg.err != nil { + m.rows[i].err = msg.err + } else { + icon, failed, total := summarize(msg.components) + m.rows[i].status = icon.String() + m.rows[i].statusVal = icon + m.rows[i].failed = failed + m.rows[i].total = total + } + break + } + } + m.done++ + m.rebuildTableRows() + return m, nil + + case detailResult: + m.detail = &msg + m.detailLoading = false + return m, nil + + default: + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd + } + + // Pass through for table navigation on unhandled keys + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m *model) rebuildTableRows() { + // Preserve cursor selection across sort + selectedName := "" + if cur := m.table.Cursor(); cur >= 0 && cur < len(m.rows) { + selectedName = m.rows[cur].name + } + + // Sort: worst status first, then alphabetical; loading/error rows go last + sort.SliceStable(m.rows, func(i, j int) bool { + ri, rj := m.rows[i], m.rows[j] + // Done rows before loading rows + if ri.done != rj.done { + return ri.done + } + if !ri.done { + return ri.name < rj.name + } + // Error rows after successful rows + if (ri.err != nil) != (rj.err != nil) { + return ri.err == nil + } + // Sort by severity descending (worst first) + if ri.statusVal != rj.statusVal { + return ri.statusVal > rj.statusVal + } + return ri.name < rj.name + }) + + // Restore cursor to the previously selected service + for i, r := range m.rows { + if r.name == selectedName { + m.table.SetCursor(i) + break + } + } + + rows := make([]table.Row, len(m.rows)) + spin := m.spinner.View() + for i, r := range m.rows { + switch { + case !r.done: + rows[i] = table.Row{r.name, spin, spin} + case r.err != nil: + rows[i] = table.Row{r.name, StatusUnknown.String(), "error"} + case r.failed == 0: + rows[i] = table.Row{r.name, r.status, fmt.Sprintf("all %d operational", r.total)} + default: + rows[i] = table.Row{r.name, r.status, fmt.Sprintf("%d/%d degraded", r.failed, r.total)} + } + } + m.table.SetRows(rows) +} + +func (m model) View() string { + if m.quitting { + return "" + } + + if m.detailLoading { + return m.spinner.View() + " Loading details..." + } + + if m.detail != nil { + return m.renderDetail() + } + + var footer string + if m.done < len(m.rows) { + footer = fmt.Sprintf("\n Fetching %d/%d services...", m.done, len(m.rows)) + } else { + footer = "\n All services checked. Press q to quit." + } + return baseStyle.Render(m.table.View()) + footer +} + +func (m model) renderDetail() string { + d := m.detail + if d.err != nil { + return fmt.Sprintf("Error fetching %s: %v\n\nPress any key to return.", d.name, d.err) + } + + var b strings.Builder + fTime := d.components.Page.UpdatedAt.Format(time.RFC822) + fmt.Fprintf(&b, "=== %s Components as of %s ===\n", d.components.Page.Name, fTime) + + tw := tabwriter.NewWriter(&b, 0, 0, 2, ' ', 0) + for _, c := range d.components.Components { + if c.Group || c.Status == StatusOperational { + continue + } + fmt.Fprintf(tw, " %s\t%s\n", c.Name, c.Status) + } + tw.Flush() + + if d.incidents != nil { + formatIncidents(&b, d.incidents.Incidents) + } + + fmt.Fprintf(&b, "\nPress any key to return.") + return b.String() +} + +// summarize computes the worst-severity icon and counts for a components response. +func summarize(cmps *ComponentsResponse) (icon Status, failed, total int) { + icon = StatusOperational + for _, c := range visibleComponents(cmps.Components) { + total++ + if c.Status != StatusOperational { + failed++ + if c.Status > icon { + icon = c.Status + } + } + } + return icon, failed, total +} diff --git a/tui_test.go b/tui_test.go new file mode 100644 index 0000000..d1ac207 --- /dev/null +++ b/tui_test.go @@ -0,0 +1,119 @@ +package main + +import "testing" + +func TestSummarizeAllOperational(t *testing.T) { + cmps := &ComponentsResponse{ + Components: []Component{ + {Name: "API", Status: StatusOperational}, + {Name: "Web", Status: StatusOperational}, + {Name: "DB", Status: StatusOperational}, + }, + } + icon, failed, total := summarize(cmps) + if icon != StatusOperational { + t.Errorf("icon = %d, want StatusOperational", icon) + } + if failed != 0 { + t.Errorf("failed = %d, want 0", failed) + } + if total != 3 { + t.Errorf("total = %d, want 3", total) + } +} + +func TestSummarizeDegraded(t *testing.T) { + cmps := &ComponentsResponse{ + Components: []Component{ + {Name: "API", Status: StatusOperational}, + {Name: "Web", Status: StatusDegraded}, + {Name: "DB", Status: StatusOperational}, + }, + } + icon, failed, total := summarize(cmps) + if icon != StatusDegraded { + t.Errorf("icon = %d, want StatusDegraded", icon) + } + if failed != 1 { + t.Errorf("failed = %d, want 1", failed) + } + if total != 3 { + t.Errorf("total = %d, want 3", total) + } +} + +func TestSummarizeWorstWins(t *testing.T) { + cmps := &ComponentsResponse{ + Components: []Component{ + {Name: "API", Status: StatusDegraded}, + {Name: "Web", Status: StatusCritical}, + {Name: "DB", Status: StatusPartial}, + }, + } + icon, failed, total := summarize(cmps) + if icon != StatusCritical { + t.Errorf("icon = %d, want StatusCritical", icon) + } + if failed != 3 { + t.Errorf("failed = %d, want 3", failed) + } + if total != 3 { + t.Errorf("total = %d, want 3", total) + } +} + +func TestSummarizeSkipsGroups(t *testing.T) { + cmps := &ComponentsResponse{ + Components: []Component{ + {Name: "Group Header", Status: StatusOperational, Group: true}, + {Name: "API", Status: StatusOperational}, + }, + } + _, _, total := summarize(cmps) + if total != 1 { + t.Errorf("total = %d, want 1 (group should be skipped)", total) + } +} + +func TestSummarizeSkipsOnlyShowIfDegradedWhenOperational(t *testing.T) { + cmps := &ComponentsResponse{ + Components: []Component{ + {Name: "API", Status: StatusOperational}, + {Name: "Hidden", Status: StatusOperational, OnlyShowIfDegraded: true}, + }, + } + _, _, total := summarize(cmps) + if total != 1 { + t.Errorf("total = %d, want 1 (hidden operational should be skipped)", total) + } +} + +func TestSummarizeShowsOnlyShowIfDegradedWhenDegraded(t *testing.T) { + cmps := &ComponentsResponse{ + Components: []Component{ + {Name: "API", Status: StatusOperational}, + {Name: "Hidden", Status: StatusDegraded, OnlyShowIfDegraded: true}, + }, + } + icon, failed, total := summarize(cmps) + if total != 2 { + t.Errorf("total = %d, want 2", total) + } + if failed != 1 { + t.Errorf("failed = %d, want 1", failed) + } + if icon != StatusDegraded { + t.Errorf("icon = %d, want StatusDegraded", icon) + } +} + +func TestSummarizeEmpty(t *testing.T) { + cmps := &ComponentsResponse{} + icon, failed, total := summarize(cmps) + if icon != StatusOperational { + t.Errorf("icon = %d, want StatusOperational for empty", icon) + } + if failed != 0 || total != 0 { + t.Errorf("failed=%d total=%d, want 0/0", failed, total) + } +}