Stacks usually run more than one service, so the last service read used to wipe the status of the stack. Merge the labels into the existing map instead. Servers are now queried through an injectable fetcher with a concurrency limit, so a large fleet no longer opens one SSH connection per server at once, and the fetch is testable. Also: - only read the recipe catalogue when a deployed version exists, and render statuses anyway if it cannot be fetched - truncate the error banner instead of joining every server error - clamp the page size to at least one row, as bubble-table otherwise renders nothing at all - stop treating keypresses as commands while the filter has focus, so q types a q rather than quitting - parse flags on a dedicated flag set with long forms, so a bad flag reports against cctuip help instead of exiting - drop golang.org/x/exp in favour of the built-in maps
692 lines
17 KiB
Go
692 lines
17 KiB
Go
// cctuip is a Co-op Cloud TUI.
|
|
package main
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"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/term"
|
|
)
|
|
|
|
// version is the cctuip version. This is overridden at build time with
|
|
// -ldflags "-X 'main.version=$(VERSION)'", see the Makefile.
|
|
var version = "v0.1.0"
|
|
|
|
// help is the cctuip CLI help output.
|
|
const help = `cctuip [options]
|
|
|
|
cctuip is a Co-op Cloud TUI.
|
|
|
|
Options:
|
|
-h, --help output help
|
|
-v, --version output version
|
|
`
|
|
|
|
// Table row, column and per-stack status keys. The same keys are used for all
|
|
// three so that they cannot drift apart.
|
|
const (
|
|
keyDomain = "domain"
|
|
keyServer = "server"
|
|
keyRecipe = "recipe"
|
|
keyStatus = "status"
|
|
keyVersion = "version"
|
|
keyUpdates = "updates"
|
|
keyChaos = "chaos"
|
|
keyChaosVersion = "chaos-version"
|
|
keyAutoUpdate = "auto-update"
|
|
keyApp = "app"
|
|
)
|
|
|
|
// statusKeys are the columns rendered from the per-stack status maps, in the
|
|
// order they appear in the table.
|
|
var statusKeys = []string{
|
|
keyStatus,
|
|
keyVersion,
|
|
keyUpdates,
|
|
keyChaos,
|
|
keyChaosVersion,
|
|
keyAutoUpdate,
|
|
}
|
|
|
|
// unknownStatus is rendered for any value which could not be determined.
|
|
const unknownStatus = "🤷"
|
|
|
|
// maxConcurrentServers caps how many servers are queried at the same time so
|
|
// that a large fleet does not open one SSH connection per server at once.
|
|
const maxConcurrentServers = 5
|
|
|
|
// maxReportedProblems caps how many problems the error banner shows before it
|
|
// collapses the rest into a count.
|
|
const maxReportedProblems = 3
|
|
|
|
// chromeRows is the number of terminal rows reserved for the counts line, the
|
|
// error banner and the footer around the table.
|
|
const chromeRows = 10
|
|
|
|
// newFlagSet registers the cctuip flags, including their long forms, on a
|
|
// dedicated flag set so that a bad flag returns an error rather than exiting
|
|
// from inside the flag package.
|
|
func newFlagSet(helpFlag, versionFlag *bool) *flag.FlagSet {
|
|
fs := flag.NewFlagSet("cctuip", flag.ContinueOnError)
|
|
fs.BoolVar(helpFlag, "h", false, "output help")
|
|
fs.BoolVar(helpFlag, "help", false, "output help")
|
|
fs.BoolVar(versionFlag, "v", false, "output version")
|
|
fs.BoolVar(versionFlag, "version", false, "output version")
|
|
|
|
return fs
|
|
}
|
|
|
|
// 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) {
|
|
servers := make(map[string]struct{})
|
|
recipes := make(map[string]struct{})
|
|
|
|
for _, app := range apps {
|
|
servers[app.Server] = struct{}{}
|
|
recipes[app.Recipe] = struct{}{}
|
|
}
|
|
|
|
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
|
|
catalogueErr error
|
|
}
|
|
|
|
// labelKey builds the Co-op Cloud label key for a stack label suffix.
|
|
func labelKey(stackName, suffix string) string {
|
|
return fmt.Sprintf("coop-cloud.%s.%s", stackName, suffix)
|
|
}
|
|
|
|
// parseServiceStatuses folds the labels of every deployed swarm service into a
|
|
// status map per stack.
|
|
//
|
|
// Services are merged into the existing map rather than replacing it because
|
|
// most stacks run more than one service (e.g. an app alongside its database)
|
|
// and the last service read would otherwise drop the keys it does not set.
|
|
func parseServiceStatuses(all []stack.StackStatus) map[string]map[string]string {
|
|
statuses := make(map[string]map[string]string)
|
|
|
|
for _, result := range all {
|
|
if result.Err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, service := range result.Services {
|
|
name := service.Spec.Labels[convert.LabelNamespace]
|
|
if name == "" {
|
|
continue
|
|
}
|
|
|
|
status, ok := statuses[name]
|
|
if !ok {
|
|
status = map[string]string{keyStatus: "deployed"}
|
|
statuses[name] = status
|
|
}
|
|
|
|
if chaos, ok := service.Spec.Labels[labelKey(name, "chaos")]; ok {
|
|
status[keyChaos] = chaos
|
|
}
|
|
|
|
if chaosVersion, ok := service.Spec.Labels[labelKey(name, "chaos-version")]; ok {
|
|
status[keyChaosVersion] = chaosVersion
|
|
}
|
|
|
|
if autoUpdate, ok := service.Spec.Labels[labelKey(name, "autoupdate")]; ok {
|
|
status[keyAutoUpdate] = autoUpdate
|
|
} else {
|
|
status[keyAutoUpdate] = "false"
|
|
}
|
|
|
|
if version, ok := service.Spec.Labels[labelKey(name, "version")]; ok {
|
|
status[keyVersion] = version
|
|
}
|
|
}
|
|
}
|
|
|
|
return statuses
|
|
}
|
|
|
|
// resolveUpdates compares each deployed version against the recipe catalogue
|
|
// and records how many newer versions are available, in place.
|
|
func resolveUpdates(
|
|
apps []config.App,
|
|
statuses map[string]map[string]string,
|
|
catl recipe.RecipeCatalogue,
|
|
) {
|
|
catalogueVersions := make(map[string][]string)
|
|
|
|
for _, app := range apps {
|
|
status, ok := statuses[app.StackName()]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
version, ok := status[keyVersion]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
parsedVersion, err := tagcmp.Parse(version)
|
|
if err != nil {
|
|
status[keyUpdates] = unknownStatus
|
|
continue
|
|
}
|
|
|
|
updates, ok := catalogueVersions[app.Recipe]
|
|
if !ok {
|
|
updates, err = recipe.GetRecipeCatalogueVersions(app.Recipe, catl)
|
|
if err != nil {
|
|
status[keyUpdates] = unknownStatus
|
|
continue
|
|
}
|
|
|
|
catalogueVersions[app.Recipe] = updates
|
|
}
|
|
|
|
var (
|
|
newUpdates []string
|
|
parseFailed bool
|
|
)
|
|
|
|
for _, update := range updates {
|
|
parsedUpdate, err := tagcmp.Parse(update)
|
|
if err != nil {
|
|
parseFailed = true
|
|
break
|
|
}
|
|
|
|
if update != version && parsedUpdate.IsGreaterThan(parsedVersion) {
|
|
newUpdates = append(newUpdates, update)
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case parseFailed:
|
|
status[keyUpdates] = unknownStatus
|
|
case len(newUpdates) == 0:
|
|
status[keyUpdates] = "✅"
|
|
default:
|
|
status[keyUpdates] = fmt.Sprintf("%v", len(newUpdates))
|
|
}
|
|
}
|
|
}
|
|
|
|
// hasDeployedVersion reports whether any of the apps is deployed with a known
|
|
// version, i.e. whether it is worth fetching the recipe catalogue at all.
|
|
func hasDeployedVersion(apps []config.App, statuses map[string]map[string]string) bool {
|
|
for _, app := range apps {
|
|
if _, ok := statuses[app.StackName()][keyVersion]; ok {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// serverResult pairs a server with the swarm services retrieved from it.
|
|
type serverResult struct {
|
|
server string
|
|
status stack.StackStatus
|
|
}
|
|
|
|
// serverFetcher retrieves the deployed swarm services for a single server.
|
|
type serverFetcher func(server string) stack.StackStatus
|
|
|
|
// newServerFetcher returns a fetcher which reaches each server's docker socket
|
|
// over SSH.
|
|
func newServerFetcher() serverFetcher {
|
|
return func(server string) stack.StackStatus {
|
|
cl, err := client.New(server)
|
|
if err != nil {
|
|
return stack.StackStatus{Err: err}
|
|
}
|
|
|
|
return stack.GetAllDeployedServices(cl, server)
|
|
}
|
|
}
|
|
|
|
// fetchAll queries each server concurrently, keeping at most limit requests in
|
|
// flight. Servers which cannot be reached (e.g. SSH login issues) are reported
|
|
// via serverErrors so that statuses for the remaining servers are still
|
|
// returned.
|
|
func fetchAll(
|
|
fetcher serverFetcher,
|
|
servers []string,
|
|
limit int,
|
|
) ([]stack.StackStatus, map[string]error) {
|
|
if limit < 1 {
|
|
limit = 1
|
|
}
|
|
|
|
results := make(chan serverResult, len(servers))
|
|
slots := make(chan struct{}, limit)
|
|
|
|
for _, server := range servers {
|
|
go func(s string) {
|
|
slots <- struct{}{}
|
|
defer func() { <-slots }()
|
|
|
|
results <- serverResult{server: s, status: fetcher(s)}
|
|
}(server)
|
|
}
|
|
|
|
all := make([]stack.StackStatus, 0, len(servers))
|
|
serverErrors := make(map[string]error)
|
|
|
|
for range servers {
|
|
res := <-results
|
|
if res.status.Err != nil {
|
|
serverErrors[res.server] = res.status.Err
|
|
continue
|
|
}
|
|
|
|
all = append(all, res.status)
|
|
}
|
|
|
|
return all, serverErrors
|
|
}
|
|
|
|
// getAppsDeployStatus retrieves apps deployment status from the servers they
|
|
// are deployed on.
|
|
func getAppsDeployStatus(m model) tea.Msg {
|
|
apps := visibleApps(&m)
|
|
|
|
var (
|
|
servers []string
|
|
seen = make(map[string]struct{})
|
|
)
|
|
|
|
for _, app := range apps {
|
|
if _, ok := seen[app.Server]; ok {
|
|
continue
|
|
}
|
|
|
|
seen[app.Server] = struct{}{}
|
|
servers = append(servers, app.Server)
|
|
}
|
|
|
|
all, serverErrors := fetchAll(newServerFetcher(), servers, maxConcurrentServers)
|
|
statuses := parseServiceStatuses(all)
|
|
|
|
// The catalogue lives behind a git fetch, so only reach for it when there
|
|
// is a deployed version to compare it against.
|
|
var catalogueErr error
|
|
|
|
if hasDeployedVersion(apps, statuses) {
|
|
catl, err := recipe.ReadRecipeCatalogue()
|
|
if err != nil {
|
|
catalogueErr = err
|
|
} else {
|
|
resolveUpdates(apps, statuses, catl)
|
|
}
|
|
}
|
|
|
|
return appsDeployStatusMsg{
|
|
statuses: statuses,
|
|
serverErrors: serverErrors,
|
|
catalogueErr: catalogueErr,
|
|
}
|
|
}
|
|
|
|
// statusOrUnknown returns a status value, falling back to 🤷.
|
|
func statusOrUnknown(status map[string]string, key string) string {
|
|
if value := status[key]; value != "" {
|
|
return value
|
|
}
|
|
|
|
return unknownStatus
|
|
}
|
|
|
|
// 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[keyApp].(config.App)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
status := appStatuses.statuses[app.StackName()]
|
|
for _, key := range statusKeys {
|
|
row.Data[key] = statusOrUnknown(status, key)
|
|
}
|
|
}
|
|
|
|
return m.table
|
|
}
|
|
|
|
// summariseProblems folds per-server and catalogue problems into a single
|
|
// error short enough for the one-line banner.
|
|
func summariseProblems(appStatuses appsDeployStatusMsg) error {
|
|
var problems []string
|
|
|
|
for server, err := range appStatuses.serverErrors {
|
|
problems = append(problems, fmt.Sprintf("%s: %s", server, err))
|
|
}
|
|
|
|
if appStatuses.catalogueErr != nil {
|
|
problems = append(problems, fmt.Sprintf("catalogue: %s", appStatuses.catalogueErr))
|
|
}
|
|
|
|
if len(problems) == 0 {
|
|
return nil
|
|
}
|
|
|
|
sort.Strings(problems)
|
|
|
|
if len(problems) > maxReportedProblems {
|
|
return fmt.Errorf("%s, …and %v more",
|
|
strings.Join(problems[:maxReportedProblems], ", "),
|
|
len(problems)-maxReportedProblems,
|
|
)
|
|
}
|
|
|
|
return errors.New(strings.Join(problems, ", "))
|
|
}
|
|
|
|
type initTableMsg struct{ table table.Model }
|
|
|
|
// newRows builds a table row per app, seeded with unknown values.
|
|
func newRows(apps []config.App) []table.Row {
|
|
rows := make([]table.Row, 0, len(apps))
|
|
|
|
for _, app := range apps {
|
|
rows = append(rows, table.NewRow(table.RowData{
|
|
keyDomain: app.Domain,
|
|
keyServer: app.Server,
|
|
keyRecipe: app.Recipe,
|
|
keyStatus: unknownStatus,
|
|
keyVersion: unknownStatus,
|
|
keyUpdates: unknownStatus,
|
|
keyChaos: unknownStatus,
|
|
keyChaosVersion: unknownStatus,
|
|
keyAutoUpdate: unknownStatus,
|
|
keyApp: app,
|
|
}))
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
// pageSize returns how many table rows fit in the given terminal height.
|
|
// bubble-table mis-paginates on a non-positive page size and silently renders
|
|
// no rows at all, so the result is always at least one row.
|
|
func pageSize(height int) int {
|
|
size := height - chromeRows
|
|
if size < 1 {
|
|
return 1
|
|
}
|
|
|
|
return size
|
|
}
|
|
|
|
// newTable builds the apps table sized for the given terminal dimensions.
|
|
func newTable(rows []table.Row, width, height int) table.Model {
|
|
colStyle := lipgloss.NewStyle().Align(lipgloss.Left)
|
|
|
|
columns := []table.Column{
|
|
table.NewFlexColumn(keyDomain, "Domain", 2).WithFiltered(true).WithStyle(colStyle),
|
|
table.NewFlexColumn(keyServer, "Server", 1).WithFiltered(true).WithStyle(colStyle),
|
|
table.NewFlexColumn(keyRecipe, "Recipe", 1).WithFiltered(true).WithStyle(colStyle),
|
|
table.NewFlexColumn(keyStatus, "Status", 1).WithStyle(colStyle),
|
|
table.NewFlexColumn(keyVersion, "Version", 2).WithStyle(colStyle),
|
|
table.NewFlexColumn(keyUpdates, "Updates", 2).WithStyle(colStyle),
|
|
table.NewFlexColumn(keyChaos, "Chaos", 1).WithStyle(colStyle),
|
|
table.NewFlexColumn(keyChaosVersion, "Chaos version", 2).WithStyle(colStyle),
|
|
table.NewFlexColumn(keyAutoUpdate, "Auto-update", 1).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"))
|
|
|
|
return table.
|
|
New(columns).
|
|
Filtered(true).
|
|
Focused(true).
|
|
WithPageSize(pageSize(height)).
|
|
WithRows(rows).
|
|
WithTargetWidth(width).
|
|
WithKeyMap(keymap)
|
|
}
|
|
|
|
// initTable loads the table layout from local-first data sources (~/.abra).
|
|
func initTable(m model) tea.Msg {
|
|
width, height, err := term.GetSize(0)
|
|
if err != nil {
|
|
return errorMsg{err}
|
|
}
|
|
|
|
return initTableMsg{table: newTable(newRows(m.apps), width, height)}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// visibleApps retrieves all apps currently visible in the table.
|
|
func visibleApps(m *model) []config.App {
|
|
var apps []config.App
|
|
|
|
for _, row := range m.table.GetVisibleRows() {
|
|
app, ok := row.Data[keyApp].(config.App)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
apps = append(apps, app)
|
|
}
|
|
|
|
return apps
|
|
}
|
|
|
|
// updateCount updates the apps/servers/recipes count.
|
|
func (m *model) updateCount() {
|
|
if m.table.GetIsFilterActive() {
|
|
apps := visibleApps(m)
|
|
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 cmds []tea.Cmd
|
|
var cmd 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()
|
|
|
|
// While the fuzzy filter has focus every keypress belongs to it.
|
|
if m.table.GetIsFilterInputFocused() {
|
|
break
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "q":
|
|
return m, tea.Quit
|
|
case "s":
|
|
if !m.pollingStatus {
|
|
m.pollingStatus = true
|
|
cmds = append(cmds, func() tea.Msg { return getAppsDeployStatus(m) })
|
|
}
|
|
}
|
|
case initTableMsg:
|
|
m.table = msg.table
|
|
case appsDeployStatusMsg:
|
|
m.pollingStatus = false
|
|
m.table = renderAppsDeployStatus(&m, msg)
|
|
|
|
if err := summariseProblems(msg); err != nil {
|
|
m.err = err
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
m.table = m.table.WithTargetWidth(msg.Width)
|
|
m.table = m.table.WithPageSize(pageSize(msg.Height))
|
|
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() {
|
|
var helpFlag bool
|
|
var versionFlag bool
|
|
|
|
fs := newFlagSet(&helpFlag, &versionFlag)
|
|
// The flag package prints its own error and usage text, but we want to
|
|
// report problems against cctuip's help instead.
|
|
fs.SetOutput(io.Discard)
|
|
|
|
if err := fs.Parse(os.Args[1:]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "cctuip: %s\n\n%s", err, help)
|
|
os.Exit(2)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|