diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4ace77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +cctuip +*.exe diff --git a/README.md b/README.md index 49f5218..8102f56 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ This is a prototype to gather some feedback. ## Install ``` -curl https://git.coopcloud.tech/decentral1se/cctuip/raw/branch/main/cctuip -o cctuip -chmod +x cctuip +go install coopcloud.tech/cctuip@latest ``` +Or grab a binary from the [releases](https://github.com/kawaiipunk/cctuip/releases). + ## Features * `abra app ls` compatibility. It does not query `-S` unless you press @@ -23,11 +24,16 @@ chmod +x cctuip * `up/down`: scroll up / down (`jk` vim bindings also work) * `page up/down`: ;page up down (`ctrl+u/d` vim binding also work) -* `/`: fuzzy filter against domain, server & recipe +* `/` or `f`: fuzzy filter against domain, server & recipe * `enter`: focus on table, select row, confirm * `esc`: reset filter +* `q`: quit * `s`: retrieve app deployment status (filter first to reduce scope) + Unreachable servers (e.g. SSH login issues) are skipped and reported in a + banner; statuses for the remaining servers are still shown. Press any key to + dismiss the banner and try again. + ## Hack ``` diff --git a/cctuip b/cctuip deleted file mode 100755 index 9624b6a..0000000 Binary files a/cctuip and /dev/null differ diff --git a/cctuip.go b/cctuip.go index e23e35b..36d0bcc 100644 --- a/cctuip.go +++ b/cctuip.go @@ -9,20 +9,24 @@ import ( "sort" "strings" - abraClient "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/recipe" + "coopcloud.tech/abra/pkg/upstream/convert" + "coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/tagcmp" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - dockerClient "github.com/docker/docker/client" "github.com/evertras/bubble-table/table" "golang.org/x/exp/slices" "golang.org/x/term" ) +// version is the cctuip version. +const version = "v0.1.0" + // help is the cctuip CLI help output. const help = `cctuip [options] @@ -30,24 +34,17 @@ cctuip is a Co-op Cloud TUI. Options: -h output help + -v output version ` var helpFlag bool +var versionFlag bool // handleCliFlags parses CLI flags. -func handleCliFlags() error { +func handleCliFlags() { flag.BoolVar(&helpFlag, "h", false, "output help") + flag.BoolVar(&versionFlag, "v", false, "output version") flag.Parse() - return nil -} - -// getClient retrieves a docker client from the abra API. -func getClient(server string) (*dockerClient.Client, error) { - cl, err := abraClient.New(server) - if err != nil { - return nil, fmt.Errorf("getClient: %s", err) - } - return cl, nil } // getApps retrieves app metadata from the abra API. @@ -89,23 +86,94 @@ func getNumServersAndRecipes(apps []config.App) (int, int) { // errorMsg delivers errors to the UI. type errorMsg struct{ err error } -// Error implements error output rendering. -func (e errorMsg) Error() string { return e.err.Error() } +// appsDeployStatusMsg delivers the deployment status of all apps and any +// per-server errors to the UI. +type appsDeployStatusMsg struct { + statuses map[string]map[string]string + serverErrors map[string]error +} -// appsDeployStatusMsg delivers the deployment status of all apps to the UI. -type appsDeployStatusMsg map[string]map[string]string - -// getAppsDeployStatus retrieves apps deployment status from a server. +// getAppsDeployStatus retrieves apps deployment status from the servers they +// are deployed on. Servers which cannot be reached (e.g. SSH login issues) are +// skipped and reported via serverErrors so that statuses for the remaining +// servers can still be rendered. func getAppsDeployStatus(m model) tea.Msg { var apps []config.App for _, row := range m.table.GetVisibleRows() { - apps = append(apps, row.Data["app"].(config.App)) + app, ok := row.Data["app"].(config.App) + if !ok { + continue + } + apps = append(apps, app) } - statuses, err := config.GetAppStatuses(apps, true) - if err != nil { - return errorMsg{err} + type serverResult struct { + server string + status stack.StackStatus + err error + } + + servers := make(map[string][]config.App) + for _, app := range apps { + servers[app.Server] = append(servers[app.Server], app) + } + + ch := make(chan serverResult, len(servers)) + for server := range servers { + go func(s string) { + cl, err := client.New(s) + if err != nil { + ch <- serverResult{server: s, err: err} + return + } + ch <- serverResult{server: s, status: stack.GetAllDeployedServices(cl, s)} + }(server) + } + + statuses := make(map[string]map[string]string) + serverErrors := make(map[string]error) + for range servers { + res := <-ch + if res.err != nil { + serverErrors[res.server] = res.err + continue + } + if res.status.Err != nil { + serverErrors[res.server] = res.status.Err + continue + } + + for _, service := range res.status.Services { + result := make(map[string]string) + name := service.Spec.Labels[convert.LabelNamespace] + + if _, ok := statuses[name]; !ok { + result["status"] = "deployed" + } + + if chaos, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.chaos", name)]; ok { + result["chaos"] = chaos + } + + if chaosVersion, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.chaos-version", name)]; ok { + result["chaosVersion"] = chaosVersion + } + + if autoUpdate, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.autoupdate", name)]; ok { + result["autoUpdate"] = autoUpdate + } else { + result["autoUpdate"] = "false" + } + + version, ok := service.Spec.Labels[fmt.Sprintf("coop-cloud.%s.version", name)] + if !ok { + continue + } + result["version"] = version + + statuses[name] = result + } } catl, err := recipe.ReadRecipeCatalogue() @@ -113,13 +181,18 @@ func getAppsDeployStatus(m model) tea.Msg { return errorMsg{err} } + catalogueVersions := make(map[string][]string) for _, app := range apps { var newUpdates []string if status, ok := statuses[app.StackName()]; ok { if version, ok := status["version"]; ok { - updates, err := recipe.GetRecipeCatalogueVersions(app.Recipe, catl) - if err != nil { - return errorMsg{err} + updates, ok := catalogueVersions[app.Recipe] + if !ok { + updates, err = recipe.GetRecipeCatalogueVersions(app.Recipe, catl) + if err != nil { + return errorMsg{err} + } + catalogueVersions[app.Recipe] = updates } parsedVersion, err := tagcmp.Parse(version) @@ -149,13 +222,17 @@ func getAppsDeployStatus(m model) tea.Msg { } } - return appsDeployStatusMsg(statuses) + return appsDeployStatusMsg{statuses: statuses, serverErrors: serverErrors} } +// renderAppsDeployStatus renders the deployment statuses into the table. func renderAppsDeployStatus(m *model, appStatuses appsDeployStatusMsg) table.Model { for _, row := range m.table.GetVisibleRows() { - app := row.Data["app"].(config.App) - appStatus := appStatuses[app.StackName()] + app, ok := row.Data["app"].(config.App) + if !ok { + continue + } + appStatus := appStatuses.statuses[app.StackName()] var ( version = appStatus["version"] updates = appStatus["updates"] @@ -230,7 +307,7 @@ func initTable(m model) tea.Msg { width, height, err := term.GetSize(0) if err != nil { - log.Fatal(err) // TODO + return errorMsg{err} } keymap := table.DefaultKeyMap() @@ -273,7 +350,11 @@ type model struct { func (m model) getFilteredApps() []config.App { var servers []config.App for _, row := range m.table.GetVisibleRows() { - servers = append(servers, row.Data["app"].(config.App)) + app, ok := row.Data["app"].(config.App) + if !ok { + continue + } + servers = append(servers, app) } return servers } @@ -313,13 +394,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + if m.err != nil { + m.err = nil + } + m.updateCount() switch msg.String() { case "q": return m, tea.Quit case "s": - if !m.table.GetIsFilterInputFocused() { + if !m.table.GetIsFilterInputFocused() && !m.pollingStatus { m.pollingStatus = true return m, func() tea.Msg { return getAppsDeployStatus(m) } } @@ -329,11 +414,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case appsDeployStatusMsg: m.pollingStatus = false m.table = renderAppsDeployStatus(&m, msg) + if len(msg.serverErrors) > 0 { + var errors []string + for server, err := range msg.serverErrors { + errors = append(errors, fmt.Sprintf("%s: %s", server, err)) + } + sort.Strings(errors) + m.err = fmt.Errorf("%s", strings.Join(errors, ", ")) + } case tea.WindowSizeMsg: m.table = m.table.WithTargetWidth(msg.Width) m.table = m.table.WithPageSize(msg.Height - 10) case errorMsg: - m.err = msg // TODO + m.pollingStatus = false + m.err = msg.err } return m, tea.Batch(cmds...) @@ -341,11 +435,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the UI. func (m model) View() string { - if m.err != nil { - // TODO - return fmt.Sprintf("\nWe had some trouble: %v\n\n", m.err) - } - body := strings.Builder{} body.WriteString(fmt.Sprintf( @@ -354,7 +443,12 @@ func (m model) View() string { )) body.WriteString(m.table.View() + "\n") - body.WriteString("cctuip v0.1.0") + + if m.err != nil { + body.WriteString(fmt.Sprintf("⚠ %v\n", m.err)) + } + + body.WriteString(fmt.Sprintf("cctuip %s", version)) if m.pollingStatus { body.WriteString(fmt.Sprintf(" %s querying app status...", m.spinner.View())) @@ -372,15 +466,17 @@ func main() { os.Exit(0) } + if versionFlag { + fmt.Println(version) + os.Exit(0) + } + apps, err := getApps() if err != nil { log.Fatal(err) } numServers, numRecipes := getNumServersAndRecipes(apps) - if err != nil { - log.Fatal(err) - } s := spinner.New() s.Spinner = spinner.Dot