166 lines
5.4 KiB
Go
166 lines
5.4 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"proxyctl/internal/app"
|
|
)
|
|
|
|
type dashboardRenderState struct {
|
|
catalog targetCatalog
|
|
selectedTargetID string
|
|
focus dashboardPane
|
|
detailsOffset int
|
|
operationProgress map[string]app.TargetProgressPhase
|
|
applyingFrame string
|
|
terminalWidth int
|
|
terminalHeight int
|
|
}
|
|
|
|
func (p terminalPresentation) renderDashboard(state dashboardRenderState, width, height int) string {
|
|
if state.terminalWidth >= wideWidth && state.terminalHeight >= wideHeight {
|
|
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 _, item := range state.catalog.items {
|
|
cursor := " "
|
|
if item.ID == state.selectedTargetID {
|
|
cursor = "> "
|
|
}
|
|
badge := p.targetBadge(item, state.operationProgress[item.ID], state.applyingFrame)
|
|
lines = append(lines, cursor+badge+" "+item.Description.Name)
|
|
}
|
|
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(protectRenderedText(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, state.operationProgress[selected.ID], state.applyingFrame)
|
|
}
|
|
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, progress app.TargetProgressPhase, applyingFrame string) string {
|
|
switch item.DetectionPhase {
|
|
case app.TargetChecking:
|
|
return p.theme.checking.Render("… Checking")
|
|
case app.TargetDetectionFailed:
|
|
return p.theme.checkFailed.Render("! Failed")
|
|
}
|
|
if !item.Available {
|
|
return p.theme.unavailable.Render("○ Unavailable")
|
|
}
|
|
actual := p.theme.renderTargetState(item.State, false)
|
|
switch progress {
|
|
case app.TargetProgressRunning:
|
|
if applyingFrame == "" {
|
|
applyingFrame = "|"
|
|
}
|
|
return p.theme.applying.Render(applyingFrame + " Applying")
|
|
case app.TargetProgressFailed:
|
|
return actual + " " + p.theme.checkFailed.Render("! Failed")
|
|
default:
|
|
return actual
|
|
}
|
|
}
|
|
|
|
func (p terminalPresentation) targetDetails(item app.TargetSnapshot, progress app.TargetProgressPhase, applyingFrame string) string {
|
|
state := p.targetBadge(item, progress, applyingFrame)
|
|
evidence := item.Evidence
|
|
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."
|
|
}
|
|
|
|
var details strings.Builder
|
|
details.WriteString(p.theme.section.Render(item.Description.Name))
|
|
fmt.Fprintf(&details, "\n%s\n%s", state, evidence)
|
|
action := targetActionLabel(item.PrimaryAction.Kind)
|
|
if item.PrimaryAction.Available {
|
|
fmt.Fprintf(&details, "\n\nSpace: %s", action)
|
|
} else if item.PrimaryAction.DisabledReason != "" {
|
|
fmt.Fprintf(&details, "\n\nUnavailable: %s", item.PrimaryAction.DisabledReason)
|
|
}
|
|
if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil {
|
|
fmt.Fprintf(&details, "\nCheck failed: %s", item.DetectionErr)
|
|
}
|
|
if item.Description.Summary != "" {
|
|
fmt.Fprintf(&details, "\n\n%s", item.Description.Summary)
|
|
}
|
|
return protectRenderedText(details.String())
|
|
}
|