diff --git a/cmd/proxyctl/main_test.go b/cmd/proxyctl/main_test.go index 384a8af..74d5a00 100644 --- a/cmd/proxyctl/main_test.go +++ b/cmd/proxyctl/main_test.go @@ -97,8 +97,8 @@ func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) { if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil { t.Fatalf("run proxyctl: %v", err) } - if !strings.Contains(output.String(), "Active Profile: office") { - t.Fatalf("default TUI output missing Active Profile: %q", output.String()) + if !strings.Contains(output.String(), "Profile: office") { + t.Fatalf("default TUI output missing Profile: %q", output.String()) } } diff --git a/cmd/proxyctl/maintenance_test.go b/cmd/proxyctl/maintenance_test.go index 003c12d..229ef2a 100644 --- a/cmd/proxyctl/maintenance_test.go +++ b/cmd/proxyctl/maintenance_test.go @@ -247,19 +247,17 @@ func TestCommandsRunAsRealProcessesInsideIsolatedUserState(t *testing.T) { } defaultView := string(defaultOutput) for _, expected := range []string{ - "proxyctl Operations Console", - "Active Profile: No Active Profile", - "Targets: ● Enabled 0 ○ Disabled 1 ◇ External 0 ! Drifted 1", + "proxyctl Profile: No Active Profile", "▶ Targets [focused]", "> ○ Disabled Shell", "! Drifted Docker daemon", "Target details", - "State: ○ Disabled", - "Evidence: no recognized Shell proxy configuration detected", - "Tab: focus details", + "○ Disabled", + "no recognized Shell proxy configuration detected", + "Tab: Details", } { if !strings.Contains(defaultView, expected) { - t.Errorf("default Operations Console missing %q; output=%q", expected, defaultOutput) + t.Errorf("default dashboard missing %q; output=%q", expected, defaultOutput) } } versionOutput, err := invoke("", "version", "--short") diff --git a/internal/app/app.go b/internal/app/app.go index fc2fa3a..578c2bb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -164,6 +164,7 @@ type Application struct { targetDescriptions []target.Description availabilities map[string]target.Availability inspections map[string]target.Inspection + mutationBlockReasons map[string]string detectionPhase map[string]TargetDetectionPhase detectionErr map[string]error detectionGeneration uint64 @@ -197,15 +198,16 @@ func NewWithTargetsAndRuntime(store profile.Store, targets []target.Target, muta return nil, err } a := &Application{ - store: store, - state: state, - 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, + store: store, + state: state, + targets: append([]target.Target(nil), targets...), + availabilities: make(map[string]target.Availability), + inspections: make(map[string]target.Inspection), + mutationBlockReasons: make(map[string]string), + detectionPhase: make(map[string]TargetDetectionPhase), + detectionErr: make(map[string]error), + mutationLock: mutationLock, + logger: logger, } for _, adapter := range a.targets { id := adapter.ID() @@ -229,7 +231,7 @@ func (a *Application) snapshotLocked() Snapshot { availability := a.availabilities[id] inspection := a.inspections[id] phase := a.detectionPhase[id] - primary, resolution := contextualTargetActions(phase, availability.Available, inspection.State, state.ActiveProfile != "") + primary, resolution := contextualTargetActions(phase, availability.Available, inspection.State, state.ActiveProfile != "", a.mutationBlockReasons[id]) snapshot.Targets = append(snapshot.Targets, TargetSnapshot{ ID: id, Description: a.targetDescriptions[i], DetectionPhase: phase, DetectionErr: a.detectionErr[id], Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence, @@ -239,7 +241,7 @@ func (a *Application) snapshotLocked() Snapshot { return snapshot } -func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool) (TargetAction, []TargetAction) { +func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool, mutationBlockReason string) (TargetAction, []TargetAction) { primaryKind := primaryTargetActionKind(state) primary := TargetAction{Kind: primaryKind, Available: primaryKind != 0} if primaryKind == 0 { @@ -253,6 +255,10 @@ func contextualTargetActions(phase TargetDetectionPhase, available bool, state t primary.Available = false primary.DisabledReason = "This action is unavailable because there is no Active Profile; open Profile management first" } + if primary.Available && mutationBlockReason != "" { + primary.Available = false + primary.DisabledReason = mutationBlockReason + } if state != target.Drifted { return primary, nil } @@ -271,6 +277,10 @@ func contextualTargetActions(phase TargetDetectionPhase, available bool, state t action.Available = false action.DisabledReason = reason } + if action.Available && mutationBlockReason != "" { + action.Available = false + action.DisabledReason = mutationBlockReason + } resolution = append(resolution, action) } return primary, resolution @@ -300,8 +310,9 @@ func targetActionDisabledReason(phase TargetDetectionPhase, available bool) stri } type targetDetectionOutcome struct { - targetID string - detection target.Detection + targetID string + detection target.Detection + mutationBlockReason string } type targetDetectionUpdate struct { @@ -327,7 +338,10 @@ func (a *Application) beginTargetDetection(ctx context.Context) (Snapshot, <-cha for i, adapter := range a.targets { adapter, id := adapter, a.targetIDs[i] go func() { - outcomes <- targetDetectionOutcome{targetID: id, detection: detectTarget(ctx, adapter, active)} + outcomes <- targetDetectionOutcome{ + targetID: id, detection: detectTarget(ctx, adapter, active), + mutationBlockReason: mutationBlockReason(ctx, adapter), + } }() } go func() { @@ -345,6 +359,7 @@ func (a *Application) beginTargetDetection(ctx context.Context) (Snapshot, <-cha } a.availabilities[outcome.targetID] = outcome.detection.Availability a.inspections[outcome.targetID] = outcome.detection.Inspection + a.mutationBlockReasons[outcome.targetID] = outcome.mutationBlockReason a.detectionPhase[outcome.targetID] = phase a.detectionErr[outcome.targetID] = outcome.detection.Err update := targetDetectionUpdate{snapshot: a.snapshotLocked(), targetID: outcome.targetID} @@ -372,6 +387,21 @@ func detectTarget(ctx context.Context, adapter target.Target, active *profile.Pr } } +func mutationBlockReason(ctx context.Context, adapter target.Target) string { + authorizer, ok := adapter.(target.MutationAuthorizer) + if !ok { + return "" + } + check := authorizer.MutationAuthority(ctx) + if check.OK { + return "" + } + if check.Evidence == "" { + return "The current process does not have authority to change this Target" + } + return check.Evidence +} + func (a *Application) Dispatch(intent Intent) Result { ctx, cancel := context.WithTimeout(context.Background(), operationTimeout) defer cancel() @@ -642,12 +672,13 @@ func (a *Application) replaceInspection(id string, inspection target.Inspection) // publishTargetDetection atomically replaces all availability, inspection, and // failure fields from one targeted check. Detect runs before this method, so no // Application state mutex is held while adapter code executes. -func (a *Application) publishTargetDetection(id string, detection target.Detection) Snapshot { +func (a *Application) publishTargetDetection(id string, detection target.Detection, mutationBlockReason string) Snapshot { a.stateMu.Lock() defer a.stateMu.Unlock() a.detectionGeneration++ a.availabilities[id] = detection.Availability a.inspections[id] = detection.Inspection + a.mutationBlockReasons[id] = mutationBlockReason a.detectionErr[id] = detection.Err a.detectionPhase[id] = TargetReady if detection.Err != nil { @@ -806,7 +837,7 @@ func (a *Application) setTargetDesired(ctx context.Context, intent Intent, publi // before action validation. Adapter calls happen without stateMu; the full // outcome is then published under one lock before policy is derived. detection := detectTarget(ctx, selected, active) - snapshot := a.publishTargetDetection(intent.TargetID, detection) + snapshot := a.publishTargetDetection(intent.TargetID, detection, mutationBlockReason(ctx, selected)) selectedSnapshot := snapshot.Targets[selectedIndex] policy, err := validateTargetAction(intent, selectedSnapshot) if err != nil { diff --git a/internal/app/target_test.go b/internal/app/target_test.go index 733bca6..713595d 100644 --- a/internal/app/target_test.go +++ b/internal/app/target_test.go @@ -103,6 +103,35 @@ func TestApplicationRequiresTargetConfirmationBeforeMutation(t *testing.T) { } } +type authorityTarget struct { + *fakeTarget + allowed bool + reason string +} + +func (t *authorityTarget) MutationAuthority(context.Context) target.Check { + return target.Check{Name: "mutation authority", OK: t.allowed, Evidence: t.reason} +} + +func TestApplicationDisablesMutationWhenCurrentProcessLacksAuthority(t *testing.T) { + adapter := &authorityTarget{fakeTarget: &fakeTarget{available: true, state: target.Disabled}, reason: "requires elevated authority"} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) + if err != nil { + t.Fatal(err) + } + createAndActivate(t, application) + refreshApplicationTargets(t, application) + + snapshot := application.Snapshot() + if snapshot.Targets[0].PrimaryAction.Available || snapshot.Targets[0].PrimaryAction.DisabledReason != adapter.reason { + t.Fatalf("authority policy = %#v", snapshot.Targets[0].PrimaryAction) + } + result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: adapter.ID()}) + if result.Err == nil || !strings.Contains(result.Err.Error(), adapter.reason) || len(adapter.calls) != 0 { + t.Fatalf("blocked mutation result = %#v calls=%v", result, adapter.calls) + } +} + type targetWithID struct { *fakeTarget id string diff --git a/internal/apt/apt.go b/internal/apt/apt.go index 54c8ae5..8b84fb6 100644 --- a/internal/apt/apt.go +++ b/internal/apt/apt.go @@ -130,6 +130,14 @@ func (a *Adapter) Confirmation(bool) string { return "" } // DiagnosticChecks observes the APT command, configuration schema, and current // filesystem authority without changing the overlay or effective settings. +func (a *Adapter) MutationAuthority(context.Context) target.Check { + check := target.ReadOnlyWriteAuthority(a.configuration.OverlayFile) + if !check.OK { + check.Evidence += "; run proxyctl with sufficient authority (normally sudo proxyctl)" + } + return check +} + func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check { commandCheck := target.Check{Name: "required command", OK: true, Evidence: "apt-config is available"} command, err := a.runner.LookPath("apt-config") diff --git a/internal/dockerdaemon/dockerdaemon.go b/internal/dockerdaemon/dockerdaemon.go index 540dde7..b05472d 100644 --- a/internal/dockerdaemon/dockerdaemon.go +++ b/internal/dockerdaemon/dockerdaemon.go @@ -132,6 +132,15 @@ func (a *Adapter) Confirmation(bool) string { // DiagnosticChecks performs only systemctl show and filesystem reads/metadata // probes; service reload/restart and all configuration mutations are excluded. +func (a *Adapter) MutationAuthority(context.Context) target.Check { + if a.authority.EffectiveUID() != 0 { + return target.Check{Name: "mutation authority", Evidence: "Docker daemon changes require root authority; restart with sudo proxyctl"} + } + check := target.ReadOnlyWriteAuthority(a.configuration.DaemonConfigPath, a.configuration.RecoveryPath) + check.Name = "mutation authority" + return check +} + func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check { commandCheck := target.Check{Name: "required command and service", OK: true, Evidence: "systemctl can inspect the loaded docker.service"} if availability, err := a.detect(ctx); err != nil || !availability.Available { diff --git a/internal/target/target.go b/internal/target/target.go index b7b1be2..c0fdb6f 100644 --- a/internal/target/target.go +++ b/internal/target/target.go @@ -78,6 +78,12 @@ type Diagnosable interface { DiagnosticChecks(context.Context, *profile.Profile) []Check } +// MutationAuthorizer optionally explains why a detected Target cannot be +// changed by the current process. It is read-only and presentation-independent. +type MutationAuthorizer interface { + MutationAuthority(context.Context) Check +} + // Target hides platform paths and commands from the application and TUI. type Target interface { ID() string diff --git a/internal/tui/active_profile_picker_test.go b/internal/tui/active_profile_picker_test.go index 652b7da..d38a9d3 100644 --- a/internal/tui/active_profile_picker_test.go +++ b/internal/tui/active_profile_picker_test.go @@ -180,7 +180,7 @@ func TestCompleteModelPickerConfirmedActivationContinuesThroughUnifiedOperation( completion <- app.SucceededOperationUpdate(completed, app.OperationStatus{Summary: `Activated Profile "new"`}, nil) close(completion) model, _ = applyOperationCommand(t, model, command) - if view := model.View(); !strings.Contains(view, "Active Profile: new") || !strings.Contains(view, `Activated Profile "new"`) { + if view := model.View(); !strings.Contains(view, "Profile: new") || !strings.Contains(view, `Activated Profile "new"`) { t.Fatalf("confirmed activation did not finish on the dashboard:\n%s", view) } } @@ -213,7 +213,7 @@ func TestCompleteModelPickerPartialFailureKeepsProgressClosesPickerAndFocusesFai close(updates) model, _ = applyOperationCommand(t, model, command) view := model.View() - if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Drifted ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Active Profile: new") { + if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Drifted ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Profile: new") { t.Fatalf("partial failure was not retained and focused: selected=%q\n%s", model.selectedTargetID, view) } } diff --git a/internal/tui/contextual_actions_test.go b/internal/tui/contextual_actions_test.go index da908c4..9a0e1ea 100644 --- a/internal/tui/contextual_actions_test.go +++ b/internal/tui/contextual_actions_test.go @@ -35,12 +35,9 @@ func TestCompleteModelRendersAndDispatchesContextualPrimaryActions(t *testing.T) module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}} model := resizedModel(t, module, themeModeNoColor, 120, 36) view := model.View() - if !strings.Contains(view, "Action: "+test.label) || !strings.Contains(view, "Space: "+test.label) { + if !strings.Contains(view, "Space: "+test.label) { t.Fatalf("contextual action not rendered:\n%s", view) } - if test.state == target.External && (!strings.Contains(view, "proxyctl will begin managing this configuration") || !strings.Contains(view, "while preserving restorable state")) { - t.Fatalf("Apply Profile ownership explanation missing:\n%s", view) - } updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) model = updated.(Model) @@ -91,7 +88,7 @@ func TestCompleteModelRendersUnknownTargetStateAsStructurallyUnavailable(t *test model := resizedModel(t, module, themeModeNoColor, 120, 36) view := model.View() - if !strings.Contains(view, "Action: Unavailable") || !strings.Contains(view, "Action unavailable:") || !strings.Contains(view, "state is unknown") || !strings.Contains(view, "Space: Unavailable disabled") { + if !strings.Contains(view, "Unavailable:") || !strings.Contains(view, "Target actions are unavailable") || !strings.Contains(view, "unknown") || !strings.Contains(view, "Space: Unavailable unavailable") { t.Fatalf("unknown Target action was not structurally unavailable:\n%s", view) } updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) @@ -108,7 +105,7 @@ func TestCompleteModelUsesStructuredDisabledReasonWithoutInferringFromState(t *t module := &targetModule{snapshot: initial} model := resizedModel(t, module, themeModeNoColor, 120, 36) view := model.View() - if !strings.Contains(view, "No Active Profile is selected") || !strings.Contains(view, "Space: Enable disabled") { + if !strings.Contains(view, "No Active Profile is selected") || !strings.Contains(view, "Space: Enable unavailable") { t.Fatalf("structured disabled reason missing:\n%s", view) } updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 888d774..3abaa38 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -1,9 +1,6 @@ package tui -import ( - "proxyctl/internal/app" - "proxyctl/internal/target" -) +import "proxyctl/internal/app" func targetActionLabel(kind app.TargetActionKind) string { switch kind { @@ -101,40 +98,3 @@ func (c targetCatalog) retain(id string, formerIndex int) string { } return c.items[max(0, min(len(c.items)-1, formerIndex))].ID } - -type targetStateCounts struct { - enabled int - 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 - } - switch item.State { - case target.Enabled: - counts.enabled++ - case target.Disabled: - counts.disabled++ - case target.External: - counts.external++ - case target.Drifted: - counts.drifted++ - } - } - return counts -} diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go index 57d3636..4514d2c 100644 --- a/internal/tui/dashboard_test.go +++ b/internal/tui/dashboard_test.go @@ -23,9 +23,8 @@ func TestCompleteModelWideOperationsConsoleShowsStableAvailableTargetDashboard(t view := resizedModel(t, module, themeModeNoColor, 120, 36).View() assertUsableFrame(t, view, 120, 36, - "proxyctl", "Active Profile: office", - "● Enabled 1", "○ Disabled 1", "◇ External 1", "! Drifted 1", - "> ● Enabled", "Shell", "State: ● Enabled", "Evidence: matches Active Profile", + "proxyctl", "Profile: office", + "> ● Enabled", "Shell", "matches Active Profile", ) if strings.Contains(view, "APT") || strings.Contains(view, "apt-config was not detected") { t.Fatalf("default dashboard included an unavailable Target:\n%s", view) @@ -71,12 +70,8 @@ func TestCompleteModelDashboardRevealsUnavailableEvidenceAndStructuredDetails(t view := model.View() assertUsableFrame(t, view, 120, 36, - "> ○ Unavailable APT", "State: ○ Unavailable", "Evidence: apt-config was not detected", - "Affects: package operations across privilege boundaries", - "Permissions: pre-existing root write authority", - "Ownership & restoration: isolated overlay; disabling reveals the", "previous configuration", - "Restart risk: none", "Persistent package proxy.", "Longer APT explanation.", - "u: hide unavailable", + "> ○ Unavailable APT", "○ Unavailable", "apt-config was not detected", + "Persistent package proxy.", "u: Hide unavailable", ) if !(strings.Index(view, "Shell") < strings.Index(view, "APT") && strings.Index(view, "APT") < strings.Index(view, "Docker daemon")) { t.Fatalf("revealing unavailable Targets changed product order:\n%s", view) @@ -95,7 +90,7 @@ func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIde } } targets[22].Description.Summary = "Selected identity summary" - targets[22].Description.Details = strings.Repeat("Scrollable detail line. ", 45) + "END OF TARGET DETAILS" + targets[22].Evidence = strings.Repeat("Scrollable evidence line. ", 45) + "END OF TARGET EVIDENCE" module := &recordingModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: targets}} model := resizedModel(t, module, themeModeNoColor, 80, 24) for range 22 { @@ -103,7 +98,7 @@ func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIde } view := model.View() - assertUsableFrame(t, view, 80, 24, "> ○ Disabled Product Target 22", "Selected identity summary", "▶ Targets [focused]") + assertUsableFrame(t, view, 80, 24, "> ○ Disabled Product Target 22", "▶ Targets [focused]") model = updateKey(model, "tab") for range 70 { @@ -111,16 +106,16 @@ func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIde } view = model.View() assertUsableFrame(t, view, 80, 24, - "> ○ Disabled Product Target 22", "▶ Target details [focused]", "END OF", "TARGET DETAILS", - "↑/k ↓/j PgUp/PgDn: scroll", "Tab: focus Targets", + "> ○ Disabled Product Target 22", "▶ Target details [focused]", "END OF", "TARGET EVIDENCE", + "↑/↓: Scroll", "Tab: Targets", ) model = resize(model, 120, 36) view = model.View() - assertUsableFrame(t, view, 120, 36, "> ○ Disabled Product Target 22", "END OF TARGET DETAILS", "▶ Target details [focused]") + assertUsableFrame(t, view, 120, 36, "> ○ Disabled Product Target 22", "END OF TARGET", "EVIDENCE", "▶ Target details [focused]") if !sameLine(view, "Targets", "Target details") { t.Fatalf("resized dashboard did not become side by side:\n%s", view) } model = resize(model, 80, 24) - assertUsableFrame(t, model.View(), 80, 24, "> ○ Disabled Product Target 22", "END OF", "TARGET DETAILS", "▶ Target details [focused]") + assertUsableFrame(t, model.View(), 80, 24, "> ○ Disabled Product Target 22", "END OF", "TARGET EVIDENCE", "▶ Target details [focused]") } diff --git a/internal/tui/dashboard_view.go b/internal/tui/dashboard_view.go index e955f68..eca6839 100644 --- a/internal/tui/dashboard_view.go +++ b/internal/tui/dashboard_view.go @@ -8,7 +8,6 @@ import ( "github.com/charmbracelet/lipgloss" "proxyctl/internal/app" - "proxyctl/internal/target" ) type dashboardRenderState struct { @@ -149,55 +148,18 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot, progress ap var details strings.Builder details.WriteString(p.theme.section.Render(item.Description.Name)) - fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence) - fmt.Fprintf(&details, "\nAction: %s", targetActionLabel(item.PrimaryAction.Kind)) - if !item.PrimaryAction.Available && item.PrimaryAction.DisabledReason != "" { - fmt.Fprintf(&details, "\nAction unavailable: %s", item.PrimaryAction.DisabledReason) - } - switch item.PrimaryAction.Kind { - case app.ApplyProfileTargetAction: - details.WriteString("\nApply Profile means proxyctl will begin managing this configuration while preserving restorable state.") - case app.ResolveDriftTargetAction: - details.WriteString("\nResolve opens Drift Resolution without choosing a recovery direction for you.") + fmt.Fprintf(&details, "\n%s\n%s", state, evidence) + action := targetActionLabel(item.PrimaryAction.Kind) + if item.PrimaryAction.Available { + fmt.Fprintf(&details, "\n\nSpace: %s", action) + } else if item.PrimaryAction.DisabledReason != "" { + fmt.Fprintf(&details, "\n\nUnavailable: %s", item.PrimaryAction.DisabledReason) } if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil { - fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr) - } - facts := []struct { - label string - value string - }{ - {"Affects", item.Description.Scope}, - {"Permissions", item.Description.Permissions}, - {"Ownership & restoration", item.Description.Ownership}, - {"Restart risk", item.Description.RestartRisk}, - } - for _, fact := range facts { - if fact.value != "" { - fmt.Fprintf(&details, "\n%s: %s", fact.label, fact.value) - } + fmt.Fprintf(&details, "\nCheck failed: %s", item.DetectionErr) } if item.Description.Summary != "" { fmt.Fprintf(&details, "\n\n%s", item.Description.Summary) } - if item.Description.Details != "" { - fmt.Fprintf(&details, "\n\n%s", item.Description.Details) - } return protectRenderedText(details.String()) } - -func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string { - counts := countAvailableTargetStates(snapshot) - 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/foundation_test.go b/internal/tui/foundation_test.go index 3f1d5d1..1830913 100644 --- a/internal/tui/foundation_test.go +++ b/internal/tui/foundation_test.go @@ -24,7 +24,7 @@ func TestCompleteModelRendersResponsiveTerminalSizeClasses(t *testing.T) { if !sameLine(view, "Targets", "Target details") { t.Fatalf("wide layout did not place list and details side by side:\n%s", view) } - assertUsableFrame(t, view, 120, 36, "Shell", "Shell summary", "Active Profile: office") + assertUsableFrame(t, view, 120, 36, "Shell", "Shell summary", "Profile: office") }) t.Run("narrow 80x24", func(t *testing.T) { @@ -33,7 +33,7 @@ func TestCompleteModelRendersResponsiveTerminalSizeClasses(t *testing.T) { if sameLine(view, "Targets", "Target details") || strings.Index(view, "Target details") < strings.Index(view, "Shell") { t.Fatalf("narrow layout did not stack list above details:\n%s", view) } - assertUsableFrame(t, view, 80, 24, "Shell", "Shell summary", "Active Profile: office") + assertUsableFrame(t, view, 80, 24, "Shell", "Shell summary", "Profile: office") }) t.Run("below minimum 59x15", func(t *testing.T) { @@ -82,11 +82,11 @@ func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) { model := resizedModel(t, module, themeModeNoColor, 120, 36) model = updateKey(model, "down") model = resize(model, 80, 24) - if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") { + if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Space: Apply Profile") { t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view) } model = resize(model, 120, 36) - if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") { + if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Space: Apply Profile") { t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view) } @@ -110,7 +110,7 @@ func TestCompleteModelSemanticThemeSupportsDarkLightAndNoColor(t *testing.T) { plain := resizedModel(t, module, themeModeNoColor, 80, 24).View() for name, view := range map[string]string{"dark": dark, "light": light, "NO_COLOR": plain} { - for _, semantic := range []string{"Active Profile: office", "Disabled", "External", ">"} { + for _, semantic := range []string{"Profile: office", "Disabled", "External", ">"} { if !strings.Contains(view, semantic) { t.Fatalf("%s theme lost non-color semantic %q:\n%s", name, semantic, view) } @@ -134,7 +134,7 @@ func TestNewModelUsesInjectedBubbleTeaOutputForTerminalCapabilities(t *testing.T if ansiPattern.MatchString(view) { t.Fatalf("non-terminal Bubble Tea output received ANSI styling: %q", view) } - for _, semantic := range []string{"Active Profile: office", "Disabled", "External", ">"} { + for _, semantic := range []string{"Profile: office", "Disabled", "External", ">"} { if !strings.Contains(view, semantic) { t.Fatalf("injected-output rendering lost semantic %q:\n%s", semantic, view) } @@ -231,19 +231,19 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl assertUsableFrame(t, model.View(), 80, 24, "> ! Drifted Docker daemon", - "State: ! Drifted", + "! Drifted", "Docker daemon selected evidence.", "Status: Refresh completed; selected Target remains actionable.", "Space: Resolve", - "p: switch Active Profile", - "m: manage Proxy Profiles", + "p: Profile", + "m: Profiles", ) model = resize(model, 120, 36) model = resize(model, 80, 24) assertUsableFrame(t, model.View(), 80, 24, "> ! Drifted Docker daemon", - "State: ! Drifted", + "! Drifted", "Docker daemon selected evidence.", "Space: Resolve", ) diff --git a/internal/tui/help_dialog_test.go b/internal/tui/help_dialog_test.go index 3971695..ed3413d 100644 --- a/internal/tui/help_dialog_test.go +++ b/internal/tui/help_dialog_test.go @@ -30,7 +30,7 @@ func TestCompleteModelContextualHelpOpensOverEveryPageAndRestoresPageState(t *te model = updateKey(model, "down") model = updateKey(model, "?") view := model.View() - assertUsableFrame(t, view, 120, 36, "Operations Console", "Contextual Help", "Dashboard", "Target details focus", "APT", "Disable") + assertUsableFrame(t, view, 120, 36, "Profile: office", "Contextual Help", "Dashboard", "Target details focus", "APT", "Disable") model = updateKey(model, " ") if len(module.intents) != 0 { @@ -259,9 +259,9 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) { t.Run("dashboard and busy details", func(t *testing.T) { model := resizedModel(t, module, themeModeNoColor, 120, 36) - assertUsableFrame(t, model.View(), 120, 36, "Space: Enable", "u: show unavailable", "r: refresh", "q/Ctrl+C: quit") + assertUsableFrame(t, model.View(), 120, 36, "Space: Enable", "u: Show hidden", "r: Refresh", "q: Quit") model = updateKey(model, "tab") - assertUsableFrame(t, model.View(), 120, 36, "PgUp/PgDn: scroll", "u: show unavailable", "q/Ctrl+C: quit") + assertUsableFrame(t, model.View(), 120, 36, "↑/↓: Scroll", "u: Show hidden", "q: Quit") updates := make(chan app.OperationUpdate) busyModule := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}} @@ -269,9 +269,12 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) { model = updateKey(model, "tab") updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace}) model = updated.(Model) - assertUsableFrame(t, model.View(), 120, 36, "u: show unavailable", "q/Ctrl+C: defer safe exit", "mutations disabled") + assertUsableFrame(t, model.View(), 120, 36, "q: Stop") model = updateKey(model, "u") - assertUsableFrame(t, model.View(), 120, 36, "u: hide unavailable") + if !model.showUnavailable { + t.Fatal("u did not toggle unavailable Targets while busy") + } + assertUsableFrame(t, model.View(), 120, 36, "q: Stop") close(updates) }) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index c1a44dd..2f39521 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -42,7 +42,7 @@ func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) { }}, }} model := NewModel(module) - if view := model.View(); !strings.Contains(view, "Active Profile: office") { + if view := model.View(); !strings.Contains(view, "Profile: office") { t.Fatalf("main view missing Active Profile:\n%s", view) } @@ -130,7 +130,7 @@ func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) { func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) { model := NewModel(&recordingModule{}) view := model.View() - if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "m: manage Proxy Profiles") { + if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "m: Profiles") { t.Fatalf("no-Profile main view is not actionable:\n%s", view) } } diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 6e8c45b..e607c47 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -14,12 +14,7 @@ func (p terminalPresentation) mainView(model presentationState, width, height in if activeProfile == "" { activeProfile = "No Active Profile" } - header := fmt.Sprintf("%s Operations Console\nActive Profile: %s Targets: %s", - p.theme.title.Render("proxyctl"), activeProfile, p.stateCountSummary(model.snapshot)) - if width < wideWidth { - header = fmt.Sprintf("%s Operations Console\nActive Profile: %s\nTargets: %s", - p.theme.title.Render("proxyctl"), activeProfile, p.stateCountSummary(model.snapshot)) - } + header := fmt.Sprintf("%s Profile: %s", p.theme.title.Render("proxyctl"), activeProfile) catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID) footer := p.dashboardFooter(model, catalog) @@ -97,62 +92,30 @@ func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) ( } func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string { - dashboard, operation, width := model.dashboard, model.operation, model.terminal.width - visibility := "u: show unavailable" + dashboard, operation := model.dashboard, model.operation + visibility := "u: Show hidden" if dashboard.showUnavailable { - visibility = "u: hide unavailable" + visibility = "u: Hide unavailable" + } + if operation.exitDeferred { + return "Operation stopping; changes disabled ?: Help" } if dashboard.focus == targetDetailsPane { - if operation.exitDeferred { - return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets " + visibility + " ?: help\nExit pending; new work disabled until safe completion" - } - if width < wideWidth { - if operation.active { - return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets\n?: help q/Ctrl+C: defer exit; mutations disabled" - } - if width < 70 { - return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Profile m: manage ?: help q: quit" - } - return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit" - } - footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: switch Active Profile m: manage Proxy Profiles ?: help q/Ctrl+C: quit" if operation.active { - footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + " ?: help\nq/Ctrl+C: defer safe exit; mutations disabled" + return "↑/↓: Scroll Tab: Targets ?: Help q: Stop" } - return footer - } - actions := "↑/k ↓/j: select Tab: focus details " + visibility - if operation.exitDeferred { - return actions + " ?: help\nExit pending; new work disabled until safe completion" + return "↑/↓: Scroll Tab: Targets " + visibility + "\np: Profile m: Profiles r: Refresh ?: Help q: Quit" } selected, ok := catalog.selected(dashboard.selectedTargetID) - availability := targetMutationAvailability(selected, ok, operation.active || operation.exitDeferred) - label := targetActionLabel(selected.PrimaryAction.Kind) - if availability.available { - actions = "Space: " + label + " " + actions - } else { - actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions + availability := targetMutationAvailability(selected, ok, operation.active) + action := "Space: " + targetActionLabel(selected.PrimaryAction.Kind) + if !availability.available { + action += " unavailable" } - if width < wideWidth { - if availability.available { - actions = "Space: " + label + " ↑/k ↓/j: select Tab: focus details" - } else { - actions = "Space: " + label + " disabled ↑/k ↓/j: select Tab: focus details" - } - - if operation.active { - return actions + "\n?: help q/Ctrl+C: defer exit; mutations disabled" - } - if width < 70 { - return actions + "\np: switch Profile m: manage ?: help q: quit" - } - return actions + "\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit" - } - footer := actions + "\np: switch Active Profile m: manage Proxy Profiles r: refresh ?: help q/Ctrl+C: quit" if operation.active { - footer = actions + "\n?: help q: defer safe exit; new mutations disabled" + return action + " ↑/↓: Select Tab: Details\n?: Help q: Stop" } - return footer + return action + " ↑/↓: Select Tab: Details " + visibility + "\np: Profile m: Profiles r: Refresh ?: Help q: Quit" } func (p terminalPresentation) profilesView(model presentationState) terminalPage { diff --git a/internal/tui/profile_management_page_test.go b/internal/tui/profile_management_page_test.go index a381be8..56cdf7c 100644 --- a/internal/tui/profile_management_page_test.go +++ b/internal/tui/profile_management_page_test.go @@ -219,7 +219,7 @@ func TestCompleteModelProfileManagementNavigationAndBusyReasonsAreConsistent(t * model = updateKey(model, "m") updated, command := model.Update(back) model = updated.(Model) - if command != nil || !strings.Contains(model.View(), "Operations Console") { + if command != nil || !strings.Contains(model.View(), "Profile:") { t.Fatalf("%q did not return to the dashboard:\n%s", back.String(), model.View()) } } diff --git a/internal/tui/progressive_detection_test.go b/internal/tui/progressive_detection_test.go index 5b7fb66..d95ba62 100644 --- a/internal/tui/progressive_detection_test.go +++ b/internal/tui/progressive_detection_test.go @@ -125,7 +125,7 @@ func TestCompleteModelReconcilesAuthoritativeSnapshotWhenDetectionGenerationClos if next != nil || model.operation.active { t.Fatalf("stale closure left detection active: next=%v active=%v", next != nil, model.operation.active) } - assertUsableFrame(t, model.View(), 80, 24, "> ● Enabled Shell", "Evidence: shell enabled") + assertUsableFrame(t, model.View(), 80, 24, "> ● Enabled Shell", "shell enabled") } func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.OperationUpdate { diff --git a/internal/tui/render_hardening_test.go b/internal/tui/render_hardening_test.go index 8b4a24f..ada2df0 100644 --- a/internal/tui/render_hardening_test.go +++ b/internal/tui/render_hardening_test.go @@ -33,7 +33,7 @@ func TestCompleteModelFixedSizeCoverageAcrossCompletedWorkflows(t *testing.T) { } return resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, width, height) }, - want: []string{"Operations Console", "Checking", "Target availability"}, + want: []string{"Profile:", "Checking", "Target availability"}, }, { name: "refresh", @@ -46,7 +46,7 @@ func TestCompleteModelFixedSizeCoverageAcrossCompletedWorkflows(t *testing.T) { } return model }, - want: []string{"Operations Console", "Checking Target availability and state"}, + want: []string{"Profile:", "Checking Target availability and state"}, }, { name: "Target action", @@ -155,7 +155,7 @@ func TestCompleteModelLightDarkAndNoColorRetainEquivalentSemantics(t *testing.T) model, _ = applyOperationCommand(t, model, command) model = updateKey(model, "e") views[mode] = progressFrame + "--- operation completed ---\n" + model.View() - for _, semantic := range []string{"[focused]", "Disabled", "Drifted", "Applying", "mutations disabled", "Current error:", "REDACTED", "Action: Enable"} { + for _, semantic := range []string{"[focused]", "Disabled", "Drifted", "Applying", "q: Stop", "Current error:", "REDACTED", "Space: Enable"} { if !strings.Contains(views[mode], semantic) { t.Fatalf("theme %d lost %q:\n%s", mode, semantic, views[mode]) } @@ -163,7 +163,7 @@ func TestCompleteModelLightDarkAndNoColorRetainEquivalentSemantics(t *testing.T) } plain := views[themeModeNoColor] - for _, symbol := range []string{"● Enabled", "○ Disabled", "! Drifted", "▶ Target details [focused]"} { + for _, symbol := range []string{"○ Disabled", "! Drifted", "▶ Target details [focused]"} { if !strings.Contains(plain, symbol) { t.Fatalf("standard Unicode/ASCII status strategy lost %q:\n%s", symbol, plain) } @@ -188,7 +188,7 @@ func TestCompleteModelBoundsUnicodeAndKeepsLongContentReachable(t *testing.T) { snapshot.Profiles[0].Name = longProfile snapshot.Targets[0].Description.Name = longName snapshot.Targets[0].Evidence = strings.Repeat("証拠 and wrapped evidence ", 80) + " EVIDENCE-END" - snapshot.Targets[0].Description.Details = strings.Repeat("詳細 line ", 100) + " DETAILS-END" + snapshot.Targets[0].Evidence = strings.Repeat("証拠 and wrapped evidence ", 120) + " EVIDENCE-END" model := resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, 80, 24) assertCellBoundedFrame(t, model.View(), 80, 24, "Disabled") @@ -196,7 +196,7 @@ func TestCompleteModelBoundsUnicodeAndKeepsLongContentReachable(t *testing.T) { for range 120 { model = updateKey(model, "pgdown") } - assertCellBoundedFrame(t, model.View(), 80, 24, "DETAILS-END") + assertCellBoundedFrame(t, model.View(), 80, 24, "EVIDENCE-END") model = updateKey(model, "m") for range 60 { @@ -318,7 +318,7 @@ func TestCompleteModelLongFieldsAndDialogsRemainReachableAcrossResizeAtNontermin func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) { snapshot := hardeningSnapshot() - snapshot.Targets[1].Description.Details = strings.Repeat("APT scrollable detail. ", 90) + " APT-VIEWPORT-END" + snapshot.Targets[1].Evidence = strings.Repeat("APT scrollable evidence. ", 90) + " APT-VIEWPORT-END" updates := make(chan app.OperationUpdate, 2) module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}} model := resizedModel(t, module, themeModeNoColor, 120, 36) @@ -354,8 +354,8 @@ func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightA model = updateKey(model, "q") assertCellBoundedFrame(t, model.View(), 80, 24, - "> ! Drifted APT", "▶ Target details [focused]", "APT-VIEWPORT-", "END", - "Applying", "Exit requested", "new work disabled") + "> ! Drifted APT", "▶ Target details [focused]", "VIEWPORT-END", + "Applying", "Exit requested", "changes disabled") updates <- app.SucceededOperationUpdate(snapshot, app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"}, diff --git a/internal/tui/target_test.go b/internal/tui/target_test.go index 6aaf6a2..04048b8 100644 --- a/internal/tui/target_test.go +++ b/internal/tui/target_test.go @@ -49,7 +49,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches", PrimaryAction: app.TargetAction{Kind: app.DisableTargetAction, Available: true}}}}, Notice: "Shell Target is now Enabled"} model := NewModel(module) view := model.View() - if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Action: Apply Profile") { + if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Space: Apply Profile") { t.Fatalf("Target panel:\n%s", view) } @@ -104,7 +104,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) { }}} model := NewModel(module) view := model.View() - if strings.Contains(view, "APT") || !strings.Contains(view, "Action: Enable") || !strings.Contains(view, "integration installed") { + if strings.Contains(view, "APT") || !strings.Contains(view, "Unavailable:") || !strings.Contains(view, "integration installed") { t.Fatalf("main Target panel:\n%s", view) } model = updateKey(model, "?") @@ -123,7 +123,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) { t.Fatalf("scrollable Target reference:\n%s", view) } model = updateKey(model, "esc") - if !strings.Contains(model.View(), "Operations Console") { + if !strings.Contains(model.View(), "Profile:") { t.Fatalf("Escape did not return from help:\n%s", model.View()) } } @@ -279,12 +279,12 @@ func TestQAndEscapeReturnFromNestedPages(t *testing.T) { for _, key := range []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune{'q'}}, {Type: tea.KeyEsc}} { model := updateKey(NewModel(module), "?") updated, command := model.Update(key) - if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") { - t.Fatalf("help key %q did not return to Operations Console", key.String()) + if command != nil || !strings.Contains(updated.(Model).View(), "Profile:") { + t.Fatalf("help key %q did not return to dashboard", key.String()) } model = updateKey(NewModel(module), "m") updated, command = model.Update(key) - if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") { + if command != nil || !strings.Contains(updated.(Model).View(), "Profile:") { t.Fatalf("Profile key %q did not return to Target panel", key.String()) } } @@ -315,7 +315,7 @@ func TestActiveProfileTransitionRendersPerTargetProgress(t *testing.T) { t.Fatal("Profile transition did not start progressive operation") } model, command = applyOperationCommand(t, model, command) - if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Active Profile: new") { + if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Profile: new") { t.Fatalf("applying progress:\n%s", model.View()) } model, command = applyOperationCommand(t, model, command)