feat: add dedicated Profile management page

This commit is contained in:
proxyctl coordinator
2026-07-17 22:37:25 +08:00
parent 7c0b4481d7
commit d34ce0cd6b
11 changed files with 583 additions and 160 deletions
+165 -77
View File
@@ -3,6 +3,7 @@ package tui
import (
"context"
"errors"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -31,22 +32,25 @@ type formState struct {
}
type Model struct {
application app.InteractiveModule
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
confirm bool
status string
operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
application app.InteractiveModule
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
selectedProfileName string
profileViewportOffset int
profileDetailsOffset int
profileDelete *profileDeleteConfirmation
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
status string
operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
}
// probeDone carries one finished Protocol Probe back to the form. address is
@@ -60,6 +64,11 @@ type probeDone struct {
gen uint64
}
type profileDeleteConfirmation struct {
name string
dialog confirmationDialog
}
func NewModel(application app.InteractiveModule, configure ...ModelOption) Model {
options := defaultModelOptions()
for _, option := range configure {
@@ -76,6 +85,7 @@ func NewModel(application app.InteractiveModule, configure ...ModelOption) Model
operation: newOperationCoordinator(),
detect: probe.Detect,
}
model.retainProfileSelection("", 0)
if targetsChecking(snapshot) {
model.startOperation(app.Intent{Kind: app.RefreshTargets})
}
@@ -115,6 +125,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := message.(tea.WindowSizeMsg); ok {
m.width = size.Width
m.height = size.Height
m.ensureProfileSelectionVisible()
return m, nil
}
@@ -140,11 +151,13 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if progress.closed {
m.snapshot = m.application.Snapshot()
m.retainTargetSelection()
m.retainProfileSelection(m.selectedProfileName, m.selected)
m.operation.unexpectedClose()
return m, m.operation.deferredQuit()
}
m.snapshot = progress.update.Snapshot
m.retainTargetSelection()
m.retainProfileSelection(m.selectedProfileName, m.selected)
if firstFailed := m.operation.apply(progress.update); firstFailed != "" {
m.selectedTargetID = firstFailed
m.detailsOffset = 0
@@ -260,15 +273,15 @@ func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, ac
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.operation.confirmation != nil {
switch key.String() {
case "y":
switch m.operation.confirmationDialog.update(key.String()) {
case confirmationAccepted:
confirmed, ok := m.operation.confirmedIntent()
if !ok {
return m, nil
}
m.status = ""
return m, m.startOperation(confirmed)
case "enter", "n", "esc":
case confirmationCancelled:
m.operation.cancelConfirmation()
}
return m, nil
@@ -296,10 +309,10 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "p":
if m.operation.active || m.operation.quitRequested {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before opening Profile management"}
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Profile management disabled: wait for the current mutation to finish"}
} else {
m.page = profilesPage
m.selected = 0
m.retainProfileSelection(m.selectedProfileName, m.selected)
m.status = ""
if m.operation.status.Kind != app.StatusError {
m.operation.status = app.OperationStatus{}
@@ -370,23 +383,14 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.confirm {
switch key.String() {
case "y":
if selected, ok := m.selectedProfile(); ok {
m.apply(app.Intent{Kind: app.DeleteProfile, Name: selected.Name, Confirm: true})
}
m.confirm = false
case "n", "esc", "q":
m.confirm = false
}
return m, nil
}
if m.busy() {
if key.String() == "q" || key.String() == "esc" {
m.page = mainPage
m.selected = 0
m.status = ""
if m.profileDelete != nil {
switch m.profileDelete.dialog.update(key.String()) {
case confirmationAccepted:
name := m.profileDelete.name
m.profileDelete = nil
m.applyProfileMutation(app.Intent{Kind: app.DeleteProfile, Name: name, Confirm: true}, "")
case confirmationCancelled:
m.profileDelete = nil
}
return m, nil
}
@@ -394,38 +398,82 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "q", "esc":
m.page = mainPage
m.selected = 0
m.status = ""
case "up", "k":
if m.selected > 0 {
m.selected--
m.selectedProfileName = m.snapshot.Profiles[m.selected].Name
m.profileDetailsOffset = 0
m.ensureProfileSelectionVisible()
}
case "down", "j":
if m.selected+1 < len(m.snapshot.Profiles) {
m.selected++
m.selectedProfileName = m.snapshot.Profiles[m.selected].Name
m.profileDetailsOffset = 0
m.ensureProfileSelectionVisible()
}
case "pgup":
m.profileDetailsOffset = max(0, m.profileDetailsOffset-5)
case "pgdown":
m.profileDetailsOffset += 5
case "n":
if !m.profileMutationAvailable("creating a Proxy Profile") {
break
}
m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}}
m.page = formPage
m.status = ""
case "e":
case "e", "enter":
if !m.profileMutationAvailable("editing a Proxy Profile") {
break
}
if selected, ok := m.selectedProfile(); ok {
m.form = formState{originalName: selected.Name, profile: selected}
m.page = formPage
m.status = ""
}
case "a", "enter":
if selected, ok := m.selectedProfile(); ok {
return m.beginProfileActivation(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
}
case "d":
if _, ok := m.selectedProfile(); ok {
m.confirm = true
if !m.profileMutationAvailable("deleting a Proxy Profile") {
break
}
if selected, ok := m.selectedProfile(); ok {
m.profileDelete = newProfileDeleteConfirmation(selected, len(m.snapshot.Profiles) == 1, selected.Name == m.snapshot.ActiveProfile)
}
}
return m, nil
}
func (m *Model) profileMutationAvailable(action string) bool {
if !m.busy() {
return true
}
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Cannot continue " + action + "; wait for the current mutation to finish"}
return false
}
func newProfileDeleteConfirmation(selected profile.Profile, final, active bool) *profileDeleteConfirmation {
details := []string{"The Proxy Profile will be removed permanently."}
if active {
details = append(details, "This is the Active Profile; deleting it leaves no Active Profile selected.")
}
if final {
details = append(details,
"This is the final Proxy Profile.",
"Existing managed configuration remains in place.",
"Affected Targets become Drifted.")
}
request := app.Confirmation{
Title: fmt.Sprintf("Delete Proxy Profile “%s”?", selected.Name),
Summary: "Deletion cannot be undone.",
Details: details,
ConfirmLabel: "Delete",
CancelLabel: "Cancel",
HighImpact: true,
}
return &profileDeleteConfirmation{name: selected.Name, dialog: newConfirmationDialog(request)}
}
func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "esc":
@@ -439,17 +487,13 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
kind = app.UpdateProfile
}
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile}
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
result := m.applyProfileMutation(intent, intent.Profile.Name)
if result.Err != nil {
m.status = "Error: " + result.Err.Error()
m.form.probeGen++ // discard any in-flight probe result while staying on the form
m.form.probing = false
return m, nil
}
m.status = result.Notice
m.page = profilesPage
m.selectName(intent.Profile.Name)
m.form = formState{}
return m, nil
case "ctrl+d":
@@ -510,21 +554,23 @@ func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
return m, nil
}
m.page = mainPage
m.selected = 0
return m, m.startOperation(intent)
}
func (m *Model) apply(intent app.Intent) {
func (m *Model) applyProfileMutation(intent app.Intent, preferredName string) app.Result {
formerName, formerIndex := m.selectedProfileName, m.selected
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 {
m.selected--
if preferredName == "" {
preferredName = formerName
}
m.retainProfileSelection(preferredName, formerIndex)
if result.Err != nil {
m.status = "Error: " + result.Err.Error()
} else {
m.status = result.Notice
}
return result
}
// probeAddress extracts a bare host:port from the typed endpoint for dialing
@@ -539,40 +585,82 @@ func probeAddress(raw string) (string, error) {
}
func (m Model) selectedProfile() (profile.Profile, bool) {
if m.selectedProfileName != "" {
for i := range m.snapshot.Profiles {
if m.snapshot.Profiles[i].Name == m.selectedProfileName {
return m.snapshot.Profiles[i], true
}
}
}
return profileAt(m.snapshot, m.selected)
}
func (m *Model) selectName(name string) {
func (m *Model) retainProfileSelection(name string, fallbackIndex int) {
if len(m.snapshot.Profiles) == 0 {
m.selected = 0
m.selectedProfileName = ""
m.profileViewportOffset = 0
m.profileDetailsOffset = 0
return
}
for i := range m.snapshot.Profiles {
if m.snapshot.Profiles[i].Name == name {
if name != "" && m.snapshot.Profiles[i].Name == name {
m.selected = i
m.selectedProfileName = name
m.ensureProfileSelectionVisible()
return
}
}
m.selected = min(max(0, fallbackIndex), len(m.snapshot.Profiles)-1)
m.selectedProfileName = m.snapshot.Profiles[m.selected].Name
m.profileDetailsOffset = 0
m.ensureProfileSelectionVisible()
}
func (m *Model) ensureProfileSelectionVisible() {
if len(m.snapshot.Profiles) == 0 {
m.profileViewportOffset = 0
return
}
// The details pane and footer consume variable height. A conservative
// capacity keeps the selected identity visible without coupling Model state
// to Lip Gloss measurements, while retaining the viewport top across resize.
capacity := max(1, m.height/2)
if m.selected < m.profileViewportOffset {
m.profileViewportOffset = m.selected
}
if m.selected >= m.profileViewportOffset+capacity {
m.profileViewportOffset = m.selected - capacity + 1
}
maximum := max(0, len(m.snapshot.Profiles)-capacity)
m.profileViewportOffset = min(maximum, max(0, m.profileViewportOffset))
}
func (m Model) View() string {
return m.presentation.Render(presentationState{
snapshot: m.snapshot,
page: m.page,
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,
confirmation: m.operation.confirmation,
status: m.status,
operationStatus: m.operation.status,
currentError: m.operation.currentError,
errorExpanded: m.operation.errorExpanded,
errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
operationActive: m.operation.active,
operationProgress: m.operation.progress,
snapshot: m.snapshot,
page: m.page,
width: m.width,
height: m.height,
selected: m.selected,
profileOffset: m.profileViewportOffset,
profileDetails: m.profileDetailsOffset,
profileDelete: m.profileDelete,
selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
form: m.form,
confirmation: m.operation.confirmation,
confirmationDialog: &m.operation.confirmationDialog,
status: m.status,
operationStatus: m.operation.status,
currentError: m.operation.currentError,
errorExpanded: m.operation.errorExpanded,
errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
operationActive: m.operation.active,
operationProgress: m.operation.progress,
})
}