pi-agent: Implement Operations Console dashboard

This commit is contained in:
proxyctl coordinator
2026-07-17 20:44:48 +08:00
parent daeb788eac
commit 8ac0695152
15 changed files with 600 additions and 138 deletions
+7 -3
View File
@@ -116,9 +116,13 @@ func New(configuration Configuration, files FileSystem, runner Runner) *Adapter
func (a *Adapter) ID() string { return "apt" }
func (a *Adapter) Description() target.Description {
return target.Description{
Name: "APT",
Summary: "Persistently configures APT proxying across Shell and privilege boundaries.",
Details: "APT supports proxy environment variables; this Target affects persistent APT package operations across Shell and privilege boundaries; it does not change Shell, desktop, Docker, or other application proxy settings. proxyctl owns only an isolated apt.conf.d overlay, leaving earlier configuration underneath so disabling restores any External setting. Writing /etc/apt normally requires pre-existing root authority; proxyctl never escalates privileges, and no service restart is performed.",
Name: "APT",
Summary: "Persistently configures APT proxying across Shell and privilege boundaries.",
Scope: "APT package operations; not Shell, desktop, Docker, or other applications",
Permissions: "pre-existing authority to write /etc/apt; proxyctl never escalates privileges",
Ownership: "isolated apt.conf.d overlay; disabling reveals any earlier External configuration",
RestartRisk: "none",
Details: "APT supports proxy environment variables; this Target affects persistent APT package operations across Shell and privilege boundaries; it does not change Shell, desktop, Docker, or other application proxy settings. proxyctl owns only an isolated apt.conf.d overlay, leaving earlier configuration underneath so disabling restores any External setting. Writing /etc/apt normally requires pre-existing root authority; proxyctl never escalates privileges, and no service restart is performed.",
}
}
+7 -3
View File
@@ -170,9 +170,13 @@ func New(configuration Configuration, files FileSystem) *Adapter {
func (a *Adapter) ID() string { return "docker-containers" }
func (a *Adapter) Description() target.Description {
return target.Description{
Name: "Docker containers/builds",
Summary: "Sets proxy defaults injected into future Docker containers and builds.",
Details: "Affects new containers and builds through the owning user's Docker client configuration; it does not affect existing containers or container networking and is not Docker daemon registry access. proxyctl owns selected proxy keys and recovery metadata while preserving other config.json content and restoring prior External values on disable. User-file write permission is required; it does not contact or restart the daemon.",
Name: "Docker containers/builds",
Summary: "Sets proxy defaults injected into future Docker containers and builds.",
Scope: "future containers and builds; not existing containers, networking, or daemon registry access",
Permissions: "write access to the owning user's Docker client configuration",
Ownership: "managed proxy keys and recovery metadata; other config.json content and prior External values are preserved",
RestartRisk: "none; the Docker daemon is not contacted",
Details: "Affects new containers and builds through the owning user's Docker client configuration; it does not affect existing containers or container networking and is not Docker daemon registry access. proxyctl owns selected proxy keys and recovery metadata while preserving other config.json content and restoring prior External values on disable. User-file write permission is required; it does not contact or restart the daemon.",
}
}
func (a *Adapter) Confirmation(bool) string { return "" }
+7 -3
View File
@@ -112,9 +112,13 @@ func New(configuration Configuration, files FileSystem, runner CommandRunner, au
func (a *Adapter) ID() string { return "docker-daemon" }
func (a *Adapter) Description() target.Description {
return target.Description{
Name: "Docker daemon",
Summary: "Configures proxying for Docker daemon operations such as registry access, not container network traffic or container/build defaults.",
Details: "Affects Docker daemon network operations such as registry access; it does not change container networking, existing containers, or Docker container/build defaults. proxyctl owns managed proxy keys plus recovery metadata and restores the original daemon.json values on disable. System-file and systemd service permissions are required; every change reloads systemd and immediately restarts Docker, which may disrupt running work. proxyctl reports permission errors and never initiates privilege escalation.",
Name: "Docker daemon",
Summary: "Configures proxying for Docker daemon operations such as registry access, not container network traffic or container/build defaults.",
Scope: "daemon network operations such as registry access; not container networking or container/build defaults",
Permissions: "system-file and systemd service authority; proxyctl never escalates privileges",
Ownership: "managed proxy keys and recovery metadata; disabling restores original daemon.json values",
RestartRisk: "high; every change reloads systemd and immediately restarts Docker",
Details: "Affects Docker daemon network operations such as registry access; it does not change container networking, existing containers, or Docker container/build defaults. proxyctl owns managed proxy keys plus recovery metadata and restores the original daemon.json values on disable. System-file and systemd service permissions are required; every change reloads systemd and immediately restarts Docker, which may disrupt running work. proxyctl reports permission errors and never initiates privilege escalation.",
}
}
func (a *Adapter) Confirmation(bool) string {
+7 -3
View File
@@ -276,9 +276,13 @@ func New(configuration Configuration, runner CommandRunner, state StateStore) *A
func (a *Adapter) ID() string { return "gnome" }
func (a *Adapter) Description() target.Description {
return target.Description{
Name: "GNOME Desktop",
Summary: "Configures the GNOME desktop system proxy.",
Details: "Affects applications that consume GNOME system network settings; not every graphical application consumes these settings, and it does not affect Shell commands or system services. proxyctl records the complete original desktop proxy values before temporarily owning the managed settings and restores them on disable. The settings belong to the desktop user and require that user's usable session bus and schema permissions; changes take effect immediately without a service restart.",
Name: "GNOME Desktop",
Summary: "Configures the GNOME desktop system proxy.",
Scope: "applications that consume GNOME system network settings; not Shell commands or system services",
Permissions: "the desktop user's usable session bus and schema permissions",
Ownership: "complete original desktop values are recorded and restored when management is disabled",
RestartRisk: "none; changes take effect immediately",
Details: "Affects applications that consume GNOME system network settings; not every graphical application consumes these settings, and it does not affect Shell commands or system services. proxyctl records the complete original desktop proxy values before temporarily owning the managed settings and restores them on disable. The settings belong to the desktop user and require that user's usable session bus and schema permissions; changes take effect immediately without a service restart.",
}
}
+9 -1
View File
@@ -173,7 +173,15 @@ func New(configuration Configuration, files FileSystem, environment Environment)
func (a *Adapter) ID() string { return "shell" }
func (a *Adapter) Description() target.Description {
return target.Description{Name: "Shell", Summary: "Exports proxy variables for commands started by integrated Bash and Zsh sessions.", Details: "Affects commands started by the integrated Shell; it does not configure system services, desktop settings, or already-running processes. proxyctl owns a generated environment fragment while its one-time startup hook remains in the user's Shell file. User-file write permission is required; after proxyctl exits, the installed wrapper reloads exports and unsets into the parent Shell."}
return target.Description{
Name: "Shell",
Summary: "Exports proxy variables for commands started by integrated Bash and Zsh sessions.",
Scope: "future commands in the integrated Shell; not services, desktop settings, or existing processes",
Permissions: "write access to the user's Shell files",
Ownership: "generated environment fragment; the one-time startup hook remains when management is disabled",
RestartRisk: "none; the installed wrapper reloads exports into the parent Shell after proxyctl exits",
Details: "Affects commands started by the integrated Shell; it does not configure system services, desktop settings, or already-running processes. proxyctl owns a generated environment fragment while its one-time startup hook remains in the user's Shell file. User-file write permission is required; after proxyctl exits, the installed wrapper reloads exports and unsets into the parent Shell.",
}
}
func (a *Adapter) Availability(context.Context) target.Availability {
switch filepath.Base(a.configuration.Shell) {
+10 -3
View File
@@ -17,10 +17,17 @@ const (
Drifted State = "Drifted"
)
// Description is display metadata for a Target. The structured operational
// facts let presentation layers establish a stable hierarchy without parsing
// prose; they do not participate in configuration or restoration behavior.
type Description struct {
Name string
Summary string
Details string
Name string
Summary string
Scope string
Permissions string
Ownership string
RestartRisk string
Details string
}
type Availability struct {
+107
View File
@@ -0,0 +1,107 @@
package tui
import (
"proxyctl/internal/app"
"proxyctl/internal/target"
)
type dashboardPane uint8
const (
targetListPane dashboardPane = iota
targetDetailsPane
)
// targetCatalog owns the dashboard's product-ordered Target projection. It is
// the only place where availability filtering and identity-based selection are
// combined, so rendering and input handling cannot accidentally select by a
// transient row number.
type targetCatalog struct {
items []app.TargetSnapshot
}
func newTargetCatalog(snapshot app.Snapshot, includeUnavailable bool) targetCatalog {
items := make([]app.TargetSnapshot, 0, len(snapshot.Targets))
for _, item := range snapshot.Targets {
if item.Available || includeUnavailable {
items = append(items, item)
}
}
return targetCatalog{items: items}
}
func (c targetCatalog) firstID() string {
if len(c.items) == 0 {
return ""
}
return c.items[0].ID
}
func (c targetCatalog) index(id string) int {
for i := range c.items {
if c.items[i].ID == id {
return i
}
}
return -1
}
func (c targetCatalog) selected(id string) (app.TargetSnapshot, bool) {
index := c.index(id)
if index < 0 {
return app.TargetSnapshot{}, false
}
return c.items[index], true
}
func (c targetCatalog) move(id string, delta int) string {
if len(c.items) == 0 {
return ""
}
index := c.index(id)
if index < 0 {
index = 0
}
index = max(0, min(len(c.items)-1, index+delta))
return c.items[index].ID
}
// retain keeps an identity when possible and otherwise chooses the product row
// nearest its former position. This is used after refresh and visibility
// changes; terminal resize never needs to touch selection at all.
func (c targetCatalog) retain(id string, formerIndex int) string {
if c.index(id) >= 0 {
return id
}
if len(c.items) == 0 {
return ""
}
return c.items[max(0, min(len(c.items)-1, formerIndex))].ID
}
type targetStateCounts struct {
enabled int
disabled int
external int
drifted int
}
func countAvailableTargetStates(snapshot app.Snapshot) targetStateCounts {
var counts targetStateCounts
for _, item := range snapshot.Targets {
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
}
+109
View File
@@ -0,0 +1,109 @@
package tui
import (
"fmt"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/target"
)
func TestCompleteModelWideOperationsConsoleShowsStableAvailableTargetDashboard(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{
ActiveProfile: "office",
Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell summary", Details: "Shell details"}, Available: true, State: target.Enabled, Evidence: "matches Active Profile"},
{ID: "apt", Description: target.Description{Name: "APT"}, Available: false, AvailabilityEvidence: "apt-config was not detected"},
{ID: "docker-client", Description: target.Description{Name: "Docker client"}, Available: true, State: target.External, Evidence: "existing settings"},
{ID: "gnome", Description: target.Description{Name: "GNOME"}, Available: true, State: target.Disabled, Evidence: "proxy mode is none"},
{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Drifted, Evidence: "managed values differ"},
},
}}
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",
)
if strings.Contains(view, "APT") || strings.Contains(view, "apt-config was not detected") {
t.Fatalf("default dashboard included an unavailable Target:\n%s", view)
}
if !sameLine(view, "Targets", "Target details") {
t.Fatalf("wide dashboard did not compose list and details side by side:\n%s", view)
}
if !(strings.Index(view, "Shell") < strings.Index(view, "Docker client") && strings.Index(view, "Docker client") < strings.Index(view, "GNOME") && strings.Index(view, "GNOME") < strings.Index(view, "Docker daemon")) {
t.Fatalf("available Target product order changed:\n%s", view)
}
}
func TestCompleteModelDashboardRevealsUnavailableEvidenceAndStructuredDetails(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled, Evidence: "fragment absent"},
{ID: "apt", Description: target.Description{
Name: "APT", Summary: "Persistent package proxy.", Details: "Longer APT explanation.",
Scope: "package operations across privilege boundaries", Permissions: "pre-existing root write authority",
Ownership: "isolated overlay; disabling reveals the previous configuration", RestartRisk: "none",
}, Available: false, AvailabilityEvidence: "apt-config was not detected"},
{ID: "docker", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Enabled, Evidence: "managed values match"},
}}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, "u")
model = updateKey(model, "down")
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",
)
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)
}
if strings.Contains(view, "Space: toggle") {
t.Fatalf("unavailable Target exposed a mutation action:\n%s", view)
}
}
func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIdentity(t *testing.T) {
targets := make([]app.TargetSnapshot, 28)
for i := range targets {
targets[i] = app.TargetSnapshot{
ID: fmt.Sprintf("target-%02d", i), Description: target.Description{Name: fmt.Sprintf("Product Target %02d", i)},
Available: true, State: target.Disabled, Evidence: fmt.Sprintf("evidence-%02d", i),
}
}
targets[22].Description.Summary = "Selected identity summary"
targets[22].Description.Details = strings.Repeat("Scrollable detail line. ", 45) + "END OF TARGET DETAILS"
module := &recordingModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: targets}}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
for range 22 {
model = updateKey(model, "down")
}
view := model.View()
assertUsableFrame(t, view, 80, 24, "> ○ Disabled Product Target 22", "Selected identity summary", "▶ Targets [focused]")
model = updateKey(model, "tab")
for range 70 {
model = updateKey(model, "down")
}
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",
)
model = resize(model, 120, 36)
view = model.View()
assertUsableFrame(t, view, 120, 36, "> ○ Disabled Product Target 22", "END OF TARGET DETAILS", "▶ 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]")
}
+159
View File
@@ -0,0 +1,159 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
"proxyctl/internal/app"
"proxyctl/internal/target"
)
type dashboardRenderState struct {
catalog targetCatalog
selectedTargetID string
focus dashboardPane
detailsOffset int
applying bool
profileTransition bool
progressTarget string
}
func (p terminalPresentation) renderDashboard(state dashboardRenderState, width, height int) string {
if width >= wideWidth && height >= 12 {
leftWidth := (width - 1) * 2 / 5
rightWidth := width - leftWidth - 1
left := p.renderTargetListPane(state, leftWidth, height)
right := p.renderTargetDetailsPane(state, rightWidth, height)
divider := strings.Repeat("│\n", max(0, height-1)) + "│"
return lipgloss.JoinHorizontal(lipgloss.Top, left, divider, right)
}
listHeight := max(5, height/2)
detailsHeight := height - listHeight - 1
if detailsHeight < 4 {
detailsHeight = 4
listHeight = max(3, height-detailsHeight-1)
}
return p.renderTargetListPane(state, width, listHeight) + "\n" +
p.renderTargetDetailsPane(state, width, detailsHeight)
}
func (p terminalPresentation) renderTargetListPane(state dashboardRenderState, width, height int) string {
heading := paneHeading("Targets", state.focus == targetListPane)
bodyHeight := max(1, height-2)
lines := make([]string, 0, len(state.catalog.items))
selectedIndex := state.catalog.index(state.selectedTargetID)
for i, item := range state.catalog.items {
cursor := " "
if item.ID == state.selectedTargetID {
cursor = "> "
}
applying := (state.progressTarget != "" && item.ID == state.progressTarget) ||
(!state.profileTransition && state.progressTarget == "" && state.applying && item.ID == state.selectedTargetID)
badge := p.targetBadge(item, applying)
lines = append(lines, cursor+badge+" "+item.Description.Name)
_ = i
}
if len(lines) == 0 {
lines = append(lines, "No Targets are currently Available.")
selectedIndex = 0
}
offset := 0
if selectedIndex >= bodyHeight {
offset = selectedIndex - bodyHeight + 1
}
body := fixedViewport(strings.Join(lines, "\n"), width, bodyHeight, offset, false)
return fitPane(heading+"\n"+paneRule(width)+"\n"+body, width, height)
}
func (p terminalPresentation) renderTargetDetailsPane(state dashboardRenderState, width, height int) string {
heading := paneHeading("Target details", state.focus == targetDetailsPane)
bodyHeight := max(1, height-2)
selected, ok := state.catalog.selected(state.selectedTargetID)
content := "Select a Target to review its details."
if ok {
content = p.targetDetails(selected)
}
body := fixedViewport(content, width, bodyHeight, state.detailsOffset, true)
return fitPane(heading+"\n"+paneRule(width)+"\n"+body, width, height)
}
func paneHeading(label string, focused bool) string {
if focused {
return "▶ " + label + " [focused]"
}
return " " + label
}
func paneRule(width int) string {
return strings.Repeat("─", max(1, width))
}
func fitPane(content string, width, height int) string {
return lipgloss.NewStyle().Width(width).Height(height).MaxWidth(width).MaxHeight(height).Render(content)
}
func fixedViewport(content string, width, height, offset int, wrap bool) string {
if wrap {
content = lipgloss.NewStyle().Width(width).Render(content)
}
view := viewport.New(width, height)
view.SetContent(strings.TrimRight(content, "\n"))
view.SetYOffset(max(0, offset))
return view.View()
}
func (p terminalPresentation) targetBadge(item app.TargetSnapshot, applying bool) string {
if !item.Available {
return p.theme.unavailable.Render("○ Unavailable")
}
return p.theme.renderTargetState(item.State, applying)
}
func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
state := p.targetBadge(item, false)
evidence := item.Evidence
if !item.Available {
evidence = item.AvailabilityEvidence
}
if evidence == "" {
evidence = "No detection evidence reported."
}
var details strings.Builder
details.WriteString(p.theme.section.Render(item.Description.Name))
fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence)
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 != "" {
fmt.Fprintf(&details, "\n\n%s", item.Description.Summary)
}
if item.Description.Details != "" {
fmt.Fprintf(&details, "\n\n%s", item.Description.Details)
}
return details.String()
}
func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string {
counts := countAvailableTargetStates(snapshot)
return 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)
}
+8 -8
View File
@@ -51,11 +51,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, "APT summary") {
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") {
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, "APT summary") {
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") {
t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view)
}
@@ -193,9 +193,9 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl
model = updated.(Model)
assertUsableFrame(t, model.View(), 80, 24,
"> [Drifted ] Docker daemon",
"Docker daemon selected summary",
"Docker daemon selected detail.",
"> ! Drifted Docker daemon",
"State: ! Drifted",
"Docker daemon selected evidence.",
"Status: Refresh completed; selected Target remains actionable.",
"Space: toggle",
"p: manage Proxy Profiles",
@@ -204,9 +204,9 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl
model = resize(model, 120, 36)
model = resize(model, 80, 24)
assertUsableFrame(t, model.View(), 80, 24,
"> [Drifted ] Docker daemon",
"Docker daemon selected summary",
"Docker daemon selected detail.",
"> ! Drifted Docker daemon",
"State: ! Drifted",
"Docker daemon selected evidence.",
"Space: toggle",
)
}
+69 -21
View File
@@ -38,6 +38,10 @@ type Model struct {
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
confirm bool
targetConfirm *app.Intent
@@ -91,13 +95,15 @@ func NewModel(application app.Module, configure ...ModelOption) Model {
for _, option := range configure {
option(&options)
}
snapshot := application.Snapshot()
return Model{
application: application,
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
snapshot: application.Snapshot(),
width: 80,
height: 24,
detect: probe.Detect,
application: application,
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
snapshot: snapshot,
selectedTargetID: newTargetCatalog(snapshot, false).firstID(),
width: 80,
height: 24,
detect: probe.Detect,
}
}
@@ -137,20 +143,15 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.refreshing = false
formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable)
formerIndex := formerCatalog.index(m.selectedTargetID)
m.snapshot = completed.result.Snapshot
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex)
if completed.result.Err != nil {
m.status = "Error: " + completed.result.Err.Error()
} else {
m.status = completed.result.Notice
}
targetCount := len(m.availableTargets())
if targetCount == 0 {
m.selected = 0
} else if m.selected < 0 {
m.selected = 0
} else if m.selected >= targetCount {
m.selected = targetCount - 1
}
return m, m.deferredQuit()
}
if progress, ok := message.(profileOperationProgress); ok {
@@ -158,6 +159,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.snapshot = progress.result.Snapshot
m.retainTargetSelection()
m.progressTarget = ""
if len(progress.result.Progress) > 0 {
item := progress.result.Progress[len(progress.result.Progress)-1]
@@ -194,6 +196,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
}
m.applying = false
m.snapshot = completed.result.Snapshot
m.retainTargetSelection()
if completed.result.Confirmation != "" {
intent := app.Intent{Kind: app.SetTargetDesired}
if m.targetConfirm != nil {
@@ -307,7 +310,7 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, nil
}
targets := m.availableTargets()
catalog := newTargetCatalog(m.snapshot, m.showUnavailable)
switch key.String() {
case "p":
if !m.busy() {
@@ -328,17 +331,53 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})}
}
}
case "tab":
if !m.busy() {
if m.dashboardFocus == targetListPane {
m.dashboardFocus = targetDetailsPane
} else {
m.dashboardFocus = targetListPane
}
}
case "u":
if !m.busy() {
formerIndex := catalog.index(m.selectedTargetID)
m.showUnavailable = !m.showUnavailable
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex)
m.detailsOffset = 0
}
case "up", "k":
if !m.busy() && m.selected > 0 {
m.selected--
if !m.busy() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-1)
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, -1)
m.detailsOffset = 0
}
}
case "down", "j":
if !m.busy() && m.selected+1 < len(targets) {
m.selected++
if !m.busy() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset++
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, 1)
m.detailsOffset = 0
}
}
case "pgup":
if !m.busy() && m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-5)
}
case "pgdown":
if !m.busy() && m.dashboardFocus == targetDetailsPane {
m.detailsOffset += 5
}
case " ":
if !m.busy() && m.selected >= 0 && m.selected < len(targets) {
selected := targets[m.selected]
if !m.busy() {
selected, ok := catalog.selected(m.selectedTargetID)
if !ok || !selected.Available {
break
}
desired := selected.State == target.Disabled || selected.State == target.External
m.applying = true
m.status = ""
@@ -571,6 +610,10 @@ func (m Model) View() string {
width: m.width,
height: m.height,
selected: m.selected,
selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
form: m.form,
confirm: m.confirm,
targetConfirm: m.targetConfirm != nil,
@@ -586,6 +629,11 @@ func (m Model) availableTargets() []app.TargetSnapshot {
return availableTargets(m.snapshot)
}
func (m *Model) retainTargetSelection() {
catalog := newTargetCatalog(m.snapshot, m.showUnavailable)
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
}
func (f formState) labels() []string {
if f.profile.Mode == profile.AdvancedMode {
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}
+35 -51
View File
@@ -4,70 +4,54 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"proxyctl/internal/profile"
)
func (p terminalPresentation) mainView(model presentationState, width, height int) terminalPage {
var header strings.Builder
header.WriteString(p.theme.title.Render("proxyctl — Target panel"))
header.WriteString("\n\n")
if model.snapshot.ActiveProfile == "" {
header.WriteString(p.theme.warning.Render("No Active Profile"))
header.WriteString("\nCreate and activate a Proxy Profile before enabling Targets.")
} else {
fmt.Fprintf(&header, "Active Profile: %s", model.snapshot.ActiveProfile)
activeProfile := model.snapshot.ActiveProfile
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))
}
list, details, focusLine := p.targetRegions(model)
page := terminalPage{
header: header.String(),
primary: list,
context: details,
footer: "Space: toggle ↑/k ↓/j: select p: manage Proxy Profiles\nr: refresh ?: help q/Esc: quit",
focusLine: focusLine,
}
if width >= wideWidth && height >= wideHeight {
leftWidth := (width - 2) * 2 / 5
rightWidth := width - leftWidth - 2
left := lipgloss.NewStyle().Width(leftWidth).Render(list)
right := lipgloss.NewStyle().Width(rightWidth).Render(details)
page.primary = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
page.context = ""
}
catalog := newTargetCatalog(model.snapshot, model.showUnavailable)
footer := p.dashboardFooter(model, catalog)
if model.targetConfirm && !model.applying {
page.footer = "y: confirm n/Esc: cancel"
footer = "y: confirm n/Esc: cancel"
}
return terminalPage{
header: header,
footer: footer,
dashboard: &dashboardRenderState{
catalog: catalog,
selectedTargetID: model.selectedTargetID,
focus: model.dashboardFocus,
detailsOffset: model.detailsOffset,
applying: model.applying,
profileTransition: model.profileTransition,
progressTarget: model.progressTarget,
},
}
return page
}
func (p terminalPresentation) targetRegions(model presentationState) (listView, detailsView string, focusLine int) {
targets := availableTargets(model.snapshot)
var list strings.Builder
list.WriteString(p.theme.selected.Render("Targets"))
if len(targets) == 0 {
list.WriteString("\nNo Targets are currently Available. Press ? to review detection reasons.")
func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
visibility := "u: show unavailable"
if model.showUnavailable {
visibility = "u: hide unavailable"
}
for i, item := range targets {
cursor := " "
if i == model.selected {
cursor = "> "
}
applying := (model.progressTarget != "" && item.ID == model.progressTarget) ||
(!model.profileTransition && model.progressTarget == "" && model.applying && i == model.selected)
fmt.Fprintf(&list, "\n%s[%s] %s", cursor, p.theme.renderTargetState(item.State, applying), item.Description.Name)
if model.dashboardFocus == targetDetailsPane {
return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: manage Proxy Profiles ?: help q/Esc: quit"
}
var details strings.Builder
details.WriteString(p.theme.section.Render("Target details"))
if model.selected >= 0 && model.selected < len(targets) {
selected := targets[model.selected]
fmt.Fprintf(&details, "\n%s\n%s\n%s", selected.Description.Summary, selected.Description.Details, selected.Evidence)
} else {
details.WriteString("\nSelect an Available Target to review its details.")
actions := "↑/k ↓/j: select Tab: focus details " + visibility
if selected, ok := catalog.selected(model.selectedTargetID); ok && selected.Available {
actions = "Space: toggle " + actions
}
return list.String(), details.String(), model.selected + 1
return actions + "\np: manage Proxy Profiles r: refresh ?: help q/Esc: quit"
}
func (p terminalPresentation) helpView(model presentationState) terminalPage {
+12 -1
View File
@@ -58,6 +58,10 @@ type presentationState struct {
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
confirm bool
targetConfirm bool
@@ -83,6 +87,7 @@ type terminalPage struct {
footer string
status string
focusLine int
dashboard *dashboardRenderState
}
func (p terminalPresentation) Render(state presentationState) string {
@@ -112,7 +117,10 @@ func (p terminalPresentation) Render(state presentationState) string {
}
page.status = state.status
page = redactTerminalPage(page)
return p.renderShell(page, width, height)
// Dashboard composition retains structured Target metadata until pane sizes
// are known. Redact the fully composed frame as the final presentation
// boundary as well, so no render path can bypass credential scrubbing.
return profile.RedactText(p.renderShell(page, width, height))
}
func redactTerminalPage(page terminalPage) terminalPage {
@@ -149,6 +157,9 @@ func (p terminalPresentation) renderShell(page terminalPage, width, height int)
bodyHeight = 1
}
body := renderPageBody(page, width, bodyHeight)
if page.dashboard != nil {
body = p.renderDashboard(*page.dashboard, width, bodyHeight)
}
sections := make([]string, 0, 3)
if header != "" {
+12 -12
View File
@@ -94,7 +94,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
}}}
model := NewModel(module)
view := model.View()
if strings.Contains(view, "APT") || !strings.Contains(view, "affects future Shell commands") || !strings.Contains(view, "integration installed") {
if strings.Contains(view, "APT") || !strings.Contains(view, "summary") || !strings.Contains(view, "integration installed") {
t.Fatalf("main Target panel:\n%s", view)
}
model = updateKey(model, "?")
@@ -103,7 +103,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
t.Fatalf("Target help:\n%s", view)
}
model = updateKey(model, "esc")
if !strings.Contains(model.View(), "Target panel") {
if !strings.Contains(model.View(), "Operations Console") {
t.Fatalf("Escape did not return from help:\n%s", model.View())
}
}
@@ -423,13 +423,13 @@ func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) {
refreshed := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "one", Description: target.Description{Name: "One"}, Available: true}}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: refreshed}}
model := NewModel(module)
model.selected = 3
model.selectedTargetID = "four"
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
updated, _ = model.Update(command())
model = updated.(Model)
if model.selected != 0 || !strings.Contains(model.View(), "> [ ] One") {
t.Fatalf("selection after multi-Target reduction = %d\n%s", model.selected, model.View())
if model.selectedTargetID != "one" || !strings.Contains(model.View(), "> ? Unknown One") {
t.Fatalf("selection after multi-Target reduction = %q\n%s", model.selectedTargetID, model.View())
}
module.result = app.Result{Snapshot: app.Snapshot{}}
@@ -437,8 +437,8 @@ func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) {
model = updated.(Model)
updated, _ = model.Update(command())
model = updated.(Model)
if model.selected != 0 {
t.Fatalf("selection with no Available Targets = %d, want 0", model.selected)
if model.selectedTargetID != "" {
t.Fatalf("selection with no Available Targets = %q, want empty", model.selectedTargetID)
}
}
@@ -458,12 +458,12 @@ func TestTargetPanelNavigationKeysAndQuitCombinations(t *testing.T) {
model := NewModel(module)
updated, _ := model.Update(down)
model = updated.(Model)
if !strings.Contains(model.View(), "> [ ] Second") {
if !strings.Contains(model.View(), "> ? Unknown Second") {
t.Fatalf("down key %q did not select second Target:\n%s", down.String(), model.View())
}
for _, up := range []tea.KeyMsg{{Type: tea.KeyUp}, {Type: tea.KeyRunes, Runes: []rune{'k'}}} {
updated, _ = model.Update(up)
if !strings.Contains(updated.(Model).View(), "> [ ] First") {
if !strings.Contains(updated.(Model).View(), "> ? Unknown First") {
t.Fatalf("up key %q did not select first Target:\n%s", up.String(), updated.(Model).View())
}
}
@@ -484,12 +484,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(), "Target panel") {
t.Fatalf("help key %q did not return to Target panel", key.String())
if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") {
t.Fatalf("help key %q did not return to Operations Console", key.String())
}
model = updateKey(NewModel(module), "p")
updated, command = model.Update(key)
if command != nil || !strings.Contains(updated.(Model).View(), "Target panel") {
if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") {
t.Fatalf("Profile key %q did not return to Target panel", key.String())
}
}
+42 -29
View File
@@ -11,16 +11,17 @@ import (
)
type semanticTheme struct {
title lipgloss.Style
section lipgloss.Style
selected lipgloss.Style
status lipgloss.Style
warning lipgloss.Style
enabled lipgloss.Style
disabled lipgloss.Style
external lipgloss.Style
drifted lipgloss.Style
applying lipgloss.Style
title lipgloss.Style
section lipgloss.Style
selected lipgloss.Style
status lipgloss.Style
warning lipgloss.Style
enabled lipgloss.Style
disabled lipgloss.Style
external lipgloss.Style
drifted lipgloss.Style
unavailable lipgloss.Style
applying lipgloss.Style
}
func newSemanticTheme(mode themeMode, output io.Writer) semanticTheme {
@@ -56,38 +57,50 @@ func newSemanticTheme(mode themeMode, output io.Writer) semanticTheme {
}
style := renderer.NewStyle
return semanticTheme{
title: style().Bold(true).Foreground(lipgloss.Color(accent)),
section: style().Bold(true).Foreground(lipgloss.Color(foreground)),
selected: style().Bold(true).Underline(true).Foreground(lipgloss.Color(accent)),
status: style().Foreground(lipgloss.Color(foreground)),
warning: style().Bold(true).Foreground(lipgloss.Color("172")),
enabled: style().Foreground(lipgloss.Color("34")),
disabled: style().Foreground(lipgloss.Color(subtle)),
external: style().Foreground(lipgloss.Color("172")),
drifted: style().Bold(true).Foreground(lipgloss.Color("160")),
applying: style().Foreground(lipgloss.Color(accent)),
title: style().Bold(true).Foreground(lipgloss.Color(accent)),
section: style().Bold(true).Foreground(lipgloss.Color(foreground)),
selected: style().Bold(true).Underline(true).Foreground(lipgloss.Color(accent)),
status: style().Foreground(lipgloss.Color(foreground)),
warning: style().Bold(true).Foreground(lipgloss.Color("172")),
enabled: style().Foreground(lipgloss.Color("34")),
disabled: style().Foreground(lipgloss.Color(subtle)),
external: style().Foreground(lipgloss.Color("172")),
drifted: style().Bold(true).Foreground(lipgloss.Color("160")),
unavailable: style().Foreground(lipgloss.Color(subtle)),
applying: style().Foreground(lipgloss.Color(accent)),
}
}
func (t semanticTheme) renderTargetState(state target.State, applying bool) string {
if applying {
return t.applying.Render("Applying…")
return t.applying.Render("… Applying")
}
label := string(state)
label := targetStateLabel(state)
switch state {
case target.Enabled:
return t.enabled.Render(paddedState(label))
return t.enabled.Render(label)
case target.Disabled:
return t.disabled.Render(paddedState(label))
return t.disabled.Render(label)
case target.External:
return t.external.Render(paddedState(label))
return t.external.Render(label)
case target.Drifted:
return t.drifted.Render(paddedState(label))
return t.drifted.Render(label)
default:
return paddedState(label)
return label
}
}
func paddedState(label string) string {
return lipgloss.NewStyle().Width(9).Render(label)
func targetStateLabel(state target.State) string {
switch state {
case target.Enabled:
return "● Enabled"
case target.Disabled:
return "○ Disabled"
case target.External:
return "◇ External"
case target.Drifted:
return "! Drifted"
default:
return "? Unknown"
}
}