159 lines
5.1 KiB
Go
159 lines
5.1 KiB
Go
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 _, 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)
|
|
}
|
|
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)
|
|
}
|