diff --git a/internal/app/app.go b/internal/app/app.go index c0f2183..bd79578 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "proxyctl/internal/profile" @@ -31,15 +32,41 @@ type Intent struct { Enabled bool } +type TargetDetectionPhase uint8 + +const ( + // TargetReady is the zero value so snapshots supplied by non-progressive + // Modules remain compatible with the established dashboard contract. + TargetReady TargetDetectionPhase = iota + TargetChecking + TargetDetectionFailed +) + type TargetSnapshot struct { ID string Description target.Description + DetectionPhase TargetDetectionPhase + DetectionErr error Available bool AvailabilityEvidence string State target.State Evidence string } +type TargetDetectionResult struct { + TargetID string + Phase TargetDetectionPhase + Availability target.Availability + Inspection target.Inspection + Err error +} + +type TargetDetectionUpdate struct { + Snapshot Snapshot + Result TargetDetectionResult + Complete bool +} + type Snapshot struct { Profiles []profile.Profile ActiveProfile string @@ -75,12 +102,22 @@ type ProgressiveModule interface { DispatchProgress(Intent) <-chan Result } +// ProgressiveDetectionModule exposes a useful all-Checking snapshot +// synchronously, followed by one structured Target result at a time. Startup +// and explicit refresh use this same presentation-independent contract. +type ProgressiveDetectionModule interface { + BeginTargetDetection() (Snapshot, <-chan TargetDetectionUpdate) +} + type Application struct { store profile.Store state profile.State targets []target.Target availabilities map[string]target.Availability inspections map[string]target.Inspection + detectionPhase map[string]TargetDetectionPhase + detectionErr map[string]error + detectionMu sync.RWMutex mutationLock MutationLock logger OperationLogger } @@ -112,24 +149,93 @@ func NewWithTargetsAndRuntime(store profile.Store, targets []target.Target, muta targets: append([]target.Target(nil), targets...), availabilities: make(map[string]target.Availability), inspections: make(map[string]target.Inspection), + detectionPhase: make(map[string]TargetDetectionPhase), + detectionErr: make(map[string]error), mutationLock: mutationLock, logger: logger, } - a.refreshTargets() + for _, adapter := range a.targets { + a.detectionPhase[adapter.ID()] = TargetChecking + } return a, nil } func (a *Application) Snapshot() Snapshot { + a.detectionMu.RLock() + defer a.detectionMu.RUnlock() + return a.snapshotLocked() +} + +func (a *Application) snapshotLocked() Snapshot { profiles := append([]profile.Profile(nil), a.state.Profiles...) snapshot := Snapshot{Profiles: profiles, ActiveProfile: a.state.ActiveProfile} for _, adapter := range a.targets { - availability := a.availabilities[adapter.ID()] - inspection := a.inspections[adapter.ID()] - snapshot.Targets = append(snapshot.Targets, TargetSnapshot{ID: adapter.ID(), Description: adapter.Description(), Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence}) + id := adapter.ID() + availability := a.availabilities[id] + inspection := a.inspections[id] + snapshot.Targets = append(snapshot.Targets, TargetSnapshot{ + ID: id, Description: adapter.Description(), DetectionPhase: a.detectionPhase[id], DetectionErr: a.detectionErr[id], + Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence, + }) } return snapshot } +func (a *Application) BeginTargetDetection() (Snapshot, <-chan TargetDetectionUpdate) { + a.detectionMu.Lock() + for _, adapter := range a.targets { + id := adapter.ID() + a.detectionPhase[id] = TargetChecking + a.detectionErr[id] = nil + } + checking := a.snapshotLocked() + a.detectionMu.Unlock() + + updates := make(chan TargetDetectionUpdate, len(a.targets)+1) + active := a.activeProfile() + outcomes := make(chan TargetDetectionResult, len(a.targets)) + for _, adapter := range a.targets { + adapter := adapter + go func() { + detection := detectTarget(adapter, active) + phase := TargetReady + if detection.Err != nil { + phase = TargetDetectionFailed + } + outcomes <- TargetDetectionResult{ + TargetID: adapter.ID(), Phase: phase, Availability: detection.Availability, + Inspection: detection.Inspection, Err: detection.Err, + } + }() + } + go func() { + defer close(updates) + for range a.targets { + result := <-outcomes + a.detectionMu.Lock() + a.availabilities[result.TargetID] = result.Availability + a.inspections[result.TargetID] = result.Inspection + a.detectionPhase[result.TargetID] = result.Phase + a.detectionErr[result.TargetID] = result.Err + snapshot := a.snapshotLocked() + a.detectionMu.Unlock() + updates <- TargetDetectionUpdate{Snapshot: snapshot, Result: result} + } + updates <- TargetDetectionUpdate{Snapshot: a.Snapshot(), Complete: true} + }() + return checking, updates +} + +func detectTarget(adapter target.Target, active *profile.Profile) target.Detection { + if detector, ok := adapter.(target.Detector); ok { + return detector.Detect(context.Background(), active) + } + return target.Detection{ + Availability: adapter.Availability(context.Background()), + Inspection: adapter.Inspect(context.Background(), active), + } +} + func (a *Application) Dispatch(intent Intent) Result { return a.dispatch(intent, nil) } @@ -319,17 +425,18 @@ func (a *Application) activeProfile() *profile.Profile { } func (a *Application) refreshTargets() { - active := a.activeProfile() - for _, adapter := range a.targets { - a.availabilities[adapter.ID()] = adapter.Availability(context.Background()) - a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active) + _, updates := a.BeginTargetDetection() + for range updates { } } func (a *Application) refreshInspections() { active := a.activeProfile() for _, adapter := range a.targets { - a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active) + inspection := adapter.Inspect(context.Background(), active) + a.detectionMu.Lock() + a.inspections[adapter.ID()] = inspection + a.detectionMu.Unlock() } } diff --git a/internal/app/progressive_detection_test.go b/internal/app/progressive_detection_test.go new file mode 100644 index 0000000..7225b38 --- /dev/null +++ b/internal/app/progressive_detection_test.go @@ -0,0 +1,127 @@ +package app_test + +import ( + "context" + "errors" + "path/filepath" + "reflect" + "testing" + + "proxyctl/internal/app" + "proxyctl/internal/profile" + "proxyctl/internal/target" +) + +type controlledDetectionTarget struct { + id string + description target.Description + release <-chan struct{} + detection target.Detection +} + +func (t *controlledDetectionTarget) ID() string { return t.id } +func (t *controlledDetectionTarget) Description() target.Description { return t.description } +func (t *controlledDetectionTarget) Availability(context.Context) target.Availability { + panic("progressive detection must use the structured Detector seam") +} +func (t *controlledDetectionTarget) Inspect(context.Context, *profile.Profile) target.Inspection { + panic("progressive detection must use the structured Detector seam") +} +func (t *controlledDetectionTarget) Confirmation(bool) string { return "" } +func (t *controlledDetectionTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult { + return target.OperationResult{} +} +func (t *controlledDetectionTarget) Detect(context.Context, *profile.Profile) target.Detection { + <-t.release + return t.detection +} + +func TestApplicationStreamsIndependentTargetDetectionInProductOrder(t *testing.T) { + firstRelease := make(chan struct{}) + secondRelease := make(chan struct{}) + failedRelease := make(chan struct{}) + adapters := []target.Target{ + &controlledDetectionTarget{id: "first", description: target.Description{Name: "First", Summary: "first description"}, release: firstRelease, detection: target.Detection{Availability: target.Availability{Available: true, Evidence: "first available"}, Inspection: target.Inspection{State: target.Enabled, Evidence: "first enabled"}}}, + &controlledDetectionTarget{id: "second", description: target.Description{Name: "Second", Summary: "second description"}, release: secondRelease, detection: target.Detection{Availability: target.Availability{Available: true, Evidence: "second available"}, Inspection: target.Inspection{State: target.External, Evidence: "second external"}}}, + &controlledDetectionTarget{id: "failed", description: target.Description{Name: "Failed", Summary: "failed description"}, release: failedRelease, detection: target.Detection{Err: errors.New("inspection command failed")}}, + } + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), adapters) + if err != nil { + t.Fatal(err) + } + + before := application.Snapshot() + assertTargetOrder(t, before, []string{"first", "second", "failed"}) + for _, item := range before.Targets { + if item.DetectionPhase != app.TargetChecking || item.Description.Name == "" || item.Description.Summary == "" { + t.Fatalf("Target identity was not useful before detection: %#v", item) + } + } + + checking, updates := application.BeginTargetDetection() + for _, item := range checking.Targets { + if item.DetectionPhase != app.TargetChecking { + t.Fatalf("Target did not begin in Checking: %#v", item) + } + } + + close(secondRelease) + second := <-updates + if second.Result.TargetID != "second" || second.Result.Phase != app.TargetReady || second.Result.Inspection.State != target.External { + t.Fatalf("second completion = %#v", second) + } + assertOnlyDetectionChanged(t, checking, second.Snapshot, "second") + assertTargetOrder(t, second.Snapshot, []string{"first", "second", "failed"}) + + close(failedRelease) + failed := <-updates + if failed.Result.TargetID != "failed" || failed.Result.Phase != app.TargetDetectionFailed || failed.Result.Err == nil { + t.Fatalf("failed completion = %#v", failed) + } + assertOnlyDetectionChanged(t, second.Snapshot, failed.Snapshot, "failed") + + close(firstRelease) + first := <-updates + if first.Result.TargetID != "first" || first.Result.Phase != app.TargetReady || first.Result.Inspection.State != target.Enabled { + t.Fatalf("first completion = %#v", first) + } + assertOnlyDetectionChanged(t, failed.Snapshot, first.Snapshot, "first") + + final := <-updates + if !final.Complete { + t.Fatalf("final update = %#v", final) + } + assertTargetOrder(t, final.Snapshot, []string{"first", "second", "failed"}) + if final.Snapshot.Targets[0].State != target.Enabled || final.Snapshot.Targets[1].State != target.External || final.Snapshot.Targets[2].DetectionPhase != app.TargetDetectionFailed { + t.Fatalf("final snapshot = %#v", final.Snapshot.Targets) + } +} + +func assertTargetOrder(t *testing.T, snapshot app.Snapshot, want []string) { + t.Helper() + got := make([]string, len(snapshot.Targets)) + for i := range snapshot.Targets { + got[i] = snapshot.Targets[i].ID + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Target order = %#v, want %#v", got, want) + } +} + +func assertOnlyDetectionChanged(t *testing.T, before, after app.Snapshot, changedID string) { + t.Helper() + if len(before.Targets) != len(after.Targets) { + t.Fatalf("Target count changed from %d to %d", len(before.Targets), len(after.Targets)) + } + for i := range before.Targets { + if before.Targets[i].ID == changedID { + if reflect.DeepEqual(before.Targets[i], after.Targets[i]) { + t.Fatalf("completed Target %q did not change", changedID) + } + continue + } + if !reflect.DeepEqual(before.Targets[i], after.Targets[i]) { + t.Fatalf("completion for %q changed unrelated Target %q: before=%#v after=%#v", changedID, before.Targets[i].ID, before.Targets[i], after.Targets[i]) + } + } +} diff --git a/internal/app/target_test.go b/internal/app/target_test.go index 92ce6fb..cb6507b 100644 --- a/internal/app/target_test.go +++ b/internal/app/target_test.go @@ -49,6 +49,9 @@ func TestApplicationTargetSeamRequiresActiveProfileAndReturnsTruthfulResults(t * if err != nil { t.Fatal(err) } + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } initial := application.Snapshot() if len(initial.Targets) != 1 || initial.Targets[0].State != target.External || initial.Targets[0].Description.Summary != "Shell summary" { t.Fatalf("initial Target snapshot = %#v", initial.Targets) @@ -104,6 +107,9 @@ func TestApplicationHidesUnavailableTargetButKeepsExplanation(t *testing.T) { if err != nil { t.Fatal(err) } + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } got := application.Snapshot().Targets[0] if got.Available || got.AvailabilityEvidence != "fake detection" { t.Fatalf("unavailable Target = %#v", got) diff --git a/internal/apt/apt.go b/internal/apt/apt.go index d0b1b40..c487d30 100644 --- a/internal/apt/apt.go +++ b/internal/apt/apt.go @@ -155,22 +155,33 @@ func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []ta } func (a *Adapter) Availability(ctx context.Context) target.Availability { + availability, _ := a.detectAvailability(ctx) + return availability +} + +func (a *Adapter) detectAvailability(ctx context.Context) (target.Availability, error) { marker, err := os.Stat(a.configuration.DebianVersionFile) if err != nil || !marker.Mode().IsRegular() { - return target.Availability{Evidence: "Debian-family marker /etc/debian_version was not detected"} + return target.Availability{Evidence: "Debian-family marker /etc/debian_version was not detected"}, nil } directory, err := os.Stat(a.configuration.ConfigDirectory) if err != nil || !directory.IsDir() { - return target.Availability{Evidence: "APT configuration directory apt.conf.d was not detected"} + return target.Availability{Evidence: "APT configuration directory apt.conf.d was not detected"}, nil } command, err := a.runner.LookPath("apt-config") if err != nil { - return target.Availability{Evidence: "Debian APT tool apt-config was not detected"} + return target.Availability{Evidence: "Debian APT tool apt-config was not detected"}, nil } if _, err := a.runner.Output(ctx, command, "dump"); err != nil { - return target.Availability{Evidence: "apt-config could not inspect effective APT configuration"} + return target.Availability{Evidence: "apt-config could not inspect effective APT configuration"}, fmt.Errorf("inspect effective APT configuration: %w", err) } - return target.Availability{Available: true, Evidence: "Debian marker, apt-config, and apt.conf.d configuration capability detected"} + return target.Availability{Available: true, Evidence: "Debian marker, apt-config, and apt.conf.d configuration capability detected"}, nil +} + +func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection { + availability, availabilityErr := a.detectAvailability(ctx) + inspection, inspectionErr := a.inspect(ctx, active) + return target.Detection{Availability: availability, Inspection: inspection, Err: errors.Join(availabilityErr, inspectionErr)} } func (a *Adapter) OverlayPath() string { return a.configuration.OverlayFile } diff --git a/internal/dockerclient/dockerclient.go b/internal/dockerclient/dockerclient.go index d35a272..b5ce78b 100644 --- a/internal/dockerclient/dockerclient.go +++ b/internal/dockerclient/dockerclient.go @@ -234,6 +234,10 @@ func (a *Adapter) ConfigPath() string { return a.configuration.ConfigFile } func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile } func (a *Adapter) LockPath() string { return a.configuration.RecoveryFile + ".lock" } +func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection { + return target.Detection{Availability: a.Availability(ctx), Inspection: a.Inspect(ctx, active)} +} + func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection { metadata, managed, metadataErr := a.readRecovery() config, _, configErr := a.readConfig() diff --git a/internal/dockerdaemon/dockerdaemon.go b/internal/dockerdaemon/dockerdaemon.go index 4f80620..cab860f 100644 --- a/internal/dockerdaemon/dockerdaemon.go +++ b/internal/dockerdaemon/dockerdaemon.go @@ -183,6 +183,11 @@ func (a *Adapter) detect(ctx context.Context) (target.Availability, error) { return target.Availability{Available: true, Evidence: "Docker daemon service is loaded and managed by systemd at " + values["FragmentPath"]}, nil } +func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection { + availability, err := a.detect(ctx) + return target.Detection{Availability: availability, Inspection: a.Inspect(ctx, active), Err: err} +} + func parseProperties(output string) map[string]string { result := map[string]string{} for _, line := range strings.Split(output, "\n") { diff --git a/internal/gnome/gnome.go b/internal/gnome/gnome.go index adb5df8..8c1bf42 100644 --- a/internal/gnome/gnome.go +++ b/internal/gnome/gnome.go @@ -348,9 +348,15 @@ func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []ta } func (a *Adapter) Availability(ctx context.Context) target.Availability { + availability, _ := a.detectAvailability(ctx) + return availability +} + +func (a *Adapter) detectAvailability(ctx context.Context) (target.Availability, error) { output, err := a.run(ctx, "list-schemas") if err != nil { - return target.Availability{Evidence: fmt.Sprintf("GNOME Target requires a usable %s command: %v", a.configuration.CommandName, redact(err))} + safeErr := redact(err) + return target.Availability{Evidence: fmt.Sprintf("GNOME Target requires a usable %s command: %v", a.configuration.CommandName, safeErr)}, safeErr } present := make(map[string]bool) for _, schema := range strings.Fields(output) { @@ -358,13 +364,19 @@ func (a *Adapter) Availability(ctx context.Context) target.Availability { } for _, schema := range requiredSchemas { if !present[schema] { - return target.Availability{Evidence: "required GNOME settings schema is unavailable: " + schema} + return target.Availability{Evidence: "required GNOME settings schema is unavailable: " + schema}, nil } } if _, err := a.readSettings(ctx); err != nil { - return target.Availability{Evidence: fmt.Sprintf("GNOME proxy schema is present but unusable for the desktop session: %v", redact(err))} + safeErr := redact(err) + return target.Availability{Evidence: fmt.Sprintf("GNOME proxy schema is present but unusable for the desktop session: %v", safeErr)}, safeErr } - return target.Availability{Available: true, Evidence: "gsettings command and GNOME proxy schemas are usable for the owning user"} + return target.Availability{Available: true, Evidence: "gsettings command and GNOME proxy schemas are usable for the owning user"}, nil +} + +func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection { + availability, err := a.detectAvailability(ctx) + return target.Detection{Availability: availability, Inspection: a.Inspect(ctx, active), Err: err} } func (a *Adapter) Inspect(ctx context.Context, active *profile.Profile) target.Inspection { diff --git a/internal/shell/shell.go b/internal/shell/shell.go index c276a6a..a51a3ea 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -206,6 +206,10 @@ func (a *Adapter) DiagnosticChecks(_ context.Context, _ *profile.Profile) []targ } } +func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection { + return target.Detection{Availability: a.Availability(ctx), Inspection: a.Inspect(ctx, active)} +} + func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection { metadata, managed, metadataErr := a.readRecovery() generated, generatedErr := a.files.ReadFile(a.configuration.EnvironmentFile) diff --git a/internal/target/target.go b/internal/target/target.go index eb13281..b7b1be2 100644 --- a/internal/target/target.go +++ b/internal/target/target.go @@ -40,6 +40,22 @@ type Inspection struct { Evidence string } +// Detection is one presentation-independent availability and configuration +// inspection outcome. Err distinguishes a failed check from a truthful +// unavailable or disabled result. +type Detection struct { + Availability Availability + Inspection Inspection + Err error +} + +// Detector is the optional structured detection seam. Targets which can +// preserve lower-level check failures implement it; legacy Targets continue to +// participate through Availability and Inspect. +type Detector interface { + Detect(context.Context, *profile.Profile) Detection +} + // OperationResult reports the adapter's immediate operation outcome. When Err // is non-nil, Inspection is advisory because it may have been captured before a // partial write or command failure. The Application explicitly calls Inspect diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 3574fac..91eccda 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -23,7 +23,7 @@ type targetCatalog struct { func newTargetCatalog(snapshot app.Snapshot, includeUnavailable bool) targetCatalog { items := make([]app.TargetSnapshot, 0, len(snapshot.Targets)) for _, item := range snapshot.Targets { - if item.Available || includeUnavailable { + if item.Available || includeUnavailable || item.DetectionPhase != app.TargetReady { items = append(items, item) } } @@ -84,11 +84,21 @@ type targetStateCounts struct { disabled int external int drifted int + checking int + failed int } func countAvailableTargetStates(snapshot app.Snapshot) targetStateCounts { var counts targetStateCounts for _, item := range snapshot.Targets { + switch item.DetectionPhase { + case app.TargetChecking: + counts.checking++ + continue + case app.TargetDetectionFailed: + counts.failed++ + continue + } if !item.Available { continue } diff --git a/internal/tui/dashboard_view.go b/internal/tui/dashboard_view.go index 1221699..968d2d7 100644 --- a/internal/tui/dashboard_view.go +++ b/internal/tui/dashboard_view.go @@ -106,6 +106,12 @@ func fixedViewport(content string, width, height, offset int, wrap bool) string } func (p terminalPresentation) targetBadge(item app.TargetSnapshot, applying bool) string { + switch item.DetectionPhase { + case app.TargetChecking: + return p.theme.checking.Render("… Checking") + case app.TargetDetectionFailed: + return p.theme.checkFailed.Render("! Check failed") + } if !item.Available { return p.theme.unavailable.Render("○ Unavailable") } @@ -115,8 +121,17 @@ func (p terminalPresentation) targetBadge(item app.TargetSnapshot, applying bool func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string { state := p.targetBadge(item, false) evidence := item.Evidence - if !item.Available { - evidence = item.AvailabilityEvidence + switch item.DetectionPhase { + case app.TargetChecking: + evidence = "Availability and configuration inspection are in progress." + case app.TargetDetectionFailed: + if evidence == "" { + evidence = item.AvailabilityEvidence + } + default: + if !item.Available { + evidence = item.AvailabilityEvidence + } } if evidence == "" { evidence = "No detection evidence reported." @@ -125,6 +140,9 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string { var details strings.Builder details.WriteString(p.theme.section.Render(item.Description.Name)) fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence) + if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil { + fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr) + } facts := []struct { label string value string @@ -150,9 +168,16 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string { func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string { counts := countAvailableTargetStates(snapshot) - return fmt.Sprintf("%s %d %s %d %s %d %s %d", + summary := fmt.Sprintf("%s %d %s %d %s %d %s %d", p.theme.renderTargetState(target.Enabled, false), counts.enabled, p.theme.renderTargetState(target.Disabled, false), counts.disabled, p.theme.renderTargetState(target.External, false), counts.external, p.theme.renderTargetState(target.Drifted, false), counts.drifted) + if counts.checking > 0 { + summary += fmt.Sprintf(" %s %d", p.theme.checking.Render("… Checking"), counts.checking) + } + if counts.failed > 0 { + summary += fmt.Sprintf(" %s %d", p.theme.checkFailed.Render("! Check failed"), counts.failed) + } + return summary } diff --git a/internal/tui/model.go b/internal/tui/model.go index 2926714..cb6dd92 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -50,6 +50,7 @@ type Model struct { status string applying bool refreshing bool + detectionUpdates <-chan app.TargetDetectionUpdate profileTransition bool progressTarget string quitRequested bool @@ -64,6 +65,11 @@ type refreshDone struct { result app.Result } +type targetDetectionProgress struct { + update app.TargetDetectionUpdate + updates <-chan app.TargetDetectionUpdate +} + type profileOperationProgress struct { result app.Result updates <-chan app.Result @@ -80,6 +86,16 @@ type probeDone struct { gen uint64 } +func waitForTargetDetection(updates <-chan app.TargetDetectionUpdate) tea.Cmd { + return func() tea.Msg { + update, ok := <-updates + if !ok { + return targetDetectionProgress{update: app.TargetDetectionUpdate{Complete: true}, updates: updates} + } + return targetDetectionProgress{update: update, updates: updates} + } +} + func waitForProfileProgress(updates <-chan app.Result) tea.Cmd { return func() tea.Msg { result, ok := <-updates @@ -96,6 +112,10 @@ func NewModel(application app.Module, configure ...ModelOption) Model { option(&options) } snapshot := application.Snapshot() + var detectionUpdates <-chan app.TargetDetectionUpdate + if progressive, ok := application.(app.ProgressiveDetectionModule); ok { + snapshot, detectionUpdates = progressive.BeginTargetDetection() + } return Model{ application: application, presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)}, @@ -103,11 +123,18 @@ func NewModel(application app.Module, configure ...ModelOption) Model { selectedTargetID: newTargetCatalog(snapshot, false).firstID(), width: 80, height: 24, + refreshing: detectionUpdates != nil, + detectionUpdates: detectionUpdates, detect: probe.Detect, } } -func (m Model) Init() tea.Cmd { return nil } +func (m Model) Init() tea.Cmd { + if m.detectionUpdates != nil { + return waitForTargetDetection(m.detectionUpdates) + } + return nil +} // busy is the single-operation guard for asynchronous Application work. Every // operation start checks it, so a completion can only belong to the one current @@ -120,6 +147,10 @@ func (m Model) operationInFlight() bool { return m.applying || m.refreshing || m.profileTransition } +func (m Model) dashboardNavigationAvailable() bool { + return !m.quitRequested && !m.applying && !m.profileTransition +} + func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if size, ok := message.(tea.WindowSizeMsg); ok { m.width = size.Width @@ -138,6 +169,21 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.quitRequested = true return m, nil } + if progress, ok := message.(targetDetectionProgress); ok { + if !m.refreshing || progress.updates != m.detectionUpdates { + return m, nil + } + if progress.update.Result.TargetID != "" { + m.applyTargetDetection(progress.update.Result) + } + if progress.update.Complete { + m.refreshing = false + m.detectionUpdates = nil + m.status = "Target availability and state checks completed" + return m, m.deferredQuit() + } + return m, waitForTargetDetection(progress.updates) + } if completed, ok := message.(refreshDone); ok { if !m.refreshing { return m, nil @@ -327,12 +373,17 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { if !m.busy() { m.refreshing = true m.status = "Refreshing Target availability and state…" + if progressive, ok := m.application.(app.ProgressiveDetectionModule); ok { + m.snapshot, m.detectionUpdates = progressive.BeginTargetDetection() + m.retainTargetSelection() + return m, waitForTargetDetection(m.detectionUpdates) + } return m, func() tea.Msg { return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})} } } case "tab": - if !m.busy() { + if m.dashboardNavigationAvailable() { if m.dashboardFocus == targetListPane { m.dashboardFocus = targetDetailsPane } else { @@ -340,14 +391,14 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { } } case "u": - if !m.busy() { + if m.dashboardNavigationAvailable() { formerIndex := catalog.index(m.selectedTargetID) m.showUnavailable = !m.showUnavailable m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex) m.detailsOffset = 0 } case "up", "k": - if !m.busy() { + if m.dashboardNavigationAvailable() { if m.dashboardFocus == targetDetailsPane { m.detailsOffset = max(0, m.detailsOffset-1) } else { @@ -356,7 +407,7 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { } } case "down", "j": - if !m.busy() { + if m.dashboardNavigationAvailable() { if m.dashboardFocus == targetDetailsPane { m.detailsOffset++ } else { @@ -365,17 +416,17 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { } } case "pgup": - if !m.busy() && m.dashboardFocus == targetDetailsPane { + if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane { m.detailsOffset = max(0, m.detailsOffset-5) } case "pgdown": - if !m.busy() && m.dashboardFocus == targetDetailsPane { + if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane { m.detailsOffset += 5 } case " ": if !m.busy() { selected, ok := catalog.selected(m.selectedTargetID) - if !ok || !selected.Available { + if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady { break } desired := selected.State == target.Disabled || selected.State == target.External @@ -634,6 +685,24 @@ func (m *Model) retainTargetSelection() { m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID)) } +func (m *Model) applyTargetDetection(result app.TargetDetectionResult) { + formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable) + formerIndex := formerCatalog.index(m.selectedTargetID) + for i := range m.snapshot.Targets { + if m.snapshot.Targets[i].ID != result.TargetID { + continue + } + m.snapshot.Targets[i].DetectionPhase = result.Phase + m.snapshot.Targets[i].DetectionErr = result.Err + m.snapshot.Targets[i].Available = result.Availability.Available + m.snapshot.Targets[i].AvailabilityEvidence = result.Availability.Evidence + m.snapshot.Targets[i].State = result.Inspection.State + m.snapshot.Targets[i].Evidence = result.Inspection.Evidence + break + } + m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex) +} + func (f formState) labels() []string { if f.profile.Mode == profile.AdvancedMode { return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"} diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 04fab60..ec603b8 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "proxyctl/internal/app" "proxyctl/internal/profile" ) @@ -48,7 +49,7 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: manage Proxy Profiles ?: help q/Esc: quit" } actions := "↑/k ↓/j: select Tab: focus details " + visibility - if selected, ok := catalog.selected(model.selectedTargetID); ok && selected.Available { + if selected, ok := catalog.selected(model.selectedTargetID); ok && selected.Available && selected.DetectionPhase == app.TargetReady { actions = "Space: toggle " + actions } return actions + "\np: manage Proxy Profiles r: refresh ?: help q/Esc: quit" diff --git a/internal/tui/progressive_detection_test.go b/internal/tui/progressive_detection_test.go new file mode 100644 index 0000000..a65aed4 --- /dev/null +++ b/internal/tui/progressive_detection_test.go @@ -0,0 +1,126 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "proxyctl/internal/app" + "proxyctl/internal/target" +) + +type progressiveDetectionModule struct { + snapshot app.Snapshot + streams []chan app.TargetDetectionUpdate + begins int +} + +func (m *progressiveDetectionModule) Snapshot() app.Snapshot { return m.snapshot } +func (m *progressiveDetectionModule) Dispatch(app.Intent) app.Result { + return app.Result{Snapshot: m.snapshot} +} +func (m *progressiveDetectionModule) BeginTargetDetection() (app.Snapshot, <-chan app.TargetDetectionUpdate) { + m.begins++ + for i := range m.snapshot.Targets { + m.snapshot.Targets[i].DetectionPhase = app.TargetChecking + m.snapshot.Targets[i].DetectionErr = nil + } + return m.snapshot, m.streams[m.begins-1] +} + +func TestCompleteModelStreamsStartupAndRefreshByTargetWithoutMovingSelection(t *testing.T) { + startup := make(chan app.TargetDetectionUpdate, 4) + refresh := make(chan app.TargetDetectionUpdate, 4) + module := &progressiveDetectionModule{ + snapshot: app.Snapshot{Targets: []app.TargetSnapshot{ + {ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell description"}}, + {ID: "apt", Description: target.Description{Name: "APT", Summary: "APT description"}}, + {ID: "docker", Description: target.Description{Name: "Docker", Summary: "Docker description"}}, + }}, + streams: []chan app.TargetDetectionUpdate{startup, refresh}, + } + model := resizedModel(t, module, themeModeNoColor, 120, 36) + assertUsableFrame(t, model.View(), 120, 36, + "> … Checking Shell", "… Checking APT", "… Checking Docker", + "Shell description", "Availability and configuration inspection are in progress.", + ) + + // Selection remains usable while checks run, and is retained by identity + // even when a later product row completes first. + model = updateKey(model, "down") + if model.selectedTargetID != "apt" { + t.Fatalf("selection during startup = %q, want apt", model.selectedTargetID) + } + command := model.Init() + if command == nil { + t.Fatal("startup did not wait for progressive detection") + } + + startup <- detectionUpdate(module.snapshot, "docker", app.TargetReady, true, target.Enabled, "docker enabled", nil) + model, command = applyDetectionCommand(t, model, command) + view := model.View() + assertOrder(t, view, "Shell", "APT", "Docker") + assertUsableFrame(t, view, 120, 36, "… Checking Shell", "> … Checking APT", "● Enabled Docker") + if model.selectedTargetID != "apt" { + t.Fatalf("out-of-order completion moved selection to %q", model.selectedTargetID) + } + + failure := errors.New("apt-config inspection failed") + startup <- detectionUpdate(module.snapshot, "shell", app.TargetDetectionFailed, false, "", "", failure) + model, command = applyDetectionCommand(t, model, command) + assertUsableFrame(t, model.View(), 120, 36, "! Check failed Shell", "> … Checking APT", "● Enabled Docker") + + startup <- detectionUpdate(module.snapshot, "apt", app.TargetReady, true, target.External, "external APT proxy", nil) + model, command = applyDetectionCommand(t, model, command) + startup <- app.TargetDetectionUpdate{Complete: true} + model, command = applyDetectionCommand(t, model, command) + if command != nil || model.selectedTargetID != "apt" { + t.Fatalf("startup completion command=%v selection=%q", command != nil, model.selectedTargetID) + } + assertUsableFrame(t, model.View(), 120, 36, "! Check failed Shell", "> ◇ External APT", "● Enabled Docker") + model = updateKey(model, "up") + assertUsableFrame(t, model.View(), 120, 36, "> ! Check failed Shell", "Check error: apt-config inspection failed") + model = updateKey(model, "down") + + updated, command := model.Update(keyMessage("r")) + model = updated.(Model) + if command == nil || module.begins != 2 { + t.Fatalf("refresh did not reuse progressive detection: command=%v begins=%d", command != nil, module.begins) + } + assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "> … Checking APT", "… Checking Docker") + + refresh <- detectionUpdate(module.snapshot, "apt", app.TargetReady, true, target.Enabled, "apt enabled", nil) + model, command = applyDetectionCommand(t, model, command) + assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "> ● Enabled APT", "… Checking Docker") + if model.selectedTargetID != "apt" { + t.Fatalf("refresh completion moved selection to %q", model.selectedTargetID) + } +} + +func detectionUpdate(snapshot app.Snapshot, id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.TargetDetectionUpdate { + return app.TargetDetectionUpdate{Result: app.TargetDetectionResult{ + TargetID: id, Phase: phase, + Availability: target.Availability{Available: available, Evidence: evidence}, + Inspection: target.Inspection{State: state, Evidence: evidence}, Err: err, + }} +} + +func applyDetectionCommand(t *testing.T, model Model, command tea.Cmd) (Model, tea.Cmd) { + t.Helper() + updated, next := model.Update(command()) + return updated.(Model), next +} + +func assertOrder(t *testing.T, view string, values ...string) { + t.Helper() + last := -1 + for _, value := range values { + index := strings.Index(view, value) + if index <= last { + t.Fatalf("%q did not follow prior Target in frame:\n%s", value, view) + } + last = index + } +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 80c54d4..eeba99b 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -21,6 +21,8 @@ type semanticTheme struct { external lipgloss.Style drifted lipgloss.Style unavailable lipgloss.Style + checking lipgloss.Style + checkFailed lipgloss.Style applying lipgloss.Style } @@ -67,6 +69,8 @@ func newSemanticTheme(mode themeMode, output io.Writer) semanticTheme { external: style().Foreground(lipgloss.Color("172")), drifted: style().Bold(true).Foreground(lipgloss.Color("160")), unavailable: style().Foreground(lipgloss.Color(subtle)), + checking: style().Foreground(lipgloss.Color(accent)), + checkFailed: style().Bold(true).Foreground(lipgloss.Color("160")), applying: style().Foreground(lipgloss.Color(accent)), } }