fix: simplify dashboard and disable unauthorized actions
hermetic-ci / test (push) Canceled after 0s

This commit is contained in:
proxyctl coordinator
2026-08-30 15:49:04 +08:00
parent 416425a9bf
commit 30f7206b14
20 changed files with 182 additions and 221 deletions
+2 -2
View File
@@ -97,8 +97,8 @@ func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) {
if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil { if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil {
t.Fatalf("run proxyctl: %v", err) t.Fatalf("run proxyctl: %v", err)
} }
if !strings.Contains(output.String(), "Active Profile: office") { if !strings.Contains(output.String(), "Profile: office") {
t.Fatalf("default TUI output missing Active Profile: %q", output.String()) t.Fatalf("default TUI output missing Profile: %q", output.String())
} }
} }
+5 -7
View File
@@ -247,19 +247,17 @@ func TestCommandsRunAsRealProcessesInsideIsolatedUserState(t *testing.T) {
} }
defaultView := string(defaultOutput) defaultView := string(defaultOutput)
for _, expected := range []string{ for _, expected := range []string{
"proxyctl Operations Console", "proxyctl Profile: No Active Profile",
"Active Profile: No Active Profile",
"Targets: ● Enabled 0 ○ Disabled 1 ◇ External 0 ! Drifted 1",
"▶ Targets [focused]", "▶ Targets [focused]",
"> ○ Disabled Shell", "> ○ Disabled Shell",
"! Drifted Docker daemon", "! Drifted Docker daemon",
"Target details", "Target details",
"State: ○ Disabled", "○ Disabled",
"Evidence: no recognized Shell proxy configuration detected", "no recognized Shell proxy configuration detected",
"Tab: focus details", "Tab: Details",
} { } {
if !strings.Contains(defaultView, expected) { 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") versionOutput, err := invoke("", "version", "--short")
+47 -16
View File
@@ -164,6 +164,7 @@ type Application struct {
targetDescriptions []target.Description targetDescriptions []target.Description
availabilities map[string]target.Availability availabilities map[string]target.Availability
inspections map[string]target.Inspection inspections map[string]target.Inspection
mutationBlockReasons map[string]string
detectionPhase map[string]TargetDetectionPhase detectionPhase map[string]TargetDetectionPhase
detectionErr map[string]error detectionErr map[string]error
detectionGeneration uint64 detectionGeneration uint64
@@ -197,15 +198,16 @@ func NewWithTargetsAndRuntime(store profile.Store, targets []target.Target, muta
return nil, err return nil, err
} }
a := &Application{ a := &Application{
store: store, store: store,
state: state, state: state,
targets: append([]target.Target(nil), targets...), targets: append([]target.Target(nil), targets...),
availabilities: make(map[string]target.Availability), availabilities: make(map[string]target.Availability),
inspections: make(map[string]target.Inspection), inspections: make(map[string]target.Inspection),
detectionPhase: make(map[string]TargetDetectionPhase), mutationBlockReasons: make(map[string]string),
detectionErr: make(map[string]error), detectionPhase: make(map[string]TargetDetectionPhase),
mutationLock: mutationLock, detectionErr: make(map[string]error),
logger: logger, mutationLock: mutationLock,
logger: logger,
} }
for _, adapter := range a.targets { for _, adapter := range a.targets {
id := adapter.ID() id := adapter.ID()
@@ -229,7 +231,7 @@ func (a *Application) snapshotLocked() Snapshot {
availability := a.availabilities[id] availability := a.availabilities[id]
inspection := a.inspections[id] inspection := a.inspections[id]
phase := a.detectionPhase[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{ snapshot.Targets = append(snapshot.Targets, TargetSnapshot{
ID: id, Description: a.targetDescriptions[i], DetectionPhase: phase, DetectionErr: a.detectionErr[id], ID: id, Description: a.targetDescriptions[i], DetectionPhase: phase, DetectionErr: a.detectionErr[id],
Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence, Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence,
@@ -239,7 +241,7 @@ func (a *Application) snapshotLocked() Snapshot {
return 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) primaryKind := primaryTargetActionKind(state)
primary := TargetAction{Kind: primaryKind, Available: primaryKind != 0} primary := TargetAction{Kind: primaryKind, Available: primaryKind != 0}
if primaryKind == 0 { if primaryKind == 0 {
@@ -253,6 +255,10 @@ func contextualTargetActions(phase TargetDetectionPhase, available bool, state t
primary.Available = false primary.Available = false
primary.DisabledReason = "This action is unavailable because there is no Active Profile; open Profile management first" 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 { if state != target.Drifted {
return primary, nil return primary, nil
} }
@@ -271,6 +277,10 @@ func contextualTargetActions(phase TargetDetectionPhase, available bool, state t
action.Available = false action.Available = false
action.DisabledReason = reason action.DisabledReason = reason
} }
if action.Available && mutationBlockReason != "" {
action.Available = false
action.DisabledReason = mutationBlockReason
}
resolution = append(resolution, action) resolution = append(resolution, action)
} }
return primary, resolution return primary, resolution
@@ -300,8 +310,9 @@ func targetActionDisabledReason(phase TargetDetectionPhase, available bool) stri
} }
type targetDetectionOutcome struct { type targetDetectionOutcome struct {
targetID string targetID string
detection target.Detection detection target.Detection
mutationBlockReason string
} }
type targetDetectionUpdate struct { type targetDetectionUpdate struct {
@@ -327,7 +338,10 @@ func (a *Application) beginTargetDetection(ctx context.Context) (Snapshot, <-cha
for i, adapter := range a.targets { for i, adapter := range a.targets {
adapter, id := adapter, a.targetIDs[i] adapter, id := adapter, a.targetIDs[i]
go func() { 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() { go func() {
@@ -345,6 +359,7 @@ func (a *Application) beginTargetDetection(ctx context.Context) (Snapshot, <-cha
} }
a.availabilities[outcome.targetID] = outcome.detection.Availability a.availabilities[outcome.targetID] = outcome.detection.Availability
a.inspections[outcome.targetID] = outcome.detection.Inspection a.inspections[outcome.targetID] = outcome.detection.Inspection
a.mutationBlockReasons[outcome.targetID] = outcome.mutationBlockReason
a.detectionPhase[outcome.targetID] = phase a.detectionPhase[outcome.targetID] = phase
a.detectionErr[outcome.targetID] = outcome.detection.Err a.detectionErr[outcome.targetID] = outcome.detection.Err
update := targetDetectionUpdate{snapshot: a.snapshotLocked(), targetID: outcome.targetID} 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 { func (a *Application) Dispatch(intent Intent) Result {
ctx, cancel := context.WithTimeout(context.Background(), operationTimeout) ctx, cancel := context.WithTimeout(context.Background(), operationTimeout)
defer cancel() defer cancel()
@@ -642,12 +672,13 @@ func (a *Application) replaceInspection(id string, inspection target.Inspection)
// publishTargetDetection atomically replaces all availability, inspection, and // publishTargetDetection atomically replaces all availability, inspection, and
// failure fields from one targeted check. Detect runs before this method, so no // failure fields from one targeted check. Detect runs before this method, so no
// Application state mutex is held while adapter code executes. // 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() a.stateMu.Lock()
defer a.stateMu.Unlock() defer a.stateMu.Unlock()
a.detectionGeneration++ a.detectionGeneration++
a.availabilities[id] = detection.Availability a.availabilities[id] = detection.Availability
a.inspections[id] = detection.Inspection a.inspections[id] = detection.Inspection
a.mutationBlockReasons[id] = mutationBlockReason
a.detectionErr[id] = detection.Err a.detectionErr[id] = detection.Err
a.detectionPhase[id] = TargetReady a.detectionPhase[id] = TargetReady
if detection.Err != nil { 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 // before action validation. Adapter calls happen without stateMu; the full
// outcome is then published under one lock before policy is derived. // outcome is then published under one lock before policy is derived.
detection := detectTarget(ctx, selected, active) detection := detectTarget(ctx, selected, active)
snapshot := a.publishTargetDetection(intent.TargetID, detection) snapshot := a.publishTargetDetection(intent.TargetID, detection, mutationBlockReason(ctx, selected))
selectedSnapshot := snapshot.Targets[selectedIndex] selectedSnapshot := snapshot.Targets[selectedIndex]
policy, err := validateTargetAction(intent, selectedSnapshot) policy, err := validateTargetAction(intent, selectedSnapshot)
if err != nil { if err != nil {
+29
View File
@@ -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 { type targetWithID struct {
*fakeTarget *fakeTarget
id string id string
+8
View File
@@ -130,6 +130,14 @@ func (a *Adapter) Confirmation(bool) string { return "" }
// DiagnosticChecks observes the APT command, configuration schema, and current // DiagnosticChecks observes the APT command, configuration schema, and current
// filesystem authority without changing the overlay or effective settings. // 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 { func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check {
commandCheck := target.Check{Name: "required command", OK: true, Evidence: "apt-config is available"} commandCheck := target.Check{Name: "required command", OK: true, Evidence: "apt-config is available"}
command, err := a.runner.LookPath("apt-config") command, err := a.runner.LookPath("apt-config")
+9
View File
@@ -132,6 +132,15 @@ func (a *Adapter) Confirmation(bool) string {
// DiagnosticChecks performs only systemctl show and filesystem reads/metadata // DiagnosticChecks performs only systemctl show and filesystem reads/metadata
// probes; service reload/restart and all configuration mutations are excluded. // 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 { 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"} 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 { if availability, err := a.detect(ctx); err != nil || !availability.Available {
+6
View File
@@ -78,6 +78,12 @@ type Diagnosable interface {
DiagnosticChecks(context.Context, *profile.Profile) []Check 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. // Target hides platform paths and commands from the application and TUI.
type Target interface { type Target interface {
ID() string ID() string
+2 -2
View File
@@ -180,7 +180,7 @@ func TestCompleteModelPickerConfirmedActivationContinuesThroughUnifiedOperation(
completion <- app.SucceededOperationUpdate(completed, app.OperationStatus{Summary: `Activated Profile "new"`}, nil) completion <- app.SucceededOperationUpdate(completed, app.OperationStatus{Summary: `Activated Profile "new"`}, nil)
close(completion) close(completion)
model, _ = applyOperationCommand(t, model, command) 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) t.Fatalf("confirmed activation did not finish on the dashboard:\n%s", view)
} }
} }
@@ -213,7 +213,7 @@ func TestCompleteModelPickerPartialFailureKeepsProgressClosesPickerAndFocusesFai
close(updates) close(updates)
model, _ = applyOperationCommand(t, model, command) model, _ = applyOperationCommand(t, model, command)
view := model.View() 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) t.Fatalf("partial failure was not retained and focused: selected=%q\n%s", model.selectedTargetID, view)
} }
} }
+3 -6
View File
@@ -35,12 +35,9 @@ func TestCompleteModelRendersAndDispatchesContextualPrimaryActions(t *testing.T)
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}} module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}}
model := resizedModel(t, module, themeModeNoColor, 120, 36) model := resizedModel(t, module, themeModeNoColor, 120, 36)
view := model.View() 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) 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}) updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model) model = updated.(Model)
@@ -91,7 +88,7 @@ func TestCompleteModelRendersUnknownTargetStateAsStructurallyUnavailable(t *test
model := resizedModel(t, module, themeModeNoColor, 120, 36) model := resizedModel(t, module, themeModeNoColor, 120, 36)
view := model.View() 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) t.Fatalf("unknown Target action was not structurally unavailable:\n%s", view)
} }
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
@@ -108,7 +105,7 @@ func TestCompleteModelUsesStructuredDisabledReasonWithoutInferringFromState(t *t
module := &targetModule{snapshot: initial} module := &targetModule{snapshot: initial}
model := resizedModel(t, module, themeModeNoColor, 120, 36) model := resizedModel(t, module, themeModeNoColor, 120, 36)
view := model.View() 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) t.Fatalf("structured disabled reason missing:\n%s", view)
} }
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
+1 -41
View File
@@ -1,9 +1,6 @@
package tui package tui
import ( import "proxyctl/internal/app"
"proxyctl/internal/app"
"proxyctl/internal/target"
)
func targetActionLabel(kind app.TargetActionKind) string { func targetActionLabel(kind app.TargetActionKind) string {
switch kind { 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 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
}
+10 -15
View File
@@ -23,9 +23,8 @@ func TestCompleteModelWideOperationsConsoleShowsStableAvailableTargetDashboard(t
view := resizedModel(t, module, themeModeNoColor, 120, 36).View() view := resizedModel(t, module, themeModeNoColor, 120, 36).View()
assertUsableFrame(t, view, 120, 36, assertUsableFrame(t, view, 120, 36,
"proxyctl", "Active Profile: office", "proxyctl", "Profile: office",
"● Enabled 1", "○ Disabled 1", "◇ External 1", "! Drifted 1", "> ● Enabled", "Shell", "matches Active Profile",
"> ● Enabled", "Shell", "State: ● Enabled", "Evidence: matches Active Profile",
) )
if strings.Contains(view, "APT") || strings.Contains(view, "apt-config was not detected") { if strings.Contains(view, "APT") || strings.Contains(view, "apt-config was not detected") {
t.Fatalf("default dashboard included an unavailable Target:\n%s", view) t.Fatalf("default dashboard included an unavailable Target:\n%s", view)
@@ -71,12 +70,8 @@ func TestCompleteModelDashboardRevealsUnavailableEvidenceAndStructuredDetails(t
view := model.View() view := model.View()
assertUsableFrame(t, view, 120, 36, assertUsableFrame(t, view, 120, 36,
"> ○ Unavailable APT", "State: ○ Unavailable", "Evidence: apt-config was not detected", "> ○ Unavailable APT", "○ Unavailable", "apt-config was not detected",
"Affects: package operations across privilege boundaries", "Persistent package proxy.", "u: Hide unavailable",
"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",
) )
if !(strings.Index(view, "Shell") < strings.Index(view, "APT") && strings.Index(view, "APT") < strings.Index(view, "Docker daemon")) { 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) 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.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}} module := &recordingModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: targets}}
model := resizedModel(t, module, themeModeNoColor, 80, 24) model := resizedModel(t, module, themeModeNoColor, 80, 24)
for range 22 { for range 22 {
@@ -103,7 +98,7 @@ func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIde
} }
view := model.View() 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") model = updateKey(model, "tab")
for range 70 { for range 70 {
@@ -111,16 +106,16 @@ func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIde
} }
view = model.View() view = model.View()
assertUsableFrame(t, view, 80, 24, assertUsableFrame(t, view, 80, 24,
"> ○ Disabled Product Target 22", "▶ Target details [focused]", "END OF", "TARGET DETAILS", "> ○ Disabled Product Target 22", "▶ Target details [focused]", "END OF", "TARGET EVIDENCE",
"↑/k ↓/j PgUp/PgDn: scroll", "Tab: focus Targets", "↑/↓: Scroll", "Tab: Targets",
) )
model = resize(model, 120, 36) model = resize(model, 120, 36)
view = model.View() 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") { if !sameLine(view, "Targets", "Target details") {
t.Fatalf("resized dashboard did not become side by side:\n%s", view) t.Fatalf("resized dashboard did not become side by side:\n%s", view)
} }
model = resize(model, 80, 24) 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]")
} }
+7 -45
View File
@@ -8,7 +8,6 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"proxyctl/internal/app" "proxyctl/internal/app"
"proxyctl/internal/target"
) )
type dashboardRenderState struct { type dashboardRenderState struct {
@@ -149,55 +148,18 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot, progress ap
var details strings.Builder var details strings.Builder
details.WriteString(p.theme.section.Render(item.Description.Name)) details.WriteString(p.theme.section.Render(item.Description.Name))
fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence) fmt.Fprintf(&details, "\n%s\n%s", state, evidence)
fmt.Fprintf(&details, "\nAction: %s", targetActionLabel(item.PrimaryAction.Kind)) action := targetActionLabel(item.PrimaryAction.Kind)
if !item.PrimaryAction.Available && item.PrimaryAction.DisabledReason != "" { if item.PrimaryAction.Available {
fmt.Fprintf(&details, "\nAction unavailable: %s", item.PrimaryAction.DisabledReason) fmt.Fprintf(&details, "\n\nSpace: %s", action)
} } else if item.PrimaryAction.DisabledReason != "" {
switch item.PrimaryAction.Kind { fmt.Fprintf(&details, "\n\nUnavailable: %s", item.PrimaryAction.DisabledReason)
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.")
} }
if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil { if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil {
fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr) fmt.Fprintf(&details, "\nCheck failed: %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)
}
} }
if item.Description.Summary != "" { if item.Description.Summary != "" {
fmt.Fprintf(&details, "\n\n%s", 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()) 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
}
+10 -10
View File
@@ -24,7 +24,7 @@ func TestCompleteModelRendersResponsiveTerminalSizeClasses(t *testing.T) {
if !sameLine(view, "Targets", "Target details") { if !sameLine(view, "Targets", "Target details") {
t.Fatalf("wide layout did not place list and details side by side:\n%s", view) 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) { 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") { 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) 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) { 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 := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, "down") model = updateKey(model, "down")
model = resize(model, 80, 24) 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) t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view)
} }
model = resize(model, 120, 36) 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) 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() plain := resizedModel(t, module, themeModeNoColor, 80, 24).View()
for name, view := range map[string]string{"dark": dark, "light": light, "NO_COLOR": plain} { 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) { if !strings.Contains(view, semantic) {
t.Fatalf("%s theme lost non-color semantic %q:\n%s", name, semantic, view) 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) { if ansiPattern.MatchString(view) {
t.Fatalf("non-terminal Bubble Tea output received ANSI styling: %q", 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) { if !strings.Contains(view, semantic) {
t.Fatalf("injected-output rendering lost semantic %q:\n%s", semantic, view) t.Fatalf("injected-output rendering lost semantic %q:\n%s", semantic, view)
} }
@@ -231,19 +231,19 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl
assertUsableFrame(t, model.View(), 80, 24, assertUsableFrame(t, model.View(), 80, 24,
"> ! Drifted Docker daemon", "> ! Drifted Docker daemon",
"State: ! Drifted", "! Drifted",
"Docker daemon selected evidence.", "Docker daemon selected evidence.",
"Status: Refresh completed; selected Target remains actionable.", "Status: Refresh completed; selected Target remains actionable.",
"Space: Resolve", "Space: Resolve",
"p: switch Active Profile", "p: Profile",
"m: manage Proxy Profiles", "m: Profiles",
) )
model = resize(model, 120, 36) model = resize(model, 120, 36)
model = resize(model, 80, 24) model = resize(model, 80, 24)
assertUsableFrame(t, model.View(), 80, 24, assertUsableFrame(t, model.View(), 80, 24,
"> ! Drifted Docker daemon", "> ! Drifted Docker daemon",
"State: ! Drifted", "! Drifted",
"Docker daemon selected evidence.", "Docker daemon selected evidence.",
"Space: Resolve", "Space: Resolve",
) )
+8 -5
View File
@@ -30,7 +30,7 @@ func TestCompleteModelContextualHelpOpensOverEveryPageAndRestoresPageState(t *te
model = updateKey(model, "down") model = updateKey(model, "down")
model = updateKey(model, "?") model = updateKey(model, "?")
view := model.View() 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, " ") model = updateKey(model, " ")
if len(module.intents) != 0 { if len(module.intents) != 0 {
@@ -259,9 +259,9 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) {
t.Run("dashboard and busy details", func(t *testing.T) { t.Run("dashboard and busy details", func(t *testing.T) {
model := resizedModel(t, module, themeModeNoColor, 120, 36) 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") 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) updates := make(chan app.OperationUpdate)
busyModule := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}} busyModule := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
@@ -269,9 +269,12 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) {
model = updateKey(model, "tab") model = updateKey(model, "tab")
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace}) updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model) 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") 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) close(updates)
}) })
+2 -2
View File
@@ -42,7 +42,7 @@ func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
}}, }},
}} }}
model := NewModel(module) 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) t.Fatalf("main view missing Active Profile:\n%s", view)
} }
@@ -130,7 +130,7 @@ func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) {
func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) { func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) {
model := NewModel(&recordingModule{}) model := NewModel(&recordingModule{})
view := model.View() 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) t.Fatalf("no-Profile main view is not actionable:\n%s", view)
} }
} }
+15 -52
View File
@@ -14,12 +14,7 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
if activeProfile == "" { if activeProfile == "" {
activeProfile = "No Active Profile" activeProfile = "No Active Profile"
} }
header := fmt.Sprintf("%s Operations Console\nActive Profile: %s Targets: %s", header := fmt.Sprintf("%s Profile: %s", p.theme.title.Render("proxyctl"), activeProfile)
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))
}
catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID) catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID)
footer := p.dashboardFooter(model, catalog) 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 { func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
dashboard, operation, width := model.dashboard, model.operation, model.terminal.width dashboard, operation := model.dashboard, model.operation
visibility := "u: show unavailable" visibility := "u: Show hidden"
if dashboard.showUnavailable { 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 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 { 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 return "↑/↓: Scroll Tab: Targets " + visibility + "\np: Profile m: Profiles r: Refresh ?: Help q: Quit"
}
actions := "↑/k ↓/j: select Tab: focus details " + visibility
if operation.exitDeferred {
return actions + " ?: help\nExit pending; new work disabled until safe completion"
} }
selected, ok := catalog.selected(dashboard.selectedTargetID) selected, ok := catalog.selected(dashboard.selectedTargetID)
availability := targetMutationAvailability(selected, ok, operation.active || operation.exitDeferred) availability := targetMutationAvailability(selected, ok, operation.active)
label := targetActionLabel(selected.PrimaryAction.Kind) action := "Space: " + targetActionLabel(selected.PrimaryAction.Kind)
if availability.available { if !availability.available {
actions = "Space: " + label + " " + actions action += " unavailable"
} else {
actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions
} }
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 { 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 { func (p terminalPresentation) profilesView(model presentationState) terminalPage {
+1 -1
View File
@@ -219,7 +219,7 @@ func TestCompleteModelProfileManagementNavigationAndBusyReasonsAreConsistent(t *
model = updateKey(model, "m") model = updateKey(model, "m")
updated, command := model.Update(back) updated, command := model.Update(back)
model = updated.(Model) 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()) t.Fatalf("%q did not return to the dashboard:\n%s", back.String(), model.View())
} }
} }
+1 -1
View File
@@ -125,7 +125,7 @@ func TestCompleteModelReconcilesAuthoritativeSnapshotWhenDetectionGenerationClos
if next != nil || model.operation.active { if next != nil || model.operation.active {
t.Fatalf("stale closure left detection active: next=%v active=%v", 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 { func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.OperationUpdate {
+9 -9
View File
@@ -33,7 +33,7 @@ func TestCompleteModelFixedSizeCoverageAcrossCompletedWorkflows(t *testing.T) {
} }
return resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, width, height) return resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, width, height)
}, },
want: []string{"Operations Console", "Checking", "Target availability"}, want: []string{"Profile:", "Checking", "Target availability"},
}, },
{ {
name: "refresh", name: "refresh",
@@ -46,7 +46,7 @@ func TestCompleteModelFixedSizeCoverageAcrossCompletedWorkflows(t *testing.T) {
} }
return model return model
}, },
want: []string{"Operations Console", "Checking Target availability and state"}, want: []string{"Profile:", "Checking Target availability and state"},
}, },
{ {
name: "Target action", name: "Target action",
@@ -155,7 +155,7 @@ func TestCompleteModelLightDarkAndNoColorRetainEquivalentSemantics(t *testing.T)
model, _ = applyOperationCommand(t, model, command) model, _ = applyOperationCommand(t, model, command)
model = updateKey(model, "e") model = updateKey(model, "e")
views[mode] = progressFrame + "--- operation completed ---\n" + model.View() 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) { if !strings.Contains(views[mode], semantic) {
t.Fatalf("theme %d lost %q:\n%s", mode, semantic, views[mode]) t.Fatalf("theme %d lost %q:\n%s", mode, semantic, views[mode])
} }
@@ -163,7 +163,7 @@ func TestCompleteModelLightDarkAndNoColorRetainEquivalentSemantics(t *testing.T)
} }
plain := views[themeModeNoColor] 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) { if !strings.Contains(plain, symbol) {
t.Fatalf("standard Unicode/ASCII status strategy lost %q:\n%s", symbol, plain) 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.Profiles[0].Name = longProfile
snapshot.Targets[0].Description.Name = longName snapshot.Targets[0].Description.Name = longName
snapshot.Targets[0].Evidence = strings.Repeat("証拠 and wrapped evidence ", 80) + " EVIDENCE-END" 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) model := resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, 80, 24)
assertCellBoundedFrame(t, model.View(), 80, 24, "Disabled") assertCellBoundedFrame(t, model.View(), 80, 24, "Disabled")
@@ -196,7 +196,7 @@ func TestCompleteModelBoundsUnicodeAndKeepsLongContentReachable(t *testing.T) {
for range 120 { for range 120 {
model = updateKey(model, "pgdown") model = updateKey(model, "pgdown")
} }
assertCellBoundedFrame(t, model.View(), 80, 24, "DETAILS-END") assertCellBoundedFrame(t, model.View(), 80, 24, "EVIDENCE-END")
model = updateKey(model, "m") model = updateKey(model, "m")
for range 60 { for range 60 {
@@ -318,7 +318,7 @@ func TestCompleteModelLongFieldsAndDialogsRemainReachableAcrossResizeAtNontermin
func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) { func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) {
snapshot := hardeningSnapshot() 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) updates := make(chan app.OperationUpdate, 2)
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}} module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}}
model := resizedModel(t, module, themeModeNoColor, 120, 36) model := resizedModel(t, module, themeModeNoColor, 120, 36)
@@ -354,8 +354,8 @@ func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightA
model = updateKey(model, "q") model = updateKey(model, "q")
assertCellBoundedFrame(t, model.View(), 80, 24, assertCellBoundedFrame(t, model.View(), 80, 24,
"> ! Drifted APT", "▶ Target details [focused]", "APT-VIEWPORT-", "END", "> ! Drifted APT", "▶ Target details [focused]", "VIEWPORT-END",
"Applying", "Exit requested", "new work disabled") "Applying", "Exit requested", "changes disabled")
updates <- app.SucceededOperationUpdate(snapshot, updates <- app.SucceededOperationUpdate(snapshot,
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"}, app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
+7 -7
View File
@@ -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"} 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) model := NewModel(module)
view := model.View() 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) t.Fatalf("Target panel:\n%s", view)
} }
@@ -104,7 +104,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
}}} }}}
model := NewModel(module) model := NewModel(module)
view := model.View() 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) t.Fatalf("main Target panel:\n%s", view)
} }
model = updateKey(model, "?") model = updateKey(model, "?")
@@ -123,7 +123,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
t.Fatalf("scrollable Target reference:\n%s", view) t.Fatalf("scrollable Target reference:\n%s", view)
} }
model = updateKey(model, "esc") 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()) 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}} { for _, key := range []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune{'q'}}, {Type: tea.KeyEsc}} {
model := updateKey(NewModel(module), "?") model := updateKey(NewModel(module), "?")
updated, command := model.Update(key) 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("help key %q did not return to Operations Console", key.String()) t.Fatalf("help key %q did not return to dashboard", key.String())
} }
model = updateKey(NewModel(module), "m") model = updateKey(NewModel(module), "m")
updated, command = model.Update(key) 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()) 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") t.Fatal("Profile transition did not start progressive operation")
} }
model, command = applyOperationCommand(t, model, command) 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()) t.Fatalf("applying progress:\n%s", model.View())
} }
model, command = applyOperationCommand(t, model, command) model, command = applyOperationCommand(t, model, command)