// cctuip is a Co-op Cloud TUI. package main import ( "flag" "fmt" "log" "os" "sort" "strings" abraClient "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/recipe" "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" ) // help is the cctuip CLI help output. const help = `cctuip [options] cctuip is a Co-op Cloud TUI. Options: -h output help ` var helpFlag bool // handleCliFlags parses CLI flags. func handleCliFlags() error { flag.BoolVar(&helpFlag, "h", false, "output help") 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. 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 } // Error implements error output rendering. func (e errorMsg) Error() string { return e.err.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. func getAppsDeployStatus(m model) tea.Msg { var apps []config.App for _, row := range m.table.GetVisibleRows() { apps = append(apps, row.Data["app"].(config.App)) } statuses, err := config.GetAppStatuses(apps, true) if err != nil { return errorMsg{err} } catl, err := recipe.ReadRecipeCatalogue() if err != nil { return errorMsg{err} } 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} } 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) } func renderAppsDeployStatus(m *model, appStatuses appsDeployStatusMsg) table.Model { for _, row := range m.table.GetVisibleRows() { app := row.Data["app"].(config.App) appStatus := appStatuses[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 { log.Fatal(err) // TODO } 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() { servers = append(servers, row.Data["app"].(config.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: m.updateCount() switch msg.String() { case "q": return m, tea.Quit case "s": if !m.table.GetIsFilterInputFocused() { 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) case tea.WindowSizeMsg: m.table = m.table.WithTargetWidth(msg.Width) m.table = m.table.WithPageSize(msg.Height - 10) case errorMsg: m.err = msg // TODO } return m, tea.Batch(cmds...) } // 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( "Servers: %v, Apps: %v, Recipes: %v\n", m.numFilteredServers, m.numFilteredApps, m.numFilteredRecipes, )) body.WriteString(m.table.View() + "\n") body.WriteString("cctuip v0.1.0") 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) } 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 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) } }