pi-agent: Implement progressive Target detection
This commit is contained in:
@@ -23,7 +23,7 @@ type targetCatalog struct {
|
||||
func newTargetCatalog(snapshot app.Snapshot, includeUnavailable bool) targetCatalog {
|
||||
items := make([]app.TargetSnapshot, 0, len(snapshot.Targets))
|
||||
for _, item := range snapshot.Targets {
|
||||
if item.Available || includeUnavailable {
|
||||
if item.Available || includeUnavailable || item.DetectionPhase != app.TargetReady {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
@@ -84,11 +84,21 @@ type targetStateCounts struct {
|
||||
disabled int
|
||||
external int
|
||||
drifted int
|
||||
checking int
|
||||
failed int
|
||||
}
|
||||
|
||||
func countAvailableTargetStates(snapshot app.Snapshot) targetStateCounts {
|
||||
var counts targetStateCounts
|
||||
for _, item := range snapshot.Targets {
|
||||
switch item.DetectionPhase {
|
||||
case app.TargetChecking:
|
||||
counts.checking++
|
||||
continue
|
||||
case app.TargetDetectionFailed:
|
||||
counts.failed++
|
||||
continue
|
||||
}
|
||||
if !item.Available {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -106,6 +106,12 @@ func fixedViewport(content string, width, height, offset int, wrap bool) string
|
||||
}
|
||||
|
||||
func (p terminalPresentation) targetBadge(item app.TargetSnapshot, applying bool) string {
|
||||
switch item.DetectionPhase {
|
||||
case app.TargetChecking:
|
||||
return p.theme.checking.Render("… Checking")
|
||||
case app.TargetDetectionFailed:
|
||||
return p.theme.checkFailed.Render("! Check failed")
|
||||
}
|
||||
if !item.Available {
|
||||
return p.theme.unavailable.Render("○ Unavailable")
|
||||
}
|
||||
@@ -115,8 +121,17 @@ func (p terminalPresentation) targetBadge(item app.TargetSnapshot, applying bool
|
||||
func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
|
||||
state := p.targetBadge(item, false)
|
||||
evidence := item.Evidence
|
||||
if !item.Available {
|
||||
evidence = item.AvailabilityEvidence
|
||||
switch item.DetectionPhase {
|
||||
case app.TargetChecking:
|
||||
evidence = "Availability and configuration inspection are in progress."
|
||||
case app.TargetDetectionFailed:
|
||||
if evidence == "" {
|
||||
evidence = item.AvailabilityEvidence
|
||||
}
|
||||
default:
|
||||
if !item.Available {
|
||||
evidence = item.AvailabilityEvidence
|
||||
}
|
||||
}
|
||||
if evidence == "" {
|
||||
evidence = "No detection evidence reported."
|
||||
@@ -125,6 +140,9 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
|
||||
var details strings.Builder
|
||||
details.WriteString(p.theme.section.Render(item.Description.Name))
|
||||
fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence)
|
||||
if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil {
|
||||
fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr)
|
||||
}
|
||||
facts := []struct {
|
||||
label string
|
||||
value string
|
||||
@@ -150,9 +168,16 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
|
||||
|
||||
func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string {
|
||||
counts := countAvailableTargetStates(snapshot)
|
||||
return fmt.Sprintf("%s %d %s %d %s %d %s %d",
|
||||
summary := fmt.Sprintf("%s %d %s %d %s %d %s %d",
|
||||
p.theme.renderTargetState(target.Enabled, false), counts.enabled,
|
||||
p.theme.renderTargetState(target.Disabled, false), counts.disabled,
|
||||
p.theme.renderTargetState(target.External, false), counts.external,
|
||||
p.theme.renderTargetState(target.Drifted, false), counts.drifted)
|
||||
if counts.checking > 0 {
|
||||
summary += fmt.Sprintf(" %s %d", p.theme.checking.Render("… Checking"), counts.checking)
|
||||
}
|
||||
if counts.failed > 0 {
|
||||
summary += fmt.Sprintf(" %s %d", p.theme.checkFailed.Render("! Check failed"), counts.failed)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
+77
-8
@@ -50,6 +50,7 @@ type Model struct {
|
||||
status string
|
||||
applying bool
|
||||
refreshing bool
|
||||
detectionUpdates <-chan app.TargetDetectionUpdate
|
||||
profileTransition bool
|
||||
progressTarget string
|
||||
quitRequested bool
|
||||
@@ -64,6 +65,11 @@ type refreshDone struct {
|
||||
result app.Result
|
||||
}
|
||||
|
||||
type targetDetectionProgress struct {
|
||||
update app.TargetDetectionUpdate
|
||||
updates <-chan app.TargetDetectionUpdate
|
||||
}
|
||||
|
||||
type profileOperationProgress struct {
|
||||
result app.Result
|
||||
updates <-chan app.Result
|
||||
@@ -80,6 +86,16 @@ type probeDone struct {
|
||||
gen uint64
|
||||
}
|
||||
|
||||
func waitForTargetDetection(updates <-chan app.TargetDetectionUpdate) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
update, ok := <-updates
|
||||
if !ok {
|
||||
return targetDetectionProgress{update: app.TargetDetectionUpdate{Complete: true}, updates: updates}
|
||||
}
|
||||
return targetDetectionProgress{update: update, updates: updates}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
result, ok := <-updates
|
||||
@@ -96,6 +112,10 @@ func NewModel(application app.Module, configure ...ModelOption) Model {
|
||||
option(&options)
|
||||
}
|
||||
snapshot := application.Snapshot()
|
||||
var detectionUpdates <-chan app.TargetDetectionUpdate
|
||||
if progressive, ok := application.(app.ProgressiveDetectionModule); ok {
|
||||
snapshot, detectionUpdates = progressive.BeginTargetDetection()
|
||||
}
|
||||
return Model{
|
||||
application: application,
|
||||
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
|
||||
@@ -103,11 +123,18 @@ func NewModel(application app.Module, configure ...ModelOption) Model {
|
||||
selectedTargetID: newTargetCatalog(snapshot, false).firstID(),
|
||||
width: 80,
|
||||
height: 24,
|
||||
refreshing: detectionUpdates != nil,
|
||||
detectionUpdates: detectionUpdates,
|
||||
detect: probe.Detect,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
func (m Model) Init() tea.Cmd {
|
||||
if m.detectionUpdates != nil {
|
||||
return waitForTargetDetection(m.detectionUpdates)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// busy is the single-operation guard for asynchronous Application work. Every
|
||||
// operation start checks it, so a completion can only belong to the one current
|
||||
@@ -120,6 +147,10 @@ func (m Model) operationInFlight() bool {
|
||||
return m.applying || m.refreshing || m.profileTransition
|
||||
}
|
||||
|
||||
func (m Model) dashboardNavigationAvailable() bool {
|
||||
return !m.quitRequested && !m.applying && !m.profileTransition
|
||||
}
|
||||
|
||||
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if size, ok := message.(tea.WindowSizeMsg); ok {
|
||||
m.width = size.Width
|
||||
@@ -138,6 +169,21 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.quitRequested = true
|
||||
return m, nil
|
||||
}
|
||||
if progress, ok := message.(targetDetectionProgress); ok {
|
||||
if !m.refreshing || progress.updates != m.detectionUpdates {
|
||||
return m, nil
|
||||
}
|
||||
if progress.update.Result.TargetID != "" {
|
||||
m.applyTargetDetection(progress.update.Result)
|
||||
}
|
||||
if progress.update.Complete {
|
||||
m.refreshing = false
|
||||
m.detectionUpdates = nil
|
||||
m.status = "Target availability and state checks completed"
|
||||
return m, m.deferredQuit()
|
||||
}
|
||||
return m, waitForTargetDetection(progress.updates)
|
||||
}
|
||||
if completed, ok := message.(refreshDone); ok {
|
||||
if !m.refreshing {
|
||||
return m, nil
|
||||
@@ -327,12 +373,17 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if !m.busy() {
|
||||
m.refreshing = true
|
||||
m.status = "Refreshing Target availability and state…"
|
||||
if progressive, ok := m.application.(app.ProgressiveDetectionModule); ok {
|
||||
m.snapshot, m.detectionUpdates = progressive.BeginTargetDetection()
|
||||
m.retainTargetSelection()
|
||||
return m, waitForTargetDetection(m.detectionUpdates)
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})}
|
||||
}
|
||||
}
|
||||
case "tab":
|
||||
if !m.busy() {
|
||||
if m.dashboardNavigationAvailable() {
|
||||
if m.dashboardFocus == targetListPane {
|
||||
m.dashboardFocus = targetDetailsPane
|
||||
} else {
|
||||
@@ -340,14 +391,14 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
case "u":
|
||||
if !m.busy() {
|
||||
if m.dashboardNavigationAvailable() {
|
||||
formerIndex := catalog.index(m.selectedTargetID)
|
||||
m.showUnavailable = !m.showUnavailable
|
||||
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex)
|
||||
m.detailsOffset = 0
|
||||
}
|
||||
case "up", "k":
|
||||
if !m.busy() {
|
||||
if m.dashboardNavigationAvailable() {
|
||||
if m.dashboardFocus == targetDetailsPane {
|
||||
m.detailsOffset = max(0, m.detailsOffset-1)
|
||||
} else {
|
||||
@@ -356,7 +407,7 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if !m.busy() {
|
||||
if m.dashboardNavigationAvailable() {
|
||||
if m.dashboardFocus == targetDetailsPane {
|
||||
m.detailsOffset++
|
||||
} else {
|
||||
@@ -365,17 +416,17 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
case "pgup":
|
||||
if !m.busy() && m.dashboardFocus == targetDetailsPane {
|
||||
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
|
||||
m.detailsOffset = max(0, m.detailsOffset-5)
|
||||
}
|
||||
case "pgdown":
|
||||
if !m.busy() && m.dashboardFocus == targetDetailsPane {
|
||||
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
|
||||
m.detailsOffset += 5
|
||||
}
|
||||
case " ":
|
||||
if !m.busy() {
|
||||
selected, ok := catalog.selected(m.selectedTargetID)
|
||||
if !ok || !selected.Available {
|
||||
if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady {
|
||||
break
|
||||
}
|
||||
desired := selected.State == target.Disabled || selected.State == target.External
|
||||
@@ -634,6 +685,24 @@ func (m *Model) retainTargetSelection() {
|
||||
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
|
||||
}
|
||||
|
||||
func (m *Model) applyTargetDetection(result app.TargetDetectionResult) {
|
||||
formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable)
|
||||
formerIndex := formerCatalog.index(m.selectedTargetID)
|
||||
for i := range m.snapshot.Targets {
|
||||
if m.snapshot.Targets[i].ID != result.TargetID {
|
||||
continue
|
||||
}
|
||||
m.snapshot.Targets[i].DetectionPhase = result.Phase
|
||||
m.snapshot.Targets[i].DetectionErr = result.Err
|
||||
m.snapshot.Targets[i].Available = result.Availability.Available
|
||||
m.snapshot.Targets[i].AvailabilityEvidence = result.Availability.Evidence
|
||||
m.snapshot.Targets[i].State = result.Inspection.State
|
||||
m.snapshot.Targets[i].Evidence = result.Inspection.Evidence
|
||||
break
|
||||
}
|
||||
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex)
|
||||
}
|
||||
|
||||
func (f formState) labels() []string {
|
||||
if f.profile.Mode == profile.AdvancedMode {
|
||||
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
)
|
||||
|
||||
@@ -48,7 +49,7 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t
|
||||
return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: manage Proxy Profiles ?: help q/Esc: quit"
|
||||
}
|
||||
actions := "↑/k ↓/j: select Tab: focus details " + visibility
|
||||
if selected, ok := catalog.selected(model.selectedTargetID); ok && selected.Available {
|
||||
if selected, ok := catalog.selected(model.selectedTargetID); ok && selected.Available && selected.DetectionPhase == app.TargetReady {
|
||||
actions = "Space: toggle " + actions
|
||||
}
|
||||
return actions + "\np: manage Proxy Profiles r: refresh ?: help q/Esc: quit"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
type progressiveDetectionModule struct {
|
||||
snapshot app.Snapshot
|
||||
streams []chan app.TargetDetectionUpdate
|
||||
begins int
|
||||
}
|
||||
|
||||
func (m *progressiveDetectionModule) Snapshot() app.Snapshot { return m.snapshot }
|
||||
func (m *progressiveDetectionModule) Dispatch(app.Intent) app.Result {
|
||||
return app.Result{Snapshot: m.snapshot}
|
||||
}
|
||||
func (m *progressiveDetectionModule) BeginTargetDetection() (app.Snapshot, <-chan app.TargetDetectionUpdate) {
|
||||
m.begins++
|
||||
for i := range m.snapshot.Targets {
|
||||
m.snapshot.Targets[i].DetectionPhase = app.TargetChecking
|
||||
m.snapshot.Targets[i].DetectionErr = nil
|
||||
}
|
||||
return m.snapshot, m.streams[m.begins-1]
|
||||
}
|
||||
|
||||
func TestCompleteModelStreamsStartupAndRefreshByTargetWithoutMovingSelection(t *testing.T) {
|
||||
startup := make(chan app.TargetDetectionUpdate, 4)
|
||||
refresh := make(chan app.TargetDetectionUpdate, 4)
|
||||
module := &progressiveDetectionModule{
|
||||
snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
|
||||
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell description"}},
|
||||
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT description"}},
|
||||
{ID: "docker", Description: target.Description{Name: "Docker", Summary: "Docker description"}},
|
||||
}},
|
||||
streams: []chan app.TargetDetectionUpdate{startup, refresh},
|
||||
}
|
||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||
assertUsableFrame(t, model.View(), 120, 36,
|
||||
"> … Checking Shell", "… Checking APT", "… Checking Docker",
|
||||
"Shell description", "Availability and configuration inspection are in progress.",
|
||||
)
|
||||
|
||||
// Selection remains usable while checks run, and is retained by identity
|
||||
// even when a later product row completes first.
|
||||
model = updateKey(model, "down")
|
||||
if model.selectedTargetID != "apt" {
|
||||
t.Fatalf("selection during startup = %q, want apt", model.selectedTargetID)
|
||||
}
|
||||
command := model.Init()
|
||||
if command == nil {
|
||||
t.Fatal("startup did not wait for progressive detection")
|
||||
}
|
||||
|
||||
startup <- detectionUpdate(module.snapshot, "docker", app.TargetReady, true, target.Enabled, "docker enabled", nil)
|
||||
model, command = applyDetectionCommand(t, model, command)
|
||||
view := model.View()
|
||||
assertOrder(t, view, "Shell", "APT", "Docker")
|
||||
assertUsableFrame(t, view, 120, 36, "… Checking Shell", "> … Checking APT", "● Enabled Docker")
|
||||
if model.selectedTargetID != "apt" {
|
||||
t.Fatalf("out-of-order completion moved selection to %q", model.selectedTargetID)
|
||||
}
|
||||
|
||||
failure := errors.New("apt-config inspection failed")
|
||||
startup <- detectionUpdate(module.snapshot, "shell", app.TargetDetectionFailed, false, "", "", failure)
|
||||
model, command = applyDetectionCommand(t, model, command)
|
||||
assertUsableFrame(t, model.View(), 120, 36, "! Check failed Shell", "> … Checking APT", "● Enabled Docker")
|
||||
|
||||
startup <- detectionUpdate(module.snapshot, "apt", app.TargetReady, true, target.External, "external APT proxy", nil)
|
||||
model, command = applyDetectionCommand(t, model, command)
|
||||
startup <- app.TargetDetectionUpdate{Complete: true}
|
||||
model, command = applyDetectionCommand(t, model, command)
|
||||
if command != nil || model.selectedTargetID != "apt" {
|
||||
t.Fatalf("startup completion command=%v selection=%q", command != nil, model.selectedTargetID)
|
||||
}
|
||||
assertUsableFrame(t, model.View(), 120, 36, "! Check failed Shell", "> ◇ External APT", "● Enabled Docker")
|
||||
model = updateKey(model, "up")
|
||||
assertUsableFrame(t, model.View(), 120, 36, "> ! Check failed Shell", "Check error: apt-config inspection failed")
|
||||
model = updateKey(model, "down")
|
||||
|
||||
updated, command := model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
if command == nil || module.begins != 2 {
|
||||
t.Fatalf("refresh did not reuse progressive detection: command=%v begins=%d", command != nil, module.begins)
|
||||
}
|
||||
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "> … Checking APT", "… Checking Docker")
|
||||
|
||||
refresh <- detectionUpdate(module.snapshot, "apt", app.TargetReady, true, target.Enabled, "apt enabled", nil)
|
||||
model, command = applyDetectionCommand(t, model, command)
|
||||
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "> ● Enabled APT", "… Checking Docker")
|
||||
if model.selectedTargetID != "apt" {
|
||||
t.Fatalf("refresh completion moved selection to %q", model.selectedTargetID)
|
||||
}
|
||||
}
|
||||
|
||||
func detectionUpdate(snapshot app.Snapshot, id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.TargetDetectionUpdate {
|
||||
return app.TargetDetectionUpdate{Result: app.TargetDetectionResult{
|
||||
TargetID: id, Phase: phase,
|
||||
Availability: target.Availability{Available: available, Evidence: evidence},
|
||||
Inspection: target.Inspection{State: state, Evidence: evidence}, Err: err,
|
||||
}}
|
||||
}
|
||||
|
||||
func applyDetectionCommand(t *testing.T, model Model, command tea.Cmd) (Model, tea.Cmd) {
|
||||
t.Helper()
|
||||
updated, next := model.Update(command())
|
||||
return updated.(Model), next
|
||||
}
|
||||
|
||||
func assertOrder(t *testing.T, view string, values ...string) {
|
||||
t.Helper()
|
||||
last := -1
|
||||
for _, value := range values {
|
||||
index := strings.Index(view, value)
|
||||
if index <= last {
|
||||
t.Fatalf("%q did not follow prior Target in frame:\n%s", value, view)
|
||||
}
|
||||
last = index
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ type semanticTheme struct {
|
||||
external lipgloss.Style
|
||||
drifted lipgloss.Style
|
||||
unavailable lipgloss.Style
|
||||
checking lipgloss.Style
|
||||
checkFailed lipgloss.Style
|
||||
applying lipgloss.Style
|
||||
}
|
||||
|
||||
@@ -67,6 +69,8 @@ func newSemanticTheme(mode themeMode, output io.Writer) semanticTheme {
|
||||
external: style().Foreground(lipgloss.Color("172")),
|
||||
drifted: style().Bold(true).Foreground(lipgloss.Color("160")),
|
||||
unavailable: style().Foreground(lipgloss.Color(subtle)),
|
||||
checking: style().Foreground(lipgloss.Color(accent)),
|
||||
checkFailed: style().Bold(true).Foreground(lipgloss.Color("160")),
|
||||
applying: style().Foreground(lipgloss.Color(accent)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user