pi-agent: Implement progressive Target detection
This commit is contained in:
+116
-9
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"proxyctl/internal/profile"
|
"proxyctl/internal/profile"
|
||||||
@@ -31,15 +32,41 @@ type Intent struct {
|
|||||||
Enabled bool
|
Enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TargetDetectionPhase uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TargetReady is the zero value so snapshots supplied by non-progressive
|
||||||
|
// Modules remain compatible with the established dashboard contract.
|
||||||
|
TargetReady TargetDetectionPhase = iota
|
||||||
|
TargetChecking
|
||||||
|
TargetDetectionFailed
|
||||||
|
)
|
||||||
|
|
||||||
type TargetSnapshot struct {
|
type TargetSnapshot struct {
|
||||||
ID string
|
ID string
|
||||||
Description target.Description
|
Description target.Description
|
||||||
|
DetectionPhase TargetDetectionPhase
|
||||||
|
DetectionErr error
|
||||||
Available bool
|
Available bool
|
||||||
AvailabilityEvidence string
|
AvailabilityEvidence string
|
||||||
State target.State
|
State target.State
|
||||||
Evidence string
|
Evidence string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TargetDetectionResult struct {
|
||||||
|
TargetID string
|
||||||
|
Phase TargetDetectionPhase
|
||||||
|
Availability target.Availability
|
||||||
|
Inspection target.Inspection
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TargetDetectionUpdate struct {
|
||||||
|
Snapshot Snapshot
|
||||||
|
Result TargetDetectionResult
|
||||||
|
Complete bool
|
||||||
|
}
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
Profiles []profile.Profile
|
Profiles []profile.Profile
|
||||||
ActiveProfile string
|
ActiveProfile string
|
||||||
@@ -75,12 +102,22 @@ type ProgressiveModule interface {
|
|||||||
DispatchProgress(Intent) <-chan Result
|
DispatchProgress(Intent) <-chan Result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProgressiveDetectionModule exposes a useful all-Checking snapshot
|
||||||
|
// synchronously, followed by one structured Target result at a time. Startup
|
||||||
|
// and explicit refresh use this same presentation-independent contract.
|
||||||
|
type ProgressiveDetectionModule interface {
|
||||||
|
BeginTargetDetection() (Snapshot, <-chan TargetDetectionUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
type Application struct {
|
type Application struct {
|
||||||
store profile.Store
|
store profile.Store
|
||||||
state profile.State
|
state profile.State
|
||||||
targets []target.Target
|
targets []target.Target
|
||||||
availabilities map[string]target.Availability
|
availabilities map[string]target.Availability
|
||||||
inspections map[string]target.Inspection
|
inspections map[string]target.Inspection
|
||||||
|
detectionPhase map[string]TargetDetectionPhase
|
||||||
|
detectionErr map[string]error
|
||||||
|
detectionMu sync.RWMutex
|
||||||
mutationLock MutationLock
|
mutationLock MutationLock
|
||||||
logger OperationLogger
|
logger OperationLogger
|
||||||
}
|
}
|
||||||
@@ -112,24 +149,93 @@ func NewWithTargetsAndRuntime(store profile.Store, targets []target.Target, muta
|
|||||||
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),
|
||||||
|
detectionErr: make(map[string]error),
|
||||||
mutationLock: mutationLock,
|
mutationLock: mutationLock,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
a.refreshTargets()
|
for _, adapter := range a.targets {
|
||||||
|
a.detectionPhase[adapter.ID()] = TargetChecking
|
||||||
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Application) Snapshot() Snapshot {
|
func (a *Application) Snapshot() Snapshot {
|
||||||
|
a.detectionMu.RLock()
|
||||||
|
defer a.detectionMu.RUnlock()
|
||||||
|
return a.snapshotLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Application) snapshotLocked() Snapshot {
|
||||||
profiles := append([]profile.Profile(nil), a.state.Profiles...)
|
profiles := append([]profile.Profile(nil), a.state.Profiles...)
|
||||||
snapshot := Snapshot{Profiles: profiles, ActiveProfile: a.state.ActiveProfile}
|
snapshot := Snapshot{Profiles: profiles, ActiveProfile: a.state.ActiveProfile}
|
||||||
for _, adapter := range a.targets {
|
for _, adapter := range a.targets {
|
||||||
availability := a.availabilities[adapter.ID()]
|
id := adapter.ID()
|
||||||
inspection := a.inspections[adapter.ID()]
|
availability := a.availabilities[id]
|
||||||
snapshot.Targets = append(snapshot.Targets, TargetSnapshot{ID: adapter.ID(), Description: adapter.Description(), Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence})
|
inspection := a.inspections[id]
|
||||||
|
snapshot.Targets = append(snapshot.Targets, TargetSnapshot{
|
||||||
|
ID: id, Description: adapter.Description(), DetectionPhase: a.detectionPhase[id], DetectionErr: a.detectionErr[id],
|
||||||
|
Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Application) BeginTargetDetection() (Snapshot, <-chan TargetDetectionUpdate) {
|
||||||
|
a.detectionMu.Lock()
|
||||||
|
for _, adapter := range a.targets {
|
||||||
|
id := adapter.ID()
|
||||||
|
a.detectionPhase[id] = TargetChecking
|
||||||
|
a.detectionErr[id] = nil
|
||||||
|
}
|
||||||
|
checking := a.snapshotLocked()
|
||||||
|
a.detectionMu.Unlock()
|
||||||
|
|
||||||
|
updates := make(chan TargetDetectionUpdate, len(a.targets)+1)
|
||||||
|
active := a.activeProfile()
|
||||||
|
outcomes := make(chan TargetDetectionResult, len(a.targets))
|
||||||
|
for _, adapter := range a.targets {
|
||||||
|
adapter := adapter
|
||||||
|
go func() {
|
||||||
|
detection := detectTarget(adapter, active)
|
||||||
|
phase := TargetReady
|
||||||
|
if detection.Err != nil {
|
||||||
|
phase = TargetDetectionFailed
|
||||||
|
}
|
||||||
|
outcomes <- TargetDetectionResult{
|
||||||
|
TargetID: adapter.ID(), Phase: phase, Availability: detection.Availability,
|
||||||
|
Inspection: detection.Inspection, Err: detection.Err,
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer close(updates)
|
||||||
|
for range a.targets {
|
||||||
|
result := <-outcomes
|
||||||
|
a.detectionMu.Lock()
|
||||||
|
a.availabilities[result.TargetID] = result.Availability
|
||||||
|
a.inspections[result.TargetID] = result.Inspection
|
||||||
|
a.detectionPhase[result.TargetID] = result.Phase
|
||||||
|
a.detectionErr[result.TargetID] = result.Err
|
||||||
|
snapshot := a.snapshotLocked()
|
||||||
|
a.detectionMu.Unlock()
|
||||||
|
updates <- TargetDetectionUpdate{Snapshot: snapshot, Result: result}
|
||||||
|
}
|
||||||
|
updates <- TargetDetectionUpdate{Snapshot: a.Snapshot(), Complete: true}
|
||||||
|
}()
|
||||||
|
return checking, updates
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectTarget(adapter target.Target, active *profile.Profile) target.Detection {
|
||||||
|
if detector, ok := adapter.(target.Detector); ok {
|
||||||
|
return detector.Detect(context.Background(), active)
|
||||||
|
}
|
||||||
|
return target.Detection{
|
||||||
|
Availability: adapter.Availability(context.Background()),
|
||||||
|
Inspection: adapter.Inspect(context.Background(), active),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Application) Dispatch(intent Intent) Result {
|
func (a *Application) Dispatch(intent Intent) Result {
|
||||||
return a.dispatch(intent, nil)
|
return a.dispatch(intent, nil)
|
||||||
}
|
}
|
||||||
@@ -319,17 +425,18 @@ func (a *Application) activeProfile() *profile.Profile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Application) refreshTargets() {
|
func (a *Application) refreshTargets() {
|
||||||
active := a.activeProfile()
|
_, updates := a.BeginTargetDetection()
|
||||||
for _, adapter := range a.targets {
|
for range updates {
|
||||||
a.availabilities[adapter.ID()] = adapter.Availability(context.Background())
|
|
||||||
a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Application) refreshInspections() {
|
func (a *Application) refreshInspections() {
|
||||||
active := a.activeProfile()
|
active := a.activeProfile()
|
||||||
for _, adapter := range a.targets {
|
for _, adapter := range a.targets {
|
||||||
a.inspections[adapter.ID()] = adapter.Inspect(context.Background(), active)
|
inspection := adapter.Inspect(context.Background(), active)
|
||||||
|
a.detectionMu.Lock()
|
||||||
|
a.inspections[adapter.ID()] = inspection
|
||||||
|
a.detectionMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"proxyctl/internal/app"
|
||||||
|
"proxyctl/internal/profile"
|
||||||
|
"proxyctl/internal/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
type controlledDetectionTarget struct {
|
||||||
|
id string
|
||||||
|
description target.Description
|
||||||
|
release <-chan struct{}
|
||||||
|
detection target.Detection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *controlledDetectionTarget) ID() string { return t.id }
|
||||||
|
func (t *controlledDetectionTarget) Description() target.Description { return t.description }
|
||||||
|
func (t *controlledDetectionTarget) Availability(context.Context) target.Availability {
|
||||||
|
panic("progressive detection must use the structured Detector seam")
|
||||||
|
}
|
||||||
|
func (t *controlledDetectionTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
|
||||||
|
panic("progressive detection must use the structured Detector seam")
|
||||||
|
}
|
||||||
|
func (t *controlledDetectionTarget) Confirmation(bool) string { return "" }
|
||||||
|
func (t *controlledDetectionTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult {
|
||||||
|
return target.OperationResult{}
|
||||||
|
}
|
||||||
|
func (t *controlledDetectionTarget) Detect(context.Context, *profile.Profile) target.Detection {
|
||||||
|
<-t.release
|
||||||
|
return t.detection
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplicationStreamsIndependentTargetDetectionInProductOrder(t *testing.T) {
|
||||||
|
firstRelease := make(chan struct{})
|
||||||
|
secondRelease := make(chan struct{})
|
||||||
|
failedRelease := make(chan struct{})
|
||||||
|
adapters := []target.Target{
|
||||||
|
&controlledDetectionTarget{id: "first", description: target.Description{Name: "First", Summary: "first description"}, release: firstRelease, detection: target.Detection{Availability: target.Availability{Available: true, Evidence: "first available"}, Inspection: target.Inspection{State: target.Enabled, Evidence: "first enabled"}}},
|
||||||
|
&controlledDetectionTarget{id: "second", description: target.Description{Name: "Second", Summary: "second description"}, release: secondRelease, detection: target.Detection{Availability: target.Availability{Available: true, Evidence: "second available"}, Inspection: target.Inspection{State: target.External, Evidence: "second external"}}},
|
||||||
|
&controlledDetectionTarget{id: "failed", description: target.Description{Name: "Failed", Summary: "failed description"}, release: failedRelease, detection: target.Detection{Err: errors.New("inspection command failed")}},
|
||||||
|
}
|
||||||
|
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), adapters)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := application.Snapshot()
|
||||||
|
assertTargetOrder(t, before, []string{"first", "second", "failed"})
|
||||||
|
for _, item := range before.Targets {
|
||||||
|
if item.DetectionPhase != app.TargetChecking || item.Description.Name == "" || item.Description.Summary == "" {
|
||||||
|
t.Fatalf("Target identity was not useful before detection: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checking, updates := application.BeginTargetDetection()
|
||||||
|
for _, item := range checking.Targets {
|
||||||
|
if item.DetectionPhase != app.TargetChecking {
|
||||||
|
t.Fatalf("Target did not begin in Checking: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(secondRelease)
|
||||||
|
second := <-updates
|
||||||
|
if second.Result.TargetID != "second" || second.Result.Phase != app.TargetReady || second.Result.Inspection.State != target.External {
|
||||||
|
t.Fatalf("second completion = %#v", second)
|
||||||
|
}
|
||||||
|
assertOnlyDetectionChanged(t, checking, second.Snapshot, "second")
|
||||||
|
assertTargetOrder(t, second.Snapshot, []string{"first", "second", "failed"})
|
||||||
|
|
||||||
|
close(failedRelease)
|
||||||
|
failed := <-updates
|
||||||
|
if failed.Result.TargetID != "failed" || failed.Result.Phase != app.TargetDetectionFailed || failed.Result.Err == nil {
|
||||||
|
t.Fatalf("failed completion = %#v", failed)
|
||||||
|
}
|
||||||
|
assertOnlyDetectionChanged(t, second.Snapshot, failed.Snapshot, "failed")
|
||||||
|
|
||||||
|
close(firstRelease)
|
||||||
|
first := <-updates
|
||||||
|
if first.Result.TargetID != "first" || first.Result.Phase != app.TargetReady || first.Result.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("first completion = %#v", first)
|
||||||
|
}
|
||||||
|
assertOnlyDetectionChanged(t, failed.Snapshot, first.Snapshot, "first")
|
||||||
|
|
||||||
|
final := <-updates
|
||||||
|
if !final.Complete {
|
||||||
|
t.Fatalf("final update = %#v", final)
|
||||||
|
}
|
||||||
|
assertTargetOrder(t, final.Snapshot, []string{"first", "second", "failed"})
|
||||||
|
if final.Snapshot.Targets[0].State != target.Enabled || final.Snapshot.Targets[1].State != target.External || final.Snapshot.Targets[2].DetectionPhase != app.TargetDetectionFailed {
|
||||||
|
t.Fatalf("final snapshot = %#v", final.Snapshot.Targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertTargetOrder(t *testing.T, snapshot app.Snapshot, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
got := make([]string, len(snapshot.Targets))
|
||||||
|
for i := range snapshot.Targets {
|
||||||
|
got[i] = snapshot.Targets[i].ID
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("Target order = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOnlyDetectionChanged(t *testing.T, before, after app.Snapshot, changedID string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(before.Targets) != len(after.Targets) {
|
||||||
|
t.Fatalf("Target count changed from %d to %d", len(before.Targets), len(after.Targets))
|
||||||
|
}
|
||||||
|
for i := range before.Targets {
|
||||||
|
if before.Targets[i].ID == changedID {
|
||||||
|
if reflect.DeepEqual(before.Targets[i], after.Targets[i]) {
|
||||||
|
t.Fatalf("completed Target %q did not change", changedID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(before.Targets[i], after.Targets[i]) {
|
||||||
|
t.Fatalf("completion for %q changed unrelated Target %q: before=%#v after=%#v", changedID, before.Targets[i].ID, before.Targets[i], after.Targets[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ func TestApplicationTargetSeamRequiresActiveProfileAndReturnsTruthfulResults(t *
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
|
||||||
|
t.Fatal(result.Err)
|
||||||
|
}
|
||||||
initial := application.Snapshot()
|
initial := application.Snapshot()
|
||||||
if len(initial.Targets) != 1 || initial.Targets[0].State != target.External || initial.Targets[0].Description.Summary != "Shell summary" {
|
if len(initial.Targets) != 1 || initial.Targets[0].State != target.External || initial.Targets[0].Description.Summary != "Shell summary" {
|
||||||
t.Fatalf("initial Target snapshot = %#v", initial.Targets)
|
t.Fatalf("initial Target snapshot = %#v", initial.Targets)
|
||||||
@@ -104,6 +107,9 @@ func TestApplicationHidesUnavailableTargetButKeepsExplanation(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
|
||||||
|
t.Fatal(result.Err)
|
||||||
|
}
|
||||||
got := application.Snapshot().Targets[0]
|
got := application.Snapshot().Targets[0]
|
||||||
if got.Available || got.AvailabilityEvidence != "fake detection" {
|
if got.Available || got.AvailabilityEvidence != "fake detection" {
|
||||||
t.Fatalf("unavailable Target = %#v", got)
|
t.Fatalf("unavailable Target = %#v", got)
|
||||||
|
|||||||
+16
-5
@@ -155,22 +155,33 @@ func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []ta
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
||||||
|
availability, _ := a.detectAvailability(ctx)
|
||||||
|
return availability
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) detectAvailability(ctx context.Context) (target.Availability, error) {
|
||||||
marker, err := os.Stat(a.configuration.DebianVersionFile)
|
marker, err := os.Stat(a.configuration.DebianVersionFile)
|
||||||
if err != nil || !marker.Mode().IsRegular() {
|
if err != nil || !marker.Mode().IsRegular() {
|
||||||
return target.Availability{Evidence: "Debian-family marker /etc/debian_version was not detected"}
|
return target.Availability{Evidence: "Debian-family marker /etc/debian_version was not detected"}, nil
|
||||||
}
|
}
|
||||||
directory, err := os.Stat(a.configuration.ConfigDirectory)
|
directory, err := os.Stat(a.configuration.ConfigDirectory)
|
||||||
if err != nil || !directory.IsDir() {
|
if err != nil || !directory.IsDir() {
|
||||||
return target.Availability{Evidence: "APT configuration directory apt.conf.d was not detected"}
|
return target.Availability{Evidence: "APT configuration directory apt.conf.d was not detected"}, nil
|
||||||
}
|
}
|
||||||
command, err := a.runner.LookPath("apt-config")
|
command, err := a.runner.LookPath("apt-config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return target.Availability{Evidence: "Debian APT tool apt-config was not detected"}
|
return target.Availability{Evidence: "Debian APT tool apt-config was not detected"}, nil
|
||||||
}
|
}
|
||||||
if _, err := a.runner.Output(ctx, command, "dump"); err != nil {
|
if _, err := a.runner.Output(ctx, command, "dump"); err != nil {
|
||||||
return target.Availability{Evidence: "apt-config could not inspect effective APT configuration"}
|
return target.Availability{Evidence: "apt-config could not inspect effective APT configuration"}, fmt.Errorf("inspect effective APT configuration: %w", err)
|
||||||
}
|
}
|
||||||
return target.Availability{Available: true, Evidence: "Debian marker, apt-config, and apt.conf.d configuration capability detected"}
|
return target.Availability{Available: true, Evidence: "Debian marker, apt-config, and apt.conf.d configuration capability detected"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection {
|
||||||
|
availability, availabilityErr := a.detectAvailability(ctx)
|
||||||
|
inspection, inspectionErr := a.inspect(ctx, active)
|
||||||
|
return target.Detection{Availability: availability, Inspection: inspection, Err: errors.Join(availabilityErr, inspectionErr)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adapter) OverlayPath() string { return a.configuration.OverlayFile }
|
func (a *Adapter) OverlayPath() string { return a.configuration.OverlayFile }
|
||||||
|
|||||||
@@ -234,6 +234,10 @@ func (a *Adapter) ConfigPath() string { return a.configuration.ConfigFile }
|
|||||||
func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile }
|
func (a *Adapter) RecoveryPath() string { return a.configuration.RecoveryFile }
|
||||||
func (a *Adapter) LockPath() string { return a.configuration.RecoveryFile + ".lock" }
|
func (a *Adapter) LockPath() string { return a.configuration.RecoveryFile + ".lock" }
|
||||||
|
|
||||||
|
func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection {
|
||||||
|
return target.Detection{Availability: a.Availability(ctx), Inspection: a.Inspect(ctx, active)}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection {
|
func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection {
|
||||||
metadata, managed, metadataErr := a.readRecovery()
|
metadata, managed, metadataErr := a.readRecovery()
|
||||||
config, _, configErr := a.readConfig()
|
config, _, configErr := a.readConfig()
|
||||||
|
|||||||
@@ -183,6 +183,11 @@ func (a *Adapter) detect(ctx context.Context) (target.Availability, error) {
|
|||||||
return target.Availability{Available: true, Evidence: "Docker daemon service is loaded and managed by systemd at " + values["FragmentPath"]}, nil
|
return target.Availability{Available: true, Evidence: "Docker daemon service is loaded and managed by systemd at " + values["FragmentPath"]}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection {
|
||||||
|
availability, err := a.detect(ctx)
|
||||||
|
return target.Detection{Availability: availability, Inspection: a.Inspect(ctx, active), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
func parseProperties(output string) map[string]string {
|
func parseProperties(output string) map[string]string {
|
||||||
result := map[string]string{}
|
result := map[string]string{}
|
||||||
for _, line := range strings.Split(output, "\n") {
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
|||||||
+16
-4
@@ -348,9 +348,15 @@ func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []ta
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
||||||
|
availability, _ := a.detectAvailability(ctx)
|
||||||
|
return availability
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) detectAvailability(ctx context.Context) (target.Availability, error) {
|
||||||
output, err := a.run(ctx, "list-schemas")
|
output, err := a.run(ctx, "list-schemas")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return target.Availability{Evidence: fmt.Sprintf("GNOME Target requires a usable %s command: %v", a.configuration.CommandName, redact(err))}
|
safeErr := redact(err)
|
||||||
|
return target.Availability{Evidence: fmt.Sprintf("GNOME Target requires a usable %s command: %v", a.configuration.CommandName, safeErr)}, safeErr
|
||||||
}
|
}
|
||||||
present := make(map[string]bool)
|
present := make(map[string]bool)
|
||||||
for _, schema := range strings.Fields(output) {
|
for _, schema := range strings.Fields(output) {
|
||||||
@@ -358,13 +364,19 @@ func (a *Adapter) Availability(ctx context.Context) target.Availability {
|
|||||||
}
|
}
|
||||||
for _, schema := range requiredSchemas {
|
for _, schema := range requiredSchemas {
|
||||||
if !present[schema] {
|
if !present[schema] {
|
||||||
return target.Availability{Evidence: "required GNOME settings schema is unavailable: " + schema}
|
return target.Availability{Evidence: "required GNOME settings schema is unavailable: " + schema}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := a.readSettings(ctx); err != nil {
|
if _, err := a.readSettings(ctx); err != nil {
|
||||||
return target.Availability{Evidence: fmt.Sprintf("GNOME proxy schema is present but unusable for the desktop session: %v", redact(err))}
|
safeErr := redact(err)
|
||||||
|
return target.Availability{Evidence: fmt.Sprintf("GNOME proxy schema is present but unusable for the desktop session: %v", safeErr)}, safeErr
|
||||||
}
|
}
|
||||||
return target.Availability{Available: true, Evidence: "gsettings command and GNOME proxy schemas are usable for the owning user"}
|
return target.Availability{Available: true, Evidence: "gsettings command and GNOME proxy schemas are usable for the owning user"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection {
|
||||||
|
availability, err := a.detectAvailability(ctx)
|
||||||
|
return target.Detection{Availability: availability, Inspection: a.Inspect(ctx, active), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adapter) Inspect(ctx context.Context, active *profile.Profile) target.Inspection {
|
func (a *Adapter) Inspect(ctx context.Context, active *profile.Profile) target.Inspection {
|
||||||
|
|||||||
@@ -206,6 +206,10 @@ func (a *Adapter) DiagnosticChecks(_ context.Context, _ *profile.Profile) []targ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) Detect(ctx context.Context, active *profile.Profile) target.Detection {
|
||||||
|
return target.Detection{Availability: a.Availability(ctx), Inspection: a.Inspect(ctx, active)}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection {
|
func (a *Adapter) Inspect(_ context.Context, active *profile.Profile) target.Inspection {
|
||||||
metadata, managed, metadataErr := a.readRecovery()
|
metadata, managed, metadataErr := a.readRecovery()
|
||||||
generated, generatedErr := a.files.ReadFile(a.configuration.EnvironmentFile)
|
generated, generatedErr := a.files.ReadFile(a.configuration.EnvironmentFile)
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ type Inspection struct {
|
|||||||
Evidence string
|
Evidence string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detection is one presentation-independent availability and configuration
|
||||||
|
// inspection outcome. Err distinguishes a failed check from a truthful
|
||||||
|
// unavailable or disabled result.
|
||||||
|
type Detection struct {
|
||||||
|
Availability Availability
|
||||||
|
Inspection Inspection
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detector is the optional structured detection seam. Targets which can
|
||||||
|
// preserve lower-level check failures implement it; legacy Targets continue to
|
||||||
|
// participate through Availability and Inspect.
|
||||||
|
type Detector interface {
|
||||||
|
Detect(context.Context, *profile.Profile) Detection
|
||||||
|
}
|
||||||
|
|
||||||
// OperationResult reports the adapter's immediate operation outcome. When Err
|
// OperationResult reports the adapter's immediate operation outcome. When Err
|
||||||
// is non-nil, Inspection is advisory because it may have been captured before a
|
// is non-nil, Inspection is advisory because it may have been captured before a
|
||||||
// partial write or command failure. The Application explicitly calls Inspect
|
// partial write or command failure. The Application explicitly calls Inspect
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type targetCatalog struct {
|
|||||||
func newTargetCatalog(snapshot app.Snapshot, includeUnavailable bool) targetCatalog {
|
func newTargetCatalog(snapshot app.Snapshot, includeUnavailable bool) targetCatalog {
|
||||||
items := make([]app.TargetSnapshot, 0, len(snapshot.Targets))
|
items := make([]app.TargetSnapshot, 0, len(snapshot.Targets))
|
||||||
for _, item := range snapshot.Targets {
|
for _, item := range snapshot.Targets {
|
||||||
if item.Available || includeUnavailable {
|
if item.Available || includeUnavailable || item.DetectionPhase != app.TargetReady {
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,11 +84,21 @@ type targetStateCounts struct {
|
|||||||
disabled int
|
disabled int
|
||||||
external int
|
external int
|
||||||
drifted int
|
drifted int
|
||||||
|
checking int
|
||||||
|
failed int
|
||||||
}
|
}
|
||||||
|
|
||||||
func countAvailableTargetStates(snapshot app.Snapshot) targetStateCounts {
|
func countAvailableTargetStates(snapshot app.Snapshot) targetStateCounts {
|
||||||
var counts targetStateCounts
|
var counts targetStateCounts
|
||||||
for _, item := range snapshot.Targets {
|
for _, item := range snapshot.Targets {
|
||||||
|
switch item.DetectionPhase {
|
||||||
|
case app.TargetChecking:
|
||||||
|
counts.checking++
|
||||||
|
continue
|
||||||
|
case app.TargetDetectionFailed:
|
||||||
|
counts.failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !item.Available {
|
if !item.Available {
|
||||||
continue
|
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 {
|
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 {
|
if !item.Available {
|
||||||
return p.theme.unavailable.Render("○ Unavailable")
|
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 {
|
func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
|
||||||
state := p.targetBadge(item, false)
|
state := p.targetBadge(item, false)
|
||||||
evidence := item.Evidence
|
evidence := item.Evidence
|
||||||
if !item.Available {
|
switch item.DetectionPhase {
|
||||||
evidence = item.AvailabilityEvidence
|
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 == "" {
|
if evidence == "" {
|
||||||
evidence = "No detection evidence reported."
|
evidence = "No detection evidence reported."
|
||||||
@@ -125,6 +140,9 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
|
|||||||
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, "\nState: %s\nEvidence: %s", state, evidence)
|
||||||
|
if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil {
|
||||||
|
fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr)
|
||||||
|
}
|
||||||
facts := []struct {
|
facts := []struct {
|
||||||
label string
|
label string
|
||||||
value string
|
value string
|
||||||
@@ -150,9 +168,16 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
|
|||||||
|
|
||||||
func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string {
|
func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string {
|
||||||
counts := countAvailableTargetStates(snapshot)
|
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.Enabled, false), counts.enabled,
|
||||||
p.theme.renderTargetState(target.Disabled, false), counts.disabled,
|
p.theme.renderTargetState(target.Disabled, false), counts.disabled,
|
||||||
p.theme.renderTargetState(target.External, false), counts.external,
|
p.theme.renderTargetState(target.External, false), counts.external,
|
||||||
p.theme.renderTargetState(target.Drifted, false), counts.drifted)
|
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
|
status string
|
||||||
applying bool
|
applying bool
|
||||||
refreshing bool
|
refreshing bool
|
||||||
|
detectionUpdates <-chan app.TargetDetectionUpdate
|
||||||
profileTransition bool
|
profileTransition bool
|
||||||
progressTarget string
|
progressTarget string
|
||||||
quitRequested bool
|
quitRequested bool
|
||||||
@@ -64,6 +65,11 @@ type refreshDone struct {
|
|||||||
result app.Result
|
result app.Result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type targetDetectionProgress struct {
|
||||||
|
update app.TargetDetectionUpdate
|
||||||
|
updates <-chan app.TargetDetectionUpdate
|
||||||
|
}
|
||||||
|
|
||||||
type profileOperationProgress struct {
|
type profileOperationProgress struct {
|
||||||
result app.Result
|
result app.Result
|
||||||
updates <-chan app.Result
|
updates <-chan app.Result
|
||||||
@@ -80,6 +86,16 @@ type probeDone struct {
|
|||||||
gen uint64
|
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 {
|
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
result, ok := <-updates
|
result, ok := <-updates
|
||||||
@@ -96,6 +112,10 @@ func NewModel(application app.Module, configure ...ModelOption) Model {
|
|||||||
option(&options)
|
option(&options)
|
||||||
}
|
}
|
||||||
snapshot := application.Snapshot()
|
snapshot := application.Snapshot()
|
||||||
|
var detectionUpdates <-chan app.TargetDetectionUpdate
|
||||||
|
if progressive, ok := application.(app.ProgressiveDetectionModule); ok {
|
||||||
|
snapshot, detectionUpdates = progressive.BeginTargetDetection()
|
||||||
|
}
|
||||||
return Model{
|
return Model{
|
||||||
application: application,
|
application: application,
|
||||||
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
|
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(),
|
selectedTargetID: newTargetCatalog(snapshot, false).firstID(),
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
refreshing: detectionUpdates != nil,
|
||||||
|
detectionUpdates: detectionUpdates,
|
||||||
detect: probe.Detect,
|
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
|
// 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
|
// 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
|
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) {
|
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if size, ok := message.(tea.WindowSizeMsg); ok {
|
if size, ok := message.(tea.WindowSizeMsg); ok {
|
||||||
m.width = size.Width
|
m.width = size.Width
|
||||||
@@ -138,6 +169,21 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.quitRequested = true
|
m.quitRequested = true
|
||||||
return m, nil
|
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 completed, ok := message.(refreshDone); ok {
|
||||||
if !m.refreshing {
|
if !m.refreshing {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -327,12 +373,17 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if !m.busy() {
|
if !m.busy() {
|
||||||
m.refreshing = true
|
m.refreshing = true
|
||||||
m.status = "Refreshing Target availability and state…"
|
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 m, func() tea.Msg {
|
||||||
return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})}
|
return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "tab":
|
case "tab":
|
||||||
if !m.busy() {
|
if m.dashboardNavigationAvailable() {
|
||||||
if m.dashboardFocus == targetListPane {
|
if m.dashboardFocus == targetListPane {
|
||||||
m.dashboardFocus = targetDetailsPane
|
m.dashboardFocus = targetDetailsPane
|
||||||
} else {
|
} else {
|
||||||
@@ -340,14 +391,14 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "u":
|
case "u":
|
||||||
if !m.busy() {
|
if m.dashboardNavigationAvailable() {
|
||||||
formerIndex := catalog.index(m.selectedTargetID)
|
formerIndex := catalog.index(m.selectedTargetID)
|
||||||
m.showUnavailable = !m.showUnavailable
|
m.showUnavailable = !m.showUnavailable
|
||||||
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex)
|
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex)
|
||||||
m.detailsOffset = 0
|
m.detailsOffset = 0
|
||||||
}
|
}
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if !m.busy() {
|
if m.dashboardNavigationAvailable() {
|
||||||
if m.dashboardFocus == targetDetailsPane {
|
if m.dashboardFocus == targetDetailsPane {
|
||||||
m.detailsOffset = max(0, m.detailsOffset-1)
|
m.detailsOffset = max(0, m.detailsOffset-1)
|
||||||
} else {
|
} else {
|
||||||
@@ -356,7 +407,7 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
if !m.busy() {
|
if m.dashboardNavigationAvailable() {
|
||||||
if m.dashboardFocus == targetDetailsPane {
|
if m.dashboardFocus == targetDetailsPane {
|
||||||
m.detailsOffset++
|
m.detailsOffset++
|
||||||
} else {
|
} else {
|
||||||
@@ -365,17 +416,17 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "pgup":
|
case "pgup":
|
||||||
if !m.busy() && m.dashboardFocus == targetDetailsPane {
|
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
|
||||||
m.detailsOffset = max(0, m.detailsOffset-5)
|
m.detailsOffset = max(0, m.detailsOffset-5)
|
||||||
}
|
}
|
||||||
case "pgdown":
|
case "pgdown":
|
||||||
if !m.busy() && m.dashboardFocus == targetDetailsPane {
|
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
|
||||||
m.detailsOffset += 5
|
m.detailsOffset += 5
|
||||||
}
|
}
|
||||||
case " ":
|
case " ":
|
||||||
if !m.busy() {
|
if !m.busy() {
|
||||||
selected, ok := catalog.selected(m.selectedTargetID)
|
selected, ok := catalog.selected(m.selectedTargetID)
|
||||||
if !ok || !selected.Available {
|
if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
desired := selected.State == target.Disabled || selected.State == target.External
|
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))
|
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 {
|
func (f formState) labels() []string {
|
||||||
if f.profile.Mode == profile.AdvancedMode {
|
if f.profile.Mode == profile.AdvancedMode {
|
||||||
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}
|
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"proxyctl/internal/app"
|
||||||
"proxyctl/internal/profile"
|
"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"
|
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
|
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
|
actions = "Space: toggle " + actions
|
||||||
}
|
}
|
||||||
return actions + "\np: manage Proxy Profiles r: refresh ?: help q/Esc: quit"
|
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
|
external lipgloss.Style
|
||||||
drifted lipgloss.Style
|
drifted lipgloss.Style
|
||||||
unavailable lipgloss.Style
|
unavailable lipgloss.Style
|
||||||
|
checking lipgloss.Style
|
||||||
|
checkFailed lipgloss.Style
|
||||||
applying lipgloss.Style
|
applying lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,8 @@ func newSemanticTheme(mode themeMode, output io.Writer) semanticTheme {
|
|||||||
external: style().Foreground(lipgloss.Color("172")),
|
external: style().Foreground(lipgloss.Color("172")),
|
||||||
drifted: style().Bold(true).Foreground(lipgloss.Color("160")),
|
drifted: style().Bold(true).Foreground(lipgloss.Color("160")),
|
||||||
unavailable: style().Foreground(lipgloss.Color(subtle)),
|
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)),
|
applying: style().Foreground(lipgloss.Color(accent)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user