Skip to content

Commit b36614d

Browse files
committed
feat(lab): add overrides command with interactive TUI
Add new lab overrides command for managing configuration overrides with an interactive terminal UI built using bubbletea.
1 parent f7540f3 commit b36614d

File tree

7 files changed

+1287
-0
lines changed

7 files changed

+1287
-0
lines changed

pkg/commands/lab.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Use 'xcli lab [command] --help' for more information about a command.`,
5353
cmd.AddCommand(NewLabRestartCommand(log, configPath))
5454
cmd.AddCommand(NewLabModeCommand(log, configPath))
5555
cmd.AddCommand(NewLabConfigCommand(log, configPath))
56+
cmd.AddCommand(NewLabOverridesCommand(configPath))
5657
cmd.AddCommand(NewLabTUICommand(log, configPath))
5758
cmd.AddCommand(NewLabDiagnoseCommand(log, configPath))
5859
cmd.AddCommand(NewLabReleaseCommand(log, configPath))

pkg/commands/lab_overrides.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/ethpandaops/xcli/pkg/config"
8+
"github.com/ethpandaops/xcli/pkg/configtui"
9+
"github.com/ethpandaops/xcli/pkg/constants"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
// NewLabOverridesCommand creates the lab overrides command.
14+
func NewLabOverridesCommand(configPath string) *cobra.Command {
15+
return &cobra.Command{
16+
Use: "overrides",
17+
Short: "Manage CBT model overrides interactively",
18+
Long: `Launch an interactive TUI to manage .cbt-overrides.yaml.
19+
20+
The TUI allows you to:
21+
- Enable/disable external models (from models/external/)
22+
- Enable/disable transformation models (from models/transformations/)
23+
- Set environment variables for backfill limits:
24+
- EXTERNAL_MODEL_MIN_TIMESTAMP: Consensus layer backfill timestamp
25+
- EXTERNAL_MODEL_MIN_BLOCK: Execution layer backfill block number
26+
27+
Changes are saved to .cbt-overrides.yaml. Run 'xcli lab config regenerate'
28+
to apply changes to CBT configuration.`,
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
labCfg, cfgPath, err := config.LoadLabConfig(configPath)
31+
if err != nil {
32+
return fmt.Errorf("failed to load config: %w", err)
33+
}
34+
35+
// Derive overrides path (same directory as .xcli.yaml).
36+
overridesPath := filepath.Join(filepath.Dir(cfgPath), constants.CBTOverridesFile)
37+
38+
return configtui.Run(labCfg.Repos.XatuCBT, overridesPath)
39+
},
40+
}
41+
}

pkg/configtui/command.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package configtui
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"sort"
8+
"strings"
9+
10+
tea "github.com/charmbracelet/bubbletea"
11+
12+
"github.com/ethpandaops/xcli/pkg/seeddata"
13+
)
14+
15+
// Run starts the config TUI.
16+
func Run(xatuCBTPath, overridesPath string) error {
17+
// Check if terminal is a TTY.
18+
if !isatty() {
19+
return fmt.Errorf("config TUI requires an interactive terminal")
20+
}
21+
22+
// Discover models.
23+
externalModels, transformModels, err := discoverModels(xatuCBTPath)
24+
if err != nil {
25+
return fmt.Errorf("failed to discover models: %w", err)
26+
}
27+
28+
// Load existing overrides.
29+
overrides, fileExists, err := LoadOverrides(overridesPath)
30+
if err != nil {
31+
return fmt.Errorf("failed to load overrides: %w", err)
32+
}
33+
34+
// Create the model.
35+
m := NewModel(xatuCBTPath, overridesPath)
36+
m.existingOverrides = overrides
37+
38+
// Initialize external models.
39+
// If no overrides file exists, default all models to disabled.
40+
m.externalModels = make([]ModelEntry, 0, len(externalModels))
41+
for _, name := range externalModels {
42+
enabled := fileExists && !IsModelDisabled(overrides, name)
43+
m.externalModels = append(m.externalModels, ModelEntry{
44+
Name: name,
45+
Enabled: enabled,
46+
})
47+
}
48+
49+
// Initialize transformation models.
50+
// If no overrides file exists, default all models to disabled.
51+
m.transformationModels = make([]ModelEntry, 0, len(transformModels))
52+
for _, name := range transformModels {
53+
enabled := fileExists && !IsModelDisabled(overrides, name)
54+
m.transformationModels = append(m.transformationModels, ModelEntry{
55+
Name: name,
56+
Enabled: enabled,
57+
})
58+
}
59+
60+
// Initialize env vars from loaded overrides.
61+
m.envMinTimestamp = overrides.Models.Env["EXTERNAL_MODEL_MIN_TIMESTAMP"]
62+
m.envMinBlock = overrides.Models.Env["EXTERNAL_MODEL_MIN_BLOCK"]
63+
m.envTimestampEnabled = m.envMinTimestamp != ""
64+
m.envBlockEnabled = m.envMinBlock != ""
65+
66+
// Load model dependencies for dependency warnings.
67+
m.dependencies = loadDependencies(xatuCBTPath, transformModels)
68+
69+
// Run the TUI.
70+
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
71+
72+
_, err = p.Run()
73+
if err != nil {
74+
return fmt.Errorf("failed to run TUI: %w", err)
75+
}
76+
77+
return nil
78+
}
79+
80+
// isatty checks if stdout is a terminal.
81+
func isatty() bool {
82+
fileInfo, err := os.Stdout.Stat()
83+
if err != nil {
84+
return false
85+
}
86+
87+
return (fileInfo.Mode() & os.ModeCharDevice) != 0
88+
}
89+
90+
// discoverModels discovers external and transformation models from the xatu-cbt repo.
91+
func discoverModels(xatuCBTPath string) (external []string, transformation []string, err error) {
92+
// Discover external models.
93+
externalDir := filepath.Join(xatuCBTPath, "models", "external")
94+
95+
entries, err := os.ReadDir(externalDir)
96+
if err != nil {
97+
return nil, nil, fmt.Errorf("failed to read external models directory: %w", err)
98+
}
99+
100+
external = make([]string, 0, len(entries))
101+
102+
for _, entry := range entries {
103+
if entry.IsDir() {
104+
continue
105+
}
106+
107+
name := entry.Name()
108+
if strings.HasSuffix(name, ".sql") {
109+
external = append(external, strings.TrimSuffix(name, ".sql"))
110+
}
111+
}
112+
113+
sort.Strings(external)
114+
115+
// Discover transformation models.
116+
transformDir := filepath.Join(xatuCBTPath, "models", "transformations")
117+
118+
entries, err = os.ReadDir(transformDir)
119+
if err != nil {
120+
return nil, nil, fmt.Errorf("failed to read transformations directory: %w", err)
121+
}
122+
123+
transformation = make([]string, 0, len(entries))
124+
125+
for _, entry := range entries {
126+
if entry.IsDir() {
127+
continue
128+
}
129+
130+
name := entry.Name()
131+
132+
// Support .sql, .yml, and .yaml extensions.
133+
for _, ext := range []string{".sql", ".yml", ".yaml"} {
134+
if strings.HasSuffix(name, ext) {
135+
transformation = append(transformation, strings.TrimSuffix(name, ext))
136+
137+
break
138+
}
139+
}
140+
}
141+
142+
sort.Strings(transformation)
143+
144+
return external, transformation, nil
145+
}
146+
147+
// loadDependencies loads the dependency graph for all transformation models.
148+
// Returns a map of model name -> list of all dependencies (recursive, flattened).
149+
func loadDependencies(xatuCBTPath string, transformModels []string) map[string][]string {
150+
deps := make(map[string][]string, len(transformModels))
151+
152+
for _, model := range transformModels {
153+
tree, err := seeddata.ResolveDependencyTree(model, xatuCBTPath, nil)
154+
if err != nil {
155+
// Skip models with dependency resolution errors.
156+
continue
157+
}
158+
159+
// Get all dependencies (external and intermediate).
160+
allDeps := make([]string, 0)
161+
allDeps = append(allDeps, tree.GetExternalDependencies()...)
162+
allDeps = append(allDeps, tree.GetIntermediateDependencies()...)
163+
deps[model] = allDeps
164+
}
165+
166+
return deps
167+
}

0 commit comments

Comments
 (0)