mirror of
https://github.com/siderolabs/talos.git
synced 2026-05-11 07:16:17 +02:00
Fixes #12933 There are many usecases for this: * exploring resources and state of the system, learning available resources * when a Talos machine is booted up in an environment without network access, learning all available network interfaces, all disks available, etc. Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com> (cherry picked from commit 5e24d5265bde9adee92c02e675140de87ee126bf)
872 lines
23 KiB
Go
872 lines
23 KiB
Go
// This Source Code Form is subject to the terms of the Mozilla Public
|
||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||
|
||
package dashboard
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/cosi-project/runtime/pkg/resource"
|
||
"github.com/cosi-project/runtime/pkg/resource/meta"
|
||
"github.com/cosi-project/runtime/pkg/safe"
|
||
"github.com/cosi-project/runtime/pkg/state"
|
||
"github.com/gdamore/tcell/v2"
|
||
"github.com/rivo/tview"
|
||
yaml "go.yaml.in/yaml/v4"
|
||
"google.golang.org/grpc/codes"
|
||
"k8s.io/client-go/util/jsonpath"
|
||
|
||
"github.com/siderolabs/talos/pkg/machinery/client"
|
||
)
|
||
|
||
const (
|
||
pageResourceTypes = "resource-types"
|
||
pageResourceList = "resource-list"
|
||
pageResourceYAML = "resource-yaml"
|
||
)
|
||
|
||
// ResourceExplorerGrid implements the resource browser screen.
|
||
type ResourceExplorerGrid struct {
|
||
tview.Grid
|
||
|
||
app *tview.Application
|
||
dashboard *Dashboard
|
||
ctx context.Context //nolint:containedctx // used to stop/start watches
|
||
|
||
pages *tview.Pages
|
||
level int // 0=types, 1=instances, 2=yaml
|
||
|
||
// Level 0: resource type list
|
||
typesWrapper *tview.Grid // contains typesTable + optional filterInput
|
||
typesTable *tview.Table
|
||
filterInput *tview.InputField
|
||
filterActive bool
|
||
filterText string
|
||
resourceDefs []*meta.ResourceDefinition
|
||
|
||
// Level 1: resource instance list
|
||
resourceTable *tview.Table
|
||
selectedRD *meta.ResourceDefinition
|
||
resources map[string]resource.Resource
|
||
sortedIDs []string // sorted IDs matching table rows (row-1 indexed)
|
||
watchCancel context.CancelFunc
|
||
dynamicColumnNames []string
|
||
dynamicColumns []func(any) (string, error)
|
||
|
||
// Level 2: YAML detail
|
||
yamlView *tview.TextView
|
||
|
||
selectedNode string
|
||
active bool
|
||
}
|
||
|
||
// NewResourceExplorerGrid initializes ResourceExplorerGrid.
|
||
func NewResourceExplorerGrid(ctx context.Context, dashboard *Dashboard) *ResourceExplorerGrid {
|
||
widget := &ResourceExplorerGrid{
|
||
Grid: *tview.NewGrid(),
|
||
app: dashboard.app,
|
||
dashboard: dashboard,
|
||
ctx: ctx,
|
||
resources: make(map[string]resource.Resource),
|
||
}
|
||
|
||
widget.SetRows(0).SetColumns(0)
|
||
|
||
// Level 0: resource types table
|
||
widget.typesTable = tview.NewTable()
|
||
widget.typesTable.SetBorder(true).
|
||
SetTitle(" Resource Types (Enter: select, /: filter) ")
|
||
widget.typesTable.SetFixed(1, 0)
|
||
widget.typesTable.SetSelectable(true, false)
|
||
widget.typesTable.SetSelectedStyle(tcell.StyleDefault.Attributes(tcell.AttrReverse))
|
||
widget.typesTable.SetSelectedFunc(func(row, _ int) {
|
||
widget.selectResourceType(row)
|
||
})
|
||
widget.typesTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
if event.Rune() == '/' {
|
||
widget.activateFilter()
|
||
|
||
return nil
|
||
}
|
||
|
||
return event
|
||
})
|
||
|
||
// Filter input shown below the types table when '/' is pressed
|
||
widget.filterInput = tview.NewInputField()
|
||
widget.filterInput.SetLabel("filter: ")
|
||
widget.filterInput.SetLabelColor(tcell.ColorYellow)
|
||
widget.filterInput.SetFieldBackgroundColor(tcell.ColorDefault)
|
||
widget.filterInput.SetChangedFunc(func(text string) {
|
||
widget.filterText = text
|
||
widget.renderTypesTable()
|
||
})
|
||
widget.filterInput.SetDoneFunc(func(key tcell.Key) {
|
||
// Esc clears the filter; Enter keeps the filtered view
|
||
widget.deactivateFilter(key == tcell.KeyEscape)
|
||
})
|
||
|
||
// Wrapper grid: table fills row 0; filter input occupies row 1 when active
|
||
widget.typesWrapper = tview.NewGrid()
|
||
widget.typesWrapper.SetRows(0).SetColumns(0)
|
||
widget.typesWrapper.AddItem(widget.typesTable, 0, 0, 1, 1, 0, 0, true)
|
||
|
||
// Level 1: resource instances table
|
||
widget.resourceTable = tview.NewTable()
|
||
widget.resourceTable.SetBorder(true)
|
||
widget.resourceTable.SetFixed(1, 0)
|
||
widget.resourceTable.SetSelectable(true, false)
|
||
widget.resourceTable.SetSelectedStyle(tcell.StyleDefault.Attributes(tcell.AttrReverse))
|
||
widget.resourceTable.SetSelectedFunc(func(row, _ int) {
|
||
widget.selectResource(row)
|
||
})
|
||
widget.resourceTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
if event.Key() == tcell.KeyEscape {
|
||
widget.goBack()
|
||
|
||
return nil
|
||
}
|
||
|
||
return event
|
||
})
|
||
|
||
// Level 2: YAML detail view
|
||
widget.yamlView = tview.NewTextView()
|
||
widget.yamlView.SetBorder(true)
|
||
widget.yamlView.SetScrollable(true)
|
||
widget.yamlView.SetDynamicColors(true)
|
||
widget.yamlView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
if event.Key() == tcell.KeyEscape {
|
||
widget.goBack()
|
||
|
||
return nil
|
||
}
|
||
|
||
return event
|
||
})
|
||
|
||
// Pages to manage which level is visible
|
||
widget.pages = tview.NewPages()
|
||
widget.pages.AddPage(pageResourceTypes, widget.typesWrapper, true, true)
|
||
widget.pages.AddPage(pageResourceList, widget.resourceTable, true, false)
|
||
widget.pages.AddPage(pageResourceYAML, widget.yamlView, true, false)
|
||
|
||
widget.AddItem(widget.pages, 0, 0, 1, 1, 0, 0, true)
|
||
|
||
widget.initTypesTableHeader()
|
||
|
||
return widget
|
||
}
|
||
|
||
// onScreenSelect implements the screenSelectListener interface.
|
||
func (widget *ResourceExplorerGrid) onScreenSelect(active bool) {
|
||
widget.active = active
|
||
|
||
if active {
|
||
switch widget.level {
|
||
case 0:
|
||
if widget.resourceDefs == nil {
|
||
widget.loadResourceTypes()
|
||
}
|
||
|
||
widget.app.SetFocus(widget.typesTable)
|
||
case 1:
|
||
// Restart watch if it stopped when screen was hidden
|
||
if widget.watchCancel == nil && widget.selectedRD != nil {
|
||
widget.startResourceWatch(widget.selectedRD)
|
||
}
|
||
|
||
widget.app.SetFocus(widget.resourceTable)
|
||
case 2:
|
||
widget.app.SetFocus(widget.yamlView)
|
||
}
|
||
} else {
|
||
// Stop watch while screen is hidden; it will be restarted on next activation
|
||
widget.stopResourceWatch()
|
||
widget.deactivateFilter(true)
|
||
}
|
||
}
|
||
|
||
// OnNodeSelect implements the NodeSelectListener interface.
|
||
func (widget *ResourceExplorerGrid) OnNodeSelect(node string) {
|
||
if node == widget.selectedNode {
|
||
return
|
||
}
|
||
|
||
widget.selectedNode = node
|
||
widget.stopResourceWatch()
|
||
widget.deactivateFilter(true)
|
||
|
||
switch widget.level {
|
||
case 0:
|
||
// Reload resource types for the new node
|
||
widget.resourceDefs = nil
|
||
widget.initTypesTableHeader()
|
||
|
||
if widget.active {
|
||
widget.loadResourceTypes()
|
||
}
|
||
case 1:
|
||
// Stay on the resource list but reload for new node
|
||
widget.resources = make(map[string]resource.Resource)
|
||
widget.sortedIDs = nil
|
||
widget.initResourceTableHeader()
|
||
|
||
if widget.active {
|
||
widget.startResourceWatch(widget.selectedRD)
|
||
}
|
||
case 2:
|
||
// Drop back to resource list for the new node
|
||
widget.level = 1
|
||
widget.pages.SwitchToPage(pageResourceList)
|
||
widget.resources = make(map[string]resource.Resource)
|
||
widget.sortedIDs = nil
|
||
widget.initResourceTableHeader()
|
||
|
||
if widget.active {
|
||
widget.app.SetFocus(widget.resourceTable)
|
||
widget.startResourceWatch(widget.selectedRD)
|
||
}
|
||
}
|
||
}
|
||
|
||
// activateFilter shows the filter input below the types table.
|
||
func (widget *ResourceExplorerGrid) activateFilter() {
|
||
if widget.filterActive {
|
||
return
|
||
}
|
||
|
||
widget.filterActive = true
|
||
widget.filterInput.SetText(widget.filterText)
|
||
widget.typesWrapper.SetRows(0, 1)
|
||
widget.typesWrapper.AddItem(widget.filterInput, 1, 0, 1, 1, 0, 0, true)
|
||
widget.app.SetFocus(widget.filterInput)
|
||
}
|
||
|
||
// deactivateFilter hides the filter input. If clearText is true, the filter is also cleared.
|
||
func (widget *ResourceExplorerGrid) deactivateFilter(clearText bool) {
|
||
if !widget.filterActive {
|
||
if clearText && widget.filterText != "" {
|
||
widget.filterText = ""
|
||
widget.filterInput.SetText("")
|
||
|
||
if widget.resourceDefs != nil {
|
||
widget.renderTypesTable()
|
||
}
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
if clearText {
|
||
widget.filterText = ""
|
||
widget.filterInput.SetText("")
|
||
}
|
||
|
||
widget.filterActive = false
|
||
widget.typesWrapper.RemoveItem(widget.filterInput)
|
||
widget.typesWrapper.SetRows(0)
|
||
|
||
if clearText && widget.resourceDefs != nil {
|
||
widget.renderTypesTable()
|
||
}
|
||
|
||
if widget.active {
|
||
widget.app.SetFocus(widget.typesTable)
|
||
}
|
||
}
|
||
|
||
// loadResourceTypes asynchronously fetches the list of resource definitions.
|
||
func (widget *ResourceExplorerGrid) loadResourceTypes() {
|
||
widget.initTypesTableHeader()
|
||
widget.typesTable.SetCell(1, 0, &tview.TableCell{
|
||
Text: "[gray]Loading...[-]",
|
||
NotSelectable: true,
|
||
})
|
||
|
||
ctx := nodeContext(widget.ctx, widget.selectedNode)
|
||
|
||
go func() {
|
||
list, err := safe.StateListAll[*meta.ResourceDefinition](ctx, widget.dashboard.cli.COSI)
|
||
if err != nil {
|
||
widget.app.QueueUpdateDraw(func() {
|
||
widget.initTypesTableHeader()
|
||
widget.typesTable.SetCell(1, 0, &tview.TableCell{
|
||
Text: fmt.Sprintf("[red]%s[-]", formatError(err)),
|
||
NotSelectable: true,
|
||
})
|
||
})
|
||
|
||
return
|
||
}
|
||
|
||
defs := make([]*meta.ResourceDefinition, 0, list.Len())
|
||
|
||
for rd := range list.All() {
|
||
defs = append(defs, rd)
|
||
}
|
||
|
||
sort.Slice(defs, func(i, j int) bool {
|
||
return defs[i].TypedSpec().Type < defs[j].TypedSpec().Type
|
||
})
|
||
|
||
widget.app.QueueUpdateDraw(func() {
|
||
widget.resourceDefs = defs
|
||
widget.renderTypesTable()
|
||
})
|
||
}()
|
||
}
|
||
|
||
// initTypesTableHeader sets the header row for the types table.
|
||
func (widget *ResourceExplorerGrid) initTypesTableHeader() {
|
||
widget.typesTable.Clear()
|
||
widget.typesTable.SetCell(0, 0, headerCell("TYPE"))
|
||
widget.typesTable.SetCell(0, 1, headerCell("NAMESPACE"))
|
||
widget.typesTable.SetCell(0, 2, headerCell("ALIASES"))
|
||
}
|
||
|
||
// renderTypesTable rebuilds the resource types table, applying the current filter.
|
||
func (widget *ResourceExplorerGrid) renderTypesTable() {
|
||
widget.initTypesTableHeader()
|
||
|
||
filter := strings.ToLower(widget.filterText)
|
||
|
||
row := 1
|
||
|
||
for _, rd := range widget.resourceDefs {
|
||
spec := rd.TypedSpec()
|
||
|
||
if filter != "" {
|
||
matched := strings.Contains(strings.ToLower(spec.Type), filter)
|
||
|
||
if !matched {
|
||
for _, alias := range spec.AllAliases {
|
||
if strings.Contains(strings.ToLower(alias), filter) {
|
||
matched = true
|
||
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if !matched {
|
||
continue
|
||
}
|
||
}
|
||
|
||
widget.typesTable.SetCell(row, 0, &tview.TableCell{
|
||
Text: spec.Type,
|
||
Align: tview.AlignLeft,
|
||
Color: tcell.ColorWhite,
|
||
Reference: rd, // used by selectResourceType to retrieve the RD
|
||
Expansion: 1,
|
||
})
|
||
widget.typesTable.SetCell(row, 1, &tview.TableCell{
|
||
Text: spec.DefaultNamespace,
|
||
Align: tview.AlignLeft,
|
||
Color: tcell.ColorWhite,
|
||
})
|
||
widget.typesTable.SetCell(row, 2, &tview.TableCell{
|
||
Text: strings.Join(spec.Aliases, ", "),
|
||
Align: tview.AlignLeft,
|
||
Color: tcell.ColorGray,
|
||
})
|
||
|
||
row++
|
||
}
|
||
|
||
// Always start at the top of the list
|
||
widget.typesTable.SetOffset(0, 0)
|
||
|
||
if widget.typesTable.GetRowCount() > 1 {
|
||
widget.typesTable.Select(1, 0)
|
||
}
|
||
}
|
||
|
||
// selectResourceType is called when the user presses Enter on a resource type row.
|
||
func (widget *ResourceExplorerGrid) selectResourceType(row int) {
|
||
if row == 0 {
|
||
return
|
||
}
|
||
|
||
cell := widget.typesTable.GetCell(row, 0)
|
||
if cell == nil || cell.Reference == nil {
|
||
return
|
||
}
|
||
|
||
rd, ok := cell.Reference.(*meta.ResourceDefinition)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
widget.selectedRD = rd
|
||
|
||
spec := rd.TypedSpec()
|
||
widget.resourceTable.SetTitle(fmt.Sprintf(" %s (ns: %s) — Enter: view YAML, Esc: back ", spec.DisplayType, spec.DefaultNamespace))
|
||
|
||
widget.buildDynamicColumns(rd)
|
||
|
||
widget.resources = make(map[string]resource.Resource)
|
||
widget.sortedIDs = nil
|
||
widget.initResourceTableHeader()
|
||
|
||
widget.level = 1
|
||
widget.pages.SwitchToPage(pageResourceList)
|
||
widget.app.SetFocus(widget.resourceTable)
|
||
|
||
widget.startResourceWatch(rd)
|
||
}
|
||
|
||
// buildDynamicColumns compiles the jsonpath expressions from the resource definition's PrintColumns.
|
||
func (widget *ResourceExplorerGrid) buildDynamicColumns(rd *meta.ResourceDefinition) {
|
||
cols := rd.TypedSpec().PrintColumns
|
||
|
||
widget.dynamicColumnNames = make([]string, 0, len(cols))
|
||
widget.dynamicColumns = make([]func(any) (string, error), 0, len(cols))
|
||
|
||
for _, col := range cols {
|
||
expr := jsonpath.New(col.Name).AllowMissingKeys(true)
|
||
if err := expr.Parse(col.JSONPath); err != nil {
|
||
// skip columns whose jsonpath can't be compiled
|
||
continue
|
||
}
|
||
|
||
widget.dynamicColumnNames = append(widget.dynamicColumnNames, strings.ToUpper(col.Name))
|
||
|
||
capturedExpr := expr
|
||
|
||
widget.dynamicColumns = append(widget.dynamicColumns, func(val any) (string, error) {
|
||
var buf bytes.Buffer
|
||
if e := capturedExpr.Execute(&buf, val); e != nil {
|
||
return "", e
|
||
}
|
||
|
||
return buf.String(), nil
|
||
})
|
||
}
|
||
}
|
||
|
||
// initResourceTableHeader sets the header row for the resource instances table.
|
||
func (widget *ResourceExplorerGrid) initResourceTableHeader() {
|
||
widget.resourceTable.Clear()
|
||
widget.resourceTable.SetCell(0, 0, headerCell("ID"))
|
||
widget.resourceTable.SetCell(0, 1, headerCell("VERSION"))
|
||
widget.resourceTable.SetCell(0, 2, headerCell("PHASE"))
|
||
widget.resourceTable.SetCell(0, 3, headerCell("OWNER"))
|
||
|
||
for i, name := range widget.dynamicColumnNames {
|
||
widget.resourceTable.SetCell(0, 4+i, headerCell(name))
|
||
}
|
||
|
||
widget.resourceTable.SetCell(1, 0, &tview.TableCell{
|
||
Text: "[gray]Loading...[-]",
|
||
NotSelectable: true,
|
||
})
|
||
}
|
||
|
||
// renderResourceTable rebuilds the resource instances table from widget.resources.
|
||
func (widget *ResourceExplorerGrid) renderResourceTable() {
|
||
ids := make([]string, 0, len(widget.resources))
|
||
for id := range widget.resources {
|
||
ids = append(ids, id)
|
||
}
|
||
|
||
sort.Strings(ids)
|
||
widget.sortedIDs = ids
|
||
|
||
// Remember the previously selected row to preserve it after a live update
|
||
selectedRow, _ := widget.resourceTable.GetSelection()
|
||
|
||
widget.initResourceTableHeader()
|
||
widget.resourceTable.RemoveRow(1) // remove "loading" header
|
||
|
||
for i, id := range ids {
|
||
res := widget.resources[id]
|
||
md := res.Metadata()
|
||
|
||
phaseText := tview.Escape(md.Phase().String())
|
||
phaseColor := tcell.ColorWhite
|
||
|
||
if md.Phase() == resource.PhaseTearingDown {
|
||
phaseText = "[red]" + phaseText + "[-]"
|
||
phaseColor = tcell.ColorDefault
|
||
}
|
||
|
||
widget.resourceTable.SetCell(i+1, 0, &tview.TableCell{
|
||
Text: tview.Escape(md.ID()),
|
||
Align: tview.AlignLeft,
|
||
Color: tcell.ColorWhite,
|
||
Expansion: 1,
|
||
})
|
||
widget.resourceTable.SetCell(i+1, 1, &tview.TableCell{
|
||
Text: tview.Escape(md.Version().String()),
|
||
Align: tview.AlignLeft,
|
||
Color: tcell.ColorGray,
|
||
})
|
||
widget.resourceTable.SetCell(i+1, 2, &tview.TableCell{
|
||
Text: phaseText,
|
||
Align: tview.AlignLeft,
|
||
Color: phaseColor,
|
||
})
|
||
widget.resourceTable.SetCell(i+1, 3, &tview.TableCell{
|
||
Text: tview.Escape(md.Owner()),
|
||
Align: tview.AlignLeft,
|
||
Color: tcell.ColorGray,
|
||
})
|
||
|
||
if len(widget.dynamicColumns) > 0 {
|
||
specVal := widget.marshalSpec(res)
|
||
|
||
for j, dynCol := range widget.dynamicColumns {
|
||
text, err := dynCol(specVal)
|
||
if err != nil {
|
||
text = ""
|
||
}
|
||
|
||
widget.resourceTable.SetCell(i+1, 4+j, &tview.TableCell{
|
||
Text: tview.Escape(text),
|
||
Align: tview.AlignLeft,
|
||
Color: tcell.ColorWhite,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Restore selection if still valid, otherwise go to first row
|
||
rowCount := widget.resourceTable.GetRowCount()
|
||
if selectedRow > 0 && selectedRow < rowCount {
|
||
widget.resourceTable.Select(selectedRow, 0)
|
||
} else if rowCount > 1 {
|
||
// Initial render: reset scroll to top before selecting the first row.
|
||
widget.resourceTable.SetOffset(0, 0)
|
||
widget.resourceTable.Select(1, 0)
|
||
}
|
||
}
|
||
|
||
// startResourceWatch begins watching resources of the given kind.
|
||
func (widget *ResourceExplorerGrid) startResourceWatch(rd *meta.ResourceDefinition) {
|
||
widget.stopResourceWatch()
|
||
|
||
ctx, cancel := context.WithCancel(widget.ctx)
|
||
widget.watchCancel = cancel
|
||
|
||
go widget.runResourceWatch(ctx, rd)
|
||
}
|
||
|
||
// stopResourceWatch cancels a running resource watch if any.
|
||
func (widget *ResourceExplorerGrid) stopResourceWatch() {
|
||
if widget.watchCancel != nil {
|
||
widget.watchCancel()
|
||
widget.watchCancel = nil
|
||
}
|
||
}
|
||
|
||
// runResourceWatch runs the WatchKind event loop for the given resource definition.
|
||
//
|
||
//nolint:gocyclo
|
||
func (widget *ResourceExplorerGrid) runResourceWatch(ctx context.Context, rd *meta.ResourceDefinition) {
|
||
nodeCtx := nodeContext(ctx, widget.selectedNode)
|
||
|
||
spec := rd.TypedSpec()
|
||
|
||
eventCh := make(chan state.Event)
|
||
|
||
md := resource.NewMetadata(spec.DefaultNamespace, spec.Type, "", resource.VersionUndefined)
|
||
|
||
if err := widget.dashboard.cli.COSI.WatchKind(nodeCtx, &md, eventCh,
|
||
state.WithBootstrapContents(true),
|
||
state.WithWatchKindUnmarshalOptions(state.WithSkipProtobufUnmarshal()),
|
||
); err != nil {
|
||
widget.app.QueueUpdateDraw(func() {
|
||
if widget.selectedRD == rd {
|
||
widget.showResourceTableError(formatError(err))
|
||
}
|
||
})
|
||
|
||
return
|
||
}
|
||
|
||
bootstrapped := false
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case event := <-eventCh:
|
||
switch event.Type {
|
||
case state.Errored:
|
||
widget.app.QueueUpdateDraw(func() {
|
||
if widget.selectedRD == rd {
|
||
widget.showResourceTableError(formatError(event.Error))
|
||
}
|
||
})
|
||
|
||
return
|
||
case state.Bootstrapped:
|
||
bootstrapped = true
|
||
|
||
widget.app.QueueUpdateDraw(func() {
|
||
if widget.selectedRD == rd && widget.level >= 1 {
|
||
widget.renderResourceTable()
|
||
}
|
||
})
|
||
case state.Noop:
|
||
// nothing to do
|
||
case state.Created, state.Updated:
|
||
res := event.Resource
|
||
wasBootstrapped := bootstrapped
|
||
|
||
widget.app.QueueUpdateDraw(func() {
|
||
if widget.selectedRD == rd && widget.level >= 1 {
|
||
widget.resources[res.Metadata().ID()] = res
|
||
|
||
if wasBootstrapped {
|
||
widget.renderResourceTable()
|
||
}
|
||
}
|
||
})
|
||
case state.Destroyed:
|
||
res := event.Resource
|
||
wasBootstrapped := bootstrapped
|
||
|
||
widget.app.QueueUpdateDraw(func() {
|
||
if widget.selectedRD == rd && widget.level >= 1 {
|
||
delete(widget.resources, res.Metadata().ID())
|
||
|
||
if wasBootstrapped {
|
||
widget.renderResourceTable()
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// marshalSpec marshals the resource spec to an unstructured value suitable for jsonpath evaluation.
|
||
func (widget *ResourceExplorerGrid) marshalSpec(res resource.Resource) any {
|
||
out, err := yaml.Marshal(res.Spec())
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
|
||
var unstructured any
|
||
|
||
if err = yaml.Unmarshal(out, &unstructured); err != nil {
|
||
return nil
|
||
}
|
||
|
||
return unstructured
|
||
}
|
||
|
||
// showResourceTableError displays an error message in the resource instances table.
|
||
func (widget *ResourceExplorerGrid) showResourceTableError(msg string) {
|
||
widget.initResourceTableHeader()
|
||
widget.resourceTable.SetCell(1, 0, &tview.TableCell{
|
||
Text: fmt.Sprintf("[red]%s[-]", tview.Escape(msg)),
|
||
NotSelectable: true,
|
||
Expansion: 1,
|
||
})
|
||
}
|
||
|
||
// selectResource is called when the user presses Enter on a resource instance row.
|
||
func (widget *ResourceExplorerGrid) selectResource(row int) {
|
||
if row == 0 || row-1 >= len(widget.sortedIDs) {
|
||
return
|
||
}
|
||
|
||
id := widget.sortedIDs[row-1]
|
||
|
||
res, ok := widget.resources[id]
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
widget.showResourceYAML(res)
|
||
}
|
||
|
||
// showResourceYAML renders the YAML of the given resource and shows the YAML view.
|
||
func (widget *ResourceExplorerGrid) showResourceYAML(res resource.Resource) {
|
||
out, err := resource.MarshalYAML(res)
|
||
if err != nil {
|
||
widget.yamlView.SetText(fmt.Sprintf("Error marshaling resource: %v", err))
|
||
} else {
|
||
outBytes, marshalErr := yaml.Marshal(out)
|
||
if marshalErr != nil {
|
||
widget.yamlView.SetText(fmt.Sprintf("Error encoding YAML: %v", marshalErr))
|
||
} else {
|
||
var node yaml.Node
|
||
|
||
if unmarshalErr := yaml.Unmarshal(outBytes, &node); unmarshalErr != nil {
|
||
widget.yamlView.SetText(fmt.Sprintf("Error encoding YAML: %v", unmarshalErr))
|
||
} else {
|
||
var sb strings.Builder
|
||
|
||
renderYAMLNode(&sb, &node, 0, false)
|
||
widget.yamlView.SetText(sb.String())
|
||
}
|
||
}
|
||
}
|
||
|
||
widget.yamlView.SetTitle(fmt.Sprintf(" %s / %s (Esc: back) ", res.Metadata().Namespace(), res.Metadata().ID()))
|
||
widget.yamlView.ScrollToBeginning()
|
||
widget.level = 2
|
||
widget.pages.SwitchToPage(pageResourceYAML)
|
||
widget.app.SetFocus(widget.yamlView)
|
||
}
|
||
|
||
// goBack returns to the previous navigation level.
|
||
func (widget *ResourceExplorerGrid) goBack() {
|
||
switch widget.level {
|
||
case 2:
|
||
widget.level = 1
|
||
widget.pages.SwitchToPage(pageResourceList)
|
||
widget.app.SetFocus(widget.resourceTable)
|
||
case 1:
|
||
widget.stopResourceWatch()
|
||
widget.resources = make(map[string]resource.Resource)
|
||
widget.sortedIDs = nil
|
||
widget.level = 0
|
||
widget.pages.SwitchToPage(pageResourceTypes)
|
||
widget.app.SetFocus(widget.typesTable)
|
||
}
|
||
}
|
||
|
||
// formatError returns a user-friendly error message.
|
||
// Permission denied errors are given a clear explanation since sensitive resources
|
||
// are only accessible with elevated privileges.
|
||
func formatError(err error) string {
|
||
if client.StatusCode(err) == codes.PermissionDenied {
|
||
return "access denied: this resource is sensitive and requires elevated permissions"
|
||
}
|
||
|
||
return err.Error()
|
||
}
|
||
|
||
// renderYAMLNode walks a yaml.Node AST and writes a tview-colored representation
|
||
// into sb. indent is the current indentation level; inSequence signals that the
|
||
// caller is rendering a sequence item and has already written the "- " prefix.
|
||
//
|
||
// Color scheme:
|
||
//
|
||
// cyan – mapping keys
|
||
// green – string scalars
|
||
// yellow – numeric, boolean, and null scalars
|
||
//
|
||
//nolint:gocyclo
|
||
func renderYAMLNode(sb *strings.Builder, node *yaml.Node, indent int, inSequence bool) {
|
||
prefix := strings.Repeat(" ", indent)
|
||
seqPrefix := strings.Repeat(" ", indent) + "- "
|
||
|
||
switch node.Kind { //nolint:exhaustive
|
||
case yaml.DocumentNode:
|
||
for _, child := range node.Content {
|
||
renderYAMLNode(sb, child, indent, false)
|
||
}
|
||
|
||
case yaml.MappingNode:
|
||
// Content is key/value pairs interleaved: [k0, v0, k1, v1, …]
|
||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||
key := node.Content[i]
|
||
val := node.Content[i+1]
|
||
|
||
linePrefix := prefix
|
||
if inSequence && i == 0 {
|
||
linePrefix = seqPrefix
|
||
}
|
||
|
||
sb.WriteString(linePrefix)
|
||
fmt.Fprintf(sb, "[darkcyan]%s[-]:", tview.Escape(key.Value))
|
||
|
||
switch val.Kind { //nolint:exhaustive
|
||
case yaml.ScalarNode:
|
||
sb.WriteString(" ")
|
||
writeScalar(sb, val)
|
||
sb.WriteString("\n")
|
||
case yaml.MappingNode, yaml.SequenceNode:
|
||
sb.WriteString("\n")
|
||
renderYAMLNode(sb, val, indent+1, false)
|
||
default:
|
||
sb.WriteString("\n")
|
||
renderYAMLNode(sb, val, indent+1, false)
|
||
}
|
||
|
||
inSequence = false // only the first key gets the "- " prefix
|
||
}
|
||
|
||
case yaml.SequenceNode:
|
||
for _, item := range node.Content {
|
||
switch item.Kind { //nolint:exhaustive
|
||
case yaml.ScalarNode:
|
||
sb.WriteString(seqPrefix)
|
||
writeScalar(sb, item)
|
||
sb.WriteString("\n")
|
||
case yaml.MappingNode:
|
||
renderYAMLNode(sb, item, indent, true)
|
||
default:
|
||
sb.WriteString(seqPrefix + "\n")
|
||
renderYAMLNode(sb, item, indent+1, false)
|
||
}
|
||
}
|
||
|
||
case yaml.ScalarNode:
|
||
sb.WriteString(prefix)
|
||
writeScalar(sb, node)
|
||
sb.WriteString("\n")
|
||
|
||
case yaml.AliasNode:
|
||
renderYAMLNode(sb, node.Alias, indent, inSequence)
|
||
}
|
||
}
|
||
|
||
// writeScalar writes a single colored scalar value into sb.
|
||
func writeScalar(sb *strings.Builder, node *yaml.Node) {
|
||
switch node.ShortTag() {
|
||
case "!!str":
|
||
// Preserve the original quoting style in the display.
|
||
var quoted string
|
||
|
||
switch node.Style { //nolint:exhaustive
|
||
case yaml.DoubleQuotedStyle:
|
||
quoted = `"` + tview.Escape(node.Value) + `"`
|
||
case yaml.SingleQuotedStyle:
|
||
quoted = `'` + tview.Escape(node.Value) + `'`
|
||
case yaml.LiteralStyle:
|
||
quoted = "|\n" + tview.Escape(node.Value)
|
||
case yaml.FoldedStyle:
|
||
quoted = ">\n" + tview.Escape(node.Value)
|
||
default:
|
||
quoted = tview.Escape(node.Value)
|
||
}
|
||
|
||
fmt.Fprintf(sb, "[green]%s[-]", quoted)
|
||
|
||
case "!!int", "!!float":
|
||
fmt.Fprintf(sb, "[yellow]%s[-]", tview.Escape(node.Value))
|
||
|
||
case "!!bool":
|
||
fmt.Fprintf(sb, "[yellow]%s[-]", tview.Escape(node.Value))
|
||
|
||
case "!!null":
|
||
fmt.Fprintf(sb, "[yellow]%s[-]", tview.Escape(node.Value))
|
||
|
||
default:
|
||
sb.WriteString(tview.Escape(node.Value))
|
||
}
|
||
}
|
||
|
||
// headerCell creates a bold header cell for tables.
|
||
func headerCell(text string) *tview.TableCell {
|
||
return &tview.TableCell{
|
||
Text: "[::b]" + text,
|
||
Align: tview.AlignLeft,
|
||
NotSelectable: true,
|
||
Color: tcell.ColorWhite,
|
||
}
|
||
}
|