From 75ba9d80aadb70d5b6bb3f7560bcff48f57965a8 Mon Sep 17 00:00:00 2001 From: Ilmars Janis Bluzmanis <9987548+darksworm@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:15:26 +0200 Subject: [PATCH] feat: name the operation the sync status pane is reporting --- ...ane_approw_status_and_events_100x30.golden | 2 +- cmd/app/view_pane.go | 12 +++- cmd/app/view_pane_golden_test.go | 14 ++-- cmd/app/view_pane_operation_test.go | 52 +++++++++++++++ pkg/api/applications.go | 54 +++++++++++---- pkg/api/applications_operation_label_test.go | 65 +++++++++++++++++++ pkg/api/applications_test.go | 1 + pkg/model/types.go | 18 ++--- 8 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 cmd/app/view_pane_operation_test.go create mode 100644 pkg/api/applications_operation_label_test.go diff --git a/cmd/app/testdata/snapshots/pane_approw_status_and_events_100x30.golden b/cmd/app/testdata/snapshots/pane_approw_status_and_events_100x30.golden index 56b78cd6..1c012ee2 100644 --- a/cmd/app/testdata/snapshots/pane_approw_status_and_events_100x30.golden +++ b/cmd/app/testdata/snapshots/pane_approw_status_and_events_100x30.golden @@ -1,7 +1,7 @@ Context: argo.example.com Argonaut dev ╭──────────────────────────────────────────────╮╭─ Application demo-app ───────────────── ⟳ 10s ─╮ │ Application [demo-app] (Degraded, Synced) ││ │ - │ ├── Deployment [demo/web] (Degraded) ││ Operation Sync │ + │ ├── Deployment [demo/web] (Degraded) ││ Operation Sync (dry run, partial) │ │ │ └── Pod [demo/web-6f7d9b-x4k2m] (Degrad… ││ Phase Failed │ │ └── Service [demo/web] (Healthy) ││ Started 2 minutes ago │ │ ││ Duration 6s │ diff --git a/cmd/app/view_pane.go b/cmd/app/view_pane.go index 2c7c19dd..691983c2 100644 --- a/cmd/app/view_pane.go +++ b/cmd/app/view_pane.go @@ -194,7 +194,17 @@ func renderSyncStatusBody(details *model.SyncStatusDetails, width int, now time. } _, phaseColor := statusGlyph(details.Phase) - field("Operation", "Sync", text) + operation := details.Operation + if operation == "" { + operation = "Sync" + } + // The qualifier is the whole point: Argo CD gives a dry run and a + // resource-scoped sync the same phase as a full one. + if details.OperationNote != "" { + field("Operation", operation+" ("+details.OperationNote+")", text) + } else { + field("Operation", operation, text) + } terminateHint := "" if details.Phase == "Running" { terminateHint = "[t] terminate" diff --git a/cmd/app/view_pane_golden_test.go b/cmd/app/view_pane_golden_test.go index 0c7fd343..03041b1f 100644 --- a/cmd/app/view_pane_golden_test.go +++ b/cmd/app/view_pane_golden_test.go @@ -134,12 +134,14 @@ func TestGolden_EventsPane_AppRow_StatusBlockAboveEvents(t *testing.T) { m.state.Events = &model.EventsState{ Target: model.EventsTarget{AppName: "demo-app"}, Details: &model.SyncStatusDetails{ - Phase: "Failed", - Message: "one or more objects failed to apply", - StartedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC), - FinishedAt: time.Date(2026, 8, 4, 12, 0, 6, 0, time.UTC), - Revision: "a1b2c3d4e5f6789", - InitiatedBy: "alice", + Operation: "Sync", + OperationNote: "dry run, partial", + Phase: "Failed", + Message: "one or more objects failed to apply", + StartedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC), + FinishedAt: time.Date(2026, 8, 4, 12, 0, 6, 0, time.UTC), + Revision: "a1b2c3d4e5f6789", + InitiatedBy: "alice", Resources: []model.SyncResourceResult{ {Kind: "Service", Namespace: "demo", Name: "web", Status: "Synced", Message: "service/web unchanged"}, {Kind: "Deployment", Namespace: "demo", Name: "web", Status: "SyncFailed", diff --git a/cmd/app/view_pane_operation_test.go b/cmd/app/view_pane_operation_test.go new file mode 100644 index 00000000..8b579ae1 --- /dev/null +++ b/cmd/app/view_pane_operation_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/darksworm/argonaut/pkg/model" +) + +func paneOperationLine(t *testing.T, details *model.SyncStatusDetails) string { + t.Helper() + lines := renderSyncStatusBody(details, 46, time.Now(), "") + for _, line := range lines { + if strings.Contains(stripANSI(line), "Operation") { + return stripANSI(line) + } + } + t.Fatalf("no Operation field rendered in:\n%s", strings.Join(lines, "\n")) + return "" +} + +func TestSyncStatusPane_QualifiesADryRunSoSucceededIsNotMisread(t *testing.T) { + line := paneOperationLine(t, &model.SyncStatusDetails{ + Operation: "Sync", + OperationNote: "dry run", + Phase: "Succeeded", + }) + + if !strings.Contains(line, "dry run") { + t.Errorf("expected the operation line to mark the dry run, got %q", line) + } +} + +func TestSyncStatusPane_NamesTheOperationItWasGiven(t *testing.T) { + line := paneOperationLine(t, &model.SyncStatusDetails{Operation: "Deleting", Phase: "Running"}) + + if !strings.Contains(line, "Deleting") { + t.Errorf("expected the operation line to read Deleting, got %q", line) + } + if strings.Contains(line, "Sync") { + t.Errorf("expected no hardcoded Sync on a deletion, got %q", line) + } +} + +func TestSyncStatusPane_PlainSyncCarriesNoQualifier(t *testing.T) { + line := paneOperationLine(t, &model.SyncStatusDetails{Operation: "Sync", Phase: "Succeeded"}) + + if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "Operation")) != "Sync" { + t.Errorf("expected a bare Sync, got %q", line) + } +} diff --git a/pkg/api/applications.go b/pkg/api/applications.go index 68d627ba..e4cd2f26 100644 --- a/pkg/api/applications.go +++ b/pkg/api/applications.go @@ -31,9 +31,10 @@ type ApplicationSource struct { // ApplicationMetadata holds the application CR's object metadata type ApplicationMetadata struct { - Name string `json:"name"` - Namespace string `json:"namespace,omitempty"` - OwnerReferences []OwnerReference `json:"ownerReferences,omitempty"` + Name string `json:"name"` + DeletionTimestamp string `json:"deletionTimestamp,omitempty"` + Namespace string `json:"namespace,omitempty"` + OwnerReferences []OwnerReference `json:"ownerReferences,omitempty"` } // ApplicationDestination identifies the target cluster and namespace @@ -94,6 +95,10 @@ type HealthStatus struct { // SyncOperation holds the sync request parameters type SyncOperation struct { Revision string `json:"revision,omitempty"` + // DryRun and Resources qualify what the operation actually did: a dry run + // applies nothing, and a non-empty Resources means only a subset was synced. + DryRun bool `json:"dryRun,omitempty"` + Resources []SyncResourceTarget `json:"resources,omitempty"` } // OperationInitiator identifies who or what started an operation @@ -661,18 +666,45 @@ func ConvertOperationState(argoApp ArgoApplication) *model.SyncStatusDetails { }) } } + operation, note := describeOperation(argoApp) return &model.SyncStatusDetails{ - Phase: opState.Phase, - Message: shortenShas(flattenWhitespace(opState.Message)), - StartedAt: opState.StartedAt, - FinishedAt: opState.FinishedAt, - Revision: opState.resolvedRevision(), - InitiatedBy: opState.Operation.InitiatedBy.Username, - Automated: opState.Operation.InitiatedBy.Automated, - Resources: resources, + Operation: operation, + OperationNote: note, + Phase: opState.Phase, + Message: shortenShas(flattenWhitespace(opState.Message)), + StartedAt: opState.StartedAt, + FinishedAt: opState.FinishedAt, + Revision: opState.resolvedRevision(), + InitiatedBy: opState.Operation.InitiatedBy.Username, + Automated: opState.Operation.InitiatedBy.Automated, + Resources: resources, } } +// describeOperation names what the last operation actually was. Argo CD +// reports a dry run and a resource-scoped sync with the same phase and the +// same result rows as a full sync, so an unqualified "Sync · Succeeded" next +// to an app that is still OutOfSync reads as the wrong conclusion. +func describeOperation(argoApp ArgoApplication) (operation, note string) { + if argoApp.Metadata.DeletionTimestamp != "" { + return "Deleting", "" + } + + sync := argoApp.Status.OperationState.Operation.Sync + if sync == nil { + return "Sync", "" + } + + var qualifiers []string + if sync.DryRun { + qualifiers = append(qualifiers, "dry run") + } + if len(sync.Resources) > 0 { + qualifiers = append(qualifiers, "partial") + } + return "Sync", strings.Join(qualifiers, ", ") +} + // HasMultipleSources returns true if the application uses multiple sources func (app *ArgoApplication) HasMultipleSources() bool { return len(app.Spec.Sources) > 0 diff --git a/pkg/api/applications_operation_label_test.go b/pkg/api/applications_operation_label_test.go new file mode 100644 index 00000000..3cf4352a --- /dev/null +++ b/pkg/api/applications_operation_label_test.go @@ -0,0 +1,65 @@ +package api + +import "testing" + +func appWithOperation(op Operation) ArgoApplication { + return ArgoApplication{ + Metadata: ApplicationMetadata{Name: "demo-app"}, + Status: ApplicationStatus{ + OperationState: OperationState{Phase: "Succeeded", Operation: op}, + }, + } +} + +func TestConvertOperationState_PlainSync_IsNotQualified(t *testing.T) { + details := ConvertOperationState(appWithOperation(Operation{Sync: &SyncOperation{Revision: "HEAD"}})) + + if details.Operation != "Sync" { + t.Errorf("expected operation %q, got %q", "Sync", details.Operation) + } + if details.OperationNote != "" { + t.Errorf("expected no qualifier on a plain sync, got %q", details.OperationNote) + } +} + +func TestConvertOperationState_DryRun_SaysSoSoASucceededPhaseIsNotMisread(t *testing.T) { + details := ConvertOperationState(appWithOperation(Operation{Sync: &SyncOperation{DryRun: true}})) + + if details.OperationNote != "dry run" { + t.Errorf("expected the operation marked as a dry run, got %q", details.OperationNote) + } +} + +func TestConvertOperationState_ResourceSubset_IsMarkedPartial(t *testing.T) { + op := Operation{Sync: &SyncOperation{Resources: []SyncResourceTarget{{Kind: "Deployment", Name: "api"}}}} + + details := ConvertOperationState(appWithOperation(op)) + + if details.OperationNote != "partial" { + t.Errorf("expected a resource-scoped sync marked partial, got %q", details.OperationNote) + } +} + +func TestConvertOperationState_DryRunOfASubset_CarriesBothQualifiers(t *testing.T) { + op := Operation{Sync: &SyncOperation{ + DryRun: true, + Resources: []SyncResourceTarget{{Kind: "Deployment", Name: "api"}}, + }} + + details := ConvertOperationState(appWithOperation(op)) + + if details.OperationNote != "dry run, partial" { + t.Errorf("expected both qualifiers, got %q", details.OperationNote) + } +} + +func TestConvertOperationState_AppBeingDeleted_ReportsDeletionNotTheOldSync(t *testing.T) { + app := appWithOperation(Operation{Sync: &SyncOperation{Revision: "HEAD"}}) + app.Metadata.DeletionTimestamp = "2026-08-18T10:00:00Z" + + details := ConvertOperationState(app) + + if details.Operation != "Deleting" { + t.Errorf("expected a deleting app to report Deleting, got %q", details.Operation) + } +} diff --git a/pkg/api/applications_test.go b/pkg/api/applications_test.go index 9f323a78..3fca6c2d 100644 --- a/pkg/api/applications_test.go +++ b/pkg/api/applications_test.go @@ -131,6 +131,7 @@ func TestConvertOperationState_FailedSync_CarriesResourceResults(t *testing.T) { details := ConvertOperationState(argoApp) want := &model.SyncStatusDetails{ + Operation: "Sync", Phase: "Failed", Message: "one or more objects failed to apply", StartedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC), diff --git a/pkg/model/types.go b/pkg/model/types.go index fc27c96b..c334b069 100644 --- a/pkg/model/types.go +++ b/pkg/model/types.go @@ -74,14 +74,16 @@ type SyncResourceResult struct { // SyncStatusDetails is the full last-operation state shown in the // sync-status pane, including per-resource results. type SyncStatusDetails struct { - Phase string `json:"phase"` - Message string `json:"message"` - StartedAt time.Time `json:"startedAt"` - FinishedAt time.Time `json:"finishedAt"` // zero while the operation is running - Revision string `json:"revision"` - InitiatedBy string `json:"initiatedBy"` - Automated bool `json:"automated"` - Resources []SyncResourceResult `json:"resources"` + Phase string `json:"phase"` + Message string `json:"message"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` // zero while the operation is running + Revision string `json:"revision"` + InitiatedBy string `json:"initiatedBy"` + Automated bool `json:"automated"` + Operation string `json:"operation"` + OperationNote string `json:"operationNote,omitempty"` + Resources []SyncResourceResult `json:"resources"` } // App represents an ArgoCD application