feat: safely control Shell Target
This commit is contained in:
+70
-2
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
type page int
|
||||
@@ -32,6 +33,11 @@ type Model struct {
|
||||
form formState
|
||||
confirm bool
|
||||
status string
|
||||
applying bool
|
||||
}
|
||||
|
||||
type targetOperationDone struct {
|
||||
result app.Result
|
||||
}
|
||||
|
||||
func NewModel(application app.Module) Model {
|
||||
@@ -41,6 +47,16 @@ func NewModel(application app.Module) Model {
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if completed, ok := message.(targetOperationDone); ok {
|
||||
m.applying = false
|
||||
m.snapshot = completed.result.Snapshot
|
||||
if completed.result.Err != nil {
|
||||
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
|
||||
} else {
|
||||
m.status = completed.result.Notice
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
key, ok := message.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
@@ -58,10 +74,29 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
targets := m.availableTargets()
|
||||
switch key.String() {
|
||||
case "p":
|
||||
m.page = profilesPage
|
||||
m.status = ""
|
||||
case "up", "k":
|
||||
if !m.applying && m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
case "down", "j":
|
||||
if !m.applying && m.selected+1 < len(targets) {
|
||||
m.selected++
|
||||
}
|
||||
case " ":
|
||||
if !m.applying && m.selected >= 0 && m.selected < len(targets) {
|
||||
selected := targets[m.selected]
|
||||
desired := selected.State != target.Enabled
|
||||
m.applying = true
|
||||
m.status = ""
|
||||
return m, func() tea.Msg {
|
||||
return targetOperationDone{result: m.application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired})}
|
||||
}
|
||||
}
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
@@ -208,6 +243,16 @@ func (m Model) View() string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) availableTargets() []app.TargetSnapshot {
|
||||
var result []app.TargetSnapshot
|
||||
for _, item := range m.snapshot.Targets {
|
||||
if item.Available {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m Model) mainView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("proxyctl — Target panel\n\n")
|
||||
@@ -217,8 +262,31 @@ func (m Model) mainView() string {
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Active Profile: %s\n", m.snapshot.ActiveProfile)
|
||||
}
|
||||
b.WriteString("\nNo Targets are available in this build.\n")
|
||||
b.WriteString("\np: manage Proxy Profiles q/Esc: quit\n")
|
||||
targets := m.availableTargets()
|
||||
if len(targets) == 0 {
|
||||
b.WriteString("\nNo Targets are available in this build.\n")
|
||||
} else {
|
||||
b.WriteString("\nTargets\n")
|
||||
for i, item := range targets {
|
||||
cursor := " "
|
||||
if i == m.selected {
|
||||
cursor = "> "
|
||||
}
|
||||
state := string(item.State)
|
||||
if m.applying && i == m.selected {
|
||||
state = "Applying…"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name)
|
||||
}
|
||||
if m.selected >= 0 && m.selected < len(targets) {
|
||||
selected := targets[m.selected]
|
||||
fmt.Fprintf(&b, "\n%s\n%s\n", selected.Description.Summary, selected.Evidence)
|
||||
}
|
||||
}
|
||||
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n")
|
||||
if m.status != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
type targetModule struct {
|
||||
snapshot app.Snapshot
|
||||
result app.Result
|
||||
intents []app.Intent
|
||||
}
|
||||
|
||||
func (m *targetModule) Snapshot() app.Snapshot { return m.snapshot }
|
||||
func (m *targetModule) Dispatch(intent app.Intent) app.Result {
|
||||
m.intents = append(m.intents, intent)
|
||||
m.snapshot = m.result.Snapshot
|
||||
return m.result
|
||||
}
|
||||
|
||||
func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing.T) {
|
||||
module := &targetModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{
|
||||
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.External, Evidence: "existing proxy"},
|
||||
{ID: "apt", Description: target.Description{Name: "APT"}, Available: false},
|
||||
}}}
|
||||
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"}}}, Notice: "Shell Target is now Enabled"}
|
||||
model := NewModel(module)
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Exports for Bash/Zsh") {
|
||||
t.Fatalf("Target panel:\n%s", view)
|
||||
}
|
||||
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
if command == nil || !strings.Contains(model.View(), "Applying") || len(module.intents) != 0 {
|
||||
t.Fatalf("operation did not expose applying state before effect:\n%s", model.View())
|
||||
}
|
||||
message := command()
|
||||
updated, _ = model.Update(message)
|
||||
model = updated.(Model)
|
||||
if len(module.intents) != 1 || module.intents[0].Kind != app.SetTargetDesired || !module.intents[0].Enabled || !strings.Contains(model.View(), "Enabled") {
|
||||
t.Fatalf("completed toggle = %#v\n%s", module.intents, model.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFailurePreservesTruthfulStateAndRendersRedactedError(t *testing.T) {
|
||||
initial := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
|
||||
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Err: errors.New("permission denied for alice:REDACTED@proxy")}}
|
||||
model := NewModel(module)
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
updated, _ = model.Update(command())
|
||||
model = updated.(Model)
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "Enabled") || !strings.Contains(view, "\x1b[31mError:") || !strings.Contains(view, "REDACTED") {
|
||||
t.Fatalf("failed operation rendering:\n%s", view)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user