diff --git a/internal/devtui/migration_wizard_view.go b/internal/devtui/migration_wizard_view.go index 26ddb2b7..8b680480 100644 --- a/internal/devtui/migration_wizard_view.go +++ b/internal/devtui/migration_wizard_view.go @@ -103,7 +103,7 @@ func (sg migrationWizard) viewReview() string { divider := tui.SectionDivider(60) b.WriteString(tui.KVRow("Environment", activeBadgeStyle.Render("Docker"))) - b.WriteString(tui.KVRow("Shop URL", urlStyle.Render(c.url))) + b.WriteString(tui.KVRow("Shop URL", tui.RenderStyledLink(c.url))) b.WriteString(tui.KVRow("Username", valueStyle.Render(c.username))) b.WriteString(tui.KVRow("Password", secretStyle.Render(strings.Repeat("•", len(c.password))))) b.WriteString(divider) diff --git a/internal/devtui/model.go b/internal/devtui/model.go index fbad57df..afa9a91c 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -422,8 +422,13 @@ func (m Model) handleConfigRestartDone(msg configRestartDoneMsg) (tea.Model, tea if msg.err != nil { m.configTab.err = msg.err m.configTab.saved = false - } else { - m.configTab.saved = true + return m, nil } - return m, nil + + m.configTab.saved = true + // The restart may have changed the runtime (PHP version, published ports, + // APP_ENV), so rediscover services and rerun the setup-health checks. + m.overview.loading = true + m.overview.healthLoading = true + return m, m.overview.Init() } diff --git a/internal/devtui/styles.go b/internal/devtui/styles.go index d7388fda..49e708e5 100644 --- a/internal/devtui/styles.go +++ b/internal/devtui/styles.go @@ -13,9 +13,6 @@ var ( valueStyle = lipgloss.NewStyle(). Foreground(tui.TextColor) - urlStyle = lipgloss.NewStyle(). - Foreground(tui.LinkColor) - secretStyle = lipgloss.NewStyle(). Foreground(tui.WarnColor) diff --git a/internal/devtui/tab_overview.go b/internal/devtui/tab_overview.go index 9c341dae..572b192b 100644 --- a/internal/devtui/tab_overview.go +++ b/internal/devtui/tab_overview.go @@ -55,7 +55,9 @@ type OverviewModel struct { sfWatchStarting bool shopwareVersion string securityEnd time.Time - cursor int // focus index: 0=Admin watcher, 1=Storefront watcher, 2..=services + health []healthCheck + healthLoading bool + cursor int // focus index: 0=Admin watcher, 1=Storefront watcher } type DiscoveredService struct { @@ -202,6 +204,15 @@ func backgroundServiceLabel(service string) (string, bool) { // service) listens on. Its published host port determines the shop URL port. const webServiceTargetPort = 8000 +// linkURL renders url as a clickable OSC 8 hyperlink in the shared link style. +// Terminals without hyperlink support show the plain styled URL instead. +func linkURL(url string) string { + if url == "" { + return "" + } + return tui.RenderStyledLink(url) +} + // deriveAdminURL returns the admin URL for the given shop URL by appending the // "admin" path segment. func deriveAdminURL(shopURL string) string { @@ -214,20 +225,25 @@ func deriveAdminURL(shopURL string) string { func NewOverviewModel(envType, shopURL, username, password, projectRoot string, exec executor.Executor, shopCfg *shop.Config) OverviewModel { return OverviewModel{ - envType: envType, - shopURL: shopURL, - adminURL: deriveAdminURL(shopURL), - username: username, - password: password, - projectRoot: projectRoot, - executor: exec, - shopCfg: shopCfg, - loading: true, + envType: envType, + shopURL: shopURL, + adminURL: deriveAdminURL(shopURL), + username: username, + password: password, + projectRoot: projectRoot, + executor: exec, + shopCfg: shopCfg, + loading: true, + healthLoading: true, } } func (m OverviewModel) Init() tea.Cmd { - return tea.Batch(discoverServices(m.projectRoot), loadShopwareVersion(m.projectRoot)) + return tea.Batch( + discoverServices(m.projectRoot), + loadShopwareVersion(m.projectRoot), + loadSetupHealth(m.projectRoot, m.executor), + ) } func (m *OverviewModel) SetSize(width, height int) { @@ -264,6 +280,9 @@ func (m OverviewModel) Update(msg tea.Msg) (OverviewModel, tea.Cmd) { } case securityEndLoadedMsg: m.securityEnd = msg.securityEnd + case setupHealthLoadedMsg: + m.healthLoading = false + m.health = msg.checks case tea.KeyPressMsg: return m.handleKey(msg) } @@ -271,8 +290,8 @@ func (m OverviewModel) Update(msg tea.Msg) (OverviewModel, tea.Cmd) { } func (m OverviewModel) focusCount() int { - // Admin watcher + Storefront watcher + discovered services - return 2 + len(m.services) + // Admin watcher + Storefront watcher + return 2 } func (m OverviewModel) handleKey(msg tea.KeyPressMsg) (OverviewModel, tea.Cmd) { @@ -313,11 +332,6 @@ func (m OverviewModel) activate() (OverviewModel, tea.Cmd) { if !m.sfWatchStarting { return m, func() tea.Msg { return startStorefrontWatchRequestMsg{} } } - default: // Service — open URL - svcIdx := m.cursor - 2 - if svcIdx < len(m.services) && m.services[svcIdx].URL != "" { - return m, openInBrowser(m.services[svcIdx].URL) - } } return m, nil } @@ -329,11 +343,91 @@ func openInBrowser(url string) tea.Cmd { } } +// overviewTwoColumnMinWidth is the tab width below which the overview falls +// back to a single stacked column instead of the report/user-action split. It +// leaves the left column enough room for a default Access row (~64 cells), so +// the table does not wrap into the right column. +const overviewTwoColumnMinWidth = 110 + +// overviewRightColumnWidth is the inner width of the "User action" column. +const overviewRightColumnWidth = 32 + func (m OverviewModel) View(width, height int) string { + usable := width - 8 + if width < overviewTwoColumnMinWidth { + return m.renderStacked(usable) + } + + leftWidth := usable - overviewRightColumnWidth - 3 + + left := lipgloss.NewStyle().Width(leftWidth).Render(m.renderProjectReport(leftWidth)) + right := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(tui.BorderColor). + PaddingLeft(2). + Height(lipgloss.Height(left)). + Render(m.renderUserActions()) + + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) +} + +// renderProjectReport renders the left column: the readonly project details +// and setup report. +func (m OverviewModel) renderProjectReport(width int) string { + divider := tui.SectionDivider(width) + var s strings.Builder + s.WriteString(helpStyle.Render("Project details and readonly setup report.")) + s.WriteString("\n\n") + s.WriteString(m.renderShopSection()) + s.WriteString(divider) + s.WriteString(m.renderAccess()) + if len(m.background) > 0 { + s.WriteString(divider) + s.WriteString(tui.TitleStyle.Render("Background processing")) + s.WriteString("\n") + s.WriteString(m.renderBackgroundProcesses()) + } + s.WriteString(divider) + s.WriteString(m.renderSetupHealth()) + return s.String() +} - divider := tui.SectionDivider(width - 8) +// renderUserActions renders the right column: everything the user can act on. +func (m OverviewModel) renderUserActions() string { + var s strings.Builder + s.WriteString(tui.SectionTitleStyle.Render("User action")) + s.WriteString("\n\n") + s.WriteString(m.renderWatchers()) + return s.String() +} + +// renderStacked is the single-column fallback for narrow terminals, keeping +// every section of the two-column layout. +func (m OverviewModel) renderStacked(width int) string { + divider := tui.SectionDivider(width) + + var s strings.Builder + s.WriteString(helpStyle.Render("Project details and readonly setup report.")) + s.WriteString("\n\n") + s.WriteString(m.renderShopSection()) + s.WriteString(divider) + s.WriteString(m.renderAccess()) + if len(m.background) > 0 { + s.WriteString(divider) + s.WriteString(tui.TitleStyle.Render("Background processing")) + s.WriteString("\n") + s.WriteString(m.renderBackgroundProcesses()) + } + s.WriteString(divider) + s.WriteString(m.renderWatchers()) + s.WriteString(divider) + s.WriteString(m.renderSetupHealth()) + return s.String() +} +func (m OverviewModel) renderShopSection() string { + var s strings.Builder s.WriteString(tui.TitleStyle.Render("Shop")) s.WriteString("\n") if m.shopwareVersion != "" { @@ -343,53 +437,123 @@ func (m OverviewModel) View(width, height int) string { s.WriteString(tui.KVRow("Security updates", renderSecurityEnd(m.securityEnd, time.Now()))) } s.WriteString(tui.KVRow("Environment", activeBadgeStyle.Render(m.envType))) - s.WriteString(tui.KVRow("Shop URL", urlStyle.Render(m.shopURL))) - s.WriteString(tui.KVRow("Admin URL", urlStyle.Render(m.adminURL))) + s.WriteString(tui.KVRow("Shop URL", linkURL(m.shopURL))) + s.WriteString(tui.KVRow("Admin URL", linkURL(m.adminURL))) + return s.String() +} - s.WriteString(divider) +// accessRow is one line of the Access table: a reachable service with its URL +// and credentials. noAuth marks services that are open without credentials, as +// opposed to credentials that are simply not known yet. +type accessRow struct { + name string + url string + username string + password string + noAuth bool +} + +// renderAccess renders the Access table: the Shop Admin login first, followed +// by every discovered auxiliary service with its credentials. +func (m OverviewModel) renderAccess() string { + rows := []accessRow{{ + name: "Shop Admin", + url: m.adminURL, + username: m.username, + password: m.password, + }} + for _, service := range m.services { + rows = append(rows, accessRow{ + name: service.Name, + url: service.URL, + username: service.Username, + password: service.Password, + noAuth: service.Username == "" && service.Password == "", + }) + } - s.WriteString(tui.TitleStyle.Render("Admin Access")) - s.WriteString("\n") - if m.username == "" && m.password == "" { - s.WriteString(" ") - s.WriteString(helpStyle.Render("Credentials will appear here once Shopware is installed.")) - s.WriteString("\n") - } else { - s.WriteString(tui.KVRow("Username", valueStyle.Render(m.username))) - s.WriteString(tui.KVRow("Password", secretStyle.Render(m.password))) + serviceWidth, urlWidth, userWidth := lipgloss.Width("Service"), lipgloss.Width("URL"), lipgloss.Width("Username") + for _, row := range rows { + serviceWidth = max(serviceWidth, lipgloss.Width(row.name)) + urlWidth = max(urlWidth, lipgloss.Width(row.url)) + userWidth = max(userWidth, lipgloss.Width(row.username)) } + serviceStyle := lipgloss.NewStyle().Width(serviceWidth + 3) + urlStyleW := lipgloss.NewStyle().Width(urlWidth + 3) + userStyle := lipgloss.NewStyle().Width(userWidth + 3) - s.WriteString(divider) + var s strings.Builder + s.WriteString(tui.TitleStyle.Render("Access")) + s.WriteString("\n") - s.WriteString(tui.TitleStyle.Render("Watchers")) + dim := lipgloss.NewStyle().Foreground(tui.MutedColor) + s.WriteString(" ") + s.WriteString(serviceStyle.Render(dim.Render("Service"))) + s.WriteString(urlStyleW.Render(dim.Render("URL"))) + s.WriteString(userStyle.Render(dim.Render("Username"))) + s.WriteString(dim.Render("Password / Auth")) s.WriteString("\n") - s.WriteString(m.renderWatcherStatus("Admin", m.adminWatchRunning, m.adminWatchStarting, "http://127.0.0.1:5173", m.cursor == 0)) - s.WriteString(m.renderWatcherStatus("Storefront", m.sfWatchRunning, m.sfWatchStarting, "http://127.0.0.1:9998", m.cursor == 1)) - if len(m.background) > 0 { - s.WriteString(divider) - s.WriteString(tui.TitleStyle.Render("Background processing")) + for _, row := range rows { + username := tui.DimStyle.Render("-") + if row.username != "" { + username = valueStyle.Render(row.username) + } + + auth := tui.DimStyle.Render("-") + switch { + case row.noAuth: + auth = tui.DimStyle.Render("no auth") + case row.password != "": + auth = secretStyle.Render(row.password) + } + + s.WriteString(" ") + s.WriteString(serviceStyle.Render(row.name)) + s.WriteString(urlStyleW.Render(linkURL(row.url))) + s.WriteString(userStyle.Render(username)) + s.WriteString(auth) s.WriteString("\n") - s.WriteString(m.renderBackgroundProcesses()) } - s.WriteString(divider) + switch { + case m.loading: + s.WriteString(" " + helpStyle.Render("Scanning for further local services...") + "\n") + case m.err != nil: + s.WriteString(" " + errorStyle.Render(m.err.Error()) + "\n") + } + if m.username == "" && m.password == "" { + s.WriteString(" " + helpStyle.Render("Admin credentials will appear here once Shopware is installed.") + "\n") + } - s.WriteString(tui.TitleStyle.Render("Services")) - s.WriteString("\n") - s.WriteString(m.renderServices()) + return s.String() +} +func (m OverviewModel) renderWatchers() string { + var s strings.Builder + s.WriteString(tui.TitleStyle.Render("Watchers")) + s.WriteString("\n") + s.WriteString(m.renderWatcherStatus("Admin", m.adminWatchRunning, m.adminWatchStarting, "http://127.0.0.1:5173", m.cursor == 0)) + s.WriteString(m.renderWatcherStatus("Storefront", m.sfWatchRunning, m.sfWatchStarting, "http://127.0.0.1:9998", m.cursor == 1)) return s.String() } func (m OverviewModel) renderBackgroundProcesses() string { + nameWidth := 0 + for _, proc := range m.background { + nameWidth = max(nameWidth, lipgloss.Width(proc.Name)) + } + nameStyle := lipgloss.NewStyle().Width(nameWidth + 3) + var s strings.Builder for _, proc := range m.background { - if proc.Running { - s.WriteString(tui.CheckboxRow("[x]", proc.Name, "running", tui.SuccessColor, true)) - } else { - s.WriteString(tui.CheckboxRow("[ ]", proc.Name, "stopped", tui.MutedColor, false)) + dot := lipgloss.NewStyle().Foreground(tui.SuccessColor).Render("●") + status := lipgloss.NewStyle().Foreground(tui.SuccessColor).Render("running") + if !proc.Running { + dot = tui.DimStyle.Render("●") + status = tui.DimStyle.Render("stopped") } + fmt.Fprintf(&s, " %s %s%s\n", dot, nameStyle.Render(proc.Name), status) } return s.String() } @@ -536,9 +700,6 @@ func (m OverviewModel) renderWatcherStatus(label string, running, starting bool, checkbox = lipgloss.NewStyle().Render("[x]") } status = lipgloss.NewStyle().Bold(true).Render("running") - if url != "" { - status += " " + urlStyle.Render(url) - } case starting: checkbox = lipgloss.NewStyle().Foreground(tui.BrandColor).Render("[~]") status = lipgloss.NewStyle().Foreground(tui.BrandColor).Render("starting...") @@ -551,36 +712,11 @@ func (m OverviewModel) renderWatcherStatus(label string, running, starting bool, status = tui.DimStyle.Render("stopped") } - return tui.KVRow(checkbox+" "+label, status) -} - -func (m OverviewModel) renderServices() string { - switch { - case m.loading: - return " " + tui.StatusBadge("scanning", tui.BrandColor) + "\n" + - " " + helpStyle.Render("Looking for published local services.") + "\n" - case m.err != nil: - return " " + tui.StatusBadge("failed", tui.ErrorColor) + "\n" + - " " + errorStyle.Render(m.err.Error()) + "\n" - case len(m.services) == 0: - return " " + helpStyle.Render("No auxiliary services detected.") + "\n" - } - - var s strings.Builder - for i, service := range m.services { - focused := m.cursor == i+2 - name := service.Name - if focused { - name = lipgloss.NewStyle().Bold(true).Foreground(tui.BrandColor).Render(service.Name) - } - row := tui.KVRow(name, urlStyle.Render(service.URL)) - s.WriteString(row) - if service.Username != "" { - s.WriteString(tui.KVRow(" Username", valueStyle.Render(service.Username))) - s.WriteString(tui.KVRow(" Password", secretStyle.Render(service.Password))) - } + row := fmt.Sprintf(" %s %s%s\n", checkbox, lipgloss.NewStyle().Width(14).Render(label), status) + if running && url != "" { + row += " " + linkURL(url) + "\n" } - return s.String() + return row } type dockerComposePSOutput struct { diff --git a/internal/devtui/tab_overview_health.go b/internal/devtui/tab_overview_health.go new file mode 100644 index 00000000..446a6925 --- /dev/null +++ b/internal/devtui/tab_overview_health.go @@ -0,0 +1,338 @@ +package devtui + +import ( + "context" + "fmt" + "image/color" + "path/filepath" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/shopware/shopware-cli/internal/envfile" + "github.com/shopware/shopware-cli/internal/executor" + "github.com/shopware/shopware-cli/internal/packagist" + "github.com/shopware/shopware-cli/internal/symfony" + "github.com/shopware/shopware-cli/internal/tui" +) + +type healthLevel int + +const ( + healthOK healthLevel = iota // matches the recommendation + healthWarn // differs from the recommendation + healthCritical // hard requirement violated (e.g. unsupported PHP) +) + +// healthCheck is one row of the readonly "Setup health" report: a named check +// grouped under a section, with the current value, the recommended value, and +// how severe the mismatch is. DocsURL links the check name (via an OSC 8 +// hyperlink) to the documentation explaining the recommendation. +type healthCheck struct { + Group string + Name string + Current string + Recommended string + Level healthLevel + DocsURL string +} + +const ( + healthGroupRuntime = "Runtime" + healthGroupLocalBehavior = "Local behavior" + healthGroupDebug = "Debug (Flow Builder)" +) + +// Documentation pages the check names link to. +const ( + hostingDocsURL = "https://developer.shopware.com/docs/guides/hosting/" + adminWorkerDocsURL = "https://developer.shopware.com/docs/guides/hosting/infrastructure/message-queue.html#admin-worker" + flowBuilderLogDocsURL = "https://developer.shopware.com/docs/guides/hosting/performance/performance-tweaks.html#logging" +) + +type setupHealthLoadedMsg struct { + checks []healthCheck +} + +// setupHealthTimeout bounds the PHP invocation used to read the runtime values +// so a stuck container cannot leave the report loading forever. +const setupHealthTimeout = 15 * time.Second + +func loadSetupHealth(projectRoot string, exec executor.Executor) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), setupHealthTimeout) + defer cancel() + return setupHealthLoadedMsg{checks: collectSetupHealth(ctx, projectRoot, exec)} + } +} + +// collectSetupHealth gathers all setup-health checks. Checks whose data source +// is unavailable (environment down, no config/packages tree) are omitted rather +// than reported as failures, since the report is informational. +func collectSetupHealth(ctx context.Context, projectRoot string, exec executor.Executor) []healthCheck { + var checks []healthCheck + checks = append(checks, runtimeHealthChecks(ctx, projectRoot, exec)...) + checks = append(checks, projectConfigHealthChecks(projectRoot)...) + return checks +} + +// runtimeHealthChecks reads PHP_VERSION and memory_limit from the PHP runtime +// the project actually uses (inside the container for docker environments). +func runtimeHealthChecks(ctx context.Context, projectRoot string, exec executor.Executor) []healthCheck { + if exec == nil { + return nil + } + + out, err := exec.PHPCommand(ctx, "-r", `echo PHP_VERSION, "\n", ini_get('memory_limit');`).Output() + if err != nil { + return nil + } + + parts := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2) + phpVersion := strings.TrimSpace(parts[0]) + memoryLimit := "" + if len(parts) > 1 { + memoryLimit = strings.TrimSpace(parts[1]) + } + + var checks []healthCheck + if phpVersion != "" { + checks = append(checks, phpVersionCheck(projectRoot, phpVersion)) + } + if memoryLimit != "" { + checks = append(checks, memoryLimitCheck(memoryLimit)) + } + return checks +} + +// phpVersionCheck compares the running PHP version against the `require.php` +// constraint of the installed Shopware release from composer.lock. +func phpVersionCheck(projectRoot, current string) healthCheck { + var constraint *packagist.PHPConstraint + if lock, err := packagist.ReadComposerLock(filepath.Join(projectRoot, "composer.lock")); err == nil { + constraint = lock.ShopwarePHPConstraint() + } + + level := healthOK + recommended := constraint.String() + if recommended == "" { + recommended = "-" + } else if !constraint.Check(current) { + level = healthCritical + } + + return healthCheck{ + Group: healthGroupRuntime, + Name: "PHP version", + Current: current, + Recommended: recommended, + Level: level, + DocsURL: hostingDocsURL, + } +} + +// minMemoryLimitBytes is the memory_limit Shopware recommends for web requests. +const minMemoryLimitBytes = 512 * 1024 * 1024 + +func memoryLimitCheck(current string) healthCheck { + level := healthWarn + if bytes, ok := parsePHPMemoryLimit(current); ok && (bytes < 0 || bytes >= minMemoryLimitBytes) { + level = healthOK + } + + return healthCheck{ + Group: healthGroupRuntime, + Name: "Memory limit", + Current: current, + Recommended: ">= 512M", + Level: level, + DocsURL: hostingDocsURL, + } +} + +// parsePHPMemoryLimit parses a php.ini shorthand byte value ("512M", "1G", +// "-1"). A negative value means unlimited. +func parsePHPMemoryLimit(value string) (int64, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + + multiplier := int64(1) + switch value[len(value)-1] { + case 'K', 'k': + multiplier = 1024 + case 'M', 'm': + multiplier = 1024 * 1024 + case 'G', 'g': + multiplier = 1024 * 1024 * 1024 + } + if multiplier != 1 { + value = value[:len(value)-1] + } + + n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, false + } + return n * multiplier, true +} + +// projectConfigHealthChecks derives checks from the project's config/packages +// tree, resolved for the environment the project runs in locally. +func projectConfigHealthChecks(projectRoot string) []healthCheck { + pc, err := symfony.NewProjectConfig(projectRoot) + if err != nil { + return nil + } + + env := appEnvironment(projectRoot) + + var checks []healthCheck + if enabled, err := pc.IsAdminWorkerEnabled(env); err == nil { + checks = append(checks, adminWorkerCheck(enabled)) + } + checks = append(checks, flowBuilderLogLevelCheck(pc, env)) + return checks +} + +// appEnvironment returns the APP_ENV configured in the project's .env files, +// defaulting to dev, which is what the development environments run with. +func appEnvironment(projectRoot string) string { + values, err := envfile.ReadValues(projectRoot, "APP_ENV") + if err != nil || values["APP_ENV"] == "" { + return "dev" + } + return values["APP_ENV"] +} + +// adminWorkerCheck reports whether Shopware's browser-based admin worker is +// disabled. The dev environments run a dedicated worker process, so the admin +// worker should be off to avoid processing the queue twice. +func adminWorkerCheck(enabled bool) healthCheck { + current := "disabled" + level := healthOK + if enabled { + current = "enabled" + level = healthWarn + } + + return healthCheck{ + Group: healthGroupLocalBehavior, + Name: "Admin Worker", + Current: current, + Recommended: "disabled", + Level: level, + DocsURL: adminWorkerDocsURL, + } +} + +// flowBuilderLogLevelPath is the monolog handler through which Shopware's Flow +// Builder writes its business event logs. Without a configured level monolog +// records everything (DEBUG), which floods the log table on busy shops. +const flowBuilderLogLevelPath = "monolog.handlers.business_event_handler_buffer.level" + +// monologLevels maps monolog level names to their severity, mirroring +// Monolog\Level. Unknown names map to 0 and are treated as below WARNING. +var monologLevels = map[string]int{ + "debug": 100, + "info": 200, + "notice": 250, + "warning": 300, + "error": 400, + "critical": 500, + "alert": 550, + "emergency": 600, +} + +func flowBuilderLogLevelCheck(pc *symfony.ProjectConfig, env string) healthCheck { + current := "debug" + if value, ok, err := pc.GetResolvedConfigValue(env, flowBuilderLogLevelPath); err == nil && ok { + if s, isString := value.(string); isString && s != "" { + current = s + } + } + + level := healthWarn + if monologLevels[strings.ToLower(current)] >= monologLevels["warning"] { + level = healthOK + } + + return healthCheck{ + Group: healthGroupDebug, + Name: "Flow Builder log level", + Current: strings.ToUpper(current), + Recommended: "min WARNING", + Level: level, + DocsURL: flowBuilderLogDocsURL, + } +} + +func (l healthLevel) color() color.Color { + switch l { + case healthWarn: + return tui.WarnColor + case healthCritical: + return tui.ErrorColor + case healthOK: + return tui.SuccessColor + default: + return tui.SuccessColor + } +} + +// renderSetupHealth renders the readonly "Setup health" report: a header row, +// followed by the checks grouped under bold group labels, each with a colored +// status dot in the gutter. The header, group labels, and check names share +// one column so the table reads aligned; only the dots sit left of it. +func (m OverviewModel) renderSetupHealth() string { + var s strings.Builder + + s.WriteString(tui.TitleStyle.Render("Setup health")) + s.WriteString("\n") + + switch { + case m.healthLoading: + s.WriteString(" " + tui.StatusBadge("checking", tui.BrandColor) + "\n") + return s.String() + case len(m.health) == 0: + s.WriteString(" " + helpStyle.Render("No setup checks available.") + "\n") + return s.String() + } + + nameWidth, currentWidth := lipgloss.Width("Check"), lipgloss.Width("Current") + for _, check := range m.health { + nameWidth = max(nameWidth, lipgloss.Width(check.Name)) + currentWidth = max(currentWidth, lipgloss.Width(check.Current)) + } + nameStyle := lipgloss.NewStyle().Width(nameWidth + 3) + currentStyle := lipgloss.NewStyle().Width(currentWidth + 3) + + row := func(dot, name, current, recommended string) string { + return fmt.Sprintf(" %s %s%s%s\n", dot, nameStyle.Render(name), currentStyle.Render(current), recommended) + } + + dim := lipgloss.NewStyle().Foreground(tui.MutedColor) + s.WriteString(row(" ", dim.Render("Check"), dim.Render("Current"), dim.Render("Recommended"))) + + group := "" + for _, check := range m.health { + if check.Group != group { + group = check.Group + s.WriteString("\n " + tui.TitleStyle.Render(group) + "\n") + } + dot := lipgloss.NewStyle().Foreground(check.Level.color()).Render("●") + name := check.Name + if check.DocsURL != "" { + // The name links to the docs page explaining the recommendation. + // The OSC 8 sequence is zero-width and does not affect alignment. + name = tui.StyledLink(check.DocsURL, check.Name, tui.LinkStyle) + } + s.WriteString(row(dot, name, check.Current, check.Recommended)) + } + + return s.String() +} diff --git a/internal/devtui/tab_overview_health_test.go b/internal/devtui/tab_overview_health_test.go new file mode 100644 index 00000000..30c21afd --- /dev/null +++ b/internal/devtui/tab_overview_health_test.go @@ -0,0 +1,194 @@ +package devtui + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/symfony" +) + +func TestParsePHPMemoryLimit(t *testing.T) { + cases := []struct { + value string + bytes int64 + ok bool + }{ + {"512M", 512 * 1024 * 1024, true}, + {"1G", 1024 * 1024 * 1024, true}, + {"131072K", 128 * 1024 * 1024, true}, + {"134217728", 128 * 1024 * 1024, true}, + {"-1", -1, true}, + {" 256m ", 256 * 1024 * 1024, true}, + {"", 0, false}, + {"abc", 0, false}, + } + + for _, tc := range cases { + bytes, ok := parsePHPMemoryLimit(tc.value) + assert.Equal(t, tc.ok, ok, tc.value) + if tc.ok { + assert.Equal(t, tc.bytes, bytes, tc.value) + } + } +} + +func TestMemoryLimitCheck(t *testing.T) { + assert.Equal(t, healthOK, memoryLimitCheck("512M").Level) + assert.Equal(t, healthOK, memoryLimitCheck("1G").Level) + // -1 means unlimited in php.ini. + assert.Equal(t, healthOK, memoryLimitCheck("-1").Level) + assert.Equal(t, healthWarn, memoryLimitCheck("128M").Level) + assert.Equal(t, healthWarn, memoryLimitCheck("garbage").Level) +} + +func writeComposerLock(t *testing.T, dir, phpConstraint string) { + t.Helper() + lock := `{"packages":[{"name":"shopware/core","version":"v6.7.0.0","require":{"php":"` + phpConstraint + `"}}]}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "composer.lock"), []byte(lock), 0o644)) +} + +func TestPHPVersionCheck(t *testing.T) { + dir := t.TempDir() + writeComposerLock(t, dir, ">=8.2") + + check := phpVersionCheck(dir, "8.3.12") + assert.Equal(t, healthOK, check.Level) + assert.Equal(t, "8.3.12", check.Current) + assert.Equal(t, ">=8.2", check.Recommended) + + assert.Equal(t, healthCritical, phpVersionCheck(dir, "8.1.0").Level) +} + +func TestPHPVersionCheck_NoComposerLock(t *testing.T) { + check := phpVersionCheck(t.TempDir(), "8.3.12") + assert.Equal(t, healthOK, check.Level) + assert.Equal(t, "-", check.Recommended) +} + +func TestAdminWorkerCheck(t *testing.T) { + disabled := adminWorkerCheck(false) + assert.Equal(t, healthOK, disabled.Level) + assert.Equal(t, "disabled", disabled.Current) + + enabled := adminWorkerCheck(true) + assert.Equal(t, healthWarn, enabled.Level) + assert.Equal(t, "enabled", enabled.Current) +} + +func writeMonologConfig(t *testing.T, dir, level string) { + t.Helper() + packages := filepath.Join(dir, "config", "packages") + require.NoError(t, os.MkdirAll(packages, 0o755)) + yaml := "monolog:\n handlers:\n business_event_handler_buffer:\n level: " + level + "\n" + require.NoError(t, os.WriteFile(filepath.Join(packages, "monolog.yaml"), []byte(yaml), 0o644)) +} + +func TestFlowBuilderLogLevelCheck(t *testing.T) { + dir := t.TempDir() + writeMonologConfig(t, dir, "warning") + + pc, err := symfony.NewProjectConfig(dir) + require.NoError(t, err) + + check := flowBuilderLogLevelCheck(pc, "dev") + assert.Equal(t, healthOK, check.Level) + assert.Equal(t, "WARNING", check.Current) +} + +func TestFlowBuilderLogLevelCheck_DefaultsToDebug(t *testing.T) { + pc, err := symfony.NewProjectConfig(t.TempDir()) + require.NoError(t, err) + + check := flowBuilderLogLevelCheck(pc, "dev") + assert.Equal(t, healthWarn, check.Level) + assert.Equal(t, "DEBUG", check.Current) +} + +func TestCollectSetupHealth_WithoutExecutor(t *testing.T) { + checks := collectSetupHealth(t.Context(), t.TempDir(), nil) + + // No runtime checks without an executor, but the config-derived checks + // still report their defaults (admin worker enabled, log level debug). + names := make([]string, 0, len(checks)) + for _, c := range checks { + names = append(names, c.Name) + } + assert.ElementsMatch(t, []string{"Admin Worker", "Flow Builder log level"}, names) +} + +func TestOverviewViewShowsSetupHealth(t *testing.T) { + m := NewOverviewModel("docker", "http://localhost:8000", "admin", "shopware", "/tmp/project", nil, nil) + m.loading = false + m.healthLoading = false + m.health = []healthCheck{ + {Group: healthGroupRuntime, Name: "PHP version", Current: "8.3.12", Recommended: ">= 8.2", Level: healthOK}, + {Group: healthGroupDebug, Name: "Flow Builder log level", Current: "DEBUG", Recommended: "min WARNING", Level: healthWarn}, + } + + for _, width := range []int{120, 80} { // two-column and stacked layouts + view := m.View(width, 40) + assert.Contains(t, view, "Setup health") + assert.Contains(t, view, "Runtime") + assert.Contains(t, view, "PHP version") + assert.Contains(t, view, "8.3.12") + assert.Contains(t, view, ">= 8.2") + assert.Contains(t, view, "Debug (Flow Builder)") + assert.Contains(t, view, "min WARNING") + assert.Contains(t, view, "Watchers") + } + + // The "User action" column heading only exists in the two-column layout. + assert.Contains(t, m.View(120, 40), "User action") + assert.NotContains(t, m.View(80, 40), "User action") +} + +func TestSetupHealthLinksAreZeroWidth(t *testing.T) { + m := NewOverviewModel("docker", "http://localhost:8000", "admin", "shopware", "/tmp/project", nil, nil) + m.loading = false + m.healthLoading = false + m.health = []healthCheck{ + {Group: healthGroupRuntime, Name: "PHP version", Current: "8.3.12", Recommended: ">= 8.2", Level: healthOK, DocsURL: hostingDocsURL}, + {Group: healthGroupRuntime, Name: "Memory limit", Current: "512M", Recommended: ">= 512M", Level: healthOK}, + } + + report := m.renderSetupHealth() + assert.Contains(t, report, "\x1b]8;;"+hostingDocsURL) + + // The hyperlink escape sequence must not shift the Current column: both + // rows (one linked, one not) align their values at the same offset. + var phpLine, memLine string + for _, line := range strings.Split(stripANSI(report), "\n") { + if strings.Contains(line, "PHP version") { + phpLine = line + } + if strings.Contains(line, "Memory limit") { + memLine = line + } + } + require.NotEmpty(t, phpLine) + require.NotEmpty(t, memLine) + assert.Equal(t, strings.Index(phpLine, "8.3.12"), strings.Index(memLine, "512M")) +} + +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\`) + +func stripANSI(s string) string { + return ansiRe.ReplaceAllString(s, "") +} + +func TestOverviewViewSetupHealthLoading(t *testing.T) { + m := NewOverviewModel("docker", "http://localhost:8000", "admin", "shopware", "/tmp/project", nil, nil) + m.loading = false + + assert.Contains(t, m.View(120, 40), "CHECKING") + + updated, _ := m.Update(setupHealthLoadedMsg{}) + assert.False(t, updated.healthLoading) + assert.Contains(t, updated.View(120, 40), "No setup checks available.") +} diff --git a/internal/devtui/tab_overview_test.go b/internal/devtui/tab_overview_test.go index 81e552f1..ef93b333 100644 --- a/internal/devtui/tab_overview_test.go +++ b/internal/devtui/tab_overview_test.go @@ -47,14 +47,16 @@ func TestOverviewBackgroundProcessesSection(t *testing.T) { {Name: "Scheduled tasks", Running: false}, } - view := m.View(m.width, m.height) - assert.Contains(t, view, "Background processing") - assert.Contains(t, view, "Queue worker") - assert.Contains(t, view, "running") - assert.Contains(t, view, "Scheduled tasks") - assert.Contains(t, view, "stopped") - assert.Contains(t, view, "[x]") - assert.Contains(t, view, "[ ]") + for _, width := range []int{120, 80} { // two-column and stacked layouts + view := m.View(width, m.height) + assert.Contains(t, view, "Background processing") + assert.Contains(t, view, "Queue worker") + assert.Contains(t, view, "running") + assert.Contains(t, view, "Scheduled tasks") + assert.Contains(t, view, "stopped") + // Background processes use the same status dots as Setup health. + assert.Contains(t, view, "●") + } } func TestNewOverviewModel(t *testing.T) { @@ -165,15 +167,35 @@ func TestKnownServices(t *testing.T) { assert.Equal(t, 15672, rabbitmq.TargetPort) } -func TestViewShowsCredentials(t *testing.T) { - m := NewOverviewModel("docker", "http://localhost:8000", "", "", "/tmp/project", nil, nil) +func TestViewShowsAccessTable(t *testing.T) { + m := NewOverviewModel("docker", "http://localhost:8000", "admin", "shopware", "/tmp/project", nil, nil) m.loading = false m.services = []DiscoveredService{ {Name: "Adminer", URL: "http://127.0.0.1:9080", Username: "root", Password: "root"}, + {Name: "Mailpit", URL: "http://127.0.0.1:8025"}, + } + + for _, width := range []int{120, 80} { // two-column and stacked layouts + view := m.View(width, 40) + assert.Contains(t, view, "Access") + assert.Contains(t, view, "Password / Auth") + assert.Contains(t, view, "Shop Admin") + assert.Contains(t, view, "http://localhost:8000/admin") + assert.Contains(t, view, "shopware") + assert.Contains(t, view, "Adminer") + assert.Contains(t, view, "http://127.0.0.1:9080") + assert.Contains(t, view, "root") + // Services without credentials are marked as open. + assert.Contains(t, view, "no auth") } +} + +func TestViewAccessTableWithoutInstallation(t *testing.T) { + m := NewOverviewModel("docker", "http://localhost:8000", "", "", "/tmp/project", nil, nil) + m.loading = false view := m.View(120, 40) - assert.Contains(t, view, "Adminer") - assert.Contains(t, view, "http://127.0.0.1:9080") - assert.Contains(t, view, "root") + assert.Contains(t, view, "Shop Admin") + assert.Contains(t, view, "Admin credentials will appear here once Shopware is installed.") + assert.NotContains(t, view, "no auth") } diff --git a/internal/tui/badge.go b/internal/tui/badge.go index 9d903a7b..13d09d77 100644 --- a/internal/tui/badge.go +++ b/internal/tui/badge.go @@ -12,21 +12,6 @@ func StatusBadge(status string, c color.Color) string { return lipgloss.NewStyle().Foreground(c).Bold(true).Render("● " + strings.ToUpper(status)) } -// CheckboxRow renders a KVRow whose key is a colored "[glyph] label" checkbox -// and whose value is the status text in the same color. It is the shared layout -// for the Watchers and Background-processing status lists. When bold is true the -// glyph and status are emphasised (used for the "running" state). -func CheckboxRow(glyph, label, status string, c color.Color, bold bool) string { - glyphStyle := lipgloss.NewStyle().Foreground(c) - statusStyle := lipgloss.NewStyle().Foreground(c) - if bold { - glyphStyle = glyphStyle.Bold(true) - statusStyle = statusStyle.Bold(true) - } - - return KVRow(glyphStyle.Render(glyph)+" "+label, statusStyle.Render(status)) -} - // TextBadge renders text on a subtle background with horizontal padding. func TextBadge(text string) string { return lipgloss.NewStyle(). diff --git a/internal/tui/badge_test.go b/internal/tui/badge_test.go deleted file mode 100644 index ac38f7d9..00000000 --- a/internal/tui/badge_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestCheckboxRow(t *testing.T) { - row := CheckboxRow("[x]", "Queue worker", "running", SuccessColor, true) - - // The glyph, label and status all render into a single KVRow line. - assert.Contains(t, row, "[x]") - assert.Contains(t, row, "Queue worker") - assert.Contains(t, row, "running") - assert.True(t, strings.HasSuffix(row, "\n")) -}