From b737ce210737782c91ab0138e8f75889473e8dfb Mon Sep 17 00:00:00 2001 From: decentral1se Date: Thu, 2 Oct 2025 10:53:44 +0200 Subject: [PATCH] feat: cctuip lands in main See https://git.coopcloud.tech/toolshed/organising/issues/657 --- cli/app/list.go | 396 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 325 insertions(+), 71 deletions(-) diff --git a/cli/app/list.go b/cli/app/list.go index 26b58711..73839ff1 100644 --- a/cli/app/list.go +++ b/cli/app/list.go @@ -3,17 +3,22 @@ package app import ( "encoding/json" "fmt" + "slices" "sort" - "strconv" "strings" "coopcloud.tech/abra/cli/internal" appPkg "coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/autocomplete" - "coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/i18n" "coopcloud.tech/abra/pkg/log" + "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" + "github.com/evertras/bubble-table/table" "github.com/spf13/cobra" ) @@ -75,7 +80,7 @@ Use "--status/-S" flag to query all servers for the live deployment status.`), sort.Sort(appPkg.ByServerAndRecipe(apps)) statuses := make(map[string]map[string]string) - if status { + if status && internal.MachineReadable { alreadySeen := make(map[string]bool) for _, app := range apps { if _, ok := alreadySeen[app.Server]; !ok { @@ -113,7 +118,7 @@ Use "--status/-S" flag to query all servers for the live deployment status.`), stats.AppCount++ totalAppsCount++ - if status { + if status && internal.MachineReadable { status := i18n.G("unknown") version := i18n.G("unknown") chaos := i18n.G("unknown") @@ -216,73 +221,8 @@ Use "--status/-S" flag to query all servers for the live deployment status.`), return } - alreadySeen := make(map[string]bool) - for _, app := range apps { - if _, ok := alreadySeen[app.Server]; ok { - continue - } - - serverStat := allStats[app.Server] - - headers := []string{i18n.G("RECIPE"), i18n.G("DOMAIN"), i18n.G("SERVER")} - if status { - headers = append(headers, []string{ - i18n.G("STATUS"), - i18n.G("CHAOS"), - i18n.G("VERSION"), - i18n.G("UPGRADE"), - i18n.G("AUTOUPDATE"), - }..., - ) - } - - table, err := formatter.CreateTable() - if err != nil { - log.Fatal(err) - } - - table.Headers(headers...) - - var rows [][]string - for _, appStat := range serverStat.Apps { - row := []string{appStat.Recipe, appStat.Domain, appStat.Server} - if status { - chaosStatus := appStat.Chaos - if chaosStatus != "unknown" { - chaosEnabled, err := strconv.ParseBool(chaosStatus) - if err != nil { - log.Fatal(err) - } - if chaosEnabled && appStat.ChaosVersion != "unknown" { - chaosStatus = appStat.ChaosVersion - } - } - - row = append(row, []string{ - appStat.Status, - chaosStatus, - appStat.Version, - appStat.Upgrade, - appStat.AutoUpdate}..., - ) - } - - rows = append(rows, row) - } - - table.Rows(rows...) - - if len(rows) > 0 { - if err := formatter.PrintTable(table); err != nil { - log.Fatal(err) - } - - if len(allStats) > 1 && len(rows) > 0 { - fmt.Println() // newline separator for multiple servers - } - } - - alreadySeen[app.Server] = true + if err := runTable(apps); err != nil { + log.Fatal(err) } }, } @@ -340,3 +280,317 @@ func init() { }, ) } + +func getNumServersAndRecipes(apps []appPkg.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.Name) { + recipes = append(recipes, app.Recipe.Name) + } + } + + return len(servers), len(recipes) +} + +type errorMsg struct{ err error } + +func (e errorMsg) Error() string { return e.err.Error() } + +type appsDeployStatusMsg map[string]map[string]string + +func getAppsDeployStatus(m model) tea.Msg { + var apps []appPkg.App + + for _, row := range m.table.GetVisibleRows() { + apps = append(apps, row.Data["app"].(appPkg.App)) + } + + statuses, err := appPkg.GetAppStatuses(apps, true) + if err != nil { + return errorMsg{err} + } + + catl, err := recipe.ReadRecipeCatalogue(true) + 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.Name, catl) + if err != nil { + return errorMsg{err} + } + + parsedVersion, err := tagcmp.Parse(version) + if err != nil { + continue + } + + for _, update := range updates { + parsedUpdate, err := tagcmp.Parse(update) + if err != nil { + continue + } + + if update != version && parsedUpdate.IsGreaterThan(parsedVersion) { + newUpdates = append(newUpdates, update) + } + } + + if len(newUpdates) != 0 { + statuses[app.StackName()]["updates"] = strings.Join(newUpdates, "\n") + } + } + } + } + + return appsDeployStatusMsg(statuses) +} + +func renderAppsDeployStatus(m *model, appStatuses appsDeployStatusMsg) table.Model { + for _, row := range m.table.GetVisibleRows() { + app := row.Data["app"].(appPkg.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"] = status + } + + if version != "" { + row.Data["version"] = version + row.Data["updates"] = updates + } + + if chaos != "" { + if chaosVersion != "" { + row.Data["chaos-version"] = chaosVersion + } + } + + if autoUpdate != "" { + row.Data["autoUpdate"] = autoUpdate + } + } + return m.table +} + +type initTableMsg struct{ table table.Model } + +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.Name, + "app": app, + })) + } + + 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).WithFiltered(true).WithStyle(colStyle), + table.NewFlexColumn("version", "VERSION", 1).WithFiltered(true).WithStyle(colStyle), + table.NewFlexColumn("updates", "UPDATES", 1).WithFiltered(true).WithStyle(colStyle), + table.NewFlexColumn("chaos-version", "CHAOS", 1).WithFiltered(true).WithStyle(colStyle), + table.NewFlexColumn("auto-update", "AUTO-UPDATE", 1).WithFiltered(true).WithStyle(colStyle), + } + + 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). + WithRows([]table.Row(rows)). + WithKeyMap(keymap). + WithMultiline(true). + WithFuzzyFilter(). + SortByAsc("domain"). + WithNoPagination(). + WithMissingDataIndicatorStyled(table.StyledCell{ + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#faa")), + Data: "-", + }) + + return initTableMsg{table: t} +} + +type model struct { + apps []appPkg.App + + numApps int + numServers int + numRecipes int + + numFilteredApps int + numFilteredServers int + numFilteredRecipes int + + initStatusGather bool + + table table.Model + spinner spinner.Model + pollingStatus bool + + width int + height int + + err error +} + +func (m model) getFilteredApps() []appPkg.App { + var servers []appPkg.App + for _, row := range m.table.GetVisibleRows() { + servers = append(servers, row.Data["app"].(appPkg.App)) + } + return servers +} + +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 + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + func() tea.Msg { return initTable(m) }, + m.spinner.Tick, + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.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 + + m.table = m.table.WithTargetWidth(m.width) + m.table = m.table.WithPageSize(m.height - 10) + + if m.initStatusGather { + m.pollingStatus = true + return m, func() tea.Msg { return getAppsDeployStatus(m) } + } + case appsDeployStatusMsg: + m.pollingStatus = false + m.table = renderAppsDeployStatus(&m, msg) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + m.table = m.table.WithTargetWidth(m.width) + m.table = m.table.WithPageSize(m.height - 10) + case errorMsg: + m.err = msg + } + + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + if m.err != nil { + return fmt.Sprintf("FATA: %v", m.err) + } + + body := strings.Builder{} + + body.WriteString(m.table.View() + "\n") + + stats := fmt.Sprintf( + "[servers] %v • [apps] %v • [recipes] %v", + m.numFilteredServers, m.numFilteredApps, m.numFilteredRecipes, + ) + + help := "[q] quit • [/] filter • [s] status" + + body.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, stats, " | ", help)) + + if m.pollingStatus { + body.WriteString(fmt.Sprintf(" | %s querying app status", m.spinner.View())) + } else { + body.WriteString(" | -") + } + + return body.String() +} + +func runTable(apps []appPkg.App) error { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + numServers, numRecipes := getNumServersAndRecipes(apps) + numApps := len(apps) + + m := model{ + apps: apps, + numApps: numApps, + numServers: numServers, + numRecipes: numRecipes, + numFilteredApps: numApps, + numFilteredServers: numServers, + numFilteredRecipes: numRecipes, + spinner: s, + initStatusGather: status, + } + + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("oops, app list tui exploded: %s", err) + } + + return nil +}