diff --git a/.gitignore b/.gitignore index b082f400..0a386a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,8 @@ # Data *.car *.sst -LOG* +LOG +LOG.* LOCK CURRENT MANIFEST-* diff --git a/cmd/mithril-tui/main.go b/cmd/mithril-tui/main.go index 3ad77b4f..5780cd8f 100644 --- a/cmd/mithril-tui/main.go +++ b/cmd/mithril-tui/main.go @@ -2,36 +2,57 @@ package main import ( "bufio" + "errors" "flag" "fmt" + "io" "net/http" "os" + "path/filepath" "sort" "strings" "time" "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( - FilterAll = iota - FilterMachine - FilterMithril + ViewMetricsAll = iota + ViewMetricsMachine + ViewMetricsMithril + ViewMithrilLogs + ViewLightbringerLogs + viewCount // sentinel for modular cycling ) +const maxLogLines = 5000 + var baseStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")) type model struct { + // Metrics view table table.Model url string err error lastUpdate time.Time allRows []table.Row - filterMode int + + // Log views + mithrilLogPath string + lightbringerLogPath string + mithrilLogLines []string + lightbringerLogLines []string + logViewport viewport.Model + + // State + viewMode int + width int + height int } type tickMsg time.Time @@ -41,23 +62,54 @@ type metricsMsg struct { err error } +type logMsg struct { + source string // "mithril" or "lightbringer" + lines []string + err error +} + func (m model) Init() tea.Cmd { - return tea.Batch( + cmds := []tea.Cmd{ fetchMetrics(m.url), tickCmd(), - ) + } + if m.mithrilLogPath != "" { + cmds = append(cmds, tailLog("mithril", m.mithrilLogPath)) + } + if m.lightbringerLogPath != "" { + cmds = append(cmds, tailLog("lightbringer", m.lightbringerLogPath)) + } + return tea.Batch(cmds...) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.logViewport.Width = msg.Width - 2 + m.logViewport.Height = msg.Height - 4 case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "tab": - m.filterMode = (m.filterMode + 1) % 3 - m.table.SetRows(m.filterRows()) + m.viewMode = (m.viewMode + 1) % viewCount + // Skip unavailable log views (single loop with bound to prevent infinite cycling) + for i := 0; i < viewCount; i++ { + if (m.viewMode == ViewMithrilLogs && m.mithrilLogPath == "") || + (m.viewMode == ViewLightbringerLogs && m.lightbringerLogPath == "") { + m.viewMode = (m.viewMode + 1) % viewCount + } else { + break + } + } + if m.viewMode <= ViewMetricsMithril { + m.table.SetRows(m.filterRows()) + } else { + m.updateLogViewport() + } } case metricsMsg: if msg.err != nil { @@ -66,40 +118,93 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.err = nil m.allRows = msg.rows - m.table.SetRows(m.filterRows()) + if m.viewMode <= ViewMetricsMithril { + m.table.SetRows(m.filterRows()) + } m.lastUpdate = time.Now() + case logMsg: + if msg.err == nil { + switch msg.source { + case "mithril": + m.mithrilLogLines = msg.lines + case "lightbringer": + m.lightbringerLogLines = msg.lines + } + if (msg.source == "mithril" && m.viewMode == ViewMithrilLogs) || + (msg.source == "lightbringer" && m.viewMode == ViewLightbringerLogs) { + m.updateLogViewport() + } + } case tickMsg: - return m, tea.Batch( + cmds := []tea.Cmd{ fetchMetrics(m.url), tickCmd(), - ) + } + if m.mithrilLogPath != "" { + cmds = append(cmds, tailLog("mithril", m.mithrilLogPath)) + } + if m.lightbringerLogPath != "" { + cmds = append(cmds, tailLog("lightbringer", m.lightbringerLogPath)) + } + return m, tea.Batch(cmds...) + } + + // Route input to the right component + if m.viewMode <= ViewMetricsMithril { + m.table, cmd = m.table.Update(msg) + } else { + m.logViewport, cmd = m.logViewport.Update(msg) } - m.table, cmd = m.table.Update(msg) return m, cmd } -func (m model) View() string { - if m.err != nil { - return fmt.Sprintf("Error fetching metrics: %v\n\nPress q to quit.", m.err) +func (m *model) updateLogViewport() { + var lines []string + switch m.viewMode { + case ViewMithrilLogs: + lines = m.mithrilLogLines + case ViewLightbringerLogs: + lines = m.lightbringerLogLines } + content := strings.Join(lines, "\n") + m.logViewport.SetContent(content) + m.logViewport.GotoBottom() +} - filterName := "All Metrics" - switch m.filterMode { - case FilterMachine: - filterName = "Machine Metrics (Go/Process)" - case FilterMithril: - filterName = "Mithril Custom Metrics" +func (m model) View() string { + viewName := "" + switch m.viewMode { + case ViewMetricsAll: + viewName = "All Metrics" + case ViewMetricsMachine: + viewName = "Machine Metrics (Go/Process)" + case ViewMetricsMithril: + viewName = "Mithril Custom Metrics" + case ViewMithrilLogs: + viewName = "Mithril Logs" + case ViewLightbringerLogs: + viewName = "Lightbringer Logs" } header := lipgloss.NewStyle(). Foreground(lipgloss.Color("205")). Bold(true). - Render(filterName) + Render(viewName) - return fmt.Sprintf("%s (Tab to switch metrics)\n%s\nLast updated: %s | q to quit", + if m.viewMode <= ViewMetricsMithril { + if m.err != nil { + return fmt.Sprintf("Error fetching metrics: %v\n\nPress q to quit.", m.err) + } + return fmt.Sprintf("%s (Tab to switch)\n%s\nLast updated: %s | q to quit", + header, + baseStyle.Render(m.table.View()), + m.lastUpdate.Format(time.TimeOnly)) + } + + // Log view + return fmt.Sprintf("%s (Tab to switch) | scroll: up/down/pgup/pgdn\n%s\nq to quit", header, - baseStyle.Render(m.table.View()), - m.lastUpdate.Format(time.TimeOnly)) + baseStyle.Render(m.logViewport.View())) } func fetchMetrics(url string) tea.Cmd { @@ -122,14 +227,10 @@ func fetchMetrics(url string) tea.Cmd { for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) - // Skip comments and empty lines if line == "" || strings.HasPrefix(line, "#") { continue } - // Expected format: - // metric_name{label="value"} 123.45 - // metric_name 123.45 fields := strings.Fields(line) if len(fields) < 2 { continue @@ -157,7 +258,6 @@ func fetchMetrics(url string) tea.Cmd { return metricsMsg{err: err} } - // Stable ordering for TUI sort.Slice(rows, func(i, j int) bool { if rows[i][0] == rows[j][0] { return rows[i][2] < rows[j][2] @@ -169,6 +269,43 @@ func fetchMetrics(url string) tea.Cmd { } } +// tailLog reads the last maxLogLines from a log file. +// Only reads the last 64KB to avoid OOM on large log files. +func tailLog(source, path string) tea.Cmd { + return func() tea.Msg { + f, err := os.Open(path) + if err != nil { + return logMsg{source: source, err: err} + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return logMsg{source: source, err: err} + } + + // Read only the tail of the file to avoid loading huge logs into memory + const tailSize = 64 * 1024 + offset := stat.Size() - tailSize + if offset < 0 { + offset = 0 + } + + buf := make([]byte, stat.Size()-offset) + _, err = f.ReadAt(buf, offset) + if err != nil && !errors.Is(err, io.EOF) { + return logMsg{source: source, err: err} + } + + lines := strings.Split(string(buf), "\n") + if len(lines) > maxLogLines { + lines = lines[len(lines)-maxLogLines:] + } + + return logMsg{source: source, lines: lines} + } +} + func tickCmd() tea.Cmd { return tea.Tick(time.Second*2, func(t time.Time) tea.Msg { return tickMsg(t) @@ -176,7 +313,7 @@ func tickCmd() tea.Cmd { } func (m model) filterRows() []table.Row { - if m.filterMode == FilterAll { + if m.viewMode == ViewMetricsAll { return m.allRows } @@ -185,9 +322,9 @@ func (m model) filterRows() []table.Row { name := row[0] isMachine := strings.HasPrefix(name, "go_") || strings.HasPrefix(name, "process_") || strings.HasPrefix(name, "promhttp_") - if m.filterMode == FilterMachine && isMachine { + if m.viewMode == ViewMetricsMachine && isMachine { filtered = append(filtered, row) - } else if m.filterMode == FilterMithril && !isMachine { + } else if m.viewMode == ViewMetricsMithril && !isMachine { filtered = append(filtered, row) } } @@ -196,8 +333,33 @@ func (m model) filterRows() []table.Row { func main() { url := flag.String("url", "http://localhost:9090/metrics", "Prometheus metrics URL") + logDir := flag.String("log-dir", "", "Mithril log directory (uses 'latest' symlink to find current run)") flag.Parse() + // Resolve log paths from the log directory + mithrilLogPath := "" + lightbringerLogPath := "" + if *logDir != "" { + latestDir := filepath.Join(*logDir, "latest") + if target, err := os.Readlink(latestDir); err == nil { + // Validate symlink target — reject traversal and absolute paths + if !strings.Contains(target, "..") && !filepath.IsAbs(target) { + runDir := filepath.Clean(filepath.Join(*logDir, target)) + cleanLogDir := filepath.Clean(*logDir) + string(os.PathSeparator) + if strings.HasPrefix(runDir+string(os.PathSeparator), cleanLogDir) { + ml := filepath.Join(runDir, "mithril.log") + if _, err := os.Stat(ml); err == nil { + mithrilLogPath = ml + } + ll := filepath.Join(runDir, "lightbringer.log") + if _, err := os.Stat(ll); err == nil { + lightbringerLogPath = ll + } + } + } + } + } + columns := []table.Column{ {Title: "Metric", Width: 40}, {Title: "Value", Width: 20}, @@ -222,12 +384,17 @@ func main() { Bold(false) t.SetStyles(s) + vp := viewport.New(80, 20) + m := model{ - table: t, - url: *url, + table: t, + url: *url, + mithrilLogPath: mithrilLogPath, + lightbringerLogPath: lightbringerLogPath, + logViewport: vp, } - if _, err := tea.NewProgram(m).Run(); err != nil { + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/cmd/mithril-tui/main_test.go b/cmd/mithril-tui/main_test.go index de0d9de0..94b4600a 100644 --- a/cmd/mithril-tui/main_test.go +++ b/cmd/mithril-tui/main_test.go @@ -96,22 +96,22 @@ func TestFilterRows(t *testing.T) { allRows: rows, } - // Test FilterAll - m.filterMode = FilterAll + // Test ViewMetricsAll + m.viewMode = ViewMetricsAll filtered := m.filterRows() assert.Len(t, filtered, 6) assert.Equal(t, rows, filtered) - // Test FilterMachine - m.filterMode = FilterMachine + // Test ViewMetricsMachine + m.viewMode = ViewMetricsMachine filtered = m.filterRows() assert.Len(t, filtered, 3) assert.Equal(t, "go_goroutines", filtered[0][0]) assert.Equal(t, "process_cpu_seconds", filtered[1][0]) assert.Equal(t, "promhttp_metric_handler_requests_total", filtered[2][0]) - // Test FilterMithril - m.filterMode = FilterMithril + // Test ViewMetricsMithril + m.viewMode = ViewMetricsMithril filtered = m.filterRows() assert.Len(t, filtered, 3) assert.Equal(t, "mithril_block_height", filtered[0][0]) diff --git a/cmd/mithril/configcmd/configcmd.go b/cmd/mithril/configcmd/configcmd.go index ad23ef2c..a1fb9b76 100644 --- a/cmd/mithril/configcmd/configcmd.go +++ b/cmd/mithril/configcmd/configcmd.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" + "github.com/Overclock-Validator/mithril/pkg/tui" "github.com/spf13/cobra" ) @@ -41,17 +43,17 @@ If config.toml already exists, this command will not overwrite it.`, Examples: mithril config set storage.accounts /mnt/accounts - mithril config set storage.blockstore /mnt/blockstore + mithril config set storage.shredstore /mnt/shredstore mithril config set storage.snapshots /mnt/snapshots mithril config set bootstrap.mode auto - mithril config set replay.txpar 48 + mithril config set tuning.txpar 48 Common keys: storage.accounts - Path to AccountsDB directory - storage.blockstore - Path to blockstore directory + storage.shredstore - Path to shredstore directory storage.snapshots - Path to snapshots directory bootstrap.mode - Startup mode: auto, snapshot, or accountsdb - replay.txpar - Transaction parallelism (recommended: 2x CPU cores) + tuning.txpar - Transaction parallelism (recommended: 2x CPU cores) network.rpc - RPC endpoint(s)`, Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { @@ -98,7 +100,7 @@ func runConfigInit() { config := generateStarterConfig() // Write to file - if err := os.WriteFile(outputPath, []byte(config), 0644); err != nil { + if err := tui.AtomicWriteFile(outputPath, []byte(config), 0600); err != nil { fmt.Printf("Error writing config file: %v\n", err) os.Exit(1) } @@ -125,8 +127,9 @@ mode = "auto" # "auto" | "snapshot" | "new-snapshot" | "accountsdb" [storage] accounts = "/mnt/mithril-accounts" # AccountsDB (~500GB, use fastest NVMe) -blockstore = "/mnt/mithril-ledger/blockstore" # NOTE: block persistence temporarily disabled +shredstore = "/mnt/mithril-ledger/shredstore" # Lightbringer shred storage snapshots = "/mnt/mithril-ledger/snapshots" # ~100GB for full + incremental +logs = "/mnt/mithril-logs" # Log files (created if missing) [network] cluster = "mainnet-beta" # Required: "mainnet-beta" | "testnet" | "devnet" @@ -136,7 +139,16 @@ rpc = ["https://api.mainnet-beta.solana.com"] source = "rpc" # "rpc" | "lightbringer" # lightbringer_endpoint = "localhost:9000" -[replay] +# [lightbringer] +# enabled = false +# binary_path = "./lightbringer" +# gossip_entrypoint = "1.2.3.4:8000" +# shredstore stored in [storage] section +# rpc_addr = "127.0.0.1:3000" +# grpc_addr = "127.0.0.1:3001" +# See config.example.toml for full Lightbringer sidecar options. + +[tuning] txpar = 24 # Recommended: 2x your CPU core count [rpc] @@ -259,7 +271,7 @@ func runConfigSet(key, value string) { } // Write back - err = os.WriteFile(configFile, []byte(strings.Join(result, "\n")), 0644) + err = tui.AtomicWriteFile(configFile, []byte(strings.Join(result, "\n")), 0600) if err != nil { fmt.Printf("Error writing config file: %v\n", err) os.Exit(1) @@ -268,14 +280,19 @@ func runConfigSet(key, value string) { fmt.Printf("Set %s = %s\n", key, quotedValue) } -// formatTOMLValue formats a value appropriately for TOML +// formatTOMLValue formats a value appropriately for TOML. +// Returns the canonical form of the parsed value to prevent injection +// via newlines or other control characters in the raw input. func formatTOMLValue(value string) string { - // Check if it's a number - if _, err := fmt.Sscanf(value, "%d", new(int)); err == nil { - return value + // Check if it's an integer — return the parsed number, not raw input + var n int + if _, err := fmt.Sscanf(value, "%d", &n); err == nil && fmt.Sprintf("%d", n) == strings.TrimSpace(value) { + return fmt.Sprintf("%d", n) } - if _, err := fmt.Sscanf(value, "%f", new(float64)); err == nil { - return value + // Check if it's a float — return the parsed number, not raw input + var f float64 + if _, err := fmt.Sscanf(value, "%f", &f); err == nil && !strings.ContainsAny(value, "\n\r") { + return strconv.FormatFloat(f, 'f', -1, 64) } // Check if it's a boolean @@ -283,13 +300,13 @@ func formatTOMLValue(value string) string { return value } - // Check if it's already an array - if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { + // Check if it's already an array (no newlines allowed) + if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") && !strings.ContainsAny(value, "\n\r") { return value } - // Otherwise, quote it as a string - return fmt.Sprintf(`"%s"`, value) + // Otherwise, quote it as a string (safe — %q escapes all control chars) + return fmt.Sprintf("%q", value) } // runConfigGet reads a key from the config file diff --git a/cmd/mithril/configcmd/edit.go b/cmd/mithril/configcmd/edit.go new file mode 100644 index 00000000..fe975c28 --- /dev/null +++ b/cmd/mithril/configcmd/edit.go @@ -0,0 +1,916 @@ +package configcmd + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/tui" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var EditCmd = cobra.Command{ + Use: "edit", + Short: "Interactively edit configuration", + Long: "Opens an interactive editor for the current config file. Pre-fills with existing values.", + Run: func(cmd *cobra.Command, args []string) { + runConfigEdit() + }, +} + +func init() { + ConfigCmd.AddCommand(&EditCmd) + EditCmd.Flags().StringVarP(&configFile, "config", "c", "config.toml", "Path to config file") +} + +// ── Theme (matches setupcmd theme) ────────────────────────────────────── + +var ( + edTeal = tui.MithrilTeal + edTextPrimary = tui.ColorTextPrimary + edTextSecond = tui.ColorTextSecondary + edTextMuted = tui.ColorTextMuted + edTextDisable = tui.ColorTextDisabled + edError = tui.ColorError +) + +// ── Screen constants ──────────────────────────────────────────────────── + +const ( + edScrSections = iota // main section picker + edScrCluster + edScrRPC + edScrLightbringer + edScrGossip + edScrStorage + edScrAccountsPath + edScrSnapshotsPath + edScrLogsPath + edScrTuning + edScrBlockRPS + edScrBlockInflight + edScrRPCPort + edScrLogLevel + edScrBootstrap + edScrDone +) + +// ── Menu item ─────────────────────────────────────────────────────────── + +type edItem struct { + label string + value string + desc string + isSep bool +} + +// ── Model ─────────────────────────────────────────────────────────────── + +type editModel struct { + configFile string + screen int + stack []int + cursor int + editing bool + inputVal string + inputErr string + inputCur int + width int + + // Config values + cluster string + rpcEndpoint string + lbEnabled bool + gossipEntry string + accountsPath string + snapshotsPath string + logsPath string + txpar string + blockMaxRPS string + blockInflight string + rpcPort string + logLevel string + bootstrapMode string + + // Full RPC array (preserved on save to avoid destroying failover endpoints) + rpcFull []string + txparWasSet bool // true if txpar was explicitly in the config + + // Original viper for fallbacks + v *viper.Viper + + // Result + saved bool + err error +} + +func newEditModel(cf string, v *viper.Viper) editModel { + cluster := v.GetString("network.cluster") + if cluster == "" { + cluster = "mainnet-beta" + } + rpcSlice := v.GetStringSlice("network.rpc") + rpcEndpoint := "" + if len(rpcSlice) > 0 { + rpcEndpoint = rpcSlice[0] + } + // rpcFull stored in model to preserve failover endpoints on save + txpar := v.GetString("tuning.txpar") + if txpar == "" { + txpar = v.GetString("replay.txpar") + } + txparWasSet := txpar != "" // track if txpar was explicitly configured + blockMaxRPS := v.GetString("block.max_rps") + if blockMaxRPS == "" { + blockMaxRPS = "8" + } + blockInflight := v.GetString("block.max_inflight") + if blockInflight == "" { + blockInflight = "8" + } + rpcPort := v.GetString("rpc.port") + if rpcPort == "" { + rpcPort = "8899" + } + logLevel := v.GetString("log.level") + if logLevel == "" { + logLevel = "info" + } + bootstrapMode := v.GetString("bootstrap.mode") + if bootstrapMode == "" { + bootstrapMode = "auto" + } + logsPath := v.GetString("storage.logs") + if logsPath == "" { + logsPath = v.GetString("log.dir") + } + + return editModel{ + configFile: cf, + screen: edScrSections, + v: v, + cluster: cluster, + rpcEndpoint: rpcEndpoint, + rpcFull: rpcSlice, + txparWasSet: txparWasSet, + lbEnabled: v.GetBool("lightbringer.enabled"), + gossipEntry: v.GetString("lightbringer.gossip_entrypoint"), + accountsPath: v.GetString("storage.accounts"), + snapshotsPath: v.GetString("storage.snapshots"), + logsPath: logsPath, + txpar: txpar, + blockMaxRPS: blockMaxRPS, + blockInflight: blockInflight, + rpcPort: rpcPort, + logLevel: logLevel, + bootstrapMode: bootstrapMode, + } +} + +func (m editModel) Init() tea.Cmd { return nil } + +// ── Navigation ────────────────────────────────────────────────────────── + +func (m *editModel) pushMenu(scr int) { + m.stack = append(m.stack, m.screen) + m.screen = scr + m.cursor = 0 + m.editing = false + m.inputErr = "" +} + +func (m *editModel) pushInput(scr int) { + m.stack = append(m.stack, m.screen) + m.screen = scr + m.editing = true + m.inputErr = "" + m.inputVal = m.inputValueForScreen(scr) + m.inputCur = len(m.inputVal) +} + +func (m *editModel) goBack() { + if len(m.stack) == 0 { + return + } + m.screen = m.stack[len(m.stack)-1] + m.stack = m.stack[:len(m.stack)-1] + m.cursor = 0 + m.inputErr = "" + if m.isInputScreen(m.screen) { + m.editing = true + m.inputVal = m.inputValueForScreen(m.screen) + m.inputCur = len(m.inputVal) + } else { + m.editing = false + } +} + +func (m editModel) isInputScreen(scr int) bool { + switch scr { + case edScrRPC, edScrGossip, edScrAccountsPath, edScrSnapshotsPath, + edScrLogsPath, edScrTuning, edScrBlockRPS, edScrBlockInflight, edScrRPCPort: + return true + } + return false +} + +func (m editModel) inputValueForScreen(scr int) string { + switch scr { + case edScrRPC: + return m.rpcEndpoint + case edScrGossip: + return m.gossipEntry + case edScrAccountsPath: + return m.accountsPath + case edScrSnapshotsPath: + return m.snapshotsPath + case edScrLogsPath: + return m.logsPath + case edScrTuning: + return m.txpar + case edScrBlockRPS: + return m.blockMaxRPS + case edScrBlockInflight: + return m.blockInflight + case edScrRPCPort: + return m.rpcPort + } + return "" +} + +// ── Menu items ────────────────────────────────────────────────────────── + +func (m editModel) currentItems() []edItem { + switch m.screen { + case edScrSections: + lbStatus := "disabled" + if m.lbEnabled { + lbStatus = "enabled" + } + return []edItem{ + {label: "Network", value: "network", desc: fmt.Sprintf("cluster=%s rpc=%s", m.cluster, truncate(m.rpcEndpoint, 35))}, + {label: "Lightbringer", value: "lightbringer", desc: lbStatus}, + {label: "Storage", value: "storage", desc: truncate(m.accountsPath, 30)}, + {label: "Tuning", value: "tuning", desc: fmt.Sprintf("txpar=%s", m.txpar)}, + {label: "Block Fetch", value: "block", desc: fmt.Sprintf("rps=%s inflight=%s", m.blockMaxRPS, m.blockInflight)}, + {label: "RPC Server", value: "rpc", desc: fmt.Sprintf("port=%s", m.rpcPort)}, + {label: "Logging", value: "log", desc: m.logLevel}, + {label: "Bootstrap", value: "bootstrap", desc: m.bootstrapMode}, + {isSep: true}, + {label: "Save & exit", value: "save"}, + } + case edScrCluster: + return []edItem{ + {label: "mainnet-beta", value: "mainnet-beta"}, + {label: "testnet", value: "testnet"}, + {label: "devnet", value: "devnet"}, + {isSep: true}, + {label: "← Back", value: "_back"}, + } + case edScrLightbringer: + return []edItem{ + {label: "Disable", value: "disable", desc: "Use RPC only"}, + {label: "Enable", value: "enable", desc: "Sidecar for lower-latency block streaming"}, + {isSep: true}, + {label: "← Back", value: "_back"}, + } + case edScrStorage: + return []edItem{ + {label: "AccountsDB", value: "accounts", desc: truncate(m.accountsPath, 30)}, + {label: "Snapshots", value: "snapshots", desc: truncate(m.snapshotsPath, 30)}, + {label: "Logs", value: "logs", desc: truncate(m.logsPath, 30)}, + {isSep: true}, + {label: "← Back", value: "_back"}, + } + case edScrLogLevel: + return []edItem{ + {label: "debug", value: "debug"}, + {label: "info", value: "info", desc: "(recommended)"}, + {label: "warn", value: "warn"}, + {label: "error", value: "error"}, + {isSep: true}, + {label: "← Back", value: "_back"}, + } + case edScrBootstrap: + return []edItem{ + {label: "auto", value: "auto", desc: "Use existing or download snapshot"}, + {label: "snapshot", value: "snapshot", desc: "Rebuild from snapshot"}, + {label: "new-snapshot", value: "new-snapshot", desc: "Always download fresh"}, + {label: "accountsdb", value: "accountsdb", desc: "Require existing data"}, + {isSep: true}, + {label: "← Back", value: "_back"}, + } + } + return nil +} + +// ── Update ────────────────────────────────────────────────────────────── + +func (m editModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + return m, nil + + case tea.KeyMsg: + if m.screen == edScrDone { + return m, tea.Quit + } + + if m.editing { + return m.updateInput(msg) + } + return m.updateMenu(msg) + } + return m, nil +} + +func (m editModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + items := m.currentItems() + maxIdx := len(items) - 1 + + switch msg.String() { + case "q", "ctrl+c": + if m.screen == edScrSections { + return m, tea.Quit + } + m.goBack() + + case "esc": + if m.screen != edScrSections { + m.goBack() + } + + case "up", "k": + m.cursor-- + if m.cursor < 0 { + m.cursor = maxIdx + } + for items[m.cursor].isSep { + m.cursor-- + if m.cursor < 0 { + m.cursor = maxIdx + } + } + + case "down", "j": + m.cursor++ + if m.cursor > maxIdx { + m.cursor = 0 + } + for items[m.cursor].isSep { + m.cursor++ + if m.cursor > maxIdx { + m.cursor = 0 + } + } + + case "enter": + if m.cursor >= 0 && m.cursor < len(items) { + value := items[m.cursor].value + if value == "_back" { + m.goBack() + return m, nil + } + m.handleSelect(value) + } + } + return m, nil +} + +func (m *editModel) handleSelect(value string) { + switch m.screen { + case edScrSections: + switch value { + case "network": + m.pushMenu(edScrCluster) + case "lightbringer": + m.pushMenu(edScrLightbringer) + case "storage": + m.pushMenu(edScrStorage) + case "tuning": + m.pushInput(edScrTuning) + case "block": + m.pushInput(edScrBlockRPS) + case "rpc": + m.pushInput(edScrRPCPort) + case "log": + m.pushMenu(edScrLogLevel) + case "bootstrap": + m.pushMenu(edScrBootstrap) + case "save": + m.saveConfig() + m.screen = edScrDone + } + + case edScrCluster: + m.cluster = value + m.pushInput(edScrRPC) + + case edScrLightbringer: + m.lbEnabled = value == "enable" + if m.lbEnabled { + m.pushInput(edScrGossip) + } else { + m.goBack() + } + + case edScrStorage: + switch value { + case "accounts": + m.pushInput(edScrAccountsPath) + case "snapshots": + m.pushInput(edScrSnapshotsPath) + case "logs": + m.pushInput(edScrLogsPath) + } + + case edScrLogLevel: + m.logLevel = value + m.goBack() + + case edScrBootstrap: + m.bootstrapMode = value + m.goBack() + } +} + +func (m editModel) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.goBack() + case "ctrl+c": + return m, tea.Quit + case "enter": + if m.validateAndApplyInput() { + m.advanceFromInput() + } + case "backspace": + if m.inputCur > 0 { + m.inputVal = m.inputVal[:m.inputCur-1] + m.inputVal[m.inputCur:] + m.inputCur-- + } + case "left": + if m.inputCur > 0 { + m.inputCur-- + } + case "right": + if m.inputCur < len(m.inputVal) { + m.inputCur++ + } + case "ctrl+a": + m.inputCur = 0 + case "ctrl+e": + m.inputCur = len(m.inputVal) + default: + ch := msg.String() + if len(ch) == 1 && ch[0] >= 32 { + m.inputVal = m.inputVal[:m.inputCur] + ch + m.inputVal[m.inputCur:] + m.inputCur++ + } + } + return m, nil +} + +func (m *editModel) validateAndApplyInput() bool { + val := strings.TrimSpace(m.inputVal) + + switch m.screen { + case edScrRPC: + if val == "" { + m.inputErr = "RPC endpoint is required" + return false + } + m.rpcEndpoint = val + + case edScrGossip: + if val == "" || !strings.Contains(val, ":") { + m.inputErr = "Format: IP:port (e.g., 198.13.130.58:8001)" + return false + } + m.gossipEntry = val + + case edScrAccountsPath: + if val == "" { + m.inputErr = "Path is required" + return false + } + m.accountsPath = filepath.Clean(val) + + case edScrSnapshotsPath: + if val == "" { + m.inputErr = "Path is required" + return false + } + m.snapshotsPath = filepath.Clean(val) + + case edScrLogsPath: + if val == "" { + m.inputErr = "Path is required" + return false + } + m.logsPath = filepath.Clean(val) + + case edScrTuning: + if _, err := strconv.Atoi(val); err != nil { + m.inputErr = "Must be a number" + return false + } + m.txpar = val + m.txparWasSet = true + + case edScrBlockRPS: + if _, err := strconv.Atoi(val); err != nil { + m.inputErr = "Must be a number" + return false + } + m.blockMaxRPS = val + + case edScrBlockInflight: + if _, err := strconv.Atoi(val); err != nil { + m.inputErr = "Must be a number" + return false + } + m.blockInflight = val + + case edScrRPCPort: + if _, err := strconv.Atoi(val); err != nil { + m.inputErr = "Must be a number" + return false + } + m.rpcPort = val + } + + m.inputErr = "" + return true +} + +func (m *editModel) advanceFromInput() { + switch m.screen { + case edScrRPC: + m.goBack() // back to sections + m.goBack() // pop cluster too + case edScrGossip: + m.goBack() // back to sections + m.goBack() // pop lightbringer too + case edScrAccountsPath, edScrSnapshotsPath, edScrLogsPath: + m.goBack() // back to storage + case edScrTuning: + m.goBack() // back to sections + case edScrBlockRPS: + m.pushInput(edScrBlockInflight) + case edScrBlockInflight: + m.goBack() // back to sections + m.goBack() // pop blockRPS too + case edScrRPCPort: + m.goBack() // back to sections + } +} + +// ── Save ──────────────────────────────────────────────────────────────── + +func (m *editModel) saveConfig() { + data, err := os.ReadFile(m.configFile) + if err != nil { + m.err = err + return + } + + content := string(data) + content = setTomlValue(content, "network", "cluster", fmt.Sprintf("%q", m.cluster)) + // Preserve failover RPC endpoints — update first, keep rest + rpcArray := m.rpcFull + if len(rpcArray) > 0 { + rpcArray[0] = m.rpcEndpoint + } else { + rpcArray = []string{m.rpcEndpoint} + } + var rpcParts []string + for _, ep := range rpcArray { + rpcParts = append(rpcParts, fmt.Sprintf("%q", ep)) + } + content = setTomlValue(content, "network", "rpc", "["+strings.Join(rpcParts, ", ")+"]") + content = setTomlValue(content, "storage", "accounts", fmt.Sprintf("%q", filepath.Clean(m.accountsPath))) + content = setTomlValue(content, "storage", "snapshots", fmt.Sprintf("%q", filepath.Clean(m.snapshotsPath))) + content = setTomlValue(content, "block", "max_rps", m.blockMaxRPS) + content = setTomlValue(content, "block", "max_inflight", m.blockInflight) + // Only write txpar if it was originally in the config or user explicitly set a value + if m.txparWasSet && m.txpar != "" { + content = setTomlValue(content, "tuning", "txpar", m.txpar) + } + content = setTomlValue(content, "rpc", "port", m.rpcPort) + content = setTomlValue(content, "log", "level", fmt.Sprintf("%q", m.logLevel)) + content = setTomlValue(content, "bootstrap", "mode", fmt.Sprintf("%q", m.bootstrapMode)) + + if m.lbEnabled { + content = setTomlValue(content, "block", "source", "\"lightbringer\"") + // Clear stale external endpoint so runtime uses managed sidecar's grpc_addr + content = setTomlValue(content, "block", "lightbringer_endpoint", "\"\"") + if !strings.Contains(content, "[lightbringer]") { + content += fmt.Sprintf("\n[lightbringer]\nenabled = true\nbinary_path = \"./lightbringer\"\ngossip_entrypoint = %q\ngrpc_addr = \"127.0.0.1:3001\"\nrpc_addr = \"127.0.0.1:3000\"\n", m.gossipEntry) + } else { + content = setTomlValue(content, "lightbringer", "enabled", "true") + if m.gossipEntry != "" { + content = setTomlValue(content, "lightbringer", "gossip_entrypoint", fmt.Sprintf("%q", m.gossipEntry)) + } + } + } else { + // Only force block.source="rpc" if no external lightbringer_endpoint is configured. + // External LB mode (enabled=false + endpoint set) is a valid runtime config. + if m.v.GetString("block.lightbringer_endpoint") == "" { + content = setTomlValue(content, "block", "source", "\"rpc\"") + } + if strings.Contains(content, "[lightbringer]") { + content = setTomlValue(content, "lightbringer", "enabled", "false") + } + } + + if m.logsPath != "" { + content = setTomlValue(content, "storage", "logs", fmt.Sprintf("%q", filepath.Clean(m.logsPath))) + content = setTomlValue(content, "log", "dir", fmt.Sprintf("%q", filepath.Clean(m.logsPath))) + } + + if err := tui.AtomicWriteFile(m.configFile, []byte(content), 0600); err != nil { + m.err = err + return + } + m.saved = true +} + +// ── Banner ────────────────────────────────────────────────────────────── + +func edBanner() string { + return tui.RenderLogo() +} + +// ── View ──────────────────────────────────────────────────────────────── + +func (m editModel) View() string { + banner := edBanner() + + if m.editing { + title, desc := m.inputTitleDesc() + return banner + "\n" + edRenderInput(title, desc, m.inputVal, m.inputErr, m.inputCur) + } + + if m.screen == edScrDone { + return m.renderDone() + } + + title, desc := m.menuTitleDesc() + return banner + "\n" + edRenderMenu(title, desc, m.currentItems(), m.cursor) +} + +func (m editModel) menuTitleDesc() (string, string) { + switch m.screen { + case edScrSections: + return "Edit Configuration", fmt.Sprintf("Editing: %s", m.configFile) + case edScrCluster: + return "Solana Cluster", "" + case edScrLightbringer: + return "Lightbringer Sidecar", "" + case edScrStorage: + return "Storage Paths", "" + case edScrLogLevel: + return "Log Level", "" + case edScrBootstrap: + return "Bootstrap Mode", "" + } + return "", "" +} + +func (m editModel) inputTitleDesc() (string, string) { + switch m.screen { + case edScrRPC: + return "RPC Endpoint", "Primary Solana RPC endpoint URL" + case edScrGossip: + return "Gossip Entrypoint", "IP:port of a Solana validator running gossip" + case edScrAccountsPath: + return "AccountsDB Path", "Path for AccountsDB storage (~500GB, fastest NVMe)" + case edScrSnapshotsPath: + return "Snapshots Path", "Path for snapshot storage (~100GB)" + case edScrLogsPath: + return "Logs Path", "Path for log files" + case edScrTuning: + return "Transaction Parallelism", fmt.Sprintf("Recommended: %d (2x CPU cores)", runtime.NumCPU()*2) + case edScrBlockRPS: + return "Block Fetch Max RPS", "Maximum RPC requests per second for block fetching" + case edScrBlockInflight: + return "Block Fetch Max Inflight", "Maximum concurrent in-flight block requests" + case edScrRPCPort: + return "Mithril RPC Port", "Port for Mithril's RPC server (0 to disable)" + } + return "", "" +} + +func (m editModel) renderDone() string { + var b strings.Builder + + if m.err != nil { + b.WriteString("\n " + lipgloss.NewStyle().Foreground(edTeal).Bold(true).Render("Save Failed")) + b.WriteString("\n\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(edError).Render("✗ "+m.err.Error())) + b.WriteString("\n\n Press any key to exit") + b.WriteString("\n") + return b.String() + } + + b.WriteString("\n " + lipgloss.NewStyle().Foreground(edTeal).Bold(true).Render("Config Updated")) + b.WriteString("\n\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(edTeal).Render("✓") + + lipgloss.NewStyle().Foreground(edTextPrimary).Render(" Saved to "+m.configFile)) + b.WriteString("\n\n Press any key to exit") + b.WriteString("\n") + return b.String() +} + +// ── Render helpers (same style as setupcmd) ───────────────────────────── + +func edRenderMenu(title, description string, items []edItem, cursor int) string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(edTeal).Bold(true).Render(title)) + b.WriteString("\n") + if description != "" { + b.WriteString(" " + lipgloss.NewStyle().Foreground(edTextMuted).Render(description)) + b.WriteString("\n") + } + b.WriteString("\n") + + maxLabel := 0 + for _, item := range items { + if !item.isSep && len(item.label) > maxLabel { + maxLabel = len(item.label) + } + } + + for i, item := range items { + if item.isSep { + b.WriteString(" " + lipgloss.NewStyle().Foreground(edTextDisable).Render(strings.Repeat("·", maxLabel+6))) + b.WriteString("\n") + continue + } + padded := fmt.Sprintf("%-*s", maxLabel+1, item.label) + if i == cursor { + arrow := lipgloss.NewStyle().Foreground(edTeal).Bold(true).Render(" ▸ ") + label := lipgloss.NewStyle().Foreground(edTeal).Bold(true).Render(padded) + line := arrow + label + if item.desc != "" { + line += " " + lipgloss.NewStyle().Foreground(edTextMuted).Render(item.desc) + } + b.WriteString(line) + } else { + label := lipgloss.NewStyle().Foreground(edTextSecond).Render(padded) + b.WriteString(" " + label) + } + b.WriteString("\n") + } + + b.WriteString("\n") + k := lipgloss.NewStyle().Foreground(edTeal) + h := lipgloss.NewStyle().Foreground(edTextDisable) + hasBack := false + for _, item := range items { + if item.value == "_back" { + hasBack = true + break + } + } + help := " " + k.Render("↑↓") + h.Render(" navigate") + + " " + k.Render("⏎") + h.Render(" select") + if hasBack { + help += " " + k.Render("esc") + h.Render(" back") + } else { + help += " " + k.Render("q") + h.Render(" quit") + } + b.WriteString(help) + return b.String() +} + +func edRenderInput(title, description, value, errMsg string, cursorPos int) string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(edTeal).Bold(true).Render(title)) + b.WriteString("\n") + if description != "" { + for _, line := range strings.Split(description, "\n") { + b.WriteString(" " + lipgloss.NewStyle().Foreground(edTextMuted).Render(line)) + b.WriteString("\n") + } + } + b.WriteString("\n") + + text := value + if cursorPos >= 0 && cursorPos <= len(text) { + before := text[:cursorPos] + after := text[cursorPos:] + cursor := lipgloss.NewStyle().Background(edTeal).Foreground(lipgloss.Color("#000000")).Render(" ") + if cursorPos < len(text) { + cursor = lipgloss.NewStyle().Background(edTeal).Foreground(lipgloss.Color("#000000")).Render(string(after[0])) + after = after[1:] + } + text = before + cursor + after + } + + prompt := lipgloss.NewStyle().Foreground(edTeal).Render("❯ ") + b.WriteString(" " + prompt + text) + b.WriteString("\n") + + underLen := len(value) + 2 + if underLen < 30 { + underLen = 30 + } + b.WriteString(" " + lipgloss.NewStyle().Foreground(edTeal).Render(strings.Repeat("─", underLen))) + b.WriteString("\n") + + if errMsg != "" { + b.WriteString("\n " + lipgloss.NewStyle().Foreground(edError).Render("✗ "+errMsg)) + b.WriteString("\n") + } + + b.WriteString("\n") + k := lipgloss.NewStyle().Foreground(edTeal) + h := lipgloss.NewStyle().Foreground(edTextDisable) + b.WriteString(" " + k.Render("⏎") + h.Render(" confirm") + + " " + k.Render("esc") + h.Render(" back") + + " " + k.Render("←→") + h.Render(" cursor")) + return b.String() +} + +// ── Entry point ───────────────────────────────────────────────────────── + +func runConfigEdit() { + if _, err := os.Stat(configFile); err != nil { + fmt.Printf("Config file not found: %s\nRun: mithril setup\n", configFile) + return + } + + v := viper.New() + v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err != nil { + fmt.Printf("Error reading config: %v\n", err) + return + } + + m := newEditModel(configFile, v) + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } +} + +// ── Utilities (kept from original) ────────────────────────────────────── + +func setTomlValue(content, section, key, value string) string { + lines := strings.Split(content, "\n") + inSection := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && !strings.HasPrefix(trimmed, "[[") { + if inSection { + // Section found but key missing — insert before next section header + result := make([]string, 0, len(lines)+1) + result = append(result, lines[:i]...) + result = append(result, key+" = "+value) + result = append(result, lines[i:]...) + return strings.Join(result, "\n") + } + sectionName := strings.Trim(trimmed, "[] ") + inSection = sectionName == section + continue + } + if inSection && (strings.HasPrefix(trimmed, key+" ") || strings.HasPrefix(trimmed, key+"=")) { + indent := "" + for _, c := range line { + if c == ' ' || c == '\t' { + indent += string(c) + } else { + break + } + } + lines[i] = fmt.Sprintf("%s%s = %s", indent, key, value) + return strings.Join(lines, "\n") + } + } + // If section was the last one (no next header), append key + if inSection { + lines = append(lines, key+" = "+value) + return strings.Join(lines, "\n") + } + // Section not found at all — append new section with key + lines = append(lines, "", "["+section+"]", key+" = "+value) + return strings.Join(lines, "\n") +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} diff --git a/cmd/mithril/dashboardcmd/components.go b/cmd/mithril/dashboardcmd/components.go new file mode 100644 index 00000000..faf896fe --- /dev/null +++ b/cmd/mithril/dashboardcmd/components.go @@ -0,0 +1,346 @@ +package dashboardcmd + +import ( + "fmt" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/tui" + "github.com/charmbracelet/lipgloss" +) + +// ── Help Item ─────────────────────────────────────────────────────────── + +type helpItem struct { + key string + desc string +} + +func renderHelpBar(items []helpItem, width int) string { + k := lipgloss.NewStyle().Foreground(tui.MithrilTeal) + h := lipgloss.NewStyle().Foreground(tui.ColorTextDisabled) + + var parts []string + for _, item := range items { + parts = append(parts, k.Render(item.key)+h.Render(" "+item.desc)) + } + line := " " + strings.Join(parts, " ") + + bg := lipgloss.NewStyle().Width(width) + return bg.Render(line) +} + +// ── Status Bar (top, below logo) ──────────────────────────────────────── + +type statusBarConfig struct { + cluster string + slot uint64 + epoch uint64 + online bool // at least one service responding + hasConfig bool +} + +func renderStatusBar(cfg statusBarConfig, width int) string { + label := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + value := lipgloss.NewStyle().Foreground(tui.ColorTextPrimary) + sep := lipgloss.NewStyle().Foreground(tui.ColorBorder).Render(" │ ") + + var parts []string + + if cfg.hasConfig { + parts = append(parts, label.Render(" ")+value.Render(cfg.cluster)) + if cfg.slot > 0 { + parts = append(parts, label.Render("slot ")+value.Render(formatNumber(cfg.slot))) + parts = append(parts, label.Render("epoch ")+value.Render(fmt.Sprintf("%d", cfg.epoch))) + // Only show Online/Offline when node has actually produced state + if cfg.online { + parts = append(parts, lipgloss.NewStyle().Foreground(tui.ColorSuccess).Render("● Online")) + } else { + parts = append(parts, lipgloss.NewStyle().Foreground(tui.ColorError).Render("● Offline")) + } + } + } else { + parts = append(parts, label.Render(" no config")) + } + + line := strings.Join(parts, sep) + + border := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(tui.ColorBorder). + Width(width - 2). + Padding(0, 1) + + return border.Render(line) +} + +// ── Footer Bar ────────────────────────────────────────────────────────── + +type footerConfig struct { + version string + configFile string +} + +func renderFooter(cfg footerConfig, width int) string { + value := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + sep := lipgloss.NewStyle().Foreground(tui.ColorBorder).Render(" │ ") + + parts := []string{ + lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true).Render(" ◎ Mithril"), + } + if cfg.version != "" { + parts = append(parts, value.Render(cfg.version)) + } + if cfg.configFile != "" { + parts = append(parts, value.Render(cfg.configFile)) + } + + return strings.Join(parts, sep) +} + +// ── Split View ────────────────────────────────────────────────────────── + +type splitViewConfig struct { + leftTitle string + leftContent string + rightTitle string + rightContent string + focusLeft bool +} + +func renderSplitView(cfg splitViewConfig, width, height int) string { + if width < 10 || height < 3 { + return "Terminal too small" + } + if width < 60 { + return renderStackedView(cfg, width, height) + } + + // Bordered split view with focus indicator + innerWidth := width - 3 // left border + center divider + right border + leftWidth := innerWidth * 22 / 100 + rightWidth := innerWidth - leftWidth + + // Focus-aware border colors + leftBorderStyle := lipgloss.NewStyle().Foreground(tui.ColorBorder) + rightBorderStyle := lipgloss.NewStyle().Foreground(tui.ColorBorder) + dividerStyle := lipgloss.NewStyle().Foreground(tui.ColorBorder) + leftTitleStyle := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + rightTitleStyle := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + + leftIndicator := "── " + rightIndicator := "── " + if cfg.focusLeft { + leftBorderStyle = lipgloss.NewStyle().Foreground(tui.MithrilTeal) + leftTitleStyle = lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + leftIndicator = "─► " + } else { + rightBorderStyle = lipgloss.NewStyle().Foreground(tui.MithrilTeal) + rightTitleStyle = lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + rightIndicator = "─► " + } + + // Top border with titles + leftTitle := leftBorderStyle.Render(leftIndicator) + leftTitleStyle.Render(cfg.leftTitle) + leftBorderStyle.Render(" ") + leftTitlePad := leftWidth + 2 - lipgloss.Width(leftTitle) + if leftTitlePad < 0 { + leftTitlePad = 0 + } + + rightTitle := dividerStyle.Render(rightIndicator) + rightTitleStyle.Render(cfg.rightTitle) + rightBorderStyle.Render(" ") + rightTitlePad := rightWidth + 2 - lipgloss.Width(rightTitle) + if rightTitlePad < 0 { + rightTitlePad = 0 + } + + top := leftBorderStyle.Render("┌") + + leftTitle + leftBorderStyle.Render(strings.Repeat("─", leftTitlePad)) + + dividerStyle.Render("┬") + + rightTitle + rightBorderStyle.Render(strings.Repeat("─", rightTitlePad)) + + rightBorderStyle.Render("┐") + + // Content rows + leftLines := padLines(cfg.leftContent, leftWidth, height) + rightLines := padLines(cfg.rightContent, rightWidth, height) + + var rows []string + rows = append(rows, top) + for i := 0; i < height; i++ { + row := leftBorderStyle.Render("│") + " " + leftLines[i] + " " + + dividerStyle.Render("│") + " " + rightLines[i] + " " + + rightBorderStyle.Render("│") + rows = append(rows, row) + } + + // Bottom border + bottom := leftBorderStyle.Render("└") + + leftBorderStyle.Render(strings.Repeat("─", leftWidth+2)) + + dividerStyle.Render("┴") + + rightBorderStyle.Render(strings.Repeat("─", rightWidth+2)) + + rightBorderStyle.Render("┘") + rows = append(rows, bottom) + + return strings.Join(rows, "\n") +} + +func renderStackedView(cfg splitViewConfig, width, height int) string { + borderStyle := lipgloss.NewStyle().Foreground(tui.ColorBorder) + titleStyle := lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + innerWidth := width - 2 + + topHeight := height * 35 / 100 + if topHeight < 3 { + topHeight = 3 + } + bottomHeight := height - topHeight - 1 + if bottomHeight < 3 { + bottomHeight = 3 + } + + // Top border + topTitle := borderStyle.Render("─► ") + titleStyle.Render(cfg.leftTitle) + borderStyle.Render(" ") + topTitlePad := innerWidth + 2 - lipgloss.Width(topTitle) + if topTitlePad < 0 { + topTitlePad = 0 + } + top := borderStyle.Render("┌") + topTitle + borderStyle.Render(strings.Repeat("─", topTitlePad)) + borderStyle.Render("┐") + + topLines := padLines(cfg.leftContent, innerWidth, topHeight) + var topRows []string + for _, l := range topLines { + topRows = append(topRows, borderStyle.Render("│")+" "+l+" "+borderStyle.Render("│")) + } + + // Mid divider + midTitle := borderStyle.Render("─► ") + titleStyle.Render(cfg.rightTitle) + borderStyle.Render(" ") + midTitlePad := innerWidth + 2 - lipgloss.Width(midTitle) + if midTitlePad < 0 { + midTitlePad = 0 + } + mid := borderStyle.Render("├") + midTitle + borderStyle.Render(strings.Repeat("─", midTitlePad)) + borderStyle.Render("┤") + + bottomLines := padLines(cfg.rightContent, innerWidth, bottomHeight) + var bottomRows []string + for _, l := range bottomLines { + bottomRows = append(bottomRows, borderStyle.Render("│")+" "+l+" "+borderStyle.Render("│")) + } + + bot := borderStyle.Render("└") + borderStyle.Render(strings.Repeat("─", innerWidth+2)) + borderStyle.Render("┘") + + return top + "\n" + strings.Join(topRows, "\n") + "\n" + mid + "\n" + strings.Join(bottomRows, "\n") + "\n" + bot +} + +// padLines splits content into lines and pads/truncates to exact width and height. +// Uses lipgloss for ANSI-aware truncation so styled text doesn't escape the pane. +func padLines(content string, width, height int) []string { + lines := strings.Split(content, "\n") + result := make([]string, height) + truncStyle := lipgloss.NewStyle().MaxWidth(width) + for i := 0; i < height; i++ { + if i < len(lines) { + line := truncStyle.Render(lines[i]) + pad := width - lipgloss.Width(line) + if pad > 0 { + line += strings.Repeat(" ", pad) + } + result[i] = line + } else { + result[i] = strings.Repeat(" ", width) + } + } + return result +} + +// ── Menu rendering for left pane ──────────────────────────────────────── + +type menuItem struct { + label string + value string + desc string + isSep bool +} + +func renderLeftMenu(items []menuItem, cursor int, width int) string { + var b strings.Builder + + // Full-row highlight for selected menu item + selectedStyle := lipgloss.NewStyle(). + Background(tui.MithrilTeal). + Foreground(lipgloss.Color("#000000")). + Bold(true) + + normalStyle := lipgloss.NewStyle(). + Foreground(tui.ColorTextSecondary) + + sepStyle := lipgloss.NewStyle(). + Foreground(tui.ColorBorder) + + for i, item := range items { + if item.isSep { + b.WriteString(sepStyle.Render(strings.Repeat("─", width)) + "\n") + continue + } + + label := fmt.Sprintf(" %-*s", width-1, item.label) + if i == cursor { + b.WriteString(selectedStyle.Render(label) + "\n") + } else { + b.WriteString(normalStyle.Render(label) + "\n") + } + } + + return b.String() +} + +// ── Utility ───────────────────────────────────────────────────────────── + +func formatNumber(n uint64) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + var result []string + for i, c := range reverseString(s) { + if i > 0 && i%3 == 0 { + result = append(result, ",") + } + result = append(result, string(c)) + } + // Reverse back + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + return strings.Join(result, "") +} + +func reverseString(s string) string { + runes := []rune(s) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes) +} + +func renderBarChart(used, total uint64, width int) string { + if total == 0 { + return strings.Repeat("░", width) + } + pct := float64(used) / float64(total) + filled := int(pct * float64(width)) + if filled > width { + filled = width + } + + bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + + var color lipgloss.Color + switch { + case pct >= 0.9: + color = tui.ColorError + case pct >= 0.8: + color = tui.ColorWarn + default: + color = tui.MithrilTeal + } + + return lipgloss.NewStyle().Foreground(color).Render(bar) +} diff --git a/cmd/mithril/dashboardcmd/dashboard.go b/cmd/mithril/dashboardcmd/dashboard.go new file mode 100644 index 00000000..2ec009bd --- /dev/null +++ b/cmd/mithril/dashboardcmd/dashboard.go @@ -0,0 +1,1005 @@ +package dashboardcmd + +import ( + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Overclock-Validator/mithril/cmd/mithril/setupcmd" + "github.com/Overclock-Validator/mithril/pkg/tui" + "github.com/Overclock-Validator/mithril/pkg/version" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +var configFile string + +var DashboardCmd = cobra.Command{ + Use: "dashboard", + Short: "Interactive node management dashboard", + Long: "Opens a full-screen TUI dashboard for monitoring and managing the Mithril node.", + Run: func(cmd *cobra.Command, args []string) { + runDashboard() + }, +} + +func init() { + DashboardCmd.Flags().StringVarP(&configFile, "config", "c", "config.toml", "Path to config file") +} + +// ── Screen constants ──────────────────────────────────────────────────── + +const ( + screenOverview = iota + screenConfig + screenEdit // inline config editing + screenDoctor + screenLogs + screenDisk +) + +// Edit modes for inline config editing +const ( + editNone = iota // not editing + editMenu // selecting from fixed options + editText // typing free-form text +) + +// Log pane selection +const ( + logPaneMithril = 0 + logPaneLightbringer = 1 +) + +type editOption struct { + label string + value string + desc string +} + +// editFieldDef defines a single editable config field. +type editFieldDef struct { + section string // TOML section name + key string // TOML key within section + label string // display label + isSep bool // visual separator +} + +// ── Async data messages ───────────────────────────────────────────────── + +type dataRefreshedMsg struct { + hasConfig bool + cfg *configData + state *nodeState + services []serviceStatus + checks []checkResult + mithrilLines []string + lbLines []string +} + +type diskRefreshedMsg struct { + disks []diskUsage +} + +func fetchDataCmd(cfgFile string) tea.Cmd { + return func() tea.Msg { + var cfg *configData + var state *nodeState + hasConfig := false + + if _, err := os.Stat(cfgFile); err == nil { + hasConfig = true + cfg = readConfig(cfgFile) + } + + if cfg != nil && cfg.accountsPath != "" { + state = readState(cfg.accountsPath) + } + + services := probeServices(cfg) + checks := runDoctorChecks(cfgFile, cfg) + + var mithrilLines, lbLines []string + if cfg != nil { + mithrilLines = readLogTail(cfg.logsPath, "mithril.log", 50) + lbLines = readLogTail(cfg.logsPath, "lightbringer.log", 50) + } + + return dataRefreshedMsg{ + hasConfig: hasConfig, + cfg: cfg, + state: state, + services: services, + checks: checks, + mithrilLines: mithrilLines, + lbLines: lbLines, + } + } +} + +func fetchDiskCmd(cfg *configData) tea.Cmd { + return func() tea.Msg { + return diskRefreshedMsg{disks: getDiskUsage(cfg)} + } +} + +func tickCmd() tea.Cmd { + return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func slowTickCmd() tea.Cmd { + return tea.Tick(30*time.Second, func(t time.Time) tea.Msg { return slowTickMsg(t) }) +} + +type tickMsg time.Time +type slowTickMsg time.Time +type childExitMsg struct{} // sent when embedded child TUI wants to quit + +// ── Model ─────────────────────────────────────────────────────────────── + +// Dashboard mode: normal dashboard or embedded sub-TUI (setup) +const ( + modeDashboard = iota + modeSetup +) + +type model struct { + width int + height int + cursor int + screen int + hasConfig bool + configFile string + + // Embedded sub-TUI (for setup) + mode int + childM tea.Model + + // Inline config editing state + editFields []editFieldDef // all editable fields + editIdx int // which field is selected + editMode int // editNone, editMenu, or editText + editOptions []editOption // options for menu-based editing + editOptCursor int // cursor in options menu + editValue string // current input value + editCursor int // cursor position in input + editErr string // validation error for current edit + + // Right pane scroll + rightScroll int + + // Data + cfg *configData + state *nodeState + services []serviceStatus + disks []diskUsage + checks []checkResult + mithrilLines []string + lbLines []string + logScroll int // scroll offset for focused log pane + logFocused bool // true when user is scrolling logs with ↑↓ + logPane int // 0=mithril (left), 1=lightbringer (right) + disksLoaded bool // true after first disk fetch completes + + // Menu + items []menuItem +} + +func newModel(cf string) model { + return model{ + configFile: cf, + screen: screenOverview, + items: []menuItem{ + {label: "Overview", value: "overview"}, + {label: "Config", value: "config"}, + {label: "Edit Config", value: "edit"}, + {label: "Doctor", value: "doctor"}, + {label: "Logs", value: "logs"}, + {label: "Disk", value: "disk"}, + {isSep: true}, + {label: "Create Config", value: "setup"}, + }, + editFields: []editFieldDef{ + {section: "network", key: "cluster", label: "Cluster"}, + {section: "network", key: "rpc", label: "RPC Endpoint"}, + {isSep: true}, + {section: "storage", key: "accounts", label: "AccountsDB Path"}, + {section: "storage", key: "snapshots", label: "Snapshots Path"}, + {section: "storage", key: "shredstore", label: "Shredstore Path"}, + {section: "storage", key: "logs", label: "Logs Path"}, + {isSep: true}, + {section: "block", key: "source", label: "Block Source"}, + {section: "block", key: "max_rps", label: "Block Max RPS"}, + {section: "block", key: "max_inflight", label: "Block Max Inflight"}, + {isSep: true}, + {section: "lightbringer", key: "enabled", label: "Lightbringer"}, + {section: "lightbringer", key: "gossip_entrypoint", label: "Gossip Entrypoint"}, + {section: "lightbringer", key: "grpc_addr", label: "LB gRPC Address"}, + {section: "lightbringer", key: "rpc_addr", label: "LB HTTP Address"}, + {isSep: true}, + {section: "tuning", key: "txpar", label: "TX Parallelism"}, + {section: "rpc", key: "port", label: "RPC Port"}, + {isSep: true}, + {section: "log", key: "level", label: "Log Level"}, + {section: "bootstrap", key: "mode", label: "Bootstrap Mode"}, + }, + } +} + +func (m model) Init() tea.Cmd { + // Non-blocking: fetch data asynchronously on startup + return tea.Batch( + fetchDataCmd(m.configFile), + tickCmd(), + slowTickCmd(), + ) +} + +// ── Update ────────────────────────────────────────────────────────────── + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // If a child TUI is active, delegate all messages to it + if m.mode != modeDashboard && m.childM != nil { + // Intercept ctrl+c and esc on first screen — return to dashboard + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "ctrl+c" { + m.mode = modeDashboard + m.childM = nil + return m, nil + } + // Esc on the wizard's first screen (mode selection) exits back to dashboard + if keyMsg.String() == "esc" && setupcmd.SetupIsFirstScreen(m.childM) { + m.mode = modeDashboard + m.childM = nil + return m, nil + } + } + + // Check if child is done BEFORE updating (the done screen's q/enter sends tea.Quit) + isDone := false + if m.mode == modeSetup { + isDone = setupcmd.SetupIsDone(m.childM) + } + + // If child is on done screen and user presses q/enter, return to dashboard + if isDone { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "q", "enter": + m.mode = modeDashboard + m.childM = nil + return m, tea.Batch( + fetchDataCmd(m.configFile), + fetchDiskCmd(m.cfg), + ) + } + } + // Still show the done screen for other keys + return m, nil + } + + newChild, childCmd := m.childM.Update(msg) + m.childM = newChild + // Intercept tea.Quit from child — return to dashboard instead of quitting + if childCmd != nil { + return m, func() tea.Msg { + result := childCmd() + if _, ok := result.(tea.QuitMsg); ok { + return childExitMsg{} + } + return result + } + } + return m, nil + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case dataRefreshedMsg: + m.hasConfig = msg.hasConfig + m.cfg = msg.cfg + m.state = msg.state + m.services = msg.services + m.checks = msg.checks + m.mithrilLines = msg.mithrilLines + m.lbLines = msg.lbLines + // Trigger disk fetch once config is loaded (first time only) + if !m.disksLoaded && m.cfg != nil { + return m, fetchDiskCmd(m.cfg) + } + return m, nil + + case diskRefreshedMsg: + m.disks = msg.disks + // Only mark loaded when we had a real config to fetch from + if m.cfg != nil { + m.disksLoaded = true + } + return m, nil + + case childExitMsg: + m.mode = modeDashboard + m.childM = nil + return m, tea.Batch( + fetchDataCmd(m.configFile), + fetchDiskCmd(m.cfg), + ) + + case tickMsg: + return m, tea.Batch(tickCmd(), fetchDataCmd(m.configFile)) + + case slowTickMsg: + return m, tea.Batch(slowTickCmd(), fetchDiskCmd(m.cfg)) + + case tea.KeyMsg: + switch msg.String() { + case "q": + if m.editMode == editNone && !m.logFocused { + return m, tea.Quit + } + // In text edit mode, insert 'q' as a character + if m.editMode == editText { + m.editValue = m.editValue[:m.editCursor] + "q" + m.editValue[m.editCursor:] + m.editCursor++ + return m, nil + } + // In editMenu or logFocused: ignore q (use esc to exit first) + case "ctrl+c": + return m, tea.Quit + + case "up", "k": + if m.logFocused { + m.logScroll-- + if m.logScroll < 0 { + m.logScroll = 0 + } + return m, nil + } + if m.screen == screenEdit && m.editMode == editMenu { + m.editOptCursor-- + if m.editOptCursor < 0 { + m.editOptCursor = len(m.editOptions) - 1 + } + } else if m.screen == screenEdit && m.editMode == editNone { + m.moveEditCursor(-1) + } else if m.editMode == editNone { + m.moveCursor(-1) + } + + case "down", "j": + if m.logFocused { + // Cap scroll — use a generous limit since wrapped lines expand count + maxScroll := len(m.mithrilLines) * 3 // approximate: up to 3x after wrapping + if m.logPane == logPaneLightbringer { + maxScroll = len(m.lbLines) * 3 + } + if m.logScroll < maxScroll { + m.logScroll++ + } + return m, nil + } + if m.screen == screenEdit && m.editMode == editMenu { + m.editOptCursor++ + if m.editOptCursor >= len(m.editOptions) { + m.editOptCursor = 0 + } + } else if m.screen == screenEdit && m.editMode == editNone { + m.moveEditCursor(1) + } else if m.editMode == editNone { + m.moveCursor(1) + } + + case "enter": + if m.screen == screenEdit && m.editMode == editNone { + m.startEditField() + return m, nil + } + if m.screen == screenEdit && m.editMode == editText { + m.applyEditField() + return m, nil + } + if m.screen == screenEdit && m.editMode == editMenu { + m.applyMenuSelection() + return m, nil + } + if cmd := m.selectCurrent(); cmd != nil { + return m, cmd + } + + case "esc": + if m.logFocused { + m.logFocused = false + return m, nil + } + if m.editMode != editNone { + m.editMode = editNone + return m, nil + } + if m.screen == screenEdit { + m.screen = screenConfig + return m, nil + } + + case "r": + return m, tea.Batch( + fetchDataCmd(m.configFile), + fetchDiskCmd(m.cfg), + ) + + case "e": + if m.hasConfig && (m.screen == screenConfig || m.screen == screenOverview) { + m.screen = screenEdit + m.editIdx = 0 + m.moveEditCursor(0) + // Move left menu cursor to "Edit Config" to stay in sync + for i, item := range m.items { + if item.value == "edit" { + m.cursor = i + break + } + } + } + + case "backspace": + if m.editMode == editText && m.editCursor > 0 { + m.editValue = m.editValue[:m.editCursor-1] + m.editValue[m.editCursor:] + m.editCursor-- + return m, nil + } + + case "left": + if m.logFocused { + m.logPane = logPaneMithril + m.logScroll = 0 + return m, nil + } + if m.editMode == editText && m.editCursor > 0 { + m.editCursor-- + return m, nil + } + + case "right": + if m.logFocused { + m.logPane = logPaneLightbringer + m.logScroll = 0 + return m, nil + } + if m.editMode == editText && m.editCursor < len(m.editValue) { + m.editCursor++ + return m, nil + } + + case "pgdown": + m.rightScroll += 5 + if m.rightScroll > 500 { // reasonable upper bound + m.rightScroll = 500 + } + case "pgup": + m.rightScroll -= 5 + if m.rightScroll < 0 { + m.rightScroll = 0 + } + + default: + // Text input for inline editing + if m.editMode == editText { + ch := msg.String() + if len(ch) == 1 && ch[0] >= 32 { + m.editValue = m.editValue[:m.editCursor] + ch + m.editValue[m.editCursor:] + m.editCursor++ + return m, nil + } + } + } + } + return m, nil +} + +// moveCursor moves the cursor by delta (+1 or -1) and skips separators. +// Terminates after a full cycle to prevent infinite loops. +func (m *model) moveCursor(delta int) { + n := len(m.items) + if n == 0 { + return + } + m.cursor = (m.cursor + delta + n) % n + start := m.cursor + for m.items[m.cursor].isSep { + m.cursor = (m.cursor + delta + n) % n + if m.cursor == start { + break // all separators — no selectable item + } + } +} + +func (m *model) selectCurrent() tea.Cmd { + if m.cursor >= len(m.items) { + return nil + } + item := m.items[m.cursor] + m.rightScroll = 0 + m.logFocused = false + m.logScroll = 0 + m.editMode = editNone + switch item.value { + case "overview": + m.screen = screenOverview + case "config": + m.screen = screenConfig + case "doctor": + m.screen = screenDoctor + case "logs": + if m.screen == screenLogs { + // Already on Logs — toggle scroll focus + m.logFocused = !m.logFocused + return nil + } + m.screen = screenLogs + case "disk": + m.screen = screenDisk + // Fetch disk data immediately when navigating to Disk screen + return fetchDiskCmd(m.cfg) + case "edit": + // Switch to inline config editing in the right pane + m.screen = screenEdit + m.editIdx = 0 + m.moveEditCursor(0) + case "setup": + // Embed setup directly, pass current config path + m.mode = modeSetup + m.childM = setupcmd.NewSetupModel(m.configFile) + return m.childM.Init() + } + return nil +} + +// ── View ──────────────────────────────────────────────────────────────── + +func (m model) View() string { + if m.width == 0 { + return "Loading..." + } + + // Logo (centered) + logo := tui.RenderLogoWidth(m.width) + + // Status bar + sbCfg := statusBarConfig{hasConfig: m.hasConfig} + if m.cfg != nil { + sbCfg.cluster = m.cfg.cluster + } + if m.state != nil { + sbCfg.slot = m.state.LastSlot + sbCfg.epoch = m.state.LastEpoch + } + for _, svc := range m.services { + if svc.up { + sbCfg.online = true + break + } + } + statusBar := renderStatusBar(sbCfg, m.width) + + // Calculate content height + logoHeight := lipgloss.Height(logo) + statusBarHeight := lipgloss.Height(statusBar) + helpHeight := 1 + footerHeight := 1 + spacing := 3 + contentHeight := m.height - logoHeight - statusBarHeight - helpHeight - footerHeight - spacing + if contentHeight < 6 { + contentHeight = 6 + } + + // Left pane: menu (pass width for full-row highlight) + leftPaneWidth := (m.width - 3) * 22 / 100 + leftContent := renderLeftMenu(m.items, m.cursor, leftPaneWidth) + + // Right pane: child TUI (setup) or screen-specific content + var rightContent string + if m.mode != modeDashboard && m.childM != nil { + rightContent = m.childM.View() + } else { + rightContent = m.renderRightPane() + } + + // Apply scroll offset for long content + rightLines := strings.Split(rightContent, "\n") + totalRightLines := len(rightLines) + + if m.rightScroll > 0 { + if m.rightScroll >= totalRightLines { + m.rightScroll = totalRightLines - 1 + } + if m.rightScroll < 0 { + m.rightScroll = 0 + } + rightLines = rightLines[m.rightScroll:] + } + + // Add scroll indicators when content overflows + scrollHint := lipgloss.NewStyle().Foreground(tui.ColorTextDisabled) + if len(rightLines) > contentHeight && contentHeight > 2 { + rightLines[contentHeight-1] = scrollHint.Render(" ▼ pgdn for more") + } + if m.rightScroll > 0 && len(rightLines) > 0 { + rightLines[0] = scrollHint.Render(" ▲ pgup to scroll up") + } + + rightContent = strings.Join(rightLines, "\n") + + // Split view + splitCfg := splitViewConfig{ + leftTitle: "Menu", + leftContent: leftContent, + rightTitle: m.rightPaneTitle(), + rightContent: rightContent, + focusLeft: true, + } + content := renderSplitView(splitCfg, m.width, contentHeight) + + // Help bar + helpItems := m.helpItems() + help := renderHelpBar(helpItems, m.width) + + // Footer + fCfg := footerConfig{ + version: version.Version, + configFile: m.configFile, + } + footer := renderFooter(fCfg, m.width) + + return lipgloss.JoinVertical(lipgloss.Left, + logo, + statusBar, + "", + content, + help, + footer, + ) +} + +// moveEditCursor moves the edit field cursor, skipping separators. +func (m *model) moveEditCursor(delta int) { + n := len(m.editFields) + if n == 0 { + return + } + if delta != 0 { + m.editIdx = (m.editIdx + delta + n) % n + } + start := m.editIdx + for m.editFields[m.editIdx].isSep { + if delta == 0 { + delta = 1 + } + m.editIdx = (m.editIdx + delta + n) % n + if m.editIdx == start { + break + } + } +} + +// getFieldValue returns the current config value for a field. +func (m model) getFieldValue(f editFieldDef) string { + if m.cfg == nil { + return "" + } + key := f.section + "." + f.key + switch key { + case "network.cluster": + return m.cfg.cluster + case "network.rpc": + if len(m.cfg.rpcEndpoints) > 0 { + return m.cfg.rpcEndpoints[0] + } + return "" + case "storage.accounts": + return m.cfg.accountsPath + case "storage.snapshots": + return m.cfg.snapshotsPath + case "storage.shredstore": + return m.cfg.shredstorePath + case "storage.logs": + return m.cfg.logsPath + case "block.source": + return m.cfg.blockSource + case "block.max_rps": + return m.cfg.blockMaxRPS + case "block.max_inflight": + return m.cfg.blockInflight + case "lightbringer.enabled": + if m.cfg.lbEnabled { + return "true" + } + return "false" + case "lightbringer.gossip_entrypoint": + return m.cfg.lbGossip + case "lightbringer.grpc_addr": + return m.cfg.lbGrpcAddr + case "lightbringer.rpc_addr": + return m.cfg.lbRpcAddr + case "tuning.txpar": + return m.cfg.txpar + case "rpc.port": + return m.cfg.rpcPort + case "log.level": + return m.cfg.logLevel + case "bootstrap.mode": + return m.cfg.bootstrapMode + } + return "" +} + +// menuOptionsFor returns menu options for a field, or nil if it's a text field. +func menuOptionsFor(section, key string) []editOption { + switch section + "." + key { + case "network.cluster": + return []editOption{ + {label: "mainnet-beta", value: "mainnet-beta"}, + {label: "testnet", value: "testnet"}, + {label: "devnet", value: "devnet"}, + } + case "block.source": + return []editOption{ + {label: "rpc", value: "rpc", desc: "Fetch blocks via RPC"}, + {label: "lightbringer", value: "lightbringer", desc: "Sidecar streaming"}, + } + case "lightbringer.enabled": + return []editOption{ + {label: "false", value: "false", desc: "Disabled"}, + {label: "true", value: "true", desc: "Enabled"}, + } + case "log.level": + return []editOption{ + {label: "debug", value: "debug"}, + {label: "info", value: "info", desc: "recommended"}, + {label: "warn", value: "warn"}, + {label: "error", value: "error"}, + } + case "bootstrap.mode": + return []editOption{ + {label: "auto", value: "auto", desc: "Use existing or download snapshot"}, + {label: "snapshot", value: "snapshot", desc: "Rebuild from snapshot"}, + {label: "new-snapshot", value: "new-snapshot", desc: "Always download fresh"}, + {label: "accountsdb", value: "accountsdb", desc: "Require existing data, fail if missing"}, + } + } + return nil +} + +// startEditField begins inline editing of the selected field. +func (m *model) startEditField() { + if m.cfg == nil || m.editIdx >= len(m.editFields) || m.editFields[m.editIdx].isSep { + return + } + f := m.editFields[m.editIdx] + m.editErr = "" + + // Check if this field has menu options + if opts := menuOptionsFor(f.section, f.key); opts != nil { + m.editMode = editMenu + m.editOptions = opts + m.editOptCursor = m.findOptionIndex(m.getFieldValue(f)) + return + } + + // Text input + m.editMode = editText + m.editValue = m.getFieldValue(f) + m.editCursor = len(m.editValue) +} + +// findOptionIndex finds the index of the current value in editOptions. +func (m *model) findOptionIndex(current string) int { + for i, opt := range m.editOptions { + if opt.value == current { + return i + } + } + return 0 +} + +// applyMenuSelection saves the selected menu option to config. +func (m *model) applyMenuSelection() { + if m.cfg == nil || m.editIdx >= len(m.editFields) || m.editOptCursor >= len(m.editOptions) { + return + } + + f := m.editFields[m.editIdx] + value := m.editOptions[m.editOptCursor].value + + if err := saveConfigValue(m.configFile, f.section, f.key, value); err != nil { + m.editErr = "Save failed: " + err.Error() + return + } + + // Sync coupled fields: lightbringer.enabled ↔ block.source + // Only auto-sync when no external lightbringer_endpoint is configured + fullKey := f.section + "." + f.key + hasExternalEndpoint := m.cfg != nil && m.cfg.lbExternalEndpoint != "" + if fullKey == "lightbringer.enabled" { + if value == "true" { + _ = saveConfigValue(m.configFile, "block", "source", "lightbringer") + // Clear stale external endpoint so runtime uses managed sidecar + if hasExternalEndpoint { + _ = saveConfigValue(m.configFile, "block", "lightbringer_endpoint", "") + } + } else if !hasExternalEndpoint { + // Only force rpc when no external endpoint + _ = saveConfigValue(m.configFile, "block", "source", "rpc") + } + } else if fullKey == "block.source" { + if value == "lightbringer" && !hasExternalEndpoint { + _ = saveConfigValue(m.configFile, "lightbringer", "enabled", "true") + } + } + + m.editMode = editNone + m.cfg = readConfig(m.configFile) +} + +// applyEditField validates and saves the edited text value to config. +func (m *model) applyEditField() { + if m.cfg == nil || m.editIdx >= len(m.editFields) { + m.editMode = editNone + return + } + + f := m.editFields[m.editIdx] + value := strings.TrimSpace(m.editValue) + key := f.section + "." + f.key + + // Validate based on field type + switch { + case key == "block.max_rps" || key == "block.max_inflight": + if _, err := strconv.Atoi(value); err != nil { + m.editErr = "Must be a number" + return + } + case key == "tuning.txpar": + if value != "" { // empty = sequential (runtime default 0) + if _, err := strconv.Atoi(value); err != nil { + m.editErr = "Must be a number (or empty for sequential)" + return + } + } + case key == "rpc.port": + port, err := strconv.Atoi(value) + if err != nil || port < 0 || port > 65535 { + m.editErr = "Must be a port number (0-65535)" + return + } + case f.section == "storage": + if value == "" { + m.editErr = "Path is required" + return + } + value = filepath.Clean(value) + case key == "lightbringer.gossip_entrypoint" || key == "lightbringer.grpc_addr" || key == "lightbringer.rpc_addr": + if value != "" { + if _, _, err := net.SplitHostPort(value); err != nil { + m.editErr = "Format: host:port (e.g., 127.0.0.1:3001)" + return + } + } + case key == "network.rpc": + if value == "" { + m.editErr = "RPC endpoint is required" + return + } + } + m.editErr = "" + + // Empty txpar means sequential mode (runtime default 0) — remove both keys + if key == "tuning.txpar" && value == "" { + _ = removeConfigKey(m.configFile, "tuning", "txpar") + _ = removeConfigKey(m.configFile, "replay", "txpar") // legacy fallback + m.editMode = editNone + m.cfg = readConfig(m.configFile) + return + } + + if err := saveConfigValue(m.configFile, f.section, f.key, value); err != nil { + m.editErr = "Save failed: " + err.Error() + return + } + + m.editMode = editNone + m.cfg = readConfig(m.configFile) +} + +func (m model) rightPaneTitle() string { + switch m.screen { + case screenOverview: + return "Overview" + case screenConfig: + return "Configuration" + case screenEdit: + if m.editMode != editNone && m.editIdx < len(m.editFields) { + return "Editing: " + m.editFields[m.editIdx].label + } + return "Edit Config" + case screenDoctor: + return "Health Check" + case screenLogs: + return "Logs" + case screenDisk: + return "Disk Usage" + } + return "" +} + +func (m model) helpItems() []helpItem { + base := []helpItem{ + {key: "↑↓", desc: "navigate"}, + {key: "⏎", desc: "select"}, + {key: "r", desc: "refresh"}, + } + + switch m.screen { + case screenLogs: + if m.logFocused { + pane := "mithril" + if m.logPane == logPaneLightbringer { + pane = "lightbringer" + } + return []helpItem{ + {key: "↑↓", desc: "scroll"}, + {key: "←→", desc: "switch pane"}, + {key: "esc", desc: "back"}, + {key: "", desc: "(" + pane + ")"}, + } + } + base = append(base, helpItem{key: "⏎", desc: "scroll logs"}) + case screenConfig: + base = append(base, helpItem{key: "e", desc: "edit"}, helpItem{key: "pgdn", desc: "scroll"}) + case screenOverview: + base = append(base, helpItem{key: "e", desc: "edit"}, helpItem{key: "pgdn", desc: "scroll"}) + case screenDoctor, screenDisk: + base = append(base, helpItem{key: "pgdn", desc: "scroll"}) + case screenEdit: + if m.editMode == editText { + return []helpItem{ + {key: "⏎", desc: "save"}, + {key: "esc", desc: "cancel"}, + {key: "←→", desc: "cursor"}, + } + } + if m.editMode == editMenu { + return []helpItem{ + {key: "↑↓", desc: "select"}, + {key: "⏎", desc: "confirm"}, + {key: "esc", desc: "cancel"}, + } + } + return []helpItem{ + {key: "↑↓", desc: "section"}, + {key: "⏎", desc: "edit"}, + {key: "esc", desc: "back"}, + } + } + + base = append(base, helpItem{key: "q", desc: "quit"}) + return base +} + +// ── Entry point ───────────────────────────────────────────────────────── + +func runDashboard() { + m := newModel(configFile) + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/mithril/dashboardcmd/data.go b/cmd/mithril/dashboardcmd/data.go new file mode 100644 index 00000000..8f2704c9 --- /dev/null +++ b/cmd/mithril/dashboardcmd/data.go @@ -0,0 +1,581 @@ +package dashboardcmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/Overclock-Validator/mithril/pkg/tui" + "github.com/spf13/viper" +) + +// ── State file reading ────────────────────────────────────────────────── + +type nodeState struct { + LastSlot uint64 `json:"last_slot"` + LastEpoch uint64 `json:"last_epoch"` + LastBankhash string `json:"last_bankhash"` + SnapshotSlot uint64 `json:"snapshot_slot"` + Stage string `json:"stage"` + LastShutdownReason string `json:"last_shutdown_reason"` + LastShutdownAt string `json:"last_shutdown_at"` + CurrentRunID string `json:"current_run_id"` + LastWriterVersion string `json:"last_writer_version"` + LastWriterCommit string `json:"last_writer_commit"` + Cluster string `json:"cluster"` +} + +func readState(accountsPath string) *nodeState { + stateFile := filepath.Join(accountsPath, "mithril_state.json") + f, err := os.Open(stateFile) + if err != nil { + return nil + } + defer f.Close() + + // Cap at 1MB to prevent OOM from corrupted state files + data := make([]byte, 1<<20) + n, err := f.Read(data) + if err != nil && n == 0 { + return nil + } + + var s nodeState + if err := json.Unmarshal(data[:n], &s); err != nil { + return nil + } + return &s +} + +// ── Config reading ────────────────────────────────────────────────────── + +type configData struct { + cluster string + rpcEndpoints []string + blockSource string + lbEnabled bool + lbGossip string + lbGrpcAddr string + lbRpcAddr string + lbExternalEndpoint string // block.lightbringer_endpoint for external LB mode + lbBinaryPath string + accountsPath string + snapshotsPath string + shredstorePath string + logsPath string + txpar string + blockMaxRPS string + blockInflight string + rpcPort string + logLevel string + bootstrapMode string +} + +func readConfig(configFile string) *configData { + v := viper.New() + v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err != nil { + return nil + } + + cluster := v.GetString("network.cluster") + if cluster == "" { + cluster = "unknown" + } + + txpar := v.GetString("tuning.txpar") + if txpar == "" { + txpar = v.GetString("replay.txpar") + } + // Don't fill in a computed default — let the UI show "auto" when empty + + logsPath := v.GetString("storage.logs") + if logsPath == "" { + logsPath = v.GetString("log.dir") + } + + return &configData{ + cluster: cluster, + rpcEndpoints: v.GetStringSlice("network.rpc"), + blockSource: v.GetString("block.source"), + lbEnabled: v.GetBool("lightbringer.enabled"), + lbGossip: v.GetString("lightbringer.gossip_entrypoint"), + lbGrpcAddr: v.GetString("lightbringer.grpc_addr"), + lbRpcAddr: v.GetString("lightbringer.rpc_addr"), + lbExternalEndpoint: v.GetString("block.lightbringer_endpoint"), + lbBinaryPath: v.GetString("lightbringer.binary_path"), + accountsPath: v.GetString("storage.accounts"), + snapshotsPath: v.GetString("storage.snapshots"), + shredstorePath: v.GetString("storage.shredstore"), + logsPath: logsPath, + txpar: txpar, + blockMaxRPS: v.GetString("block.max_rps"), + blockInflight: v.GetString("block.max_inflight"), + rpcPort: v.GetString("rpc.port"), + logLevel: v.GetString("log.level"), + bootstrapMode: v.GetString("bootstrap.mode"), + } +} + +// ── Service probing ───────────────────────────────────────────────────── + +type serviceStatus struct { + name string + addr string + up bool +} + +// Default service addresses (must match config template defaults). +const ( + defaultRPCPort = "8899" + defaultLBGRPC = "127.0.0.1:3001" + defaultLBHTTP = "127.0.0.1:3000" +) + +func probeServices(cfg *configData) []serviceStatus { + rpcPort := defaultRPCPort + grpcAddr := defaultLBGRPC + httpAddr := defaultLBHTTP + + if cfg != nil { + if cfg.rpcPort != "" && cfg.rpcPort != "0" { + rpcPort = cfg.rpcPort + } + if cfg.lbGrpcAddr != "" { + grpcAddr = cfg.lbGrpcAddr + } + if cfg.lbRpcAddr != "" { + httpAddr = cfg.lbRpcAddr + } + } + + var services []serviceStatus + // Skip RPC probe when port=0 (disabled) + if cfg == nil || cfg.rpcPort != "0" { + services = append(services, serviceStatus{name: "Mithril RPC", addr: "127.0.0.1:" + rpcPort}) + } + // Probe lightbringer services based on the same mode matrix as runtime: + // - blockSource=lightbringer + enabled: managed sidecar → probe grpc + http + // - blockSource=lightbringer + endpoint: external → probe endpoint only + // - blockSource=rpc: no LB probes regardless of stale endpoint + if cfg != nil && cfg.blockSource == "lightbringer" && cfg.lbExternalEndpoint != "" && !cfg.lbEnabled { + services = append(services, serviceStatus{name: "LB External", addr: cfg.lbExternalEndpoint}) + } else if cfg != nil && cfg.lbEnabled { + services = append(services, + serviceStatus{name: "LB gRPC", addr: grpcAddr}, + serviceStatus{name: "LB HTTP", addr: httpAddr}, + ) + } + + // Probe all services concurrently to avoid 6s blocking when endpoints are down + var wg sync.WaitGroup + for i := range services { + wg.Add(1) + go func(idx int) { + defer wg.Done() + conn, err := net.DialTimeout("tcp", services[idx].addr, 2*time.Second) + if err == nil { + conn.Close() + services[idx].up = true + } + }(i) + } + wg.Wait() + + return services +} + +// ── Disk usage ────────────────────────────────────────────────────────── + +type diskUsage struct { + label string + path string + used uint64 // GB + total uint64 // GB + pct int +} + +func getDiskUsage(cfg *configData) []diskUsage { + if cfg == nil { + return nil + } + + paths := []struct { + label string + path string + }{ + {"accounts", cfg.accountsPath}, + {"snapshots", cfg.snapshotsPath}, + {"shredstore", cfg.shredstorePath}, + {"logs", cfg.logsPath}, + } + + // Run df calls in parallel — each takes up to 2s on slow filesystems + type duResult struct { + idx int + du *diskUsage + } + ch := make(chan duResult, len(paths)) + count := 0 + for i, p := range paths { + if p.path == "" { + continue + } + count++ + go func(idx int, label, path string) { + defer func() { + if recover() != nil { + ch <- duResult{idx: idx, du: nil} + } + }() + ch <- duResult{idx: idx, du: getDiskUsageForPath(label, path)} + }(i, p.label, p.path) + } + + collected := make([]*diskUsage, len(paths)) + for range count { + r := <-ch + collected[r.idx] = r.du + } + + var results []diskUsage + for _, du := range collected { + if du != nil { + results = append(results, *du) + } + } + return results +} + +func getDiskUsageForPath(label, path string) *diskUsage { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + return nil + } + + out, err := exec.Command("df", "-BG", "--", path).Output() + if err != nil { + // Try without -BG for macOS + out, err = exec.Command("df", "-g", "--", path).Output() + if err != nil { + return nil + } + } + + lines := strings.Split(string(out), "\n") + if len(lines) < 2 { + return nil + } + + fields := strings.Fields(lines[1]) + if len(fields) < 5 { + return nil + } + + total := parseGB(fields[1]) + used := parseGB(fields[2]) + pct := 0 + if total > 0 { + pct = int(float64(used) / float64(total) * 100) + } + + return &diskUsage{ + label: label, + path: path, + used: used, + total: total, + pct: pct, + } +} + +func parseGB(s string) uint64 { + s = strings.TrimSuffix(s, "G") + s = strings.TrimSpace(s) + n, _ := strconv.ParseUint(s, 10, 64) + return n +} + +// ── Doctor checks (structured) ────────────────────────────────────────── + +type checkResult struct { + name string + status string // "pass", "warn", "fail" + msg string +} + +func runDoctorChecks(configFile string, cfg *configData) []checkResult { + var results []checkResult + + // Config file + if _, err := os.Stat(configFile); err != nil { + results = append(results, checkResult{"Config file", "fail", "not found: " + configFile}) + return results + } + results = append(results, checkResult{"Config file", "pass", "found (" + configFile + ")"}) + + if cfg == nil { + results = append(results, checkResult{"Config parse", "fail", "could not parse config"}) + return results + } + + // Cluster + if cfg.cluster != "" && cfg.cluster != "unknown" { + results = append(results, checkResult{"Network", "pass", cfg.cluster}) + } else { + results = append(results, checkResult{"Network", "fail", "cluster not set"}) + } + + // RPC + if len(cfg.rpcEndpoints) > 0 { + results = append(results, checkResult{"RPC endpoint", "pass", cfg.rpcEndpoints[0]}) + } else { + results = append(results, checkResult{"RPC endpoint", "fail", "no RPC endpoints configured"}) + } + + // AccountsDB path + if cfg.accountsPath != "" { + if _, err := os.Stat(cfg.accountsPath); err == nil { + results = append(results, checkResult{"AccountsDB path", "pass", cfg.accountsPath}) + } else { + results = append(results, checkResult{"AccountsDB path", "warn", cfg.accountsPath + " (will be created)"}) + } + } else { + results = append(results, checkResult{"AccountsDB path", "fail", "not set"}) + } + + // Lightbringer + if cfg.lbEnabled { + // Check binary — use config value or default + binaryPath := cfg.lbBinaryPath + if binaryPath == "" { + binaryPath = "./lightbringer" + } + if _, err := os.Stat(binaryPath); err == nil { + results = append(results, checkResult{"Lightbringer binary", "pass", binaryPath}) + } else { + results = append(results, checkResult{"Lightbringer binary", "fail", "not found at " + binaryPath}) + } + + // Check gossip format + if cfg.lbGossip != "" { + if _, _, err := net.SplitHostPort(cfg.lbGossip); err != nil { + results = append(results, checkResult{"Gossip entrypoint", "fail", "invalid format: " + cfg.lbGossip}) + } else { + results = append(results, checkResult{"Gossip entrypoint", "pass", cfg.lbGossip}) + } + } else { + results = append(results, checkResult{"Gossip entrypoint", "fail", "not set"}) + } + } else if cfg.blockSource == "lightbringer" && cfg.lbExternalEndpoint != "" { + // External Lightbringer mode — sidecar disabled but endpoint configured + results = append(results, checkResult{"Lightbringer", "pass", "external at " + cfg.lbExternalEndpoint}) + } else if cfg.blockSource == "lightbringer" && cfg.lbExternalEndpoint == "" { + // Invalid: source=lightbringer but no sidecar and no endpoint + results = append(results, checkResult{"Lightbringer", "fail", "block.source=lightbringer requires enabled sidecar or endpoint"}) + } else { + results = append(results, checkResult{"Lightbringer", "pass", "disabled"}) + } + + // Logs + if cfg.logsPath != "" { + results = append(results, checkResult{"Log directory", "pass", cfg.logsPath}) + } else { + results = append(results, checkResult{"Log directory", "warn", "not configured (logs to stderr)"}) + } + + return results +} + +// ── Log tailing ───────────────────────────────────────────────────────── + +func readLogTail(logsPath string, filename string, maxLines int) []string { + if logsPath == "" { + return []string{"(no log directory configured)"} + } + + // Validate filename is a simple base name (no path separators) + if filename != filepath.Base(filename) || filename == "" { + return []string{"(invalid log filename)"} + } + + latestDir := filepath.Join(logsPath, "latest") + target, err := os.Readlink(latestDir) + if err != nil { + return []string{"(no latest run found in " + logsPath + ")"} + } + + // Validate symlink target — reject traversal and absolute paths + if strings.Contains(target, "..") || filepath.IsAbs(target) { + return []string{"(invalid latest symlink)"} + } + + logFile := filepath.Clean(filepath.Join(logsPath, target, filename)) + + // Verify resolved path is still under logsPath + cleanLogs := filepath.Clean(logsPath) + string(os.PathSeparator) + if !strings.HasPrefix(logFile, cleanLogs) { + return []string{"(log path escapes directory)"} + } + + // Read only the tail of the file to avoid loading huge logs into memory + f, err := os.Open(logFile) + if err != nil { + return []string{"(could not read " + logFile + ")"} + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return []string{"(could not stat " + logFile + ")"} + } + + // Read last 64KB — enough for ~500 log lines + const tailSize = 64 * 1024 + offset := stat.Size() - tailSize + if offset < 0 { + offset = 0 + } + + buf := make([]byte, stat.Size()-offset) + _, err = f.ReadAt(buf, offset) + if err != nil && !errors.Is(err, io.EOF) { + return []string{"(read error: " + err.Error() + ")"} + } + + lines := strings.Split(string(buf), "\n") + if len(lines) > maxLines { + lines = lines[len(lines)-maxLines:] + } + return lines +} + +// ── Config saving ─────────────────────────────────────────────────────── + +// saveConfigValue writes a single config field to the TOML file. +func saveConfigValue(configFile, section, key, value string) error { + data, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + content := string(data) + + // Determine TOML value format based on field type + fullKey := section + "." + key + var tomlValue string + switch { + case fullKey == "block.max_rps" || fullKey == "block.max_inflight" || + fullKey == "tuning.txpar" || fullKey == "rpc.port": + tomlValue = value // numeric — no quoting + case fullKey == "lightbringer.enabled": + tomlValue = value // boolean — no quoting + case fullKey == "network.rpc": + // Preserve failover endpoints — read existing array, update first element + v := viper.New() + v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err == nil { + existing := v.GetStringSlice("network.rpc") + if len(existing) > 1 { + existing[0] = value + var parts []string + for _, ep := range existing { + parts = append(parts, fmt.Sprintf("%q", ep)) + } + tomlValue = "[" + strings.Join(parts, ", ") + "]" + break + } + } + tomlValue = fmt.Sprintf("[%q]", value) + default: + tomlValue = fmt.Sprintf("%q", value) // quoted string + } + + content = setTomlValueInline(content, section, key, tomlValue) + if err := tui.AtomicWriteFile(configFile, []byte(content), 0600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} + +// setTomlValueInline replaces a value in a TOML file, preserving structure. +// removeConfigKey removes a key from a TOML file (comments it out). +func removeConfigKey(configFile, section, key string) error { + data, err := os.ReadFile(configFile) + if err != nil { + return err + } + + lines := strings.Split(string(data), "\n") + inSection := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && !strings.HasPrefix(trimmed, "[[") { + sectionName := strings.Trim(trimmed, "[] ") + inSection = sectionName == section + continue + } + if inSection && (strings.HasPrefix(trimmed, key+" ") || strings.HasPrefix(trimmed, key+"=")) { + lines[i] = "# " + line // comment out instead of deleting + content := strings.Join(lines, "\n") + return tui.AtomicWriteFile(configFile, []byte(content), 0600) + } + } + return nil // key not found, nothing to remove +} + +func setTomlValueInline(content, section, key, value string) string { + lines := strings.Split(content, "\n") + inSection := false + sectionFound := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && !strings.HasPrefix(trimmed, "[[") { + if inSection { + // Section found but key missing — insert before next section header + result := make([]string, 0, len(lines)+1) + result = append(result, lines[:i]...) + result = append(result, key+" = "+value) + result = append(result, lines[i:]...) + return strings.Join(result, "\n") + } + sectionName := strings.Trim(trimmed, "[] ") + inSection = sectionName == section + if inSection { + sectionFound = true + } + continue + } + if inSection && (strings.HasPrefix(trimmed, key+" ") || strings.HasPrefix(trimmed, key+"=")) { + indent := "" + for _, c := range line { + if c == ' ' || c == '\t' { + indent += string(c) + } else { + break + } + } + lines[i] = fmt.Sprintf("%s%s = %s", indent, key, value) + return strings.Join(lines, "\n") + } + } + // If section was the last one (no next header), append key + if inSection { + lines = append(lines, key+" = "+value) + return strings.Join(lines, "\n") + } + // Section not found at all — append new section with key + if !sectionFound { + lines = append(lines, "", "["+section+"]", key+" = "+value) + return strings.Join(lines, "\n") + } + return content +} diff --git a/cmd/mithril/dashboardcmd/views.go b/cmd/mithril/dashboardcmd/views.go new file mode 100644 index 00000000..6e58347d --- /dev/null +++ b/cmd/mithril/dashboardcmd/views.go @@ -0,0 +1,805 @@ +package dashboardcmd + +import ( + "fmt" + "os" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/tui" + "github.com/charmbracelet/lipgloss" +) + +func (m model) renderRightPane() string { + if !m.hasConfig { + return m.renderNoConfig() + } + + switch m.screen { + case screenOverview: + return m.renderOverview() + case screenConfig: + return m.renderConfigView() + case screenEdit: + return m.renderEditView() + case screenDoctor: + return m.renderDoctorView() + case screenLogs: + return m.renderLogsView() + case screenDisk: + return m.renderDiskView() + } + return "" +} + +// ── No Config ─────────────────────────────────────────────────────────── + +func (m model) renderNoConfig() string { + var b strings.Builder + title := lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + muted := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + text := lipgloss.NewStyle().Foreground(tui.ColorTextPrimary) + + b.WriteString(title.Render("No configuration found") + "\n\n") + b.WriteString(muted.Render(" Looked for: ") + text.Render(m.configFile) + "\n\n") + b.WriteString(text.Render(" Select 'Create Config' from the menu to create one,") + "\n") + b.WriteString(text.Render(" or from the command line:") + "\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(tui.ColorTextSecondary).Render(" $ mithril setup") + "\n") + + return b.String() +} + +// ── Overview ──────────────────────────────────────────────────────────── + +func (m model) renderOverview() string { + var b strings.Builder + pass := lipgloss.NewStyle().Foreground(tui.ColorSuccess) + fail := lipgloss.NewStyle().Foreground(tui.ColorError) + warn := lipgloss.NewStyle().Foreground(tui.ColorWarn) + label := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + value := lipgloss.NewStyle().Foreground(tui.ColorTextPrimary) + header := lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + + // Health summary + passed := 0 + total := len(m.checks) + for _, c := range m.checks { + if c.status == "pass" { + passed++ + } + } + + b.WriteString(header.Render("Health") + "\n\n") + for _, c := range m.checks { + icon := pass.Render("✓") + if c.status == "warn" { + icon = warn.Render("~") + } else if c.status == "fail" { + icon = fail.Render("✗") + } + b.WriteString(" " + icon + " " + label.Render(c.name) + "\n") + } + b.WriteString("\n") + b.WriteString(" " + label.Render(fmt.Sprintf("%d/%d checks passed", passed, total)) + "\n") + b.WriteString("\n") + + // Services — only show when node has state (has been started before) + if m.state != nil { + b.WriteString(header.Render("Services") + "\n\n") + for _, svc := range m.services { + dot := fail.Render("○") + status := label.Render("down") + if svc.up { + dot = pass.Render("●") + status = pass.Render("up") + } + b.WriteString(" " + dot + " " + value.Render(fmt.Sprintf("%-14s", svc.name)) + label.Render(fmt.Sprintf("%-20s", svc.addr)) + status + "\n") + } + b.WriteString("\n") + } + + // Node state + if m.state != nil && m.state.LastSlot > 0 { + b.WriteString(header.Render("Node State") + "\n\n") + b.WriteString(label.Render(" Slot ") + value.Render(formatNumber(m.state.LastSlot)) + "\n") + b.WriteString(label.Render(" Epoch ") + value.Render(fmt.Sprintf("%d", m.state.LastEpoch)) + "\n") + if m.state.LastBankhash != "" { + short := m.state.LastBankhash + if len(short) > 12 { + short = short[:12] + "..." + } + b.WriteString(label.Render(" Bankhash ") + value.Render(short) + "\n") + } + if m.state.SnapshotSlot > 0 { + b.WriteString(label.Render(" Snapshot ") + value.Render(formatNumber(m.state.SnapshotSlot)) + "\n") + } + if m.state.LastShutdownReason != "" { + reason := value.Render(m.state.LastShutdownReason) + if m.state.LastShutdownAt != "" { + reason += label.Render(" at ") + value.Render(m.state.LastShutdownAt) + } + b.WriteString(label.Render(" Shutdown ") + reason + "\n") + } + if m.state.Stage != "" { + b.WriteString(label.Render(" Stage ") + value.Render(m.state.Stage) + "\n") + } + if m.state.LastWriterVersion != "" { + ver := value.Render(m.state.LastWriterVersion) + if m.state.LastWriterCommit != "" { + short := m.state.LastWriterCommit + if len(short) > 8 { + short = short[:8] + } + ver += label.Render(" (") + value.Render(short) + label.Render(")") + } + b.WriteString(label.Render(" Writer ") + ver + "\n") + } + } + + // When node hasn't run yet, show next steps instead of empty space + if m.state == nil && m.cfg != nil { + cmd := lipgloss.NewStyle().Foreground(tui.ColorTextSecondary) + b.WriteString("\n") + b.WriteString(header.Render("Next Steps") + "\n\n") + b.WriteString(label.Render(" Node has not been started yet.") + "\n\n") + b.WriteString(label.Render(" 1. Review your config ") + cmd.Render("← Config") + "\n") + b.WriteString(label.Render(" 2. Run health checks ") + cmd.Render("← Doctor") + "\n") + b.WriteString(label.Render(" 3. Start the node:") + "\n") + b.WriteString(cmd.Render(" $ mithril run --config "+m.configFile) + "\n") + } + + return b.String() +} + +// ── Config View ───────────────────────────────────────────────────────── + +// configSection holds a parsed TOML section for display. +type configSection struct { + name string + keys []string + vals []string +} + +func (m model) renderConfigView() string { + data, err := os.ReadFile(m.configFile) + if err != nil { + return "Could not read config: " + m.configFile + } + + // Parse TOML into sections + var sections []configSection + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasPrefix(trimmed, "[") && !strings.HasPrefix(trimmed, "[[") { + sections = append(sections, configSection{name: strings.Trim(trimmed, "[] ")}) + continue + } + if len(sections) > 0 { + if idx := strings.Index(trimmed, "="); idx > 0 { + k := strings.TrimSpace(trimmed[:idx]) + raw := strings.TrimSpace(trimmed[idx+1:]) + v := stripTomlQuotes(stripInlineComment(raw)) + s := §ions[len(sections)-1] + s.keys = append(s.keys, k) + s.vals = append(s.vals, v) + } + } + } + + sectionStyle := lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + keyStyle := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + valStyle := lipgloss.NewStyle().Foreground(tui.ColorTextPrimary) + hintStyle := lipgloss.NewStyle().Foreground(tui.ColorTextDisabled) + + // Count total lines needed for single-column display + totalLines := 0 + for _, s := range sections { + totalLines += 1 + len(s.keys) // header + kvs + } + totalLines += len(sections) - 1 // blank lines between sections + + // Estimate available height in right pane + availHeight := m.height - 18 + if availHeight < 10 { + availHeight = 10 + } + + // If it fits in one column, render single column + if totalLines <= availHeight { + return m.renderConfigSingleColumn(sections, sectionStyle, keyStyle, valStyle, hintStyle) + } + + // Otherwise split into two columns side by side + return m.renderConfigTwoColumns(sections, sectionStyle, keyStyle, valStyle, hintStyle) +} + +// renderConfigColumn renders sections as lines for a single column. +// Dynamically calculates key padding from the longest key in the column. +func renderConfigColumn(secs []configSection, colWidth int, sectionStyle, keyStyle, valStyle lipgloss.Style) []string { + // Find longest key for proper alignment + keyPad := 12 + for _, s := range secs { + for _, k := range s.keys { + if len(k)+2 > keyPad { // +2 for indent + keyPad = len(k) + 2 + } + } + } + // Cap key padding — leave room for values + maxKeyPad := colWidth * 45 / 100 + if keyPad > maxKeyPad { + keyPad = maxKeyPad + } + + maxVal := colWidth - keyPad - 3 + if maxVal < 8 { + maxVal = 8 + } + + var lines []string + for i, s := range secs { + if i > 0 { + lines = append(lines, "") // breathing room between sections + } + lines = append(lines, sectionStyle.Render(s.name)) + for j := range s.keys { + v := s.vals[j] + // Mask sensitive values (tokens, secrets, passwords) + k := strings.ToLower(s.keys[j]) + if strings.Contains(k, "token") || strings.Contains(k, "secret") || strings.Contains(k, "password") { + if len(v) > 4 { + v = v[:2] + strings.Repeat("*", len(v)-4) + v[len(v)-2:] + } else if len(v) > 0 { + v = "****" + } + } + if len(v) > maxVal { + v = v[:maxVal-3] + "..." + } + lines = append(lines, " "+keyStyle.Render(fmt.Sprintf("%-*s ", keyPad, s.keys[j]))+valStyle.Render(v)) + } + } + return lines +} + +func (m model) renderConfigSingleColumn(sections []configSection, sectionStyle, keyStyle, valStyle, hintStyle lipgloss.Style) string { + rightPaneWidth := (m.width - 3) * 78 / 100 + lines := renderConfigColumn(sections, rightPaneWidth, sectionStyle, keyStyle, valStyle) + + var b strings.Builder + for _, l := range lines { + b.WriteString(l + "\n") + } + b.WriteString("\n") + ks := lipgloss.NewStyle().Foreground(tui.MithrilTeal) + b.WriteString(" " + ks.Render("e") + hintStyle.Render(" edit") + " " + ks.Render("r") + hintStyle.Render(" refresh") + "\n") + return b.String() +} + +func (m model) renderConfigTwoColumns(sections []configSection, sectionStyle, keyStyle, valStyle, hintStyle lipgloss.Style) string { + // Split sections into two groups by total line count (balanced) + totalLines := 0 + for _, s := range sections { + totalLines += 2 + len(s.keys) // header + kvs + spacing + } + midpoint := totalLines / 2 + + lineCount := 0 + splitIdx := len(sections) + for i, s := range sections { + sLines := 2 + len(s.keys) + if lineCount+sLines > midpoint && lineCount > 0 { + splitIdx = i + break + } + lineCount += sLines + } + + // Column sizing: right pane width → two columns with clean gap + rightPaneWidth := (m.width - 3) * 78 / 100 + colGap := 4 + colWidth := (rightPaneWidth - colGap) / 2 + + leftLines := renderConfigColumn(sections[:splitIdx], colWidth, sectionStyle, keyStyle, valStyle) + rightLines := renderConfigColumn(sections[splitIdx:], colWidth, sectionStyle, keyStyle, valStyle) + + maxRows := len(leftLines) + if len(rightLines) > maxRows { + maxRows = len(rightLines) + } + + gap := strings.Repeat(" ", colGap) + truncStyle := lipgloss.NewStyle().MaxWidth(colWidth) + + var b strings.Builder + for i := 0; i < maxRows; i++ { + left := "" + right := "" + if i < len(leftLines) { + left = truncStyle.Render(leftLines[i]) + } + if i < len(rightLines) { + right = rightLines[i] + } + leftPad := colWidth - lipgloss.Width(left) + if leftPad > 0 { + left += strings.Repeat(" ", leftPad) + } + b.WriteString(left + gap + right + "\n") + } + + b.WriteString("\n") + ks := lipgloss.NewStyle().Foreground(tui.MithrilTeal) + b.WriteString(" " + ks.Render("e") + hintStyle.Render(" edit") + " " + ks.Render("r") + hintStyle.Render(" refresh") + "\n") + return b.String() +} + +// stripInlineComment removes the inline comment from a TOML value. +// e.g., `"auto" # some comment` → `"auto"` +func stripInlineComment(v string) string { + // Don't strip # inside quoted strings + inQuote := false + for i, c := range v { + if c == '"' { + inQuote = !inQuote + } + if c == '#' && !inQuote { + return strings.TrimSpace(v[:i]) + } + } + return v +} + +// stripTomlQuotes removes TOML string quotes and array brackets for display. +func stripTomlQuotes(v string) string { + // Array of strings: ["value"] or ["v1", "v2"] + if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") { + inner := v[1 : len(v)-1] + // Split by comma and clean each element + parts := strings.Split(inner, ",") + var clean []string + for _, p := range parts { + p = strings.TrimSpace(p) + p = strings.Trim(p, "\"") + if p != "" { + clean = append(clean, p) + } + } + return strings.Join(clean, ", ") + } + // Simple quoted string + if strings.HasPrefix(v, "\"") && strings.HasSuffix(v, "\"") && len(v) >= 2 { + return v[1 : len(v)-1] + } + return v +} + +// ── Edit View (inline config editing) ─────────────────────────────── + +func (m model) renderEditView() string { + if m.cfg == nil { + return "No config loaded." + } + + // When actively editing a field, show a focused full-pane view + if m.editMode != editNone && m.editIdx < len(m.editFields) { + return m.renderEditFocused() + } + + // Otherwise show the compact field list + return m.renderEditList() +} + +// renderEditList shows all fields in a compact scrollable list. +func (m model) renderEditList() string { + label := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + value := lipgloss.NewStyle().Foreground(tui.ColorTextPrimary) + active := lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + hint := lipgloss.NewStyle().Foreground(tui.ColorTextDisabled) + + var lines []string + selectedStart := 0 + + for i, f := range m.editFields { + if f.isSep { + lines = append(lines, "") + continue + } + + isSelected := i == m.editIdx + val := m.getFieldValue(f) + + // Auto-detect indicator for unset txpar + displayVal := val + isAuto := val == "" && f.section == "tuning" && f.key == "txpar" + if isAuto { + displayVal = "not set (sequential)" + } + if len(displayVal) > 35 { + displayVal = displayVal[:32] + "..." + } + + if isSelected { + selectedStart = len(lines) + if isAuto { + lines = append(lines, active.Render(fmt.Sprintf(" ▸ %-22s", f.label))+hint.Render(displayVal)) + } else { + lines = append(lines, active.Render(fmt.Sprintf(" ▸ %-22s", f.label))+value.Render(displayVal)) + } + } else { + if isAuto { + lines = append(lines, label.Render(fmt.Sprintf(" %-22s", f.label))+hint.Render(displayVal)) + } else { + lines = append(lines, label.Render(fmt.Sprintf(" %-22s", f.label))+value.Render(displayVal)) + } + } + } + + // Auto-scroll: show a window centered on the selected field + maxVisible := m.height - 20 + if maxVisible < 10 { + maxVisible = 10 + } + if len(lines) > maxVisible { + start := selectedStart - maxVisible/3 + if start < 0 { + start = 0 + } + end := start + maxVisible + if end > len(lines) { + end = len(lines) + start = end - maxVisible + if start < 0 { + start = 0 + } + } + lines = lines[start:end] + } + + return strings.Join(lines, "\n") + "\n" +} + +// renderEditFocused shows a single field's edit UI in the full right pane. +func (m model) renderEditFocused() string { + f := m.editFields[m.editIdx] + titleStyle := lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + subtitleStyle := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + valueStyle := lipgloss.NewStyle().Foreground(tui.ColorTextPrimary) + activeStyle := lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(tui.ColorTextDisabled) + errorStyle := lipgloss.NewStyle().Foreground(tui.ColorError) + keyStyle := lipgloss.NewStyle().Foreground(tui.MithrilTeal) + + var b strings.Builder + + // ── Title block ── + b.WriteString("\n") + b.WriteString(" " + titleStyle.Render(f.label) + "\n") + b.WriteString(" " + subtitleStyle.Render(f.section+"."+f.key) + "\n") + b.WriteString("\n") + + if m.editMode == editMenu { + // ── Menu options ── + maxLabel := 0 + for _, opt := range m.editOptions { + if len(opt.label) > maxLabel { + maxLabel = len(opt.label) + } + } + + for j, opt := range m.editOptions { + padded := fmt.Sprintf("%-*s", maxLabel+2, opt.label) + if j == m.editOptCursor { + line := " " + activeStyle.Render("▸ "+padded) + if opt.desc != "" { + line += subtitleStyle.Render(opt.desc) + } + b.WriteString(line + "\n") + } else { + line := " " + valueStyle.Render(padded) + if opt.desc != "" { + line += subtitleStyle.Render(opt.desc) + } + b.WriteString(line + "\n") + } + } + + b.WriteString("\n") + if m.editErr != "" { + b.WriteString(" " + errorStyle.Render("✗ "+m.editErr) + "\n\n") + } + + // ── Hints ── + b.WriteString(" " + keyStyle.Render("↑↓") + hintStyle.Render(" select") + + " " + keyStyle.Render("⏎") + hintStyle.Render(" confirm") + + " " + keyStyle.Render("esc") + hintStyle.Render(" cancel") + "\n") + + } else if m.editMode == editText { + // ── Current value ── + currentVal := m.getFieldValue(f) + isAuto := currentVal == "" && f.section == "tuning" && f.key == "txpar" + if isAuto { + b.WriteString(" " + subtitleStyle.Render("Current: ") + hintStyle.Render("not set (sequential)") + "\n") + } else if currentVal != "" { + b.WriteString(" " + subtitleStyle.Render("Current: ") + valueStyle.Render(currentVal) + "\n") + } + b.WriteString("\n") + + // ── Input field ── + text := m.editValue + if m.editCursor >= 0 && m.editCursor <= len(text) { + before := text[:m.editCursor] + after := text[m.editCursor:] + cur := lipgloss.NewStyle().Background(tui.MithrilTeal).Foreground(lipgloss.Color("#000000")).Render(" ") + if m.editCursor < len(text) { + cur = lipgloss.NewStyle().Background(tui.MithrilTeal).Foreground(lipgloss.Color("#000000")).Render(string(after[0])) + after = after[1:] + } + text = before + cur + after + } + + prompt := lipgloss.NewStyle().Foreground(tui.MithrilTeal).Render(" ❯ ") + b.WriteString(prompt + text + "\n") + + underLen := len(m.editValue) + 4 + if underLen < 30 { + underLen = 30 + } + b.WriteString(" " + lipgloss.NewStyle().Foreground(tui.ColorBorder).Render(strings.Repeat("─", underLen)) + "\n") + + // ── Contextual hint ── + if isAuto && m.editValue == "" { + b.WriteString("\n " + hintStyle.Render("Leave empty for sequential mode (0), or set worker count") + "\n") + } + + b.WriteString("\n") + if m.editErr != "" { + b.WriteString(" " + errorStyle.Render("✗ "+m.editErr) + "\n\n") + } + + // ── Hints ── + b.WriteString(" " + keyStyle.Render("⏎") + hintStyle.Render(" save") + + " " + keyStyle.Render("esc") + hintStyle.Render(" cancel") + + " " + keyStyle.Render("←→") + hintStyle.Render(" cursor") + "\n") + } + + return b.String() +} + +// ── Doctor View ───────────────────────────────────────────────────────── + +func (m model) renderDoctorView() string { + var b strings.Builder + pass := lipgloss.NewStyle().Foreground(tui.ColorSuccess) + fail := lipgloss.NewStyle().Foreground(tui.ColorError) + warn := lipgloss.NewStyle().Foreground(tui.ColorWarn) + label := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + + passed := 0 + total := len(m.checks) + + for _, c := range m.checks { + icon := pass.Render("✓") + if c.status == "warn" { + icon = warn.Render("~") + } else if c.status == "fail" { + icon = fail.Render("✗") + } + b.WriteString(" " + icon + " " + lipgloss.NewStyle().Foreground(tui.ColorTextPrimary).Render(fmt.Sprintf("%-20s", c.name)) + label.Render(c.msg) + "\n") + if c.status == "pass" { + passed++ + } + } + + b.WriteString("\n") + summary := fmt.Sprintf(" %d/%d checks passed", passed, total) + if passed == total { + b.WriteString(pass.Render(summary+" — ready to run!") + "\n") + } else { + b.WriteString(warn.Render(summary) + "\n") + } + + b.WriteString("\n") + k := lipgloss.NewStyle().Foreground(tui.MithrilTeal) + hint := lipgloss.NewStyle().Foreground(tui.ColorTextDisabled) + b.WriteString(" " + k.Render("r") + hint.Render(" re-run checks") + "\n") + + return b.String() +} + +// ── Logs View ─────────────────────────────────────────────────────────── + +func (m model) renderLogsView() string { + if len(m.mithrilLines) == 0 && len(m.lbLines) == 0 { + mutedStyle := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + cmdStyle := lipgloss.NewStyle().Foreground(tui.ColorTextSecondary) + return mutedStyle.Render(" Logs will appear here after starting the node.") + "\n\n" + + cmdStyle.Render(" $ mithril run --config "+m.configFile) + "\n" + } + + titleStyle := lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + hintStyle := lipgloss.NewStyle().Foreground(tui.ColorTextDisabled) + + // Calculate column widths + rightPaneWidth := (m.width - 3) * 78 / 100 + colGap := 3 + colWidth := (rightPaneWidth - colGap) / 2 + + // Wrap long lines within column width so full messages are readable + mLines := wrapLogLines(m.mithrilLines, colWidth) + lLines := wrapLogLines(m.lbLines, colWidth) + if m.logFocused && m.logScroll > 0 { + if m.logPane == logPaneMithril { + if m.logScroll < len(mLines) { + mLines = mLines[m.logScroll:] + } else { + mLines = nil + } + } else { + if m.logScroll < len(lLines) { + lLines = lLines[m.logScroll:] + } else { + lLines = nil + } + } + } + + // Render log lines with color coding + colorLine := func(line string) string { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return "" + } + switch { + case strings.Contains(line, " WARN ") || strings.HasPrefix(trimmed, "WARN"): + return lipgloss.NewStyle().Foreground(tui.ColorWarn).Render(line) + case strings.Contains(line, " ERROR ") || strings.HasPrefix(trimmed, "ERROR") || strings.Contains(line, "FATAL"): + return lipgloss.NewStyle().Foreground(tui.ColorError).Render(line) + default: + return mutedStyle.Render(line) + } + } + + // Build side-by-side output with divider + maxRows := len(mLines) + if len(lLines) > maxRows { + maxRows = len(lLines) + } + availHeight := m.height - 22 + if availHeight < 5 { + availHeight = 5 + } + if maxRows > availHeight { + maxRows = availHeight + } + + divStyle := lipgloss.NewStyle().Foreground(tui.ColorBorder) + div := divStyle.Render("│") + truncStyle := lipgloss.NewStyle().MaxWidth(colWidth) + + // Headers — underline the focused pane title + var b strings.Builder + var mTitle, lTitle string + mTitleStyle := titleStyle + lTitleStyle := titleStyle + if m.logFocused && m.logPane == logPaneMithril { + mTitleStyle = lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true).Underline(true) + } else if m.logFocused && m.logPane == logPaneLightbringer { + lTitleStyle = lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true).Underline(true) + } + mTitle = mTitleStyle.Render("mithril") + lTitle = lTitleStyle.Render("lightbringer") + + b.WriteString(mTitle) + leftPad := colWidth - lipgloss.Width(mTitle) + if leftPad > 0 { + b.WriteString(strings.Repeat(" ", leftPad)) + } + b.WriteString(" " + div + " " + lTitle + "\n") + + // Divider line under headers + b.WriteString(divStyle.Render(strings.Repeat("─", colWidth)) + " " + div + " " + divStyle.Render(strings.Repeat("─", colWidth)) + "\n") + + // Active pane line highlight style + activeLineStyle := lipgloss.NewStyle().Foreground(tui.ColorTextPrimary) + + for i := 0; i < maxRows; i++ { + left := "" + right := "" + + if i < len(mLines) { + if m.logFocused && m.logPane == logPaneMithril { + // Active pane: brighter text + left = truncStyle.Render(activeLineStyle.Render(mLines[i])) + } else { + left = truncStyle.Render(colorLine(mLines[i])) + } + } + if i < len(lLines) { + if m.logFocused && m.logPane == logPaneLightbringer { + right = truncStyle.Render(activeLineStyle.Render(lLines[i])) + } else { + right = truncStyle.Render(colorLine(lLines[i])) + } + } + + lPad := colWidth - lipgloss.Width(left) + if lPad > 0 { + left += strings.Repeat(" ", lPad) + } + b.WriteString(left + " " + div + " " + right + "\n") + } + + if m.logFocused { + b.WriteString("\n" + hintStyle.Render(" ↑↓ scroll ←→ switch esc back") + "\n") + } + + return b.String() +} + +// wrapLogLines wraps each line to fit within the given width. +// Continuation lines are indented with 2 spaces for readability. +func wrapLogLines(lines []string, width int) []string { + if width < 10 { + width = 10 + } + var result []string + for _, line := range lines { + if len(line) <= width { + result = append(result, line) + continue + } + // First chunk at full width, continuations indented + result = append(result, line[:width]) + remaining := line[width:] + contWidth := width - 2 // indent continuation + for len(remaining) > 0 { + if len(remaining) <= contWidth { + result = append(result, " "+remaining) + break + } + result = append(result, " "+remaining[:contWidth]) + remaining = remaining[contWidth:] + } + } + return result +} + +// ── Disk View ─────────────────────────────────────────────────────────── + +func (m model) renderDiskView() string { + if len(m.disks) == 0 { + muted := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + if !m.disksLoaded { + return muted.Render(" Loading disk usage...") + "\n" + } + if m.cfg != nil && m.cfg.accountsPath != "" { + return muted.Render(" No disk data available.") + "\n\n" + + muted.Render(" Storage paths may not exist on this machine yet.") + "\n" + + muted.Render(" Disk usage will appear once the node has been started.") + "\n" + } + return muted.Render(" Disk usage will appear once storage paths are configured.") + "\n" + } + + var b strings.Builder + label := lipgloss.NewStyle().Foreground(tui.ColorTextMuted) + value := lipgloss.NewStyle().Foreground(tui.ColorTextPrimary) + + for _, d := range m.disks { + b.WriteString(lipgloss.NewStyle().Foreground(tui.MithrilTeal).Bold(true).Render(d.label) + "\n") + b.WriteString(label.Render(" "+d.path) + "\n") + b.WriteString(" " + value.Render(fmt.Sprintf("%dG / %dG %d%%", d.used, d.total, d.pct)) + "\n") + b.WriteString(" " + renderBarChart(d.used, d.total, 30) + "\n") + b.WriteString("\n") + } + + b.WriteString(lipgloss.NewStyle().Foreground(tui.ColorTextDisabled).Render(" Normal: <80% Warning: 80-90% Critical: >90%") + "\n") + b.WriteString(lipgloss.NewStyle().Foreground(tui.ColorTextDisabled).Render(" Press 'r' to refresh") + "\n") + + return b.String() +} + +// Setup is launched directly as an embedded child TUI via selectCurrent(). diff --git a/cmd/mithril/main.go b/cmd/mithril/main.go index 0ddd3013..29cf8501 100644 --- a/cmd/mithril/main.go +++ b/cmd/mithril/main.go @@ -5,10 +5,14 @@ import ( "flag" "os" "os/signal" + "syscall" "github.com/Overclock-Validator/mithril/cmd/mithril/configcmd" + "github.com/Overclock-Validator/mithril/cmd/mithril/dashboardcmd" "github.com/Overclock-Validator/mithril/cmd/mithril/node" + "github.com/Overclock-Validator/mithril/cmd/mithril/setupcmd" "github.com/Overclock-Validator/mithril/cmd/mithril/statecmd" + "github.com/Overclock-Validator/mithril/cmd/mithril/statuscmd" "github.com/Overclock-Validator/mithril/pkg/config" "github.com/spf13/cobra" "k8s.io/klog/v2" @@ -46,14 +50,18 @@ func init() { cmd.PersistentFlags().StringVar(&config.ConfigFile, "config", "", "Path to TOML config file") cmd.AddCommand( - &node.Run, // Primary command for running Mithril - &configcmd.ConfigCmd, // Config management (init, etc.) - &statecmd.StateCmd, // State file inspection and management + &node.Run, // Primary command for running Mithril + &configcmd.ConfigCmd, // Config management (init, etc.) + &statecmd.StateCmd, // State file inspection and management + &setupcmd.SetupCmd, // Interactive setup wizard + &setupcmd.DoctorCmd, // System health check + &statuscmd.StatusCmd, // Node status + &dashboardcmd.DashboardCmd, // Interactive dashboard ) } func main() { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() cobra.CheckErr(cmd.ExecuteContext(ctx)) } diff --git a/cmd/mithril/node/node.go b/cmd/mithril/node/node.go index e74c5917..0df20252 100644 --- a/cmd/mithril/node/node.go +++ b/cmd/mithril/node/node.go @@ -22,6 +22,7 @@ import ( "github.com/Overclock-Validator/mithril/pkg/accountsdb" "github.com/Overclock-Validator/mithril/pkg/arena" "github.com/Overclock-Validator/mithril/pkg/config" + "github.com/Overclock-Validator/mithril/pkg/lightbringer" "github.com/Overclock-Validator/mithril/pkg/lthash" "github.com/Overclock-Validator/mithril/pkg/mlog" "github.com/Overclock-Validator/mithril/pkg/progress" @@ -93,6 +94,20 @@ var ( borrowedAccountArenaSize uint64 rpcPort int + + // Lightbringer sidecar config + lightbringerEnabled bool + lightbringerBinaryPath string + lightbringerGossipEntrypoint string + lightbringerStorage string + lightbringerRpcAddr string + lightbringerGrpcAddr string + lightbringerConfigDir string + lightbringerInfluxdbHost string + lightbringerInfluxdbDatabase string + lightbringerInfluxdbToken string + lightbringerBlockConfirmHTTP string + lightbringerBlockConfirmWS string ) func init() { @@ -304,10 +319,17 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { bootstrapMode = "auto" // default: use existing AccountsDB if valid, else download snapshot } - // [replay] section + // [replay] section (txpar moved to [tuning], with backwards-compatible fallback) numReplaySlots = getInt64("num-slots", "replay.num_slots") endSlot = getInt64("end-slot", "replay.end_slot") - txParallelism = getInt64("txpar", "replay.txpar") + if config.IsSet("tuning.txpar") { + txParallelism = getInt64("txpar", "tuning.txpar") + } else if config.IsSet("replay.txpar") { + txParallelism = getInt64("txpar", "replay.txpar") + mlog.Log.Warnf("config: replay.txpar is deprecated, move to tuning.txpar") + } else { + txParallelism = getInt64("txpar", "tuning.txpar") // CLI flag or default + } // [storage] section (with fallback to legacy [ledger] keys for backwards compatibility) // snapshotArchivePath: CLI flags --snapshot/--snapshot-archive-path ONLY (explicit file path) @@ -328,7 +350,11 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { if err := checkDirWritable(accountsPath, "AccountsDB"); err != nil { return err } - blockstorePath = getString("ledger-path", "storage.blockstore") + blockstorePath = getString("ledger-path", "storage.shredstore") + if blockstorePath == "" && config.IsSet("storage.blockstore") { + blockstorePath = getString("ledger-path", "storage.blockstore") + mlog.Log.Warnf("config: storage.blockstore is deprecated, use storage.shredstore") + } if blockstorePath == "" { blockstorePath = getString("ledger-path", "ledger.path") } @@ -365,14 +391,68 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { } lightbringerEndpoint = getString("lightbringer-endpoint", "block.lightbringer_endpoint") + // [lightbringer] section — sidecar management + lightbringerEnabled = config.GetBool("lightbringer.enabled") + lightbringerBinaryPath = config.GetString("lightbringer.binary_path") + if lightbringerBinaryPath == "" { + lightbringerBinaryPath = "./lightbringer" + } + lightbringerGossipEntrypoint = config.GetString("lightbringer.gossip_entrypoint") + lightbringerStorage = getString("ledger-path", "storage.shredstore") + if lightbringerStorage == "" && config.IsSet("lightbringer.storage") { + lightbringerStorage = getString("ledger-path", "lightbringer.storage") + mlog.Log.Warnf("config: lightbringer.storage is deprecated, use storage.shredstore") + } + if lightbringerStorage == "" { + lightbringerStorage = "./shred-store" + } + lightbringerRpcAddr = config.GetString("lightbringer.rpc_addr") + if lightbringerRpcAddr == "" { + lightbringerRpcAddr = "127.0.0.1:3000" + } + lightbringerGrpcAddr = config.GetString("lightbringer.grpc_addr") + if lightbringerGrpcAddr == "" { + lightbringerGrpcAddr = "127.0.0.1:3001" + } + lightbringerConfigDir = config.GetString("lightbringer.config_dir") + if lightbringerConfigDir == "" { + lightbringerConfigDir = "." + } + lightbringerInfluxdbHost = config.GetString("lightbringer.influxdb_host") + lightbringerInfluxdbDatabase = config.GetString("lightbringer.influxdb_database") + lightbringerInfluxdbToken = config.GetString("lightbringer.influxdb_token") + lightbringerBlockConfirmHTTP = config.GetString("lightbringer.block_confirmation_rpc_http") + lightbringerBlockConfirmWS = config.GetString("lightbringer.block_confirmation_rpc_websocket") + + // Auto-sync: when lightbringer is enabled, override block source settings + if lightbringerEnabled { + if lightbringerGossipEntrypoint == "" { + return fmt.Errorf("lightbringer.enabled=true but lightbringer.gossip_entrypoint is empty") + } + // Default block.source to "lightbringer" if not explicitly configured + if blockSource == "rpc" && !flagChanged("block-source") { + blockSource = "lightbringer" + } else if blockSource == "rpc" && flagChanged("block-source") { + mlog.Log.Warnf("lightbringer.enabled=true but --block-source=rpc was set explicitly; sidecar will start but will not be used for block delivery") + } + // Auto-sync grpc_addr to lightbringer_endpoint + if lightbringerEndpoint == "" { + lightbringerEndpoint = lightbringerGrpcAddr + } else if lightbringerEndpoint != lightbringerGrpcAddr { + mlog.Log.Warnf("lightbringer.grpc_addr (%s) differs from block.lightbringer_endpoint (%s) — using block.lightbringer_endpoint", + lightbringerGrpcAddr, lightbringerEndpoint) + } + } + + // Validate block source requirements switch blockSource { case "rpc": if len(rpcEndpoints) == 0 { return fmt.Errorf("block.source=rpc but no RPC endpoints provided (set network.rpc)") } case "lightbringer": - if lightbringerEndpoint == "" { - return fmt.Errorf("block.source=lightbringer but no block.lightbringer_endpoint provided") + if lightbringerEndpoint == "" && !lightbringerEnabled { + return fmt.Errorf("block.source=lightbringer requires either lightbringer.enabled=true or block.lightbringer_endpoint") } if len(rpcEndpoints) == 0 { return fmt.Errorf("block.source=lightbringer requires RPC endpoints for catchup (set network.rpc)") @@ -639,9 +719,67 @@ func runLive(c *cobra.Command, args []string) { // Now start the metrics server (after banner so errors don't appear first) statsd.StartMetricsServer() + // Lightbringer sidecar management + var lbManager *lightbringer.Manager useLightbringer := blockSource == "lightbringer" - if useLightbringer { - mlog.Log.Infof("block.source=lightbringer enabled: RPC catchup will hand off to Lightbringer live stream at %s", lightbringerEndpoint) + + if lightbringerEnabled { + lbLogWriter := mlog.Log.CreateSubprocessWriter("lightbringer") + + lbManager = lightbringer.NewManager(lightbringer.ManagerConfig{ + BinaryPath: lightbringerBinaryPath, + ConfigDir: lightbringerConfigDir, + GrpcAddr: lightbringerGrpcAddr, + TOML: lightbringer.LightbringerTOML{ + GossipEntrypoint: lightbringerGossipEntrypoint, + Storage: lightbringerStorage, + RpcAddr: lightbringerRpcAddr, + GrpcAddr: lightbringerGrpcAddr, + InfluxdbHost: lightbringerInfluxdbHost, + InfluxdbDatabase: lightbringerInfluxdbDatabase, + InfluxdbToken: lightbringerInfluxdbToken, + BlockConfirmRpcHTTP: lightbringerBlockConfirmHTTP, + BlockConfirmRpcWS: lightbringerBlockConfirmWS, + }, + LogWriter: lbLogWriter, + }) + + configPath, err := lbManager.WriteConfig() + if err != nil { + klog.Fatalf("failed to write Lightbringer config: %v", err) + } + mlog.Log.Infof("lightbringer: wrote config to %s", configPath) + + if err := lbManager.Start(); err != nil { + mlog.Log.Warnf("lightbringer: failed to start: %v — falling back to RPC", err) + useLightbringer = false + if len(rpcEndpoints) == 0 { + klog.Fatalf("lightbringer failed to start and no RPC endpoints configured for fallback (set network.rpc)") + } + } else { + defer func() { + if err := lbManager.Stop(10 * time.Second); err != nil { + mlog.Log.Warnf("lightbringer: shutdown error: %v", err) + } + }() + + // Monitor for crashes and auto-restart in background + lbStopMonitor := make(chan struct{}) + go lbManager.MonitorAndRestart(lbStopMonitor, 5) + defer close(lbStopMonitor) + + if err := lbManager.WaitReady(30 * time.Second); err != nil { + mlog.Log.Warnf("lightbringer: %v — falling back to RPC", err) + useLightbringer = false + if len(rpcEndpoints) == 0 { + lbManager.Stop(5 * time.Second) // stop before fatal exit since klog.Fatalf bypasses defers + klog.Fatalf("lightbringer not ready and no RPC endpoints configured for fallback (set network.rpc)") + } + } + } + } else if useLightbringer { + // block.source=lightbringer but lightbringer.enabled=false — standalone Lightbringer mode + mlog.Log.Infof("block.source=lightbringer with external Lightbringer at %s", lightbringerEndpoint) } dbgOpts, err := replay.NewDebugOptions(debugTxs, debugAcctWrites) @@ -1654,9 +1792,12 @@ func printStartupInfo(commandName string) { // Block source fmt.Printf(" Block source: %s%s%s", gold, blockSource, reset) - if blockSource == "lightbringer" { - fmt.Printf(" %s(rpc catchup -> live stream)%s\n", dim, reset) - } else { + switch { + case blockSource == "lightbringer" && lightbringerEnabled: + fmt.Printf(" %s(managed sidecar)%s\n", dim, reset) + case blockSource == "lightbringer": + fmt.Printf(" %s(external)%s\n", dim, reset) + default: fmt.Println() } diff --git a/cmd/mithril/setupcmd/components.go b/cmd/mithril/setupcmd/components.go new file mode 100644 index 00000000..91f19da1 --- /dev/null +++ b/cmd/mithril/setupcmd/components.go @@ -0,0 +1,273 @@ +package setupcmd + +import ( + "fmt" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/tui" + "github.com/charmbracelet/lipgloss" +) + +// ── Menu Component ────────────────────────────────────────────────────── + +type menuItem struct { + label string + value string + desc string + isSep bool +} + +func menuOption(label, value string) menuItem { + return menuItem{label: label, value: value} +} + +func menuOptionDesc(label, value, desc string) menuItem { + return menuItem{label: label, value: value, desc: desc} +} + +func menuSeparator() menuItem { + return menuItem{isSep: true} +} + +func menuBack() menuItem { + return menuItem{label: "← Back", value: "_back"} +} + +func renderMenu(title, description string, items []menuItem, cursor int, _ int) string { + var b strings.Builder + + // ── Header ── + b.WriteString("\n") + b.WriteString(" " + lipgloss.NewStyle(). + Foreground(mithrilTeal). + Bold(true). + Render(title)) + b.WriteString("\n") + + if description != "" { + b.WriteString(" " + lipgloss.NewStyle(). + Foreground(colorTextMuted). + Render(description)) + b.WriteString("\n") + } + b.WriteString("\n") + + // ── Find max label width ── + maxLabel := 0 + for _, item := range items { + if !item.isSep && len(item.label) > maxLabel { + maxLabel = len(item.label) + } + } + + // ── Items ── + for i, item := range items { + if item.isSep { + b.WriteString(" " + lipgloss.NewStyle(). + Foreground(colorTextDisabled). + Render(strings.Repeat("·", maxLabel+6))) + b.WriteString("\n") + continue + } + + padded := fmt.Sprintf("%-*s", maxLabel+1, item.label) + + if i == cursor { + // Selected: teal arrow + teal label + dim description on right + arrow := lipgloss.NewStyle().Foreground(mithrilTeal).Bold(true).Render(" ▸ ") + label := lipgloss.NewStyle().Foreground(mithrilTeal).Bold(true).Render(padded) + line := arrow + label + if item.desc != "" { + line += " " + lipgloss.NewStyle().Foreground(colorTextMuted).Render(item.desc) + } + b.WriteString(line) + } else { + // Normal: indented, secondary color + label := lipgloss.NewStyle().Foreground(colorTextSecondary).Render(padded) + b.WriteString(" " + label) + } + b.WriteString("\n") + } + + // ── Help ── + b.WriteString("\n") + k := lipgloss.NewStyle().Foreground(mithrilTeal) + h := lipgloss.NewStyle().Foreground(colorTextDisabled) + + hasBack := false + for _, item := range items { + if item.value == "_back" { + hasBack = true + break + } + } + + help := " " + k.Render("↑↓") + h.Render(" navigate") + + " " + k.Render("⏎") + h.Render(" select") + if hasBack { + help += " " + k.Render("esc") + h.Render(" back") + } else { + help += " " + k.Render("q") + h.Render(" quit") + } + b.WriteString(help) + + return b.String() +} + +// ── Input Component ───────────────────────────────────────────────────── + +func renderInput(title, description, value, errMsg string, cursorPos int) string { + var b strings.Builder + + // ── Header ── + b.WriteString("\n") + b.WriteString(" " + lipgloss.NewStyle(). + Foreground(mithrilTeal). + Bold(true). + Render(title)) + b.WriteString("\n") + + if description != "" { + lines := strings.Split(description, "\n") + for _, line := range lines { + b.WriteString(" " + lipgloss.NewStyle(). + Foreground(colorTextMuted). + Render(line)) + b.WriteString("\n") + } + } + b.WriteString("\n") + + // ── Input field ── + // Build text with block cursor + text := value + if cursorPos >= 0 && cursorPos <= len(text) { + before := text[:cursorPos] + after := text[cursorPos:] + cursor := lipgloss.NewStyle(). + Background(mithrilTeal). + Foreground(lipgloss.Color("#000000")). + Render(" ") + if cursorPos < len(text) { + cursor = lipgloss.NewStyle(). + Background(mithrilTeal). + Foreground(lipgloss.Color("#000000")). + Render(string(after[0])) + after = after[1:] + } + text = before + cursor + after + } + + prompt := lipgloss.NewStyle().Foreground(mithrilTeal).Render("❯ ") + b.WriteString(" " + prompt + text) + b.WriteString("\n") + + // ── Underline ── + underLen := len(value) + 2 + if underLen < 30 { + underLen = 30 + } + b.WriteString(" " + lipgloss.NewStyle().Foreground(colorBorderActive).Render(strings.Repeat("─", underLen))) + b.WriteString("\n") + + // ── Error ── + if errMsg != "" { + b.WriteString("\n " + lipgloss.NewStyle(). + Foreground(colorError). + Render("✗ "+errMsg)) + b.WriteString("\n") + } + + // ── Help ── + b.WriteString("\n") + k := lipgloss.NewStyle().Foreground(mithrilTeal) + h := lipgloss.NewStyle().Foreground(colorTextDisabled) + b.WriteString(" " + k.Render("⏎") + h.Render(" confirm") + + " " + k.Render("esc") + h.Render(" back") + + " " + k.Render("←→") + h.Render(" cursor")) + + return b.String() +} + +// ── Review Box ────────────────────────────────────────────────────────── + +func renderReview(title string, rows [][]string) string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(" " + lipgloss.NewStyle(). + Foreground(mithrilTeal). + Bold(true). + Render(title)) + b.WriteString("\n\n") + + // Find max label width + maxLabel := 0 + for _, row := range rows { + if len(row[0]) > maxLabel { + maxLabel = len(row[0]) + } + } + + // Build aligned content + var content strings.Builder + for _, row := range rows { + label := lipgloss.NewStyle(). + Foreground(colorTextMuted). + Render(fmt.Sprintf("%-*s", maxLabel+1, row[0])) + value := lipgloss.NewStyle(). + Foreground(colorTextPrimary). + Render(row[1]) + content.WriteString(" " + label + " " + value + "\n") + } + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(1, 2). + Render(content.String()) + + b.WriteString(box) + return b.String() +} + +// ── Logo ──────────────────────────────────────────────────────────── + +func renderLogo() string { + return tui.RenderLogo() +} + +// ── Done Screen ───────────────────────────────────────────────────── + +func renderDone(configPath string, err error) string { + var b strings.Builder + k := lipgloss.NewStyle().Foreground(mithrilTeal) + h := lipgloss.NewStyle().Foreground(colorTextDisabled) + + if err != nil { + b.WriteString("\n " + lipgloss.NewStyle().Foreground(mithrilTeal).Bold(true).Render("Setup Failed")) + b.WriteString("\n\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(colorError).Render("✗ "+err.Error())) + b.WriteString("\n\n Press " + k.Render("q") + h.Render(" to exit")) + b.WriteString("\n") + return b.String() + } + + b.WriteString("\n " + lipgloss.NewStyle().Foreground(mithrilTeal).Bold(true).Render("Setup Complete")) + b.WriteString("\n\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(colorSuccess).Render("✓") + + lipgloss.NewStyle().Foreground(colorTextPrimary).Render(" Config written to "+configPath)) + b.WriteString("\n\n") + + cmdStyle := lipgloss.NewStyle().Foreground(colorTextSecondary) + b.WriteString(" " + lipgloss.NewStyle().Foreground(colorTextMuted).Render("Next steps:")) + b.WriteString("\n") + b.WriteString(" " + cmdStyle.Render("$ mithril run --config "+configPath)) + b.WriteString("\n") + b.WriteString(" " + cmdStyle.Render("$ mithril doctor --config "+configPath)) + b.WriteString("\n\n") + b.WriteString(" Press " + k.Render("q") + h.Render(" to exit")) + b.WriteString("\n") + + return b.String() +} diff --git a/cmd/mithril/setupcmd/detect_linux.go b/cmd/mithril/setupcmd/detect_linux.go new file mode 100644 index 00000000..5ec0a468 --- /dev/null +++ b/cmd/mithril/setupcmd/detect_linux.go @@ -0,0 +1,56 @@ +//go:build linux + +package setupcmd + +import ( + "fmt" + "os/exec" + "strings" +) + +// DiskInfo represents a detected disk/mount +type DiskInfo struct { + Path string + Device string + SizeGB string + FreeGB string + FSType string +} + +// DetectDisks returns mounted filesystems with free space info. +func DetectDisks() []DiskInfo { + out, err := exec.Command("df", "-BG", "--output=target,source,size,avail,fstype").Output() + if err != nil { + return nil + } + + var disks []DiskInfo + lines := strings.Split(string(out), "\n") + for _, line := range lines[1:] { // skip header + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + mount := fields[0] + // Skip system mounts + if mount == "/" || strings.HasPrefix(mount, "/boot") || strings.HasPrefix(mount, "/snap") || + strings.HasPrefix(mount, "/sys") || strings.HasPrefix(mount, "/proc") || strings.HasPrefix(mount, "/dev") || + strings.HasPrefix(mount, "/run") { + continue + } + disks = append(disks, DiskInfo{ + Path: mount, + Device: fields[1], + SizeGB: strings.TrimSuffix(fields[2], "G"), + FreeGB: strings.TrimSuffix(fields[3], "G"), + FSType: fields[4], + }) + } + + return disks +} + +// FormatDiskOption returns a display string for a disk. +func (d DiskInfo) FormatDiskOption() string { + return fmt.Sprintf("%s (%s GB free / %s GB total) — %s", d.Path, d.FreeGB, d.SizeGB, d.Device) +} diff --git a/cmd/mithril/setupcmd/detect_other.go b/cmd/mithril/setupcmd/detect_other.go new file mode 100644 index 00000000..98a3f043 --- /dev/null +++ b/cmd/mithril/setupcmd/detect_other.go @@ -0,0 +1,22 @@ +//go:build !linux + +package setupcmd + +// DiskInfo represents a detected disk/mount +type DiskInfo struct { + Path string + Device string + SizeGB string + FreeGB string + FSType string +} + +// DetectDisks returns nil on non-Linux platforms. +func DetectDisks() []DiskInfo { + return nil +} + +// FormatDiskOption returns a display string for a disk. +func (d DiskInfo) FormatDiskOption() string { + return d.Path +} diff --git a/cmd/mithril/setupcmd/doctor.go b/cmd/mithril/setupcmd/doctor.go new file mode 100644 index 00000000..cc659afe --- /dev/null +++ b/cmd/mithril/setupcmd/doctor.go @@ -0,0 +1,170 @@ +package setupcmd + +import ( + "fmt" + "net" + "os" + "strings" + "time" + + "github.com/Overclock-Validator/mithril/pkg/config" +) + +func runDoctor() { + fmt.Println() + fmt.Println(titleStyle.Render("◎ Mithril Doctor")) + fmt.Println() + + passed := 0 + total := 0 + + // 1. Config file + total++ + configPath := config.ConfigFile + if configPath == "" { + configPath = "config.toml" + } + if _, err := os.Stat(configPath); err == nil { + fmt.Printf(" %s Config file found (%s)\n", successStyle.Render("✓"), configPath) + passed++ + + // Check if config needs migration (missing new sections) + data, _ := os.ReadFile(configPath) + content := string(data) + if !strings.Contains(content, "[lightbringer]") || !strings.Contains(content, "[consensus]") { + fmt.Printf(" %s Config is missing new sections (lightbringer/consensus)\n", warnStyle.Render("~")) + fmt.Printf(" %s Run: mithril doctor --migrate to add them\n", dimStyle.Render("→")) + } + } else { + fmt.Printf(" %s Config file not found (%s)\n", errorStyle.Render("✗"), configPath) + fmt.Printf(" %s Run: mithril setup\n", dimStyle.Render("→")) + } + + // Load config for further checks + if err := config.InitConfig(); err != nil { + fmt.Printf(" %s Failed to parse config: %v\n", errorStyle.Render("✗"), err) + fmt.Printf("\n %d/%d checks passed\n", passed, total) + return + } + + // 2. Cluster + total++ + cluster := config.GetString("network.cluster") + if cluster == "mainnet-beta" || cluster == "testnet" || cluster == "devnet" { + fmt.Printf(" %s Network: %s\n", successStyle.Render("✓"), cluster) + passed++ + } else if cluster == "" { + fmt.Printf(" %s network.cluster not set\n", errorStyle.Render("✗")) + } else { + fmt.Printf(" %s Invalid cluster: %s\n", errorStyle.Render("✗"), cluster) + } + + // 3. RPC endpoint + total++ + rpcEndpoints := config.GetStringSlice("network.rpc") + if len(rpcEndpoints) > 0 { + ep := rpcEndpoints[0] + fmt.Printf(" %s RPC endpoint configured (%s)\n", successStyle.Render("✓"), ep) + passed++ + } else { + fmt.Printf(" %s No RPC endpoints configured\n", errorStyle.Render("✗")) + fmt.Printf(" %s Set network.rpc in config\n", dimStyle.Render("→")) + } + + // 4. Storage paths + total++ + accountsPath := config.GetString("storage.accounts") + if accountsPath != "" { + if info, err := os.Stat(accountsPath); err == nil && info.IsDir() { + fmt.Printf(" %s AccountsDB path exists (%s)\n", successStyle.Render("✓"), accountsPath) + passed++ + } else if accountsPath != "" { + fmt.Printf(" %s AccountsDB path: %s (will be created)\n", warnStyle.Render("~"), accountsPath) + passed++ + } + } else { + fmt.Printf(" %s storage.accounts not set\n", errorStyle.Render("✗")) + } + + // 5. Lightbringer + lbEnabled := config.GetBool("lightbringer.enabled") + if lbEnabled { + total++ + binaryPath := config.GetString("lightbringer.binary_path") + if binaryPath == "" { + binaryPath = "./lightbringer" + } + if _, err := os.Stat(binaryPath); err == nil { + fmt.Printf(" %s Lightbringer binary found (%s)\n", successStyle.Render("✓"), binaryPath) + passed++ + } else { + fmt.Printf(" %s Lightbringer binary not found at %s\n", errorStyle.Render("✗"), binaryPath) + } + + total++ + gossip := config.GetString("lightbringer.gossip_entrypoint") + if gossip != "" { + if _, _, err := net.SplitHostPort(gossip); err != nil { + fmt.Printf(" %s Gossip entrypoint invalid format (%s): %v\n", errorStyle.Render("✗"), gossip, err) + } else { + fmt.Printf(" %s Gossip entrypoint set (%s)\n", successStyle.Render("✓"), gossip) + passed++ + } + } else { + fmt.Printf(" %s lightbringer.gossip_entrypoint not set\n", errorStyle.Render("✗")) + } + + total++ + grpcAddr := config.GetString("lightbringer.grpc_addr") + if grpcAddr == "" { + grpcAddr = "127.0.0.1:3001" + } + conn, err := net.DialTimeout("tcp", grpcAddr, 2*time.Second) + if err == nil { + conn.Close() + fmt.Printf(" %s Lightbringer gRPC port responding (%s)\n", successStyle.Render("✓"), grpcAddr) + passed++ + } else { + fmt.Printf(" %s Lightbringer gRPC not responding at %s (not running yet?)\n", dimStyle.Render("-"), grpcAddr) + // Don't count as failure — it's not running yet + total-- + } + } else { + // Check for invalid or external Lightbringer configs + blockSource := config.GetString("block.source") + lbEndpoint := config.GetString("block.lightbringer_endpoint") + if blockSource == "lightbringer" && lbEndpoint != "" { + fmt.Printf(" %s Lightbringer: external at %s\n", successStyle.Render("✓"), lbEndpoint) + passed++ + total++ + } else if blockSource == "lightbringer" { + fmt.Printf(" %s block.source=lightbringer but no sidecar enabled and no endpoint set\n", errorStyle.Render("✗")) + total++ + } else { + fmt.Printf(" %s Lightbringer: disabled\n", dimStyle.Render("-")) + } + } + + // 6. Logs directory + total++ + logsDir := config.GetString("storage.logs") + if logsDir == "" { + logsDir = config.GetString("log.dir") + } + if logsDir != "" { + fmt.Printf(" %s Log directory: %s\n", successStyle.Render("✓"), logsDir) + passed++ + } else { + fmt.Printf(" %s No log directory configured (logs go to stderr only)\n", warnStyle.Render("~")) + passed++ // Not critical + } + + // Summary + fmt.Println() + if passed == total { + fmt.Printf(" %s\n", successStyle.Render(fmt.Sprintf("%d/%d checks passed — ready to run!", passed, total))) + } else { + fmt.Printf(" %s\n", warnStyle.Render(fmt.Sprintf("%d/%d checks passed", passed, total))) + } + fmt.Println() +} diff --git a/cmd/mithril/setupcmd/migrate.go b/cmd/mithril/setupcmd/migrate.go new file mode 100644 index 00000000..6ac65362 --- /dev/null +++ b/cmd/mithril/setupcmd/migrate.go @@ -0,0 +1,66 @@ +package setupcmd + +import ( + "fmt" + "os" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/tui" +) + +// MigrateConfig checks if a config file is missing [lightbringer] or [consensus] +// sections and adds them. Returns true if any migration was performed. +func MigrateConfig(configPath string) bool { + data, err := os.ReadFile(configPath) + if err != nil { + return false + } + + content := string(data) + hasLB := strings.Contains(content, "[lightbringer]") + hasConsensus := strings.Contains(content, "[consensus]") + + if hasLB && hasConsensus { + return false // already up to date + } + + var additions string + + if !hasConsensus { + additions += ` +# ============================================================================ +# [consensus] - Vote-Anchored Consensus (added by mithril setup --migrate) +# ============================================================================ +# [consensus] +# skip_path_max_depth = 64 +# unresolved_policy = "halt" +# enforce_on_source = "lightbringer" +` + } + + if !hasLB { + additions += ` +# ============================================================================ +# [lightbringer] - Lightbringer Sidecar (added by mithril setup --migrate) +# ============================================================================ +# Enable to manage Lightbringer from Mithril. See config.example.toml for details. +# +# [lightbringer] +# enabled = false +# binary_path = "./lightbringer" +# gossip_entrypoint = "" +# shredstore path is in [storage] section (storage.shredstore) +# rpc_addr = "127.0.0.1:3000" +# grpc_addr = "127.0.0.1:3001" +` + } + + newContent := strings.TrimRight(content, "\n") + "\n" + additions + + if err := tui.AtomicWriteFile(configPath, []byte(newContent), 0600); err != nil { + fmt.Printf(" %s Failed to update config: %v\n", errorStyle.Render("✗"), err) + return false + } + + return true +} diff --git a/cmd/mithril/setupcmd/setup.go b/cmd/mithril/setupcmd/setup.go new file mode 100644 index 00000000..2b1c36c7 --- /dev/null +++ b/cmd/mithril/setupcmd/setup.go @@ -0,0 +1,935 @@ +package setupcmd + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/Overclock-Validator/mithril/pkg/config" + "github.com/Overclock-Validator/mithril/pkg/tui" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" +) + +var ( + outputPath string + migrateFlag bool + + SetupCmd = cobra.Command{ + Use: "setup", + Short: "Interactive setup for Mithril configuration", + Run: func(cmd *cobra.Command, args []string) { + runSetup() + }, + } + + DoctorCmd = cobra.Command{ + Use: "doctor", + Short: "Check system health and configuration", + Run: func(cmd *cobra.Command, args []string) { + if migrateFlag { + runMigrate() + return + } + runDoctor() + }, + } +) + +func init() { + SetupCmd.Flags().StringVar(&outputPath, "output", "config.toml", "Output path for generated config") + DoctorCmd.Flags().BoolVar(&migrateFlag, "migrate", false, "Add missing config sections") +} + +func runMigrate() { + configPath := "config.toml" + if config.ConfigFile != "" { + configPath = config.ConfigFile + } + fmt.Println() + fmt.Println(titleStyle.Render("◎ Config Migration")) + fmt.Println() + if _, err := os.Stat(configPath); err != nil { + fmt.Printf(" %s Config file not found (%s)\n", errorStyle.Render("✗"), configPath) + return + } + if MigrateConfig(configPath) { + fmt.Printf(" %s Added missing sections to %s\n", successStyle.Render("✓"), configPath) + } else { + fmt.Printf(" %s Config already up to date (%s)\n", successStyle.Render("✓"), configPath) + } + fmt.Println() +} + +// ── Screens ───────────────────────────────────────────────────────────── + +type screen int + +const ( + scrMode screen = iota + scrCluster + scrRPC + scrLightbringer + scrGossip + scrStorage // accountsPath + scrStorageSnap // snapshotsPath + scrStorageLogs // logsPath + scrBootstrap + scrBlockTuning // maxRPS + scrBlockInflight // maxInflight + scrReplay + scrConsensus + scrSnapshot + scrLogLevel + scrRPCPort + scrReview + scrOverwrite // confirm overwrite existing config + scrDone +) + +// ── Model ─────────────────────────────────────────────────────────────── + +type setupModel struct { + screen screen + prevStack []screen // for back navigation + cursor int + width int + height int + + // Input mode + editing bool + inputVal string + inputCur int // cursor position within input + inputErr string + + // Config values + mode string // quick, full, manual + cluster string + rpcEndpoint string + enableLB bool + gossipEntry string + accountsPath string + snapshotsPath string + logsPath string + bootstrapMode string + blockMaxRPS string + blockInflight string + txpar string + consensusPolicy string + snapshotKeep string + logLevel string + rpcPort string + + // System + cpuCores int + disks []DiskInfo + configPath string + embedded bool // true when running inside dashboard (skip logo) + err error +} + +func newSetupModel() setupModel { + absPath, _ := filepath.Abs(outputPath) + return setupModel{ + screen: scrMode, + cpuCores: runtime.NumCPU(), + disks: DetectDisks(), + cluster: "mainnet-beta", + rpcEndpoint: "https://api.mainnet-beta.solana.com", + accountsPath: "/mnt/mithril-accounts", + snapshotsPath: "/mnt/mithril-ledger/snapshots", + logsPath: "/mnt/mithril-logs", + bootstrapMode: "auto", + blockMaxRPS: "8", + blockInflight: "8", + txpar: fmt.Sprintf("%d", runtime.NumCPU()*2), + consensusPolicy: "halt", + snapshotKeep: "1", + logLevel: "info", + rpcPort: "8899", + configPath: absPath, + } +} + +func (m setupModel) Init() tea.Cmd { return nil } + +// ── Navigation helpers ────────────────────────────────────────────────── + +// inputValueForScreen returns the current config value for an input screen. +// Returns ("", false) for non-input (menu) screens. +func (m *setupModel) inputValueForScreen(scr screen) (string, bool) { + switch scr { + case scrRPC: + return m.rpcEndpoint, true + case scrGossip: + return m.gossipEntry, true + case scrStorage: + return m.accountsPath, true + case scrStorageSnap: + return m.snapshotsPath, true + case scrStorageLogs: + return m.logsPath, true + case scrBlockTuning: + return m.blockMaxRPS, true + case scrBlockInflight: + return m.blockInflight, true + case scrReplay: + return m.txpar, true + case scrRPCPort: + return m.rpcPort, true + } + return "", false +} + +// pushMenu navigates to a menu screen (no text input). +func (m *setupModel) pushMenu(next screen) { + m.prevStack = append(m.prevStack, m.screen) + m.screen = next + m.cursor = 0 + m.editing = false + m.inputErr = "" +} + +// pushInput navigates to an input screen and activates text editing. +func (m *setupModel) pushInput(next screen) { + m.prevStack = append(m.prevStack, m.screen) + m.screen = next + m.cursor = 0 + m.inputErr = "" + if val, ok := m.inputValueForScreen(next); ok { + m.editing = true + m.inputVal = val + m.inputCur = len(val) + } +} + +// goBack returns to the previous screen, re-enabling editing if needed. +func (m *setupModel) goBack() { + if len(m.prevStack) == 0 { + return + } + m.screen = m.prevStack[len(m.prevStack)-1] + m.prevStack = m.prevStack[:len(m.prevStack)-1] + m.cursor = 0 + m.inputErr = "" + + if val, ok := m.inputValueForScreen(m.screen); ok { + m.editing = true + m.inputVal = val + m.inputCur = len(val) + } else { + m.editing = false + } +} + +// ── Menu items per screen ─────────────────────────────────────────────── + +func (m setupModel) currentItems() []menuItem { + switch m.screen { + case scrMode: + return []menuItem{ + menuOptionDesc("Quick Start", "quick", "Answer a few questions, we handle the rest"), + menuOptionDesc("Full Config", "full", "Customize every setting with explanations"), + menuOptionDesc("Manual", "manual", "Generate config.toml template for editing"), + } + case scrCluster: + return []menuItem{ + menuOptionDesc("mainnet-beta", "mainnet-beta", "Production Solana network"), + menuOptionDesc("testnet", "testnet", "Test network (more stable)"), + menuOptionDesc("devnet", "devnet", "Development network (frequent resets)"), + menuSeparator(), + menuBack(), + } + case scrLightbringer: + return []menuItem{ + menuOptionDesc("Disable", "disable", "Use RPC only (default)"), + menuOptionDesc("Enable", "enable", "Sidecar for lower-latency block streaming"), + menuSeparator(), + menuBack(), + } + case scrBootstrap: + return []menuItem{ + menuOptionDesc("auto", "auto", "Use existing data or download snapshot (recommended)"), + menuOptionDesc("snapshot", "snapshot", "Rebuild from snapshot"), + menuOptionDesc("new-snapshot", "new-snapshot", "Always download fresh"), + menuOptionDesc("accountsdb", "accountsdb", "Require existing data, fail if missing"), + menuSeparator(), + menuBack(), + } + case scrConsensus: + return []menuItem{ + menuOptionDesc("halt", "halt", "Stop and write diagnostic (recommended)"), + menuOptionDesc("warn", "warn", "Log warning and continue (debug only)"), + menuSeparator(), + menuBack(), + } + case scrSnapshot: + return []menuItem{ + menuOptionDesc("Keep 1", "1", "For debugging and faster restarts (recommended)"), + menuOptionDesc("Stream only", "0", "Saves disk but needs re-download"), + menuSeparator(), + menuBack(), + } + case scrLogLevel: + return []menuItem{ + menuOption("debug", "debug"), + menuOptionDesc("info", "info", "Startup, progress, warnings (recommended)"), + menuOption("warn", "warn"), + menuOption("error", "error"), + menuSeparator(), + menuBack(), + } + case scrReview: + return []menuItem{ + menuOption("Save config & exit", "save"), + menuOption("Go back", "back"), + } + case scrOverwrite: + return []menuItem{ + menuOptionDesc("Overwrite", "overwrite", "Replace existing config with new one"), + menuOptionDesc("Go back", "back", "Return to review your settings"), + menuSeparator(), + menuOptionDesc("Exit", "exit", "Discard and go back"), + } + } + return nil +} + +// ── Update ────────────────────────────────────────────────────────────── + +func (m setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + if m.screen == scrDone { + if msg.String() == "q" || msg.String() == "enter" { + return m, tea.Quit + } + return m, nil + } + if m.editing { + return m.updateInput(msg) + } + return m.updateMenu(msg) + } + return m, nil +} + +func (m setupModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + items := m.currentItems() + maxIdx := len(items) - 1 + + switch msg.String() { + case "up", "k": + m.cursor-- + for m.cursor >= 0 && items[m.cursor].isSep { + m.cursor-- + } + if m.cursor < 0 { + m.cursor = 0 + } + case "down", "j": + m.cursor++ + for m.cursor <= maxIdx && items[m.cursor].isSep { + m.cursor++ + } + if m.cursor > maxIdx { + m.cursor = maxIdx + } + case "esc": + if m.screen != scrMode { + m.goBack() + } + case "q": + if m.screen == scrMode { + return m, tea.Quit + } + m.goBack() + case "enter": + if m.cursor >= 0 && m.cursor <= maxIdx { + selected := items[m.cursor].value + if selected == "_back" { + m.goBack() + } else { + return m.handleSelect(selected) + } + } + } + return m, nil +} + +func (m setupModel) handleSelect(value string) (tea.Model, tea.Cmd) { + switch m.screen { + case scrMode: + m.mode = value + if value == "manual" { + return m.generateManual() + } + m.pushMenu(scrCluster) + + case scrCluster: + m.cluster = value + switch value { + case "mainnet-beta": + m.rpcEndpoint = "https://api.mainnet-beta.solana.com" + case "testnet": + m.rpcEndpoint = "https://api.testnet.solana.com" + case "devnet": + m.rpcEndpoint = "https://api.devnet.solana.com" + } + m.pushInput(scrRPC) + + case scrLightbringer: + m.enableLB = value == "enable" + if m.enableLB { + m.pushInput(scrGossip) + } else if m.mode == "quick" { + m.pushMenu(scrReview) + } else { + m.pushInput(scrStorage) + } + + case scrBootstrap: + m.bootstrapMode = value + m.pushInput(scrBlockTuning) + + case scrConsensus: + m.consensusPolicy = value + m.pushMenu(scrSnapshot) + + case scrSnapshot: + m.snapshotKeep = value + m.pushMenu(scrLogLevel) + + case scrLogLevel: + m.logLevel = value + m.pushInput(scrRPCPort) + + case scrReview: + switch value { + case "save": + return m.generateConfig() + case "back": + m.goBack() + } + + case scrOverwrite: + switch value { + case "overwrite": + // User confirmed — proceed with save (scrOverwrite is set, so the + // existence check in generateConfig/generateManual will be skipped) + if m.mode == "manual" { + return m.generateManual() + } + return m.generateConfig() + case "back": + if m.mode == "manual" { + m.screen = scrMode // manual has no review screen + } else { + m.screen = scrReview + } + m.cursor = 0 + case "exit": + return m, tea.Quit + } + } + return m, nil +} + +func (m setupModel) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + if m.validateAndApplyInput() { + m.editing = false + m.advanceFromInput() + } + return m, nil + case "esc": + m.editing = false + m.goBack() + return m, nil + case "backspace": + if m.inputCur > 0 { + m.inputVal = m.inputVal[:m.inputCur-1] + m.inputVal[m.inputCur:] + m.inputCur-- + } + case "left": + if m.inputCur > 0 { + m.inputCur-- + } + case "right": + if m.inputCur < len(m.inputVal) { + m.inputCur++ + } + case "ctrl+a": + m.inputCur = 0 + case "ctrl+e": + m.inputCur = len(m.inputVal) + default: + ch := msg.String() + if len(ch) == 1 && ch[0] >= 32 { + m.inputVal = m.inputVal[:m.inputCur] + ch + m.inputVal[m.inputCur:] + m.inputCur++ + } + } + m.inputErr = "" + return m, nil +} + +// Validation helpers to reduce repetition +func (m *setupModel) requireNonEmpty(val string) bool { + if val == "" { + m.inputErr = "required" + return false + } + return true +} + +func (m *setupModel) requirePositiveInt(val string) bool { + n, err := strconv.Atoi(val) + if err != nil || n < 1 { + m.inputErr = "must be a positive number" + return false + } + return true +} + +func (m *setupModel) requirePort(val string) bool { + n, err := strconv.Atoi(val) + if err != nil || n < 0 || n > 65535 { + m.inputErr = "must be 0–65535" + return false + } + return true +} + +func (m *setupModel) validateAndApplyInput() bool { + val := strings.TrimSpace(m.inputVal) + val = strings.ReplaceAll(val, "\n", "") + m.inputErr = "" + + switch m.screen { + case scrRPC: + if val != "" && !strings.HasPrefix(val, "http://") && !strings.HasPrefix(val, "https://") { + m.inputErr = "must start with http:// or https://" + return false + } + if val != "" { + m.rpcEndpoint = val + } + + case scrGossip: + if !m.requireNonEmpty(val) || !strings.Contains(val, ":") { + if m.inputErr == "" { + m.inputErr = "must be IP:port (e.g., 1.2.3.4:8000)" + } + return false + } + m.gossipEntry = val + + case scrStorage: + if !m.requireNonEmpty(val) { return false } + m.accountsPath = filepath.Clean(val) + + case scrStorageSnap: + if !m.requireNonEmpty(val) { return false } + m.snapshotsPath = filepath.Clean(val) + + case scrStorageLogs: + if !m.requireNonEmpty(val) { return false } + m.logsPath = filepath.Clean(val) + + case scrBlockTuning: + if val != "" { + if !m.requirePositiveInt(val) { return false } + m.blockMaxRPS = val + } + + case scrBlockInflight: + if val != "" { + if !m.requirePositiveInt(val) { return false } + m.blockInflight = val + } + + case scrReplay: + if val != "" { + if !m.requirePositiveInt(val) { return false } + m.txpar = val + } + + case scrRPCPort: + if val != "" { + if !m.requirePort(val) { return false } + m.rpcPort = val + } + } + return true +} + +func (m *setupModel) advanceFromInput() { + switch m.screen { + case scrRPC: + if m.mode == "quick" { + m.pushMenu(scrReview) // Quick Start skips Lightbringer (disabled by default) + } else { + m.pushMenu(scrLightbringer) // Full Config lets user enable it + } + case scrGossip: + if m.mode == "quick" { + m.pushMenu(scrReview) + } else { + m.pushInput(scrStorage) + } + case scrStorage: + m.pushInput(scrStorageSnap) + case scrStorageSnap: + m.pushInput(scrStorageLogs) + case scrStorageLogs: + m.pushMenu(scrBootstrap) + case scrBlockTuning: + m.pushInput(scrBlockInflight) + case scrBlockInflight: + m.pushInput(scrReplay) + case scrReplay: + m.pushMenu(scrConsensus) + case scrRPCPort: + m.pushMenu(scrReview) + } +} + +// ── View ──────────────────────────────────────────────────────────────── + +func (m setupModel) View() string { + // Skip logo when embedded in dashboard right pane + banner := "" + if !m.embedded { + switch m.screen { + case scrDone: + // no banner on done screen + default: + banner = renderLogo() + } + } + + switch m.screen { + case scrDone: + return "\n" + renderDone(m.configPath, m.err) + "\n" + + case scrRPC: + return banner + "\n" + renderInput("RPC Endpoint", + "Solana RPC endpoint for fetching blocks and cluster data\n"+ + "Public endpoint works to start · upgrade to private RPC for production", + m.inputVal, m.inputErr, m.inputCur) + + case scrGossip: + return banner + "\n" + renderInput("Gossip Entrypoint", + "IP:port of a Solana validator running gossip\n"+ + "Used to receive shreds from the network", + m.inputVal, m.inputErr, m.inputCur) + + case scrStorage: + desc := "AccountsDB stores all ~500M on-chain accounts · needs fastest NVMe\n" + + "Heavy random I/O — put this on your best drive" + if len(m.disks) > 0 { + desc += "\n" + for _, d := range m.disks { + desc += "› " + d.FormatDiskOption() + } + } + return banner + "\n" + renderInput("AccountsDB Path", desc, m.inputVal, m.inputErr, m.inputCur) + + case scrStorageSnap: + return banner + "\n" + renderInput("Snapshots Path", + "Downloaded snapshots for bootstrapping · ~100 GB for full + incremental\n"+ + "Can be on a slower drive than AccountsDB", + m.inputVal, m.inputErr, m.inputCur) + + case scrStorageLogs: + return banner + "\n" + renderInput("Logs Path", + "Runtime logs, replay timings, and diagnostics\n"+ + "Auto-rotated · each run gets its own directory", + m.inputVal, m.inputErr, m.inputCur) + + case scrBlockTuning: + return banner + "\n" + renderInput("Max Requests Per Second", + "How aggressively to fetch blocks from RPC\n"+ + "Match your provider's rate limit · typical: 5–10 for public, 50+ for private", + m.inputVal, m.inputErr, m.inputCur) + + case scrBlockInflight: + return banner + "\n" + renderInput("Max Inflight Workers", + "Concurrent block fetch workers · should match max RPS\n"+ + fmt.Sprintf("Current max RPS: %s", m.blockMaxRPS), + m.inputVal, m.inputErr, m.inputCur) + + case scrReplay: + rec := fmt.Sprintf("%d", m.cpuCores*2) + return banner + "\n" + renderInput("Transaction Parallelism", + fmt.Sprintf("Parallel workers for block execution\n"+ + "Your system: %d cores · recommended: %s workers (2× cores)", m.cpuCores, rec), + m.inputVal, m.inputErr, m.inputCur) + + case scrRPCPort: + return banner + "\n" + renderInput("Mithril RPC Port", + "JSON-RPC interface for querying Mithril's state\n"+ + "Default: 8899 · set to 0 to disable", + m.inputVal, m.inputErr, m.inputCur) + + case scrReview: + rows := [][]string{ + {"Cluster", m.cluster}, + {"RPC", m.rpcEndpoint}, + } + if m.enableLB { + rows = append(rows, []string{"Lightbringer", "enabled (gossip: " + m.gossipEntry + ")"}) + } else { + rows = append(rows, []string{"Lightbringer", "disabled"}) + } + if m.mode == "quick" { + rows = append(rows, []string{"AccountsDB", m.accountsPath + " (default)"}) + rows = append(rows, []string{"Parallelism", m.txpar + " workers (auto)"}) + } else { + rows = append(rows, []string{"AccountsDB", m.accountsPath}) + rows = append(rows, []string{"Snapshots", m.snapshotsPath}) + rows = append(rows, []string{"Logs", m.logsPath}) + rows = append(rows, []string{"Parallelism", m.txpar + " workers"}) + } + if m.mode == "full" { + rows = append(rows, []string{"Bootstrap", m.bootstrapMode}) + rows = append(rows, []string{"Block RPS", m.blockMaxRPS}) + rows = append(rows, []string{"Inflight", m.blockInflight}) + rows = append(rows, []string{"RPC Port", m.rpcPort}) + rows = append(rows, []string{"Consensus", m.consensusPolicy}) + rows = append(rows, []string{"Snapshot keep", m.snapshotKeep}) + rows = append(rows, []string{"Log Level", m.logLevel}) + } + review := renderReview("Configuration Review", rows) + items := m.currentItems() + menu := renderMenu("", "", items, m.cursor, m.width) + return banner + "\n" + review + "\n\n" + menu + "\n" + + case scrOverwrite: + msg := warnStyle.Render(" Config already exists: ") + m.configPath + items := m.currentItems() + menu := renderMenu("Overwrite config?", msg, items, m.cursor, m.width) + return banner + "\n" + menu + "\n" + + default: + // Menu screens + title := "" + desc := "" + switch m.screen { + case scrMode: + title = "Mithril Setup" + desc = "" + items := m.currentItems() + return banner + "\n\n" + renderMenu(title, desc, items, m.cursor, m.width) + "\n" + case scrCluster: + title = "Solana Cluster" + case scrLightbringer: + title = "Lightbringer Sidecar" + desc = "Lightbringer sidecar for lower-latency block streaming." + case scrBootstrap: + title = "Bootstrap Mode" + desc = "How Mithril initializes on startup." + case scrConsensus: + title = "Consensus Policy" + desc = "Action when blocks can't be verified via votes." + case scrSnapshot: + title = "Snapshot Storage" + desc = "How many downloaded snapshots to keep." + case scrLogLevel: + title = "Log Level" + } + items := m.currentItems() + return banner + "\n" + renderMenu(title, desc, items, m.cursor, m.width) + "\n" + } +} + +// ── Config Generation ─────────────────────────────────────────────────── + +func (m setupModel) generateConfig() (tea.Model, tea.Cmd) { + // If config exists and user hasn't confirmed overwrite yet, ask first + if _, err := os.Stat(m.configPath); err == nil && m.screen != scrOverwrite { + m.screen = scrOverwrite + m.cursor = 0 + return m, nil + } + + var cfg strings.Builder + cfg.WriteString("# Mithril Configuration\n") + cfg.WriteString("# Generated by: mithril setup\n\n") + cfg.WriteString("name = \"mithril\"\n\n") + + cfg.WriteString("[bootstrap]\n") + fmt.Fprintf(&cfg, "mode = %q\n\n", m.bootstrapMode) + + cfg.WriteString("[storage]\n") + fmt.Fprintf(&cfg, "accounts = %q\n", filepath.Clean(m.accountsPath)) + cfg.WriteString("shredstore = \"/mnt/mithril-ledger/shredstore\"\n") + fmt.Fprintf(&cfg, "snapshots = %q\n", filepath.Clean(m.snapshotsPath)) + fmt.Fprintf(&cfg, "logs = %q\n\n", filepath.Clean(m.logsPath)) + + cfg.WriteString("[network]\n") + fmt.Fprintf(&cfg, "cluster = %q\n", m.cluster) + fmt.Fprintf(&cfg, "rpc = [%q]\n\n", m.rpcEndpoint) + + cfg.WriteString("[block]\n") + if m.enableLB { + cfg.WriteString("source = \"lightbringer\"\n") + } else { + cfg.WriteString("source = \"rpc\"\n") + } + fmt.Fprintf(&cfg, "max_rps = %s\n", m.blockMaxRPS) + fmt.Fprintf(&cfg, "max_inflight = %s\n\n", m.blockInflight) + + if m.enableLB { + cfg.WriteString("[lightbringer]\n") + cfg.WriteString("enabled = true\n") + cfg.WriteString("binary_path = \"./lightbringer\"\n") + fmt.Fprintf(&cfg, "gossip_entrypoint = %q\n", m.gossipEntry) + cfg.WriteString("grpc_addr = \"127.0.0.1:3001\"\n") + cfg.WriteString("rpc_addr = \"127.0.0.1:3000\"\n\n") + } + + cfg.WriteString("[tuning]\n") + fmt.Fprintf(&cfg, "txpar = %s\n\n", m.txpar) + + cfg.WriteString("[consensus]\n") + fmt.Fprintf(&cfg, "unresolved_policy = %q\n", m.consensusPolicy) + cfg.WriteString("skip_path_max_depth = 64\n\n") + + cfg.WriteString("[snapshot]\n") + fmt.Fprintf(&cfg, "max_full_snapshots = %s\n\n", m.snapshotKeep) + + cfg.WriteString("[rpc]\n") + fmt.Fprintf(&cfg, "port = %s\n\n", m.rpcPort) + + cfg.WriteString("[log]\n") + fmt.Fprintf(&cfg, "dir = %q\n", filepath.Clean(m.logsPath)) + fmt.Fprintf(&cfg, "level = %q\n", m.logLevel) + cfg.WriteString("to_stdout = true\n") + cfg.WriteString("max_size_mb = 100\n") + cfg.WriteString("max_age_days = 7\n") + + if err := tui.AtomicWriteFile(m.configPath, []byte(cfg.String()), 0600); err != nil { + m.err = err + } + m.screen = scrDone + return m, nil +} + +func (m setupModel) generateManual() (tea.Model, tea.Cmd) { + if _, err := os.Stat(m.configPath); err == nil && m.screen != scrOverwrite { + m.screen = scrOverwrite + m.cursor = 0 + return m, nil + } + + template := fmt.Sprintf(`# Mithril Configuration +# Generated by: mithril setup (manual mode) +# See config.example.toml for detailed documentation of all options. + +name = "mithril" + +[bootstrap] +mode = "auto" # "auto" | "snapshot" | "new-snapshot" | "accountsdb" + +[storage] +accounts = "/mnt/mithril-accounts" # AccountsDB (~500GB, use fastest NVMe) +shredstore = "/mnt/mithril-ledger/shredstore" # Lightbringer shred storage +snapshots = "/mnt/mithril-ledger/snapshots" # ~100GB for full + incremental +logs = "/mnt/mithril-logs" # Log files (created if missing) + +[network] +cluster = "mainnet-beta" # Required: "mainnet-beta" | "testnet" | "devnet" +rpc = ["https://api.mainnet-beta.solana.com"] + +[block] +source = "rpc" # "rpc" | "lightbringer" +# lightbringer_endpoint = "localhost:9000" +max_rps = 8 +max_inflight = 8 + +# [lightbringer] +# enabled = false +# binary_path = "./lightbringer" +# gossip_entrypoint = "1.2.3.4:8000" +# shredstore stored in [storage] section above +# rpc_addr = "127.0.0.1:3000" +# grpc_addr = "127.0.0.1:3001" +# See config.example.toml for full Lightbringer sidecar options. + +[tuning] +txpar = %d # Recommended: 2x your CPU core count + +[consensus] +unresolved_policy = "halt" # "halt" | "warn" +skip_path_max_depth = 64 + +[snapshot] +max_full_snapshots = 1 # 0 = stream only, saves disk + +[rpc] +port = 8899 # Mithril's RPC server (0 = disabled) + +[log] +dir = "/mnt/mithril-logs" # Log files (created if missing) +level = "info" # "debug" | "info" | "warn" | "error" +to_stdout = true # Also write to stdout +max_size_mb = 100 # Max log file size before rotation +max_age_days = 7 # Delete logs older than this + +# Advanced options (defaults work well for most setups) +# See config.example.toml for: [tuning], [debug], [snapshot] tuning, [reporting] +`, runtime.NumCPU()*2) + + if err := tui.AtomicWriteFile(m.configPath, []byte(template), 0600); err != nil { + m.err = err + } + m.screen = scrDone + return m, nil +} + +func runSetup() { + p := tea.NewProgram(newSetupModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } +} + +// NewSetupModel creates a setup model for embedding in the dashboard. +// configPath overrides the output path (pass "" for default). +func NewSetupModel(configPath string) tea.Model { + m := newSetupModel() + if configPath != "" { + m.configPath = configPath + } + m.embedded = true // skip logo when inside dashboard + return m +} + +// SetupIsDone returns true if the setup model has reached the done screen. +func SetupIsDone(m tea.Model) bool { + if sm, ok := m.(setupModel); ok { + return sm.screen == scrDone + } + return false +} + +// SetupIsFirstScreen returns true if the setup wizard is on the initial mode selection screen. +func SetupIsFirstScreen(m tea.Model) bool { + if sm, ok := m.(setupModel); ok { + return sm.screen == scrMode + } + return false +} diff --git a/cmd/mithril/setupcmd/theme.go b/cmd/mithril/setupcmd/theme.go new file mode 100644 index 00000000..8562acdc --- /dev/null +++ b/cmd/mithril/setupcmd/theme.go @@ -0,0 +1,42 @@ +package setupcmd + +import ( + "github.com/Overclock-Validator/mithril/pkg/tui" + "github.com/charmbracelet/lipgloss" +) + +// Re-export shared theme for use within setupcmd package. +var ( + mithrilTeal = tui.MithrilTeal + + colorTextPrimary = tui.ColorTextPrimary + colorTextSecondary = tui.ColorTextSecondary + colorTextMuted = tui.ColorTextMuted + colorTextDisabled = tui.ColorTextDisabled + + colorSuccess = tui.ColorSuccess + colorError = tui.ColorError + colorWarn = tui.ColorWarn + + colorBorder = tui.ColorBorder + colorBorderActive = tui.ColorBorderActive +) + +// Shared styles (re-exported from tui package) +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(mithrilTeal) + + successStyle = lipgloss.NewStyle(). + Foreground(colorSuccess) + + errorStyle = lipgloss.NewStyle(). + Foreground(colorError) + + warnStyle = lipgloss.NewStyle(). + Foreground(colorWarn) + + dimStyle = lipgloss.NewStyle(). + Foreground(colorTextMuted) +) diff --git a/cmd/mithril/statuscmd/status.go b/cmd/mithril/statuscmd/status.go new file mode 100644 index 00000000..74cea115 --- /dev/null +++ b/cmd/mithril/statuscmd/status.go @@ -0,0 +1,178 @@ +package statuscmd + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "time" + + "github.com/Overclock-Validator/mithril/pkg/config" + "github.com/Overclock-Validator/mithril/pkg/tui" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +var ( + accountsPath string + + StatusCmd = cobra.Command{ + Use: "status", + Short: "Show current Mithril node status", + Long: "Check if Mithril is running, what slot it's at, and Lightbringer connectivity.", + Run: func(cmd *cobra.Command, args []string) { + runStatus() + }, + } +) + +func init() { + StatusCmd.Flags().StringVar(&accountsPath, "accounts", "", "Path to AccountsDB directory (to find state file)") +} + +var ( + titleStyle = tui.TitleStyle + successStyle = tui.SuccessStyle + warnStyle = tui.WarnStyle + dimStyle = tui.DimStyle + valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) +) + +// mithrilState mirrors the relevant fields from pkg/state/state.go +type mithrilState struct { + SnapshotSlot uint64 `json:"snapshot_slot"` + LastSlot uint64 `json:"last_slot"` + LastBankhash string `json:"last_bankhash"` + GenesisHash string `json:"genesis_hash"` + ShutdownReason string `json:"last_shutdown_reason"` +} + +func runStatus() { + fmt.Println() + fmt.Println(titleStyle.Render("◎ Mithril Status")) + fmt.Println() + + // Try to find state file + stateFound := false + searchPaths := []string{accountsPath} + if accountsPath == "" { + searchPaths = []string{ + "/mnt/mithril-accounts", + "./data/accounts", + ".", + } + } + + var state mithrilState + var statePath string + for _, dir := range searchPaths { + p := filepath.Join(dir, "mithril_state.json") + data, err := os.ReadFile(p) + if err != nil { + continue + } + if err := json.Unmarshal(data, &state); err != nil { + continue + } + statePath = p + stateFound = true + break + } + + if stateFound { + fmt.Printf(" %s State file: %s\n", successStyle.Render("✓"), dimStyle.Render(statePath)) + fmt.Printf(" %s Last slot: %s\n", successStyle.Render("✓"), valueStyle.Render(fmt.Sprintf("%d", state.LastSlot))) + if state.SnapshotSlot > 0 { + fmt.Printf(" %s Snapshot: %s\n", dimStyle.Render("-"), valueStyle.Render(fmt.Sprintf("slot %d", state.SnapshotSlot))) + } + if state.ShutdownReason != "" { + fmt.Printf(" %s Last stop: %s\n", dimStyle.Render("-"), valueStyle.Render(state.ShutdownReason)) + } + if state.LastBankhash != "" { + short := state.LastBankhash + if len(short) > 12 { + short = short[:12] + "..." + } + fmt.Printf(" %s Bankhash: %s\n", dimStyle.Render("-"), dimStyle.Render(short)) + } + } else { + fmt.Printf(" %s No state file found\n", warnStyle.Render("~")) + fmt.Printf(" %s Mithril hasn't run yet, or --accounts path is wrong\n", dimStyle.Render("")) + } + + fmt.Println() + + // Read service addresses from config (fall back to defaults) + if err := config.InitConfig(); err != nil { + fmt.Printf(" %s Failed to read config: %v\n", warnStyle.Render("~"), err) + fmt.Println(" Using default service addresses") + } + rpcAddr := "127.0.0.1:8899" + lbAddr := "127.0.0.1:3001" + lbHTTP := "127.0.0.1:3000" + if port := config.GetString("rpc.port"); port != "" && port != "0" { + rpcAddr = "127.0.0.1:" + port + } + if addr := config.GetString("lightbringer.grpc_addr"); addr != "" { + lbAddr = addr + } + if addr := config.GetString("lightbringer.rpc_addr"); addr != "" { + lbHTTP = addr + } + blockSource := config.GetString("block.source") + lightbringerEnabled := config.GetBool("lightbringer.enabled") + effectiveBlockSource := blockSource + if effectiveBlockSource == "" { + effectiveBlockSource = "rpc" + } + // Match run-time behavior: enabling the managed sidecar promotes block delivery + // to Lightbringer unless a CLI flag explicitly forced RPC. + if lightbringerEnabled && effectiveBlockSource == "rpc" { + effectiveBlockSource = "lightbringer" + } + + // External LB mode: use block.lightbringer_endpoint if set + if extAddr := config.GetString("block.lightbringer_endpoint"); extAddr != "" { + lbAddr = extAddr + } + + fmt.Println(" " + dimStyle.Render("Services:")) + + // Check Mithril RPC (skip if port=0 means disabled) + if config.GetString("rpc.port") == "0" { + fmt.Printf(" %s Mithril RPC disabled (port=0)\n", dimStyle.Render("-")) + } else { + conn, err := net.DialTimeout("tcp", rpcAddr, 2*time.Second) + if err == nil { + conn.Close() + fmt.Printf(" %s Mithril RPC responding on %s\n", successStyle.Render("✓"), rpcAddr) + } else { + fmt.Printf(" %s Mithril RPC not responding on %s\n", dimStyle.Render("-"), rpcAddr) + } + } + + // Only probe Lightbringer when it is the effective block source. + if effectiveBlockSource == "lightbringer" { + conn, err := net.DialTimeout("tcp", lbAddr, 2*time.Second) + if err == nil { + conn.Close() + fmt.Printf(" %s Lightbringer gRPC responding on %s\n", successStyle.Render("✓"), lbAddr) + } else { + fmt.Printf(" %s Lightbringer gRPC not responding on %s\n", dimStyle.Render("-"), lbAddr) + } + + // Only probe HTTP when using managed sidecar (not external) + if lightbringerEnabled { + conn, err = net.DialTimeout("tcp", lbHTTP, 2*time.Second) + if err == nil { + conn.Close() + fmt.Printf(" %s Lightbringer HTTP responding on %s\n", successStyle.Render("✓"), lbHTTP) + } else { + fmt.Printf(" %s Lightbringer HTTP not responding on %s\n", dimStyle.Render("-"), lbHTTP) + } + } + } + + fmt.Println() +} diff --git a/config.example.toml b/config.example.toml index 4a412c1c..c2bda37a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -54,18 +54,14 @@ name = "mithril" # Periodic dumps of account state from the network. Mithril downloads # one on first run to bootstrap AccountsDB. # -# Blockstore (varies) -# Incoming blocks from RPC/Lightbringer. Size depends on how much history -# you want to keep (0 to hundreds of GB). -# NOTE: Block download to disk is TEMPORARILY DISABLED. Blocks are -# currently streamed directly from RPC and not persisted to disk. -# This path is reserved for future use when block persistence is -# re-implemented. +# Shredstore (varies) +# Lightbringer stores received shreds here for block streaming +# and potential repair serving. Size depends on retention settings. # # Recommended setup (two NVMe drives): # /mnt/mithril-accounts/ <- Fast NVMe (~500GB for AccountsDB) # /mnt/mithril-ledger/ <- Larger NVMe for everything else -# ├── blockstore/ +# ├── shredstore/ # └── snapshots/ # # See scripts/disk-setup.sh for automated setup. @@ -75,12 +71,9 @@ name = "mithril" # Put this on your fastest NVMe due to heavy random I/O. accounts = "/mnt/mithril-accounts" - # Blockstore - incoming blocks from RPC/Lightbringer - # Size depends on how much block history you keep. - # NOTE: Block persistence is temporarily disabled. Blocks are streamed - # directly from RPC without saving to disk. This setting is reserved - # for when block persistence is re-implemented. - blockstore = "/mnt/mithril-ledger/blockstore" + # Shredstore - Lightbringer stores received shreds here + # Used for block streaming and potential repair serving. + shredstore = "/mnt/mithril-ledger/shredstore" # Snapshots - downloaded on first run (~100GB for full + incremental) snapshots = "/mnt/mithril-ledger/snapshots" @@ -117,15 +110,16 @@ name = "mithril" rpc = ["https://api.mainnet-beta.solana.com"] # ============================================================================ -# [block] - Block Streaming +# [block] - Block Source & Streaming # ============================================================================ +# +# Block source configuration. See also [lightbringer] below for sidecar settings. [block] # Where to stream new blocks from: - # "rpc" - Fetch blocks via RPC (uses endpoints from [network].rpc) - # "lightbringer" - Stream blocks from a Lightbringer endpoint (faster, lower latency) - # Mithril will use RPC for catchup and hand off to the - # live Lightbringer stream near the tip. + # "rpc" - Fetch blocks via getBlock RPC calls + # "lightbringer" - Stream via Lightbringer sidecar (see [lightbringer] section) + # Uses RPC for catchup, hands off to live stream near tip. source = "rpc" # Lightbringer endpoint address (only used when source = "lightbringer") @@ -190,22 +184,6 @@ name = "mithril" # Provides enough buffer to hide RPC latency (~300ms) behind execution (~100ms). near_tip_lookahead = 2 -# ============================================================================ -# [replay] - Block Replay -# ============================================================================ -# -# Controls how Mithril processes and verifies blocks. - -[replay] - # Transaction parallelism. Set to 0 for sequential execution, - # or >0 to execute a topsort tx plan with the given number of workers. - # Recommended: 2x your CPU core count (e.g., 192 for a 96-core machine) - txpar = 24 - - # Finite replay: stop after N slots or at a specific slot (optional) - # num_slots = 0 - # end_slot = -1 - # ============================================================================ # [consensus] - Vote-Anchored Consensus # ============================================================================ @@ -235,6 +213,60 @@ name = "mithril" enforce_on_source = "lightbringer" +# ============================================================================ +# [lightbringer] - Lightbringer Sidecar (block.source = "lightbringer") +# ============================================================================ +# +# Lightbringer is a Turbine sidecar that receives and caches shreds, +# streaming assembled blocks to Mithril via gRPC. Lower latency than +# RPC polling. +# +# When enabled, Mithril manages Lightbringer's full lifecycle: +# 1. Generates Lightbringer.toml from these settings +# 2. Spawns the Lightbringer process +# 3. Captures its logs to lightbringer.log in the run directory +# 4. Shuts it down gracefully when Mithril stops +# +# Enabling this automatically sets block.source = "lightbringer" and +# block.lightbringer_endpoint to match grpc_addr below. +# +# Requirements: +# - Lightbringer binary must be available at binary_path +# - A Solana gossip entrypoint is required when enabled + +[lightbringer] + # Enable managed Lightbringer sidecar (default: false) + enabled = false + + # Path to the Lightbringer binary + binary_path = "./lightbringer" + + # Solana gossip entrypoint (REQUIRED when enabled) + # This is the IP:port of a Solana validator or RPC node running gossip. + # gossip_entrypoint = "1.2.3.4:8000" + + # Lightbringer's debug HTTP endpoint (for inspecting stored shreds) + rpc_addr = "127.0.0.1:3000" + + # Lightbringer's gRPC stream endpoint (Mithril connects here for blocks) + # This value is automatically used as block.lightbringer_endpoint. + grpc_addr = "127.0.0.1:3001" + + # Directory where Mithril writes the generated Lightbringer.toml + # Defaults to current working directory. + config_dir = "." + + # Optional: InfluxDB metrics for Lightbringer + # (written as [influxdb] section in generated Lightbringer.toml) + # influxdb_host = "http://localhost:8181" + # influxdb_database = "test" + # influxdb_token = "xyz" + + # Optional: Block confirmation via Solana RPC + # (written as [block_confirmation] section in generated Lightbringer.toml) + # block_confirmation_rpc_http = "http://localhost:8899" + # block_confirmation_rpc_websocket = "ws://localhost:8899" + # ============================================================================ # [rpc] - Mithril RPC Server # ============================================================================ @@ -257,6 +289,11 @@ name = "mithril" # The defaults work well for most deployments. [tuning] + # Transaction parallelism for block execution. + # Set to 0 for sequential execution, or >0 for parallel workers. + # Recommended: 2x your CPU core count (e.g., 192 for a 96-core machine) + txpar = 24 + # Zstd decoder concurrency (defaults to NumCPU) # zstd_decoder_concurrency = 16 diff --git a/go.mod b/go.mod index 26c6388f..ca31970e 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ replace github.com/gagliardetto/binary => github.com/palmerlao/binary v0.0.0-202 require ( github.com/cespare/xxhash/v2 v2.3.0 - github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/cockroachdb/pebble v1.1.5 @@ -32,16 +32,21 @@ require ( require ( github.com/VividCortex/ewma v1.2.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cockroachdb/errors v1.11.3 // indirect github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect github.com/cockroachdb/redact v1.1.5 // indirect github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/getsentry/sentry-go v0.27.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -52,6 +57,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/go.sum b/go.sum index 1615d8d8..7b1cd4a5 100644 --- a/go.sum +++ b/go.sum @@ -22,10 +22,13 @@ github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpH github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI= github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -35,23 +38,33 @@ github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCk github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= @@ -89,6 +102,8 @@ github.com/dgryski/go-sip13 v0.0.0-20200911182023-62edffca9245 h1:9cOfvEwjQxdwKu github.com/dgryski/go-sip13 v0.0.0-20200911182023-62edffca9245/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -226,6 +241,8 @@ github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dz github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= diff --git a/pkg/config/config.go b/pkg/config/config.go index fb09701f..e201b0c6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -131,6 +131,28 @@ type SnapshotConfig struct { MinIncrementalSpeedMBs float64 `toml:"min_incremental_speed_mbs" mapstructure:"min_incremental_speed_mbs"` } +// LightbringerConfig holds Lightbringer sidecar configuration. +// When Enabled, Mithril manages Lightbringer's lifecycle: generates its config, +// spawns the process, captures logs, and shuts it down on exit. +type LightbringerConfig struct { + Enabled bool `toml:"enabled" mapstructure:"enabled"` // Enable managed Lightbringer sidecar + BinaryPath string `toml:"binary_path" mapstructure:"binary_path"` // Path to lightbringer binary + GossipEntrypoint string `toml:"gossip_entrypoint" mapstructure:"gossip_entrypoint"` // Solana gossip entrypoint (required when enabled) + Storage string `toml:"storage" mapstructure:"storage"` // Shred storage directory + RpcAddr string `toml:"rpc_addr" mapstructure:"rpc_addr"` // Debug HTTP endpoint + GrpcAddr string `toml:"grpc_addr" mapstructure:"grpc_addr"` // gRPC stream endpoint (auto-synced to block.lightbringer_endpoint) + ConfigDir string `toml:"config_dir" mapstructure:"config_dir"` // Directory to write Lightbringer.toml + + // Optional: InfluxDB metrics — written as [influxdb] section in generated Lightbringer.toml + InfluxdbHost string `toml:"influxdb_host" mapstructure:"influxdb_host"` + InfluxdbDatabase string `toml:"influxdb_database" mapstructure:"influxdb_database"` + InfluxdbToken string `toml:"influxdb_token" mapstructure:"influxdb_token"` + + // Optional: Block confirmation — written as [block_confirmation] section in generated Lightbringer.toml + BlockConfirmRpcHTTP string `toml:"block_confirmation_rpc_http" mapstructure:"block_confirmation_rpc_http"` + BlockConfirmRpcWS string `toml:"block_confirmation_rpc_websocket" mapstructure:"block_confirmation_rpc_websocket"` +} + // LogConfig holds logging configuration type LogConfig struct { Dir string `toml:"dir" mapstructure:"dir"` // Log directory (default: /mnt/mithril-logs) @@ -155,15 +177,16 @@ type Config struct { ScratchDirectory string `toml:"scratch_directory" mapstructure:"scratch_directory"` // was: scratchdir // Sections - Ledger LedgerConfig `toml:"ledger" mapstructure:"ledger"` - Rpc RpcConfig `toml:"rpc" mapstructure:"rpc"` - Replay ReplayConfig `toml:"replay" mapstructure:"replay"` - Block BlockConfig `toml:"block" mapstructure:"block"` - Consensus ConsensusConfig `toml:"consensus" mapstructure:"consensus"` - Snapshot SnapshotConfig `toml:"snapshot" mapstructure:"snapshot"` - Development DevelopmentConfig `toml:"development" mapstructure:"development"` - Reporting ReportingConfig `toml:"reporting" mapstructure:"reporting"` - Log LogConfig `toml:"log" mapstructure:"log"` + Ledger LedgerConfig `toml:"ledger" mapstructure:"ledger"` + Rpc RpcConfig `toml:"rpc" mapstructure:"rpc"` + Replay ReplayConfig `toml:"replay" mapstructure:"replay"` + Block BlockConfig `toml:"block" mapstructure:"block"` + Consensus ConsensusConfig `toml:"consensus" mapstructure:"consensus"` + Lightbringer LightbringerConfig `toml:"lightbringer" mapstructure:"lightbringer"` + Snapshot SnapshotConfig `toml:"snapshot" mapstructure:"snapshot"` + Development DevelopmentConfig `toml:"development" mapstructure:"development"` + Reporting ReportingConfig `toml:"reporting" mapstructure:"reporting"` + Log LogConfig `toml:"log" mapstructure:"log"` } // ConfigFile holds the path to the config file (set via --config flag) diff --git a/pkg/lightbringer/config.go b/pkg/lightbringer/config.go new file mode 100644 index 00000000..1694f7b6 --- /dev/null +++ b/pkg/lightbringer/config.go @@ -0,0 +1,109 @@ +package lightbringer + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const configFileName = "Lightbringer.toml" + +// LightbringerTOML represents the Lightbringer.toml structure that Lightbringer expects. +// This mirrors the Rust ConfigRaw struct in the Lightbringer source. +type LightbringerTOML struct { + GossipEntrypoint string + Storage string + RpcAddr string + GrpcAddr string + + // Optional sections + InfluxdbHost string + InfluxdbDatabase string + InfluxdbToken string + + BlockConfirmRpcHTTP string + BlockConfirmRpcWS string +} + +// Validate checks that required fields are non-empty. +func (c *LightbringerTOML) Validate() error { + if c.GossipEntrypoint == "" { + return fmt.Errorf("gossip_entrypoint is required") + } + if c.GrpcAddr == "" { + return fmt.Errorf("grpc_addr is required") + } + return nil +} + +// GenerateTOML produces a valid Lightbringer.toml string from the config. +func (c *LightbringerTOML) GenerateTOML() string { + var b strings.Builder + + fmt.Fprintf(&b, "gossip_entrypoint = %q\n", c.GossipEntrypoint) + fmt.Fprintf(&b, "storage = %q\n", c.Storage) + fmt.Fprintf(&b, "rpc_addr = %q\n", c.RpcAddr) + fmt.Fprintf(&b, "grpc_addr = %q\n", c.GrpcAddr) + + if c.InfluxdbHost != "" { + b.WriteString("\n[influxdb]\n") + fmt.Fprintf(&b, "host = %q\n", c.InfluxdbHost) + fmt.Fprintf(&b, "database = %q\n", c.InfluxdbDatabase) + fmt.Fprintf(&b, "token = %q\n", c.InfluxdbToken) + } + + if c.BlockConfirmRpcHTTP != "" { + b.WriteString("\n[block_confirmation]\n") + fmt.Fprintf(&b, "rpc_http = %q\n", c.BlockConfirmRpcHTTP) + fmt.Fprintf(&b, "rpc_websocket = %q\n", c.BlockConfirmRpcWS) + } + + return b.String() +} + +// WriteConfigFile writes Lightbringer.toml to the given directory using an atomic +// write pattern (write to temp file, then rename) to prevent partial writes. +func (c *LightbringerTOML) WriteConfigFile(dir string) (string, error) { + content := c.GenerateTOML() + targetPath := filepath.Join(dir, configFileName) + + // Write to temp file in same directory (required for atomic rename on same filesystem). + // CreateTemp uses mode 0600; we keep this restrictive since the file may contain tokens. + tmpFile, err := os.CreateTemp(dir, "Lightbringer.toml.tmp.*") + if err != nil { + return "", fmt.Errorf("failed to create temp config file: %w", err) + } + // Ensure restrictive permissions regardless of umask + if err := tmpFile.Chmod(0600); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("failed to set config file permissions: %w", err) + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.WriteString(content); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return "", fmt.Errorf("failed to write config content: %w", err) + } + + if err := tmpFile.Sync(); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return "", fmt.Errorf("failed to sync config file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("failed to close temp config file: %w", err) + } + + // Atomic rename + if err := os.Rename(tmpPath, targetPath); err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("failed to rename config file: %w", err) + } + + return targetPath, nil +} diff --git a/pkg/lightbringer/config_test.go b/pkg/lightbringer/config_test.go new file mode 100644 index 00000000..4ec59887 --- /dev/null +++ b/pkg/lightbringer/config_test.go @@ -0,0 +1,212 @@ +package lightbringer + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidate_RequiredFields(t *testing.T) { + tests := []struct { + name string + cfg LightbringerTOML + wantErr string + }{ + { + name: "empty gossip entrypoint", + cfg: LightbringerTOML{GrpcAddr: "127.0.0.1:3001"}, + wantErr: "gossip_entrypoint is required", + }, + { + name: "empty grpc addr", + cfg: LightbringerTOML{GossipEntrypoint: "1.2.3.4:8000"}, + wantErr: "grpc_addr is required", + }, + { + name: "valid minimal config", + cfg: LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + GrpcAddr: "127.0.0.1:3001", + }, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestGenerateTOML_RequiredFieldsOnly(t *testing.T) { + cfg := LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: "/data/shreds", + RpcAddr: "127.0.0.1:3000", + GrpcAddr: "127.0.0.1:3001", + } + + toml := cfg.GenerateTOML() + + assert.Contains(t, toml, `gossip_entrypoint = "1.2.3.4:8000"`) + assert.Contains(t, toml, `storage = "/data/shreds"`) + assert.Contains(t, toml, `rpc_addr = "127.0.0.1:3000"`) + assert.Contains(t, toml, `grpc_addr = "127.0.0.1:3001"`) + assert.NotContains(t, toml, "[influxdb]") + assert.NotContains(t, toml, "[block_confirmation]") +} + +func TestGenerateTOML_WithInfluxDB(t *testing.T) { + cfg := LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: "./shreds", + RpcAddr: "127.0.0.1:3000", + GrpcAddr: "127.0.0.1:3001", + InfluxdbHost: "http://localhost:8181", + InfluxdbDatabase: "metrics", + InfluxdbToken: "secret-token", + } + + toml := cfg.GenerateTOML() + + assert.Contains(t, toml, "[influxdb]") + assert.Contains(t, toml, `host = "http://localhost:8181"`) + assert.Contains(t, toml, `database = "metrics"`) + assert.Contains(t, toml, `token = "secret-token"`) + assert.NotContains(t, toml, "[block_confirmation]") +} + +func TestGenerateTOML_WithBlockConfirmation(t *testing.T) { + cfg := LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: "./shreds", + RpcAddr: "127.0.0.1:3000", + GrpcAddr: "127.0.0.1:3001", + BlockConfirmRpcHTTP: "http://localhost:8899", + BlockConfirmRpcWS: "ws://localhost:8899", + } + + toml := cfg.GenerateTOML() + + assert.Contains(t, toml, "[block_confirmation]") + assert.Contains(t, toml, `rpc_http = "http://localhost:8899"`) + assert.Contains(t, toml, `rpc_websocket = "ws://localhost:8899"`) + assert.NotContains(t, toml, "[influxdb]") +} + +func TestGenerateTOML_BothOptionalSections(t *testing.T) { + cfg := LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: "./shreds", + RpcAddr: "127.0.0.1:3000", + GrpcAddr: "127.0.0.1:3001", + InfluxdbHost: "http://localhost:8181", + InfluxdbDatabase: "db", + InfluxdbToken: "tok", + BlockConfirmRpcHTTP: "http://localhost:8899", + BlockConfirmRpcWS: "ws://localhost:8899", + } + + toml := cfg.GenerateTOML() + + // Both sections present + assert.Contains(t, toml, "[influxdb]") + assert.Contains(t, toml, "[block_confirmation]") + // influxdb appears before block_confirmation + assert.Less(t, strings.Index(toml, "[influxdb]"), strings.Index(toml, "[block_confirmation]")) +} + +func TestGenerateTOML_SpecialCharactersInPaths(t *testing.T) { + cfg := LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: "/data/path with spaces/shreds", + RpcAddr: "127.0.0.1:3000", + GrpcAddr: "127.0.0.1:3001", + } + + toml := cfg.GenerateTOML() + + // %q should properly escape the path + assert.Contains(t, toml, `storage = "/data/path with spaces/shreds"`) +} + +func TestWriteConfigFile_CreatesValidFile(t *testing.T) { + dir := t.TempDir() + + cfg := LightbringerTOML{ + GossipEntrypoint: "10.0.0.1:8000", + Storage: "/tmp/shreds", + RpcAddr: "0.0.0.0:3000", + GrpcAddr: "0.0.0.0:3001", + } + + path, err := cfg.WriteConfigFile(dir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "Lightbringer.toml"), path) + + // Read back and verify + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(content), `gossip_entrypoint = "10.0.0.1:8000"`) + assert.Contains(t, string(content), `grpc_addr = "0.0.0.0:3001"`) +} + +func TestWriteConfigFile_OverwritesExisting(t *testing.T) { + dir := t.TempDir() + + cfg1 := LightbringerTOML{ + GossipEntrypoint: "1.1.1.1:8000", + GrpcAddr: "127.0.0.1:3001", + Storage: "./s1", + RpcAddr: "127.0.0.1:3000", + } + cfg2 := LightbringerTOML{ + GossipEntrypoint: "2.2.2.2:8000", + GrpcAddr: "127.0.0.1:3001", + Storage: "./s2", + RpcAddr: "127.0.0.1:3000", + } + + // Write first + _, err := cfg1.WriteConfigFile(dir) + require.NoError(t, err) + + // Overwrite + path, err := cfg2.WriteConfigFile(dir) + require.NoError(t, err) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(content), `gossip_entrypoint = "2.2.2.2:8000"`) + assert.NotContains(t, string(content), "1.1.1.1") +} + +func TestWriteConfigFile_NoTempFileLeftOnSuccess(t *testing.T) { + dir := t.TempDir() + + cfg := LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + GrpcAddr: "127.0.0.1:3001", + Storage: "./shreds", + RpcAddr: "127.0.0.1:3000", + } + + _, err := cfg.WriteConfigFile(dir) + require.NoError(t, err) + + // Only Lightbringer.toml should exist — no temp files + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, "Lightbringer.toml", entries[0].Name()) +} diff --git a/pkg/lightbringer/manager.go b/pkg/lightbringer/manager.go new file mode 100644 index 00000000..42a39858 --- /dev/null +++ b/pkg/lightbringer/manager.go @@ -0,0 +1,349 @@ +package lightbringer + +import ( + "bufio" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/Overclock-Validator/mithril/pkg/mlog" +) + +// Manager handles the lifecycle of a Lightbringer child process: +// config generation, spawning, log capture, health checking, and shutdown. +type Manager struct { + binaryPath string + configDir string + tomlCfg LightbringerTOML + grpcAddr string + logWriter io.Writer + + cmd *exec.Cmd + done chan struct{} // closed when the child process exits + mu sync.Mutex + running atomic.Bool + stopping atomic.Bool // set by Stop to prevent MonitorAndRestart from restarting +} + +// ManagerConfig holds the parameters needed to create a Manager. +type ManagerConfig struct { + BinaryPath string + ConfigDir string + GrpcAddr string + TOML LightbringerTOML + LogWriter io.Writer // where captured stdout/stderr is written +} + +// NewManager creates a new Lightbringer process manager. +func NewManager(cfg ManagerConfig) *Manager { + return &Manager{ + binaryPath: cfg.BinaryPath, + configDir: cfg.ConfigDir, + tomlCfg: cfg.TOML, + grpcAddr: cfg.GrpcAddr, + logWriter: cfg.LogWriter, + } +} + +// WriteConfig generates Lightbringer.toml in the configured directory. +// Returns an error if required fields are missing. +func (m *Manager) WriteConfig() (string, error) { + if err := m.tomlCfg.Validate(); err != nil { + return "", fmt.Errorf("invalid lightbringer config: %w", err) + } + if err := os.MkdirAll(m.configDir, 0700); err != nil { + return "", fmt.Errorf("failed to create config directory %s: %w", m.configDir, err) + } + return m.tomlCfg.WriteConfigFile(m.configDir) +} + +// Start spawns the Lightbringer process and begins capturing its output. +// On Linux, Pdeathsig ensures the kernel sends SIGTERM to Lightbringer if +// Mithril exits for any reason (including os.Exit, SIGKILL, panic). +func (m *Manager) Start() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.running.Load() { + return fmt.Errorf("already running") + } + + m.stopping.Store(false) // reset in case of previous Stop() + + // Resolve binary path to absolute before exec. Relative paths like + // ./lightbringer are validated from cwd but cmd.Dir causes chdir(configDir) + // before execve on Unix, so the binary would be looked up from configDir. + // Making the path absolute here ensures consistent resolution. + binaryAbs := m.binaryPath + if !filepath.IsAbs(binaryAbs) { + abs, err := filepath.Abs(binaryAbs) + if err != nil { + return fmt.Errorf("cannot resolve binary path %s: %w", m.binaryPath, err) + } + binaryAbs = abs + } + if _, err := os.Stat(binaryAbs); err != nil { + return fmt.Errorf("binary not found at %s: %w", binaryAbs, err) + } + + // Spawn in a dedicated goroutine with LockOSThread to keep Pdeathsig valid. + // Pdeathsig fires when the *OS thread* that called fork() dies. Without + // LockOSThread, Go may recycle the thread, triggering a premature death + // signal while Mithril is still alive (Go issue #27505). + type startResult struct { + cmd *exec.Cmd + stdout io.ReadCloser + stderr io.ReadCloser + err error + } + resultCh := make(chan startResult, 1) + done := make(chan struct{}) + + go func() { + runtime.LockOSThread() + // Do NOT defer UnlockOSThread here — we unlock only after Wait returns + + cmd := exec.Command(binaryAbs) + cmd.Dir = m.configDir + cmd.SysProcAttr = childSysProcAttr() // Pdeathsig on Linux, empty on other platforms + + stdout, err := cmd.StdoutPipe() + if err != nil { + resultCh <- startResult{err: fmt.Errorf("stdout pipe: %w", err)} + runtime.UnlockOSThread() + return + } + stderr, err := cmd.StderrPipe() + if err != nil { + resultCh <- startResult{err: fmt.Errorf("stderr pipe: %w", err)} + runtime.UnlockOSThread() + return + } + + if err := cmd.Start(); err != nil { + resultCh <- startResult{err: fmt.Errorf("failed to start: %w", err)} + runtime.UnlockOSThread() + return + } + + resultCh <- startResult{cmd: cmd, stdout: stdout, stderr: stderr} + + // Block this goroutine (and its locked thread) until the child exits. + // This keeps Pdeathsig valid for the entire lifetime of the child. + waitErr := cmd.Wait() + + // Signal exit to the manager + m.running.Store(false) + if waitErr != nil { + mlog.Log.Warnf("lightbringer: process exited with error: %v", waitErr) + } else { + mlog.Log.Infof("lightbringer: process exited cleanly") + } + close(done) + + runtime.UnlockOSThread() + }() + + res := <-resultCh + if res.err != nil { + return res.err + } + + m.cmd = res.cmd + m.done = done + m.running.Store(true) + + mlog.Log.Infof("lightbringer: started process (pid=%d, binary=%s)", + res.cmd.Process.Pid, m.binaryPath) + + go m.captureOutput("stdout", res.stdout) + go m.captureOutput("stderr", res.stderr) + + return nil +} + +// WaitReady polls the gRPC endpoint until it accepts a TCP connection, +// or the timeout expires. +func (m *Manager) WaitReady(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + pollInterval := 500 * time.Millisecond + + for time.Now().Before(deadline) { + if !m.running.Load() { + return fmt.Errorf("process exited before becoming ready") + } + + remaining := time.Until(deadline) + dialTimeout := 2 * time.Second + if remaining < dialTimeout { + dialTimeout = remaining + } + + conn, err := net.DialTimeout("tcp", m.grpcAddr, dialTimeout) + if err == nil { + _ = conn.Close() + mlog.Log.Infof("lightbringer: gRPC endpoint ready at %s", m.grpcAddr) + return nil + } + + time.Sleep(pollInterval) + } + + return fmt.Errorf("gRPC endpoint %s not ready after %s", m.grpcAddr, timeout) +} + +// captureOutput reads from a pipe line-by-line and writes to the log writer. +func (m *Manager) captureOutput(name string, reader io.ReadCloser) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64*1024), 256*1024) + + for scanner.Scan() { + line := scanner.Text() + if m.logWriter != nil { + fmt.Fprintf(m.logWriter, "%s\n", line) + } + } + if err := scanner.Err(); err != nil { + mlog.Log.Warnf("lightbringer: %s capture error: %v", name, err) + } +} + +// Stop sends SIGTERM to the Lightbringer process and waits for it to exit. +// If the process doesn't exit within the timeout, it sends SIGKILL. +func (m *Manager) Stop(timeout time.Duration) error { + m.stopping.Store(true) // signal MonitorAndRestart to not restart + m.mu.Lock() + if !m.running.Load() { + m.mu.Unlock() + return nil + } + + proc := m.cmd.Process + done := m.done + m.mu.Unlock() + + mlog.Log.Infof("lightbringer: sending SIGTERM to pid %d", proc.Pid) + + if err := proc.Signal(syscall.SIGTERM); err != nil { + mlog.Log.Warnf("lightbringer: SIGTERM failed: %v", err) + } + + select { + case <-done: + mlog.Log.Infof("lightbringer: stopped cleanly") + return nil + case <-time.After(timeout): + mlog.Log.Warnf("lightbringer: did not stop within %s, sending SIGKILL", timeout) + if err := proc.Signal(syscall.SIGKILL); err != nil { + mlog.Log.Warnf("lightbringer: SIGKILL failed: %v", err) + } + + select { + case <-done: + return nil + case <-time.After(5 * time.Second): + return fmt.Errorf("process %d did not exit after SIGKILL", proc.Pid) + } + } +} + +// IsRunning returns true if the Lightbringer process is currently running. +func (m *Manager) IsRunning() bool { + return m.running.Load() +} + +// Done returns a channel that is closed when the Lightbringer process exits. +// Returns nil if Start has not been called. +func (m *Manager) Done() <-chan struct{} { + m.mu.Lock() + defer m.mu.Unlock() + return m.done +} + +// Pid returns the PID of the running Lightbringer process, or 0 if not running. +func (m *Manager) Pid() int { + m.mu.Lock() + defer m.mu.Unlock() + if !m.running.Load() || m.cmd == nil || m.cmd.Process == nil { + return 0 + } + return m.cmd.Process.Pid +} + +// MonitorAndRestart watches for unexpected Lightbringer exits and restarts +// with exponential backoff. Stops monitoring when stopCh is closed. +// maxRetries=0 means unlimited retries. Returns when stopped or max retries exceeded. +func (m *Manager) MonitorAndRestart(stopCh <-chan struct{}, maxRetries int) { + backoff := 2 * time.Second + maxBackoff := 60 * time.Second + retries := 0 + + for { + done := m.Done() + if done == nil { + return + } + + select { + case <-stopCh: + return + case <-done: + } + + // Process exited — wait for running flag to be cleared + // (small window between done closing and running.Store(false)) + for i := 0; i < 10 && m.running.Load(); i++ { + time.Sleep(10 * time.Millisecond) + } + + // Check if this was a deliberate stop + if m.stopping.Load() { + return + } + select { + case <-stopCh: + return + default: + } + + retries++ + if maxRetries > 0 && retries > maxRetries { + mlog.Log.Errorf("lightbringer: exceeded %d restart attempts, giving up", maxRetries) + return + } + + mlog.Log.Warnf("lightbringer: process exited unexpectedly, restarting in %s (attempt %d)", backoff, retries) + + select { + case <-stopCh: + return + case <-time.After(backoff): + } + + // Exponential backoff for next attempt + backoff = backoff * 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + + if _, err := m.WriteConfig(); err != nil { + mlog.Log.Errorf("lightbringer: failed to write config for restart: %v", err) + return + } + + if err := m.Start(); err != nil { + mlog.Log.Errorf("lightbringer: failed to restart: %v — giving up", err) + return + } + mlog.Log.Infof("lightbringer: restarted successfully (attempt %d)", retries) + continue // re-fetch new done channel at top of loop + } +} diff --git a/pkg/lightbringer/manager_test.go b/pkg/lightbringer/manager_test.go new file mode 100644 index 00000000..5908e445 --- /dev/null +++ b/pkg/lightbringer/manager_test.go @@ -0,0 +1,242 @@ +package lightbringer + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createFakeBinary creates a simple shell script that acts as a fake Lightbringer process. +// It sleeps until killed, writing a startup message to stdout. +func createFakeBinary(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "fake-lightbringer") + script := "#!/bin/sh\necho \"fake lightbringer started\"\nsleep 300\n" + err := os.WriteFile(path, []byte(script), 0755) + require.NoError(t, err) + return path +} + +// createFastExitBinary creates a binary that exits immediately with a message. +func createFastExitBinary(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "fast-exit-lightbringer") + script := "#!/bin/sh\necho \"started and exiting\"\nexit 0\n" + err := os.WriteFile(path, []byte(script), 0755) + require.NoError(t, err) + return path +} + +func TestManager_WriteConfig(t *testing.T) { + dir := t.TempDir() + mgr := NewManager(ManagerConfig{ + BinaryPath: "/nonexistent", + ConfigDir: dir, + GrpcAddr: "127.0.0.1:3001", + TOML: LightbringerTOML{ + GossipEntrypoint: "10.0.0.1:8000", + Storage: "/data/shreds", + RpcAddr: "127.0.0.1:3000", + GrpcAddr: "127.0.0.1:3001", + }, + }) + + path, err := mgr.WriteConfig() + require.NoError(t, err) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(content), `gossip_entrypoint = "10.0.0.1:8000"`) +} + +func TestManager_WriteConfig_ValidationFails(t *testing.T) { + dir := t.TempDir() + mgr := NewManager(ManagerConfig{ + BinaryPath: "/nonexistent", + ConfigDir: dir, + GrpcAddr: "127.0.0.1:3001", + TOML: LightbringerTOML{ + // Missing GossipEntrypoint + GrpcAddr: "127.0.0.1:3001", + }, + }) + + _, err := mgr.WriteConfig() + assert.ErrorContains(t, err, "gossip_entrypoint is required") +} + +func TestManager_StartStop(t *testing.T) { + dir := t.TempDir() + binaryPath := createFakeBinary(t, dir) + + var logBuf bytes.Buffer + mgr := NewManager(ManagerConfig{ + BinaryPath: binaryPath, + ConfigDir: dir, + GrpcAddr: "127.0.0.1:39999", + TOML: LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: filepath.Join(dir, "shreds"), + RpcAddr: "127.0.0.1:39998", + GrpcAddr: "127.0.0.1:39999", + }, + LogWriter: &logBuf, + }) + + // Write config first + _, err := mgr.WriteConfig() + require.NoError(t, err) + + // Start + err = mgr.Start() + require.NoError(t, err) + assert.True(t, mgr.IsRunning()) + assert.Greater(t, mgr.Pid(), 0) + + // Give it a moment to emit startup message + time.Sleep(200 * time.Millisecond) + + // Stop + err = mgr.Stop(5 * time.Second) + require.NoError(t, err) + assert.False(t, mgr.IsRunning()) + assert.Equal(t, 0, mgr.Pid()) + + // Verify log capture got the startup message + assert.Contains(t, logBuf.String(), "fake lightbringer started") +} + +func TestManager_StartFailsBinaryNotFound(t *testing.T) { + dir := t.TempDir() + mgr := NewManager(ManagerConfig{ + BinaryPath: filepath.Join(dir, "nonexistent-binary"), + ConfigDir: dir, + GrpcAddr: "127.0.0.1:3001", + TOML: LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + GrpcAddr: "127.0.0.1:3001", + }, + }) + + err := mgr.Start() + assert.ErrorContains(t, err, "binary not found") +} + +func TestManager_DoubleStartFails(t *testing.T) { + dir := t.TempDir() + binaryPath := createFakeBinary(t, dir) + + mgr := NewManager(ManagerConfig{ + BinaryPath: binaryPath, + ConfigDir: dir, + GrpcAddr: "127.0.0.1:39999", + TOML: LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: filepath.Join(dir, "shreds"), + RpcAddr: "127.0.0.1:39998", + GrpcAddr: "127.0.0.1:39999", + }, + }) + + _, err := mgr.WriteConfig() + require.NoError(t, err) + + err = mgr.Start() + require.NoError(t, err) + defer mgr.Stop(5 * time.Second) + + // Second start should fail + err = mgr.Start() + assert.ErrorContains(t, err, "already running") +} + +func TestManager_StopWhenNotRunning(t *testing.T) { + mgr := NewManager(ManagerConfig{ + BinaryPath: "/nonexistent", + ConfigDir: t.TempDir(), + GrpcAddr: "127.0.0.1:3001", + }) + + // Stop on a never-started manager should be no-op + err := mgr.Stop(1 * time.Second) + assert.NoError(t, err) +} + +func TestManager_DoneChannelClosesOnExit(t *testing.T) { + dir := t.TempDir() + binaryPath := createFastExitBinary(t, dir) + + mgr := NewManager(ManagerConfig{ + BinaryPath: binaryPath, + ConfigDir: dir, + GrpcAddr: "127.0.0.1:39999", + TOML: LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: filepath.Join(dir, "shreds"), + RpcAddr: "127.0.0.1:39998", + GrpcAddr: "127.0.0.1:39999", + }, + }) + + _, err := mgr.WriteConfig() + require.NoError(t, err) + + err = mgr.Start() + require.NoError(t, err) + + // Process exits quickly — done channel should close + select { + case <-mgr.Done(): + // expected + case <-time.After(5 * time.Second): + t.Fatal("done channel did not close after process exit") + } + + assert.False(t, mgr.IsRunning()) +} + +func TestManager_DoneBeforeStart(t *testing.T) { + mgr := NewManager(ManagerConfig{ + BinaryPath: "/nonexistent", + ConfigDir: t.TempDir(), + GrpcAddr: "127.0.0.1:3001", + }) + + // Done() before Start() returns nil + done := mgr.Done() + assert.Nil(t, done) +} + +func TestManager_WaitReadyFailsWhenProcessDies(t *testing.T) { + dir := t.TempDir() + binaryPath := createFastExitBinary(t, dir) + + mgr := NewManager(ManagerConfig{ + BinaryPath: binaryPath, + ConfigDir: dir, + GrpcAddr: "127.0.0.1:39999", + TOML: LightbringerTOML{ + GossipEntrypoint: "1.2.3.4:8000", + Storage: filepath.Join(dir, "shreds"), + RpcAddr: "127.0.0.1:39998", + GrpcAddr: "127.0.0.1:39999", + }, + }) + + _, err := mgr.WriteConfig() + require.NoError(t, err) + + err = mgr.Start() + require.NoError(t, err) + + // Process exits immediately — WaitReady should detect and fail + time.Sleep(200 * time.Millisecond) // let it exit + + err = mgr.WaitReady(3 * time.Second) + assert.ErrorContains(t, err, "process exited before becoming ready") +} diff --git a/pkg/lightbringer/procattr_linux.go b/pkg/lightbringer/procattr_linux.go new file mode 100644 index 00000000..eff8b95c --- /dev/null +++ b/pkg/lightbringer/procattr_linux.go @@ -0,0 +1,14 @@ +//go:build linux + +package lightbringer + +import "syscall" + +// childSysProcAttr returns SysProcAttr with Pdeathsig set. +// On Linux, Pdeathsig makes the kernel send SIGTERM to the child when the +// parent's spawning thread dies — surviving os.Exit, SIGKILL, panic, etc. +func childSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGTERM, + } +} diff --git a/pkg/lightbringer/procattr_other.go b/pkg/lightbringer/procattr_other.go new file mode 100644 index 00000000..f30a4897 --- /dev/null +++ b/pkg/lightbringer/procattr_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package lightbringer + +import "syscall" + +// childSysProcAttr returns SysProcAttr without Pdeathsig. +// Pdeathsig is Linux-only. On other platforms (macOS for development), +// child cleanup relies on the signal handler and deferred Stop(). +func childSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{} +} diff --git a/pkg/mlog/mlog.go b/pkg/mlog/mlog.go index 167cf73b..2f871d63 100644 --- a/pkg/mlog/mlog.go +++ b/pkg/mlog/mlog.go @@ -187,6 +187,57 @@ func appendRunsLogEntry(runsLogPath string, ts time.Time, runID, commit, runDir } } +// CreateSubprocessWriter creates a lumberjack-backed log writer for a named subprocess +// (e.g., "lightbringer") in the current run directory. Returns os.Stderr if file +// logging is not initialized. +func (l *logger) CreateSubprocessWriter(name string) io.Writer { + l.mu.Lock() + defer l.mu.Unlock() + + if !l.initialized || l.runDir == "" { + // No file logging — at least prefix the output so it's identifiable in interleaved stderr + return newPrefixWriter(os.Stderr, "["+name+"] ") + } + + logPath := filepath.Join(l.runDir, name+".log") + fileWriter := &lumberjack.Logger{ + Filename: logPath, + MaxSize: 100, // 100MB default + MaxAge: 7, + MaxBackups: 5, + LocalTime: false, + Compress: true, + } + + if l.toStdout { + return io.MultiWriter(newPrefixWriter(os.Stderr, "["+name+"] "), fileWriter) + } + return fileWriter +} + +// prefixWriter wraps an io.Writer and prepends a prefix to each line. +type prefixWriter struct { + w io.Writer + prefix string +} + +func newPrefixWriter(w io.Writer, prefix string) *prefixWriter { + return &prefixWriter{w: w, prefix: prefix} +} + +func (pw *prefixWriter) Write(p []byte) (int, error) { + lines := bytes.Split(p, []byte("\n")) + for i, line := range lines { + if len(line) == 0 && i == len(lines)-1 { + continue // skip trailing empty line from split + } + if _, err := fmt.Fprintf(pw.w, "%s%s\n", pw.prefix, line); err != nil { + return 0, err + } + } + return len(p), nil +} + // flushLoop periodically flushes the buffer and syncs to disk func (l *logger) flushLoop() { defer l.wg.Done() diff --git a/pkg/tui/fileutil.go b/pkg/tui/fileutil.go new file mode 100644 index 00000000..9c0a76c7 --- /dev/null +++ b/pkg/tui/fileutil.go @@ -0,0 +1,49 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" +) + +// AtomicWriteFile writes data to a file atomically using a temp file + rename. +// Prevents partial/corrupt files on crash. The file is created with the given permissions. +func AtomicWriteFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + + tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("set permissions: %w", err) + } + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("write content: %w", err) + } + + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("sync: %w", err) + } + + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("close temp file: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("rename: %w", err) + } + + return nil +} diff --git a/pkg/tui/logo.go b/pkg/tui/logo.go new file mode 100644 index 00000000..ac58d25b --- /dev/null +++ b/pkg/tui/logo.go @@ -0,0 +1,73 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const logoWidth = 65 // visible width of the ASCII art + +var logoLines = []string{ + ` _______ __________________ _______ _________ _`, + ` ( )\__ __/\__ __/|\ /|( ____ )\__ __/( \`, + ` | () () | ) ( ) ( | ) ( || ( )| ) ( | (`, + ` | || || | | | | | | (___) || (____)| | | | |`, + ` | |(_)| | | | | | | ___ || __) | | | |`, + ` | | | | | | | | | ( ) || (\ ( | | | |`, + ` | ) ( |___) (___ | | | ) ( || ) \ \_____) (___| (____/\`, + ` |/ \|\_______/ )_( |/ \||/ \__/\_______/(_______/`, +} + +// RenderLogo returns the full Mithril ASCII art logo, left-aligned with divider. +func RenderLogo() string { + return RenderLogoWidth(0) +} + +// RenderLogoWidth returns the logo centered within the given width. +// Falls back to compact banner if terminal is too narrow for the ASCII art. +func RenderLogoWidth(width int) string { + // If terminal is narrower than the logo, use compact banner + if width > 0 && width < logoWidth+4 { + return RenderBanner() + } + + style := lipgloss.NewStyle().Foreground(MithrilTeal) + + // Pad all lines to the same width so centering aligns correctly + var padded []string + for _, line := range logoLines { + pad := logoWidth - len(line) + if pad > 0 { + line += strings.Repeat(" ", pad) + } + padded = append(padded, line) + } + + // Center the block within the terminal width + if width > logoWidth { + leftPad := (width - logoWidth) / 2 + prefix := strings.Repeat(" ", leftPad) + var lines []string + for _, line := range padded { + lines = append(lines, style.Render(prefix+line)) + } + return strings.Join(lines, "\n") + } + + // No centering needed + var lines []string + for _, line := range padded { + lines = append(lines, style.Render(line)) + } + return strings.Join(lines, "\n") +} + +// RenderBanner returns a compact one-line banner. +func RenderBanner() string { + name := lipgloss.NewStyle().Foreground(MithrilTeal).Bold(true).Render("\u25ce MITHRIL") + divider := lipgloss.NewStyle(). + Foreground(ColorTextDisabled). + Render(" " + strings.Repeat("\u2500", 50)) + return "\n " + name + "\n" + divider +} diff --git a/pkg/tui/theme.go b/pkg/tui/theme.go new file mode 100644 index 00000000..eab5333d --- /dev/null +++ b/pkg/tui/theme.go @@ -0,0 +1,46 @@ +// Package tui provides shared theme constants and styles for all Mithril TUI commands. +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +// Mithril Server Theme — matches the teal accent used across all TUI commands. +// Primary color: ANSI 85 (teal), same as pkg/progress/progress.go bars. +var ( + MithrilTeal = lipgloss.Color("85") // Primary accent + + // Text hierarchy (dark-mode optimized: no pure white, disabled still readable) + ColorTextPrimary = lipgloss.Color("#e0e0e0") // body text — bright but not glowing + ColorTextSecondary = lipgloss.Color("#b0b0b0") // secondary labels — ~70% brightness + ColorTextMuted = lipgloss.Color("#787878") // key labels, captions — clearly readable + ColorTextDisabled = lipgloss.Color("#606060") // hints, shortcuts — visible on dark bg + + // Semantic + ColorSuccess = MithrilTeal // teal doubles as success indicator + ColorError = lipgloss.Color("196") + ColorWarn = lipgloss.Color("214") + + // Borders + ColorBorder = lipgloss.Color("240") // unfocused + ColorBorderActive = MithrilTeal // focused +) + +// Shared styles +var ( + TitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(MithrilTeal) + + SuccessStyle = lipgloss.NewStyle(). + Foreground(ColorSuccess) + + ErrorStyle = lipgloss.NewStyle(). + Foreground(ColorError) + + WarnStyle = lipgloss.NewStyle(). + Foreground(ColorWarn) + + DimStyle = lipgloss.NewStyle(). + Foreground(ColorTextMuted) +)