Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ func renderStatus(rep appstatus.Report) {
case !rep.Health.Reachable && rep.Total == 0:
pterm.Error.Println("Cluster is not reachable. Is it running and is your kube-context correct?")
if rep.HealthErr != nil {
pterm.Error.Printf(" cause: %v\n", rep.HealthErr)
// Same shape as the shared error panel's cause row — dim key,
// indented under the headline, no repeated error tag.
pterm.DefaultBasicText.Printf(" %s %v\n", pterm.FgGray.Sprintf("%-7s", "cause"), rep.HealthErr)
}
return
case !rep.Health.Reachable:
Expand Down
3 changes: 2 additions & 1 deletion cmd/app/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ func previewOutOfSync(ctx context.Context, manager *argocd.Manager, verbose, pru
for _, a := range apps {
if a.Sync != argocd.ArgoCDSyncSynced {
outOfSync++
pterm.Info.Printf(" OutOfSync: %s (health=%s, sync=%s)\n", a.Name, a.Health, a.Sync)
// List item, not a standalone status: no repeated info tag.
pterm.DefaultBasicText.Printf(" OutOfSync: %s (health=%s, sync=%s)\n", a.Name, a.Health, a.Sync)
}
}
if outOfSync == 0 {
Expand Down
64 changes: 39 additions & 25 deletions internal/chart/providers/argocd/wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/flamingo-stack/openframe-cli/internal/chart/utils/config"
"github.com/flamingo-stack/openframe-cli/internal/platform"
"github.com/flamingo-stack/openframe-cli/internal/shared/executor"
sharedui "github.com/flamingo-stack/openframe-cli/internal/shared/ui"
uispinner "github.com/flamingo-stack/openframe-cli/internal/shared/ui/spinner"
"github.com/pterm/pterm"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
Expand Down Expand Up @@ -83,9 +84,9 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
// Show initial verbose info if enabled
if config.Verbose {
pterm.Info.Println("Starting ArgoCD application synchronization...")
pterm.Debug.Println(" - Waiting for applications to be created by app-of-apps")
pterm.Debug.Println(" - Each application must reach Healthy + Synced status")
pterm.Debug.Println(" - Progress updates every 10 seconds in verbose mode")
pterm.Debug.Println("Waiting for applications to be created by app-of-apps")
pterm.Debug.Println("Each application must reach Healthy + Synced status")
pterm.Debug.Println("Progress updates every 10 seconds in verbose mode")
}

// Display: the live dashboard (interactive terminal, non-verbose) shows
Expand Down Expand Up @@ -114,12 +115,18 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
// waitNote routes one-off in-wait announcements: pinned under the live
// dashboard when it is active (a plain print would be visually swallowed
// by the area redraw within 2s), ordinary silence-aware prints otherwise.
waitNote := func(styled string) {
// It takes the status printer plus the RAW message — not a pre-styled
// string — so the non-dashboard path prints through the printer's own
// writer: that is where the CI ::warning:: annotation tee lives, and a
// pre-styled DefaultBasicText print silently bypassed it. The dashboard
// path never annotates, but it only runs on interactive terminals — CI is
// always the printer path.
waitNote := func(p *pterm.PrefixPrinter, format string, args ...any) {
if dash != nil {
dash.Note(styled)
dash.Note(p.Sprintf(format, args...))
return
}
pterm.DefaultBasicText.Println(styled)
p.Printfln(format, args...)
}

// Function to stop spinner safely
Expand Down Expand Up @@ -424,7 +431,7 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn

// Reset consecutive failures on successful query
if consecutiveFailures > 0 {
waitNote(pterm.Success.Sprint("Application queries restored"))
waitNote(&pterm.Success, "Application queries restored")
consecutiveFailures = 0
}

Expand Down Expand Up @@ -513,18 +520,18 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
if config.SyncStragglersOnStall {
if !stragglerSyncTriggered {
stragglerSyncTriggered = true
stallNote(pterm.Warning.Sprintf("No progress for %s; triggering sync of %d OutOfSync application(s): %v",
stallAfter.Round(time.Second), len(stragglers), stragglers))
stallNote(&pterm.Warning, "No progress for %s; triggering sync of %d OutOfSync application(s): %v",
stallAfter.Round(time.Second), len(stragglers), stragglers)
patched, failedCount, syncErr := m.syncApplicationsByName(localCtx, stragglers, false)
if failedCount > 0 {
stallNote(pterm.Warning.Sprintf("Straggler sync: %d triggered, %d failed (first error: %v)", patched, failedCount, syncErr))
stallNote(&pterm.Warning, "Straggler sync: %d triggered, %d failed (first error: %v)", patched, failedCount, syncErr)
}
}
} else if !stallHintShown {
stallHintShown = true
stallNote(pterm.Warning.Sprintf("No progress for %s; %d application(s) are OutOfSync and may have auto-sync disabled: %v",
stallAfter.Round(time.Second), len(stragglers), stragglers))
stallNote(pterm.Info.Sprint("They will not sync on their own — run `openframe app upgrade --sync` (or sync them in ArgoCD) to roll them out."))
stallNote(&pterm.Warning, "No progress for %s; %d application(s) are OutOfSync and may have auto-sync disabled: %v",
stallAfter.Round(time.Second), len(stragglers), stragglers)
stallNote(&pterm.Info, "They will not sync on their own — run `openframe app upgrade --sync` (or sync them in ArgoCD) to roll them out.")
}
}

Expand Down Expand Up @@ -598,15 +605,15 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
// out to its timeout. triggerRepoServerRecovery already
// hard-refreshed app.Name; cover the rest.
if refreshed := m.hardRefreshApplications(localCtx, appNames(unknownApps)); refreshed > 0 {
waitNote(pterm.Info.Sprintf("Hard-refreshed %d application(s) stuck in Unknown.", refreshed))
waitNote(&pterm.Info, "Hard-refreshed %d application(s) stuck in Unknown.", refreshed)
}
} else {
waitNote(pterm.Warning.Sprint("Could not restart the ArgoCD repo-server; continuing to wait."))
waitNote(&pterm.Warning, "Could not restart the ArgoCD repo-server; continuing to wait.")
}
} else if repoServerRecoveryAttempts == maxRepoServerRecoveryAttempts {
repoServerRecoveryAttempts++ // prevent repeated attempts
waitNote(pterm.Warning.Sprintf("ArgoCD repo-server did not recover after %d restarts; continuing to wait for the timeout.",
maxRepoServerRecoveryAttempts))
waitNote(&pterm.Warning, "ArgoCD repo-server did not recover after %d restarts; continuing to wait for the timeout.",
maxRepoServerRecoveryAttempts)
}
break // Only recover one app at a time
}
Expand All @@ -618,12 +625,12 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
// (throttled); the per-application dump stays behind --verbose.
if len(unknownApps) > 0 && elapsed > 5*time.Minute && time.Since(lastUnknownWarn) >= 5*time.Minute {
lastUnknownWarn = time.Now()
waitNote(pterm.Warning.Sprintf("%d application(s) have 'Unknown' status after %s. Possible causes: controller pod not ready, git repository unreachable, or resource constraints.",
len(unknownApps), elapsed.Round(time.Second)))
waitNote(&pterm.Warning, "%d application(s) have 'Unknown' status after %s. Possible causes: controller pod not ready, git repository unreachable, or resource constraints.",
len(unknownApps), elapsed.Round(time.Second))
if config.Verbose {
describeUnknownApps(unknownApps)
} else if dash == nil {
pterm.Info.Println(" Re-run with --verbose for per-application detail.")
pterm.Info.Println("Re-run with --verbose for per-application detail.")
}
}

Expand All @@ -634,7 +641,7 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
lastStuckSummary = time.Now()
for _, app := range apps {
if app.Health != ArgoCDHealthHealthy && app.Health != ArgoCDHealthMissing {
line := fmt.Sprintf(" Stuck app %s: health=%s sync=%s", app.Name, app.Health, app.Sync)
line := fmt.Sprintf("Stuck app %s: health=%s sync=%s", app.Name, app.Health, app.Sync)
if app.Condition != "" {
line += " condition=" + app.Condition
}
Expand All @@ -656,13 +663,20 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
lastProgressPrint = time.Now()
delta := currentlyReady - heartbeatLastReady
heartbeatLastReady = currentlyReady
pterm.Info.Printf("[%s] apps %d/%d ready (%+d since last check) · elapsed %s\n",
time.Now().Format("15:04:05"), currentlyReady, totalApps, delta, elapsed.Round(time.Second))
beat := fmt.Sprintf("apps %d/%d ready (%+d since last check) · elapsed %s",
currentlyReady, totalApps, delta, elapsed.Round(time.Second))
// The embedded clock serves plain (non-verbose) CI logs; under
// --verbose every status line is already timestamped by the
// writer, and a second clock on the same row is noise.
if !sharedui.TimestampsActive() {
beat = fmt.Sprintf("[%s] ", time.Now().Format("15:04:05")) + beat
}
pterm.Info.Println(beat)
if p := pendingSummary(apps, 6); p != "" {
pterm.Info.Printf(" pending: %s\n", p)
pterm.Info.Printf("pending: %s\n", p)
}
if config.Verbose && len(healthyApps) > 0 && len(healthyApps) <= 5 {
pterm.Debug.Printf(" Recently completed: %v\n", healthyApps)
pterm.Debug.Printf("Recently completed: %v\n", healthyApps)
}
}

Expand Down
3 changes: 2 additions & 1 deletion internal/chart/services/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ func (c *ClusterSelector) SelectCluster(args []string, nonInteractive, verbose b
if verbose {
pterm.Info.Printf("Found %d clusters\n", len(clusters))
for _, cluster := range clusters {
pterm.Info.Printf(" - %s (%s)\n", cluster.Name, cluster.Status)
// Items under the Info header: no repeated info tag per line.
pterm.DefaultBasicText.Printf(" - %s (%s)\n", cluster.Name, cluster.Status)
}
}

Expand Down
4 changes: 3 additions & 1 deletion internal/cluster/providers/eks/teardown.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,9 @@ func (p *Provider) sweepOrphanedVolumes(ctx context.Context, rec tfengine.Record
func printOrphanList(volumes []string, name string) {
pterm.Warning.Printf("%d EBS volume(s) tagged for cluster %q survived the destroy (PVC-provisioned, outside terraform state):\n", len(volumes), name)
for _, id := range volumes {
pterm.Warning.Printf(" - %s\n", id)
// Items under the Warning header go through DefaultBasicText so the
// warning tag isn't repeated per line (same pattern as cleanup lists).
pterm.DefaultBasicText.Printf(" - %s\n", id)
}
}

Expand Down
7 changes: 5 additions & 2 deletions internal/cluster/providers/gke/teardown.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,13 @@ func printOrphanList(disks []disk, name string) {
for _, d := range disks {
// The location tells the operator WHICH cluster's disks these are —
// GKE cluster names repeat across locations.
// List items go through DefaultBasicText (the repo's header+items
// pattern): the Warning header above carries the severity, and
// repeating the warning tag on every item just breaks the column.
if loc := d.location(); loc != "" {
pterm.Warning.Printf(" - %s (%s)\n", d.name, loc)
pterm.DefaultBasicText.Printf(" - %s (%s)\n", d.name, loc)
} else {
pterm.Warning.Printf(" - %s\n", d.name)
pterm.DefaultBasicText.Printf(" - %s\n", d.name)
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion internal/shared/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ func (eh *ErrorHandler) handleGenericError(err error) {
// In GitHub Actions the failure also becomes a job/PR annotation, so the
// cause is visible without opening the 40-minute log.
ui.ErrorAnnotation(headline, firstLine(cause))
pterm.Error.Printf("%s %s\n", ui.Glyphs().Fail, headline)
// No manual failure glyph: the themed pterm.Error prefix carries it
// (✖ interactively, the "error" tag otherwise).
pterm.Error.Printf("%s\n", headline)
if cause != "" {
panelRow("cause", cause)
}
Expand Down
9 changes: 9 additions & 0 deletions internal/shared/ui/ghactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ func ErrorAnnotation(title, message string) {
fmt.Printf("::error title=%s::%s\n", escapeAnnotationProperty(title), escapeAnnotationData(message))
}

// WarningAnnotation surfaces a warning as a ::warning:: annotation (shown in
// the job summary and on the PR when applicable).
func WarningAnnotation(title, message string) {
if !InGitHubActions() {
return
}
fmt.Printf("::warning title=%s::%s\n", escapeAnnotationProperty(title), escapeAnnotationData(message))
}

// AppendStepSummary appends a markdown fragment to the job's Step Summary.
// Best-effort: a missing or unwritable summary file is silently skipped.
func AppendStepSummary(markdown string) {
Expand Down
31 changes: 28 additions & 3 deletions internal/shared/ui/silent.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,41 @@ func ApplyGlobalOutputFlags(cmd *cobra.Command) {
}
if v, _ := cmd.Flags().GetBool("verbose"); v && !silentFlag {
pterm.EnableDebugMessages()
// Timestamped debug lines: --verbose exists to correlate the CLI's
// actions with cluster events, which needs a clock on every line.
pterm.Debug = *pterm.Debug.WithWriter(NewTimestampWriter(os.Stdout))
// Timestamped status lines: --verbose exists to correlate the CLI's
// actions with cluster events, which needs a clock on every line. ALL
// status printers get the clock, not just Debug — info/warning lines
// are events on the same timeline, and a timestamp on only some rows
// leaves the message columns ragged. One shared writer keeps the
// line-start state consistent across printers.
timestamped = true
ts := NewTimestampWriter(os.Stdout)
for _, p := range []*pterm.PrefixPrinter{
&pterm.Debug, &pterm.Info, &pterm.Warning, &pterm.Error, &pterm.Success,
} {
*p = *p.WithWriter(ts)
}
}
// Last: the theme reads IsPlain/IsSilent, which the flags above just set.
// Under --verbose it also composes with the timestamp writers above: the
// CI annotation tee wraps Warning's writer, so it sees the rendered text
// BEFORE the clock is prepended — annotations stay timestamp-free.
ApplyStatusPrefixTheme()
}

// silent records whether --silent suppressed non-error output. Read by the logo
// renderer so it can honor the flag.
var silent bool

// timestamped records whether the status printers carry a per-line wall clock
// (--verbose). Long-running loops that embed their OWN clock in messages (the
// ArgoCD wait heartbeat does, for plain CI logs) consult this to avoid
// printing two clocks on one line.
var timestamped bool

// TimestampsActive reports whether status-printer lines are already
// timestamped by the --verbose writer.
func TimestampsActive() bool { return timestamped }

// SetSilent honors the --silent flag's contract ("suppress all output except
// errors"): it routes every non-error pterm printer to io.Discard and marks the
// UI silent so the ASCII logo is skipped. Error and Fatal printers are left
Expand Down
Loading
Loading