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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> -a name # add a service to your config
ruok --help # show help
ruok --version # show version
```
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
45 changes: 19 additions & 26 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 {
Expand All @@ -28,40 +28,21 @@ 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()
if err != nil {
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)
Expand All @@ -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)
}
Expand All @@ -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",
Expand Down Expand Up @@ -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,
},
},
}
}
26 changes: 26 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
54 changes: 54 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
63 changes: 63 additions & 0 deletions impact.go
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading