// cctuip is a Co-op Cloud TUI. package main import ( "flag" "fmt" "log" "os" "sort" "strings" "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" "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] 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() { flag.BoolVar(&helpFlag, "h", false, "output help") flag.BoolVar(&versionFlag, "v", false, "output version") flag.Parse() } // getApps retrieves app metadata from the abra API. func getApps() ([]config.App, error) { appFiles, err := config.LoadAppFiles("") if err != nil { return []config.App{}, fmt.Errorf("getApps: %s", err) } apps, err := config.GetApps(appFiles, "") if err != nil { return []config.App{}, fmt.Errorf("getApps: %s", err) } sort.Sort(config.ByName(apps)) return apps, nil } // getNumServersAndRecipes totals servers and recipes. func getNumServersAndRecipes(apps []config.App) (int, int) { var ( servers []string recipes []string ) for _, app := range apps { if !slices.Contains(servers, app.Server) { servers = append(servers, app.Server) } if !slices.Contains(recipes, app.Recipe) { recipes = append(recipes, app.Recipe) } } return len(servers), len(recipes) } // errorMsg delivers errors to the UI. type errorMsg struct{ 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 } // 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() { app, ok := row.Data["app"].(config.App) if !ok { continue } apps = append(apps, app) } 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() if err != nil { 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, 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) if err != nil { statuses[app.StackName()]["updates"] = "🤷" continue } for _, update := range updates { parsedUpdate, err := tagcmp.Parse(update) if err != nil { statuses[app.StackName()]["updates"] = "🤷" continue } if update != version && parsedUpdate.IsGreaterThan(parsedVersion) { newUpdates = append(newUpdates, update) } } if len(newUpdates) == 0 { statuses[app.StackName()]["updates"] = "✅" } else { statuses[app.StackName()]["updates"] = fmt.Sprintf("%v", len(newUpdates)) } } } } 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, ok := row.Data["app"].(config.App) if !ok { continue } appStatus := appStatuses.statuses[app.StackName()] var ( version = appStatus["version"] updates = appStatus["updates"] status = appStatus["status"] chaos = appStatus["chaos"] chaosVersion = appStatus["chaosVersion"] autoUpdate = appStatus["autoUpdate"] ) if status == "" { row.Data["status"] = "🤷" } else { row.Data["status"] = status } if version == "" { row.Data["version"] = "🤷" } else { row.Data["version"] = version row.Data["updates"] = updates } if chaos == "" { row.Data["chaos"] = "🤷" } else { row.Data["chaos"] = chaos } if chaosVersion == "" { row.Data["chaos-version"] = "🤷" } else { row.Data["chaos-version"] = chaosVersion } if autoUpdate == "" { row.Data["autoUpdate"] = "🤷" } else { row.Data["autoUpdate"] = autoUpdate } } return m.table } type initTableMsg struct{ table table.Model } // initTable loads the table layout from local-first data sources (~/.abra). func initTable(m model) tea.Msg { var rows []table.Row for _, app := range m.apps { rows = append(rows, table.NewRow(table.RowData{ "domain": app.Domain, "server": app.Server, "recipe": app.Recipe, "status": "🤷", "version": "🤷", "updates": "🤷", "chaos": "🤷", "chaos-version": "🤷", "auto-update": "🤷", "app": app, // attach app itself for faster lookups })) } colStyle := lipgloss.NewStyle().Align(lipgloss.Left) columns := []table.Column{ table.NewFlexColumn("domain", "Domain", 2).WithFiltered(true).WithStyle(colStyle), table.NewFlexColumn("server", "Server", 1).WithFiltered(true).WithStyle(colStyle), table.NewFlexColumn("recipe", "Recipe", 1).WithFiltered(true).WithStyle(colStyle), table.NewFlexColumn("status", "Status", 1).WithStyle(colStyle), table.NewFlexColumn("version", "Version", 2).WithStyle(colStyle), table.NewFlexColumn("updates", "Updates", 2).WithStyle(colStyle), table.NewFlexColumn("chaos", "Chaos", 1).WithStyle(colStyle), table.NewFlexColumn("chaos-version", "Chaos version", 2).WithStyle(colStyle), table.NewFlexColumn("auto-update", "Auto-update", 1).WithStyle(colStyle), } width, height, err := term.GetSize(0) if err != nil { return errorMsg{err} } keymap := table.DefaultKeyMap() keymap.Filter = key.NewBinding(key.WithKeys("/", "f")) keymap.PageDown = key.NewBinding(key.WithKeys("right", "l", "pgdown", "ctrl+d")) keymap.PageUp = key.NewBinding(key.WithKeys("left", "h", "pgup", "ctrl+u")) t := table. New(columns). Filtered(true). Focused(true). WithPageSize(height - 10). WithRows([]table.Row(rows)). WithTargetWidth(width). WithKeyMap(keymap) return initTableMsg{table: t} } // model is the TUI application state. type model struct { apps []config.App numApps int numServers int numRecipes int numFilteredApps int numFilteredServers int numFilteredRecipes int table table.Model spinner spinner.Model pollingStatus bool err error } // getFilteredApps retrieves all visible apps. func (m model) getFilteredApps() []config.App { var servers []config.App for _, row := range m.table.GetVisibleRows() { app, ok := row.Data["app"].(config.App) if !ok { continue } servers = append(servers, app) } return servers } // updateCount updates the apps/servers/recipes count. func (m *model) updateCount() { if m.table.GetIsFilterActive() { apps := m.getFilteredApps() m.numFilteredApps = len(apps) m.numFilteredServers, m.numFilteredRecipes = getNumServersAndRecipes(apps) } else { m.numFilteredApps = m.numApps m.numFilteredServers = m.numServers m.numFilteredRecipes = m.numRecipes } } // Init initialises a new model. All I/O happens here and the results are fed // into the UI for further updates and rendering. func (m model) Init() tea.Cmd { return tea.Batch( func() tea.Msg { return initTable(m) }, m.spinner.Tick, ) } // Update handles updates to the TUI via I/O and the user. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd m.table, cmd = m.table.Update(msg) cmds = append(cmds, cmd) m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, 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() && !m.pollingStatus { m.pollingStatus = true return m, func() tea.Msg { return getAppsDeployStatus(m) } } } case initTableMsg: m.table = msg.table 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.pollingStatus = false m.err = msg.err } return m, tea.Batch(cmds...) } // View renders the UI. func (m model) View() string { body := strings.Builder{} body.WriteString(fmt.Sprintf( "Servers: %v, Apps: %v, Recipes: %v\n", m.numFilteredServers, m.numFilteredApps, m.numFilteredRecipes, )) body.WriteString(m.table.View() + "\n") 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())) } return body.String() } // main is the command-line entrypoint. func main() { handleCliFlags() if helpFlag { fmt.Print(help) os.Exit(0) } if versionFlag { fmt.Println(version) os.Exit(0) } apps, err := getApps() if err != nil { log.Fatal(err) } numServers, numRecipes := getNumServersAndRecipes(apps) s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) numApps := len(apps) m := model{ apps: apps, numApps: numApps, numServers: numServers, numRecipes: numRecipes, numFilteredApps: numApps, numFilteredServers: numServers, numFilteredRecipes: numRecipes, spinner: s, } p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { log.Fatalf("oops, cctuip exploded: %s", err) } }