mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 10:52:04 +00:00
1670 lines
44 KiB
Go
1670 lines
44 KiB
Go
/*
|
|
Copyright © 2026 Mulga Defense Corporation
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
// Package ui presents the interactive installer TUI using bubbletea and lipgloss.
|
|
package ui
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/mulgadc/spinifex/cmd/installer/branding"
|
|
"github.com/mulgadc/spinifex/cmd/installer/install"
|
|
"github.com/mulgadc/spinifex/spinifex/admin"
|
|
)
|
|
|
|
// screen represents which step of the wizard is active.
|
|
type screen int
|
|
|
|
const (
|
|
screenWelcome screen = iota
|
|
screenDisk
|
|
screenDiskConfirm
|
|
screenNetworkWAN
|
|
screenNetworkLAN
|
|
screenIdentity
|
|
screenPassword
|
|
screenJoinConfig
|
|
screenConfirm
|
|
screenDone // signals completion; program exits
|
|
)
|
|
|
|
// nicInfo holds display info for a network interface.
|
|
type nicInfo struct {
|
|
Name string
|
|
IsWiFi bool
|
|
}
|
|
|
|
// wanField tracks which element is focused on the WAN network screen.
|
|
type wanField int
|
|
|
|
const (
|
|
wanFieldNIC wanField = iota // NIC picker
|
|
wanFieldMethod // DHCP / Static toggle
|
|
wanFieldIP // static only
|
|
wanFieldMask // static only
|
|
wanFieldGateway // static only
|
|
wanFieldDNS // static only
|
|
wanFieldSSID // WiFi NIC only
|
|
wanFieldWiFiPass // WiFi NIC only
|
|
)
|
|
|
|
// lanField tracks which element is focused on the LAN network screen.
|
|
type lanField int
|
|
|
|
const (
|
|
lanFieldNIC lanField = iota // NIC picker (WAN NIC shown greyed)
|
|
lanFieldMethod // DHCP / Static toggle
|
|
lanFieldIP // static only
|
|
lanFieldMask // static only
|
|
lanFieldDNS // static only
|
|
lanFieldSSID // WiFi NIC only
|
|
lanFieldWiFiPass // WiFi NIC only
|
|
)
|
|
|
|
// model is the top-level bubbletea model for the installer wizard.
|
|
type model struct {
|
|
screen screen
|
|
width int
|
|
height int
|
|
|
|
// Disk selection
|
|
disks []diskInfo
|
|
diskCursor int
|
|
eraseInput textinput.Model
|
|
|
|
// NIC list (shared between WAN and LAN screens)
|
|
nics []nicInfo
|
|
|
|
// WAN network screen
|
|
wanNicCursor int
|
|
wanNicManualInput textinput.Model // used when no NICs are auto-detected
|
|
wanDHCP bool
|
|
wanFocus wanField
|
|
wanIP textinput.Model
|
|
wanMask textinput.Model
|
|
wanGateway textinput.Model
|
|
wanDNS textinput.Model
|
|
wanSSID textinput.Model
|
|
wanWiFiPass textinput.Model
|
|
|
|
// LAN network screen (only shown when len(nics) > 1)
|
|
lanNicCursor int
|
|
lanDHCP bool
|
|
lanFocus lanField
|
|
lanIP textinput.Model
|
|
lanMask textinput.Model
|
|
lanDNS textinput.Model
|
|
lanSSID textinput.Model
|
|
lanWiFiPass textinput.Model
|
|
|
|
// Identity
|
|
hostnameInput textinput.Model
|
|
clusterRole int // 0 = init, 1 = join
|
|
|
|
// Join config
|
|
joinIPInput textinput.Model
|
|
joinPortInput textinput.Model
|
|
|
|
// Credentials (email + password)
|
|
emailInput textinput.Model
|
|
passwordInput textinput.Model
|
|
passwordConfirmInput textinput.Model
|
|
credsFocus int // 0 = email, 1 = password, 2 = confirm
|
|
|
|
// Accumulated validation error shown on current screen
|
|
validationErr string
|
|
|
|
// Final result — set when screenDone is reached
|
|
result *install.Config
|
|
err error
|
|
}
|
|
|
|
// Run launches the bubbletea program connected to ttyPath and returns the
|
|
// completed Config when the user finishes the wizard.
|
|
func Run(ttyPath string) (*install.Config, error) {
|
|
disks, err := availableDisks()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing disks: %w", err)
|
|
}
|
|
if len(disks) == 0 {
|
|
return nil, errors.New("no block devices found")
|
|
}
|
|
|
|
nics, err := availableNICs()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing network interfaces: %w", err)
|
|
}
|
|
|
|
m := newModel(disks, nics)
|
|
|
|
var opts []tea.ProgramOption
|
|
opts = append(opts, tea.WithAltScreen())
|
|
|
|
if ttyPath != "" {
|
|
tty, err := os.OpenFile(ttyPath, os.O_RDWR, 0)
|
|
if err != nil {
|
|
// Requested TTY unavailable (e.g. serial console selected but no
|
|
// serial port present). Fall back to tty1 rather than aborting so
|
|
// the installer remains usable on the display.
|
|
slog.Warn("ui: could not open requested TTY, falling back to tty1", "tty", ttyPath, "err", err)
|
|
if tty, err = os.OpenFile("/dev/tty1", os.O_RDWR, 0); err != nil {
|
|
return nil, fmt.Errorf("open fallback console /dev/tty1: %w", err)
|
|
}
|
|
}
|
|
opts = append(opts, tea.WithInput(tty), tea.WithOutput(tty))
|
|
}
|
|
|
|
p := tea.NewProgram(m, opts...)
|
|
final, err := p.Run()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fm, ok := final.(model)
|
|
if !ok {
|
|
return nil, errors.New("unexpected model type")
|
|
}
|
|
if fm.err != nil {
|
|
return nil, fm.err
|
|
}
|
|
return fm.result, nil
|
|
}
|
|
|
|
func newModel(disks []diskInfo, nics []nicInfo) model {
|
|
eraseIn := textinput.New()
|
|
eraseIn.Placeholder = "yes"
|
|
eraseIn.CharLimit = 3
|
|
|
|
wanNicManualIn := textinput.New()
|
|
wanNicManualIn.Placeholder = "e.g. eth0, enp0s1"
|
|
wanNicManualIn.CharLimit = 32
|
|
|
|
wanIPIn := textinput.New()
|
|
wanIPIn.Placeholder = "192.168.1.10"
|
|
|
|
wanMaskIn := textinput.New()
|
|
wanMaskIn.Placeholder = "255.255.255.0 or 24"
|
|
|
|
wanGWIn := textinput.New()
|
|
wanGWIn.Placeholder = "192.168.1.1"
|
|
|
|
wanDNSIn := textinput.New()
|
|
wanDNSIn.Placeholder = "1.1.1.1, 8.8.8.8"
|
|
|
|
wanSSIDIn := textinput.New()
|
|
wanSSIDIn.Placeholder = "Network SSID"
|
|
wanSSIDIn.CharLimit = 64
|
|
|
|
wanWiFiPassIn := textinput.New()
|
|
wanWiFiPassIn.Placeholder = "WiFi password"
|
|
wanWiFiPassIn.EchoMode = textinput.EchoPassword
|
|
wanWiFiPassIn.CharLimit = 128
|
|
|
|
lanIPIn := textinput.New()
|
|
lanIPIn.Placeholder = "10.10.8.2"
|
|
|
|
lanMaskIn := textinput.New()
|
|
lanMaskIn.Placeholder = "255.255.255.0 or 24"
|
|
|
|
lanDNSIn := textinput.New()
|
|
lanDNSIn.Placeholder = "1.1.1.1, 8.8.8.8"
|
|
|
|
lanSSIDIn := textinput.New()
|
|
lanSSIDIn.Placeholder = "Network SSID"
|
|
lanSSIDIn.CharLimit = 64
|
|
|
|
lanWiFiPassIn := textinput.New()
|
|
lanWiFiPassIn.Placeholder = "WiFi password"
|
|
lanWiFiPassIn.EchoMode = textinput.EchoPassword
|
|
lanWiFiPassIn.CharLimit = 128
|
|
|
|
hostnameIn := textinput.New()
|
|
hostnameIn.Placeholder = "node1"
|
|
hostnameIn.CharLimit = 64
|
|
|
|
joinIPIn := textinput.New()
|
|
joinIPIn.Placeholder = "192.168.1.10"
|
|
|
|
joinPortIn := textinput.New()
|
|
joinPortIn.Placeholder = "4432"
|
|
joinPortIn.CharLimit = 5
|
|
|
|
emailIn := textinput.New()
|
|
emailIn.Placeholder = "admin@mydomain.com"
|
|
emailIn.CharLimit = 254 // RFC 5321 upper bound
|
|
emailIn.Width = 40
|
|
|
|
passIn := textinput.New()
|
|
passIn.Placeholder = "Admin password"
|
|
passIn.EchoMode = textinput.EchoPassword
|
|
passIn.CharLimit = 128
|
|
|
|
passConfirmIn := textinput.New()
|
|
passConfirmIn.Placeholder = "Confirm password"
|
|
passConfirmIn.EchoMode = textinput.EchoPassword
|
|
passConfirmIn.CharLimit = 128
|
|
|
|
// Initial LAN cursor: first NIC that is not the WAN NIC (cursor 0).
|
|
lanCursor := 0
|
|
if len(nics) > 1 {
|
|
lanCursor = 1 // wanNicCursor starts at 0
|
|
}
|
|
|
|
return model{
|
|
screen: screenWelcome,
|
|
disks: disks,
|
|
nics: nics,
|
|
eraseInput: eraseIn,
|
|
wanNicManualInput: wanNicManualIn,
|
|
wanDHCP: true, // DHCP is the default
|
|
wanIP: wanIPIn,
|
|
wanMask: wanMaskIn,
|
|
wanGateway: wanGWIn,
|
|
wanDNS: wanDNSIn,
|
|
wanSSID: wanSSIDIn,
|
|
wanWiFiPass: wanWiFiPassIn,
|
|
lanNicCursor: lanCursor,
|
|
lanDHCP: true, // DHCP is the default
|
|
lanIP: lanIPIn,
|
|
lanMask: lanMaskIn,
|
|
lanDNS: lanDNSIn,
|
|
lanSSID: lanSSIDIn,
|
|
lanWiFiPass: lanWiFiPassIn,
|
|
hostnameInput: hostnameIn,
|
|
emailInput: emailIn,
|
|
passwordInput: passIn,
|
|
passwordConfirmInput: passConfirmIn,
|
|
joinIPInput: joinIPIn,
|
|
joinPortInput: joinPortIn,
|
|
}
|
|
}
|
|
|
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
|
|
|
var (
|
|
styleLogo = lipgloss.NewStyle().
|
|
Foreground(branding.ColorPrimary).
|
|
Bold(true)
|
|
|
|
styleTitle = lipgloss.NewStyle().
|
|
Foreground(branding.ColorPrimary).
|
|
Bold(true).
|
|
MarginBottom(1)
|
|
|
|
styleSubtitle = lipgloss.NewStyle().
|
|
Foreground(branding.ColorMuted)
|
|
|
|
styleBox = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(branding.ColorBorder).
|
|
Padding(1, 2)
|
|
|
|
styleSelected = lipgloss.NewStyle().
|
|
Foreground(branding.ColorBackground).
|
|
Background(branding.ColorPrimary).
|
|
Bold(true)
|
|
|
|
styleWarning = lipgloss.NewStyle().
|
|
Foreground(branding.ColorWarning).
|
|
Bold(true)
|
|
|
|
styleError = lipgloss.NewStyle().
|
|
Foreground(branding.ColorError)
|
|
|
|
styleMuted = lipgloss.NewStyle().
|
|
Foreground(branding.ColorMuted)
|
|
|
|
styleSuccess = lipgloss.NewStyle().
|
|
Foreground(branding.ColorSuccess)
|
|
|
|
styleLabel = lipgloss.NewStyle().
|
|
Foreground(branding.ColorAccent).
|
|
Bold(true)
|
|
|
|
styleHelp = lipgloss.NewStyle().
|
|
Foreground(branding.ColorMuted).
|
|
MarginTop(1)
|
|
)
|
|
|
|
// ── Init / Update / View ──────────────────────────────────────────────────────
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return textinput.Blink
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
m.validationErr = ""
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
m.err = errors.New("installation cancelled")
|
|
return m, tea.Quit
|
|
}
|
|
return m.handleKey(msg)
|
|
}
|
|
|
|
// Forward to active input
|
|
return m.updateActiveInput(msg)
|
|
}
|
|
|
|
func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
key := msg.String()
|
|
switch m.screen {
|
|
case screenWelcome:
|
|
if key == "enter" || key == " " {
|
|
m.screen = screenDisk
|
|
}
|
|
|
|
case screenDisk:
|
|
switch key {
|
|
case "up", "k":
|
|
if m.diskCursor > 0 {
|
|
m.diskCursor--
|
|
}
|
|
case "down", "j":
|
|
if m.diskCursor < len(m.disks)-1 {
|
|
m.diskCursor++
|
|
}
|
|
case "enter":
|
|
m.screen = screenDiskConfirm
|
|
m.eraseInput.Focus()
|
|
m.eraseInput.SetValue("")
|
|
}
|
|
|
|
case screenDiskConfirm:
|
|
switch key {
|
|
case "enter":
|
|
if strings.ToLower(strings.TrimSpace(m.eraseInput.Value())) != "yes" {
|
|
m.validationErr = "Type 'yes' to confirm disk erasure"
|
|
return m, nil
|
|
}
|
|
m.screen = screenNetworkWAN
|
|
m.wanFocus = wanFieldNIC
|
|
m = m.withFocusedWANField()
|
|
case "esc":
|
|
m.screen = screenDisk
|
|
return m, nil
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.eraseInput, cmd = m.eraseInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
case screenNetworkWAN:
|
|
return m.handleWANKey(key, msg)
|
|
|
|
case screenNetworkLAN:
|
|
return m.handleLANKey(key, msg)
|
|
|
|
case screenIdentity:
|
|
switch key {
|
|
case "esc":
|
|
m.hostnameInput.Blur()
|
|
if len(m.nics) > 1 {
|
|
m.screen = screenNetworkLAN
|
|
m = m.withFocusedLANField()
|
|
} else {
|
|
m.screen = screenNetworkWAN
|
|
m = m.withFocusedWANField()
|
|
}
|
|
case "tab", "down":
|
|
if m.hostnameInput.Focused() {
|
|
m.hostnameInput.Blur()
|
|
} else {
|
|
m.hostnameInput.Focus()
|
|
}
|
|
case "left", "right":
|
|
if m.hostnameInput.Focused() {
|
|
var cmd tea.Cmd
|
|
m.hostnameInput, cmd = m.hostnameInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
if key == "left" {
|
|
m.clusterRole = 0
|
|
} else {
|
|
m.clusterRole = 1
|
|
}
|
|
case "enter":
|
|
if m.hostnameInput.Focused() {
|
|
if strings.TrimSpace(m.hostnameInput.Value()) == "" {
|
|
m.validationErr = "Hostname is required"
|
|
return m, nil
|
|
}
|
|
m.hostnameInput.Blur()
|
|
return m, nil
|
|
}
|
|
if strings.TrimSpace(m.hostnameInput.Value()) == "" {
|
|
m.validationErr = "Hostname is required"
|
|
m.hostnameInput.Focus()
|
|
return m, nil
|
|
}
|
|
m.screen = screenPassword
|
|
m.emailInput.Focus()
|
|
m.credsFocus = 0
|
|
default:
|
|
if m.hostnameInput.Focused() {
|
|
var cmd tea.Cmd
|
|
m.hostnameInput, cmd = m.hostnameInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
case screenPassword:
|
|
switch key {
|
|
case "tab", "down":
|
|
m = m.setCredsFocus((m.credsFocus + 1) % 3)
|
|
case "shift+tab", "up":
|
|
m = m.setCredsFocus((m.credsFocus + 2) % 3)
|
|
case "enter":
|
|
// On email field: validate, then advance to password.
|
|
if m.credsFocus == 0 {
|
|
if err := admin.ValidateEmail(m.emailInput.Value()); err != nil {
|
|
m.validationErr = err.Error()
|
|
return m, nil
|
|
}
|
|
m.validationErr = ""
|
|
m = m.setCredsFocus(1)
|
|
return m, nil
|
|
}
|
|
// On password field: just advance (defer validation to confirm).
|
|
if m.credsFocus == 1 {
|
|
m = m.setCredsFocus(2)
|
|
return m, nil
|
|
}
|
|
// On confirm: validate email (again, in case user tabbed past),
|
|
// then validate password + match.
|
|
if err := admin.ValidateEmail(m.emailInput.Value()); err != nil {
|
|
m.validationErr = err.Error()
|
|
m = m.setCredsFocus(0)
|
|
return m, nil
|
|
}
|
|
pw := m.passwordInput.Value()
|
|
confirm := m.passwordConfirmInput.Value()
|
|
if pw == "" {
|
|
m.validationErr = "Password is required"
|
|
m = m.setCredsFocus(1)
|
|
return m, nil
|
|
}
|
|
if pw != confirm {
|
|
m.validationErr = "Passwords do not match"
|
|
return m, nil
|
|
}
|
|
m.validationErr = ""
|
|
if m.clusterRole == 1 {
|
|
m.screen = screenJoinConfig
|
|
m.joinIPInput.Focus()
|
|
} else {
|
|
m.screen = screenConfirm
|
|
}
|
|
case "esc":
|
|
m.emailInput.Blur()
|
|
m.passwordInput.Blur()
|
|
m.passwordConfirmInput.Blur()
|
|
m.screen = screenIdentity
|
|
m.hostnameInput.Focus()
|
|
default:
|
|
var cmd tea.Cmd
|
|
switch m.credsFocus {
|
|
case 0:
|
|
m.emailInput, cmd = m.emailInput.Update(msg)
|
|
case 1:
|
|
m.passwordInput, cmd = m.passwordInput.Update(msg)
|
|
default:
|
|
m.passwordConfirmInput, cmd = m.passwordConfirmInput.Update(msg)
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
case screenJoinConfig:
|
|
switch key {
|
|
case "tab", "down":
|
|
if m.joinIPInput.Focused() {
|
|
m.joinIPInput.Blur()
|
|
m.joinPortInput.Focus()
|
|
} else {
|
|
m.joinPortInput.Blur()
|
|
m.joinIPInput.Focus()
|
|
}
|
|
case "enter":
|
|
if m.joinIPInput.Focused() {
|
|
m.joinIPInput.Blur()
|
|
m.joinPortInput.Focus()
|
|
return m, nil
|
|
}
|
|
joinIP := strings.TrimSpace(m.joinIPInput.Value())
|
|
if net.ParseIP(joinIP) == nil {
|
|
m.validationErr = "Invalid primary node IP"
|
|
return m, nil
|
|
}
|
|
m.screen = screenConfirm
|
|
case "esc":
|
|
m.joinIPInput.Blur()
|
|
m.joinPortInput.Blur()
|
|
m.screen = screenPassword
|
|
m = m.setCredsFocus(2)
|
|
default:
|
|
var cmd tea.Cmd
|
|
if m.joinIPInput.Focused() {
|
|
m.joinIPInput, cmd = m.joinIPInput.Update(msg)
|
|
} else {
|
|
m.joinPortInput, cmd = m.joinPortInput.Update(msg)
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
case screenConfirm:
|
|
switch key {
|
|
case "enter", "y", "Y":
|
|
m.result = m.buildConfig()
|
|
m.screen = screenDone
|
|
return m, tea.Quit
|
|
case "n", "N":
|
|
m.err = errors.New("installation cancelled")
|
|
return m, tea.Quit
|
|
case "esc":
|
|
if m.clusterRole == 1 {
|
|
m.screen = screenJoinConfig
|
|
m.joinIPInput.Focus()
|
|
m.joinPortInput.Blur()
|
|
} else {
|
|
m.screen = screenPassword
|
|
m = m.setCredsFocus(0)
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// setCredsFocus moves focus among the three credential inputs (email,
|
|
// password, confirm) and ensures exactly one is focused. Returns the
|
|
// updated model — callers must reassign (m = m.setCredsFocus(...)).
|
|
// Value receiver keeps model's method set consistent with the other
|
|
// bubbletea View/buildConfig methods (avoids golangci-lint recvcheck).
|
|
func (m model) setCredsFocus(i int) model {
|
|
m.emailInput.Blur()
|
|
m.passwordInput.Blur()
|
|
m.passwordConfirmInput.Blur()
|
|
switch i {
|
|
case 0:
|
|
m.emailInput.Focus()
|
|
case 1:
|
|
m.passwordInput.Focus()
|
|
default:
|
|
m.passwordConfirmInput.Focus()
|
|
i = 2
|
|
}
|
|
m.credsFocus = i
|
|
return m
|
|
}
|
|
|
|
// ── WAN screen key handling ───────────────────────────────────────────────────
|
|
|
|
func (m model) handleWANKey(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
isWiFi := len(m.nics) > 0 && m.nics[m.wanNicCursor].IsWiFi
|
|
|
|
switch key {
|
|
case "tab", "down":
|
|
m.wanFocus = m.wanNextFocus(m.wanFocus, true)
|
|
m = m.withFocusedWANField()
|
|
case "shift+tab", "up":
|
|
m.wanFocus = m.wanNextFocus(m.wanFocus, false)
|
|
m = m.withFocusedWANField()
|
|
|
|
case "left", "h":
|
|
switch m.wanFocus {
|
|
case wanFieldNIC:
|
|
if len(m.nics) > 0 && m.wanNicCursor > 0 {
|
|
m.wanNicCursor--
|
|
}
|
|
case wanFieldMethod:
|
|
m.wanDHCP = true
|
|
// If current focus would be invalid in DHCP mode, snap back.
|
|
m.wanFocus = m.wanClampFocus(m.wanFocus)
|
|
m = m.withFocusedWANField()
|
|
}
|
|
case "right", "l":
|
|
switch m.wanFocus {
|
|
case wanFieldNIC:
|
|
if len(m.nics) > 0 && m.wanNicCursor < len(m.nics)-1 {
|
|
m.wanNicCursor++
|
|
}
|
|
case wanFieldMethod:
|
|
m.wanDHCP = false
|
|
m = m.withFocusedWANField()
|
|
}
|
|
|
|
case "esc":
|
|
m.screen = screenDiskConfirm
|
|
m = m.withFocusedWANField()
|
|
return m, nil
|
|
|
|
case "enter":
|
|
// Always check last-field first: if we're on it, validate and advance screen.
|
|
if m.wanFocus == m.wanLastFocus() {
|
|
if errMsg := m.validateWAN(); errMsg != "" {
|
|
m.validationErr = errMsg
|
|
return m, nil
|
|
}
|
|
if len(m.nics) > 1 {
|
|
m.screen = screenNetworkLAN
|
|
m.lanFocus = lanFieldNIC
|
|
m.lanNicCursor = m.initialLANCursor()
|
|
m = m.withFocusedLANField()
|
|
} else {
|
|
m.screen = screenIdentity
|
|
m.hostnameInput.Focus()
|
|
}
|
|
return m, nil
|
|
}
|
|
// Otherwise advance to the next field.
|
|
m.wanFocus = m.wanNextFocus(m.wanFocus, true)
|
|
m = m.withFocusedWANField()
|
|
|
|
default:
|
|
// Forward keystrokes to the active text input.
|
|
switch m.wanFocus {
|
|
case wanFieldIP:
|
|
var cmd tea.Cmd
|
|
m.wanIP, cmd = m.wanIP.Update(msg)
|
|
return m, cmd
|
|
case wanFieldMask:
|
|
var cmd tea.Cmd
|
|
m.wanMask, cmd = m.wanMask.Update(msg)
|
|
return m, cmd
|
|
case wanFieldGateway:
|
|
var cmd tea.Cmd
|
|
m.wanGateway, cmd = m.wanGateway.Update(msg)
|
|
return m, cmd
|
|
case wanFieldDNS:
|
|
var cmd tea.Cmd
|
|
m.wanDNS, cmd = m.wanDNS.Update(msg)
|
|
return m, cmd
|
|
case wanFieldSSID:
|
|
if isWiFi {
|
|
var cmd tea.Cmd
|
|
m.wanSSID, cmd = m.wanSSID.Update(msg)
|
|
return m, cmd
|
|
}
|
|
case wanFieldWiFiPass:
|
|
if isWiFi {
|
|
var cmd tea.Cmd
|
|
m.wanWiFiPass, cmd = m.wanWiFiPass.Update(msg)
|
|
return m, cmd
|
|
}
|
|
case wanFieldNIC:
|
|
if len(m.nics) == 0 {
|
|
var cmd tea.Cmd
|
|
m.wanNicManualInput, cmd = m.wanNicManualInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// wanNextFocus returns the next valid focus index, skipping fields that are
|
|
// hidden in the current DHCP/WiFi configuration.
|
|
func (m model) wanNextFocus(current wanField, forward bool) wanField {
|
|
return nextFocusInList[wanField](current, m.wanVisibleFields(), forward)
|
|
}
|
|
|
|
func (m model) wanLastFocus() wanField {
|
|
fields := m.wanVisibleFields()
|
|
return fields[len(fields)-1]
|
|
}
|
|
|
|
func (m model) wanVisibleFields() []wanField {
|
|
isWiFi := len(m.nics) > 0 && m.nics[m.wanNicCursor].IsWiFi
|
|
fields := []wanField{wanFieldNIC, wanFieldMethod}
|
|
if !m.wanDHCP {
|
|
fields = append(fields, wanFieldIP, wanFieldMask, wanFieldGateway, wanFieldDNS)
|
|
}
|
|
if isWiFi {
|
|
fields = append(fields, wanFieldSSID, wanFieldWiFiPass)
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// wanClampFocus snaps the current focus to the nearest visible field (used
|
|
// when toggling between DHCP and static removes visible fields).
|
|
func (m model) wanClampFocus(current wanField) wanField {
|
|
for _, f := range m.wanVisibleFields() {
|
|
if f >= current {
|
|
return f
|
|
}
|
|
}
|
|
fields := m.wanVisibleFields()
|
|
return fields[len(fields)-1]
|
|
}
|
|
|
|
func (m model) withFocusedWANField() model {
|
|
m.wanIP.Blur()
|
|
m.wanMask.Blur()
|
|
m.wanGateway.Blur()
|
|
m.wanDNS.Blur()
|
|
m.wanSSID.Blur()
|
|
m.wanWiFiPass.Blur()
|
|
m.wanNicManualInput.Blur()
|
|
switch m.wanFocus {
|
|
case wanFieldIP:
|
|
m.wanIP.Focus()
|
|
case wanFieldMask:
|
|
m.wanMask.Focus()
|
|
case wanFieldGateway:
|
|
m.wanGateway.Focus()
|
|
case wanFieldDNS:
|
|
m.wanDNS.Focus()
|
|
case wanFieldSSID:
|
|
m.wanSSID.Focus()
|
|
case wanFieldWiFiPass:
|
|
m.wanWiFiPass.Focus()
|
|
case wanFieldNIC:
|
|
if len(m.nics) == 0 {
|
|
m.wanNicManualInput.Focus()
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m model) validateWAN() string {
|
|
if len(m.nics) == 0 && strings.TrimSpace(m.wanNicManualInput.Value()) == "" {
|
|
return "Enter interface name (e.g. eth0, enp0s1)"
|
|
}
|
|
if !m.wanDHCP {
|
|
ip := strings.TrimSpace(m.wanIP.Value())
|
|
mask := strings.TrimSpace(m.wanMask.Value())
|
|
gw := strings.TrimSpace(m.wanGateway.Value())
|
|
dns := strings.TrimSpace(m.wanDNS.Value())
|
|
if net.ParseIP(ip) == nil {
|
|
return "Invalid WAN IP address"
|
|
}
|
|
if !validSubnetMask(mask) {
|
|
return "Invalid subnet mask (e.g. 255.255.255.0 or 24)"
|
|
}
|
|
if net.ParseIP(gw) == nil {
|
|
return "Invalid gateway address"
|
|
}
|
|
if dns == "" {
|
|
return "Enter at least one DNS nameserver"
|
|
}
|
|
}
|
|
isWiFi := len(m.nics) > 0 && m.nics[m.wanNicCursor].IsWiFi
|
|
if isWiFi && strings.TrimSpace(m.wanSSID.Value()) == "" {
|
|
return "WiFi SSID is required"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ── LAN screen key handling ───────────────────────────────────────────────────
|
|
|
|
func (m model) handleLANKey(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
isWiFi := len(m.nics) > m.lanNicCursor && m.nics[m.lanNicCursor].IsWiFi
|
|
|
|
switch key {
|
|
case "tab", "down":
|
|
m.lanFocus = m.lanNextFocus(m.lanFocus, true)
|
|
m = m.withFocusedLANField()
|
|
case "shift+tab", "up":
|
|
m.lanFocus = m.lanNextFocus(m.lanFocus, false)
|
|
m = m.withFocusedLANField()
|
|
|
|
case "left", "h":
|
|
switch m.lanFocus {
|
|
case lanFieldNIC:
|
|
m = m.moveLANCursorLeft()
|
|
case lanFieldMethod:
|
|
m.lanDHCP = true
|
|
m.lanFocus = m.lanClampFocus(m.lanFocus)
|
|
m = m.withFocusedLANField()
|
|
}
|
|
case "right", "l":
|
|
switch m.lanFocus {
|
|
case lanFieldNIC:
|
|
m = m.moveLANCursorRight()
|
|
case lanFieldMethod:
|
|
m.lanDHCP = false
|
|
m = m.withFocusedLANField()
|
|
}
|
|
|
|
case "esc":
|
|
m.screen = screenNetworkWAN
|
|
m = m.withFocusedWANField()
|
|
return m, nil
|
|
|
|
case "enter":
|
|
if m.lanFocus == m.lanLastFocus() {
|
|
if errMsg := m.validateLAN(); errMsg != "" {
|
|
m.validationErr = errMsg
|
|
return m, nil
|
|
}
|
|
m.screen = screenIdentity
|
|
m.hostnameInput.Focus()
|
|
return m, nil
|
|
}
|
|
m.lanFocus = m.lanNextFocus(m.lanFocus, true)
|
|
m = m.withFocusedLANField()
|
|
|
|
default:
|
|
switch m.lanFocus {
|
|
case lanFieldIP:
|
|
var cmd tea.Cmd
|
|
m.lanIP, cmd = m.lanIP.Update(msg)
|
|
return m, cmd
|
|
case lanFieldMask:
|
|
var cmd tea.Cmd
|
|
m.lanMask, cmd = m.lanMask.Update(msg)
|
|
return m, cmd
|
|
case lanFieldDNS:
|
|
var cmd tea.Cmd
|
|
m.lanDNS, cmd = m.lanDNS.Update(msg)
|
|
return m, cmd
|
|
case lanFieldSSID:
|
|
if isWiFi {
|
|
var cmd tea.Cmd
|
|
m.lanSSID, cmd = m.lanSSID.Update(msg)
|
|
return m, cmd
|
|
}
|
|
case lanFieldWiFiPass:
|
|
if isWiFi {
|
|
var cmd tea.Cmd
|
|
m.lanWiFiPass, cmd = m.lanWiFiPass.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) lanNextFocus(current lanField, forward bool) lanField {
|
|
return nextFocusInList[lanField](current, m.lanVisibleFields(), forward)
|
|
}
|
|
|
|
func (m model) lanLastFocus() lanField {
|
|
fields := m.lanVisibleFields()
|
|
return fields[len(fields)-1]
|
|
}
|
|
|
|
func (m model) lanVisibleFields() []lanField {
|
|
isWiFi := len(m.nics) > m.lanNicCursor && m.nics[m.lanNicCursor].IsWiFi
|
|
fields := []lanField{lanFieldNIC, lanFieldMethod}
|
|
if !m.lanDHCP {
|
|
fields = append(fields, lanFieldIP, lanFieldMask, lanFieldDNS)
|
|
}
|
|
if isWiFi {
|
|
fields = append(fields, lanFieldSSID, lanFieldWiFiPass)
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func (m model) lanClampFocus(current lanField) lanField {
|
|
for _, f := range m.lanVisibleFields() {
|
|
if f >= current {
|
|
return f
|
|
}
|
|
}
|
|
fields := m.lanVisibleFields()
|
|
return fields[len(fields)-1]
|
|
}
|
|
|
|
func (m model) withFocusedLANField() model {
|
|
m.lanIP.Blur()
|
|
m.lanMask.Blur()
|
|
m.lanDNS.Blur()
|
|
m.lanSSID.Blur()
|
|
m.lanWiFiPass.Blur()
|
|
switch m.lanFocus {
|
|
case lanFieldIP:
|
|
m.lanIP.Focus()
|
|
case lanFieldMask:
|
|
m.lanMask.Focus()
|
|
case lanFieldDNS:
|
|
m.lanDNS.Focus()
|
|
case lanFieldSSID:
|
|
m.lanSSID.Focus()
|
|
case lanFieldWiFiPass:
|
|
m.lanWiFiPass.Focus()
|
|
}
|
|
return m
|
|
}
|
|
|
|
// initialLANCursor returns the first NIC index that is not the WAN NIC.
|
|
func (m model) initialLANCursor() int {
|
|
for i := range m.nics {
|
|
if i != m.wanNicCursor {
|
|
return i
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// moveLANCursorLeft/Right skip the WAN NIC so it can never be selected.
|
|
// If no valid position exists in that direction, the cursor stays put.
|
|
func (m model) moveLANCursorLeft() model {
|
|
for i := m.lanNicCursor - 1; i >= 0; i-- {
|
|
if i != m.wanNicCursor {
|
|
m.lanNicCursor = i
|
|
return m
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m model) moveLANCursorRight() model {
|
|
for i := m.lanNicCursor + 1; i < len(m.nics); i++ {
|
|
if i != m.wanNicCursor {
|
|
m.lanNicCursor = i
|
|
return m
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m model) validateLAN() string {
|
|
if !m.lanDHCP {
|
|
ip := strings.TrimSpace(m.lanIP.Value())
|
|
mask := strings.TrimSpace(m.lanMask.Value())
|
|
dns := strings.TrimSpace(m.lanDNS.Value())
|
|
if net.ParseIP(ip) == nil {
|
|
return "Invalid LAN IP address"
|
|
}
|
|
if !validSubnetMask(mask) {
|
|
return "Invalid subnet mask (e.g. 255.255.255.0 or 24)"
|
|
}
|
|
if dns == "" {
|
|
return "Enter at least one DNS nameserver"
|
|
}
|
|
}
|
|
isWiFi := len(m.nics) > m.lanNicCursor && m.nics[m.lanNicCursor].IsWiFi
|
|
if isWiFi && strings.TrimSpace(m.lanSSID.Value()) == "" {
|
|
return "WiFi SSID is required"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// nextFocusInList finds current in the list and returns the next/prev entry,
|
|
// wrapping around. Used by both WAN and LAN focus helpers.
|
|
func nextFocusInList[T ~int](current T, list []T, forward bool) T {
|
|
for i, v := range list {
|
|
if v == current {
|
|
if forward {
|
|
return list[(i+1)%len(list)]
|
|
}
|
|
return list[(i-1+len(list))%len(list)]
|
|
}
|
|
}
|
|
// current not in list (e.g. after a mode change) — return first/last
|
|
if forward {
|
|
return list[0]
|
|
}
|
|
return list[len(list)-1]
|
|
}
|
|
|
|
func (m model) updateActiveInput(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch m.screen {
|
|
case screenDiskConfirm:
|
|
var cmd tea.Cmd
|
|
m.eraseInput, cmd = m.eraseInput.Update(msg)
|
|
return m, cmd
|
|
case screenNetworkWAN:
|
|
switch m.wanFocus {
|
|
case wanFieldIP:
|
|
var cmd tea.Cmd
|
|
m.wanIP, cmd = m.wanIP.Update(msg)
|
|
return m, cmd
|
|
case wanFieldMask:
|
|
var cmd tea.Cmd
|
|
m.wanMask, cmd = m.wanMask.Update(msg)
|
|
return m, cmd
|
|
case wanFieldGateway:
|
|
var cmd tea.Cmd
|
|
m.wanGateway, cmd = m.wanGateway.Update(msg)
|
|
return m, cmd
|
|
case wanFieldDNS:
|
|
var cmd tea.Cmd
|
|
m.wanDNS, cmd = m.wanDNS.Update(msg)
|
|
return m, cmd
|
|
case wanFieldSSID:
|
|
var cmd tea.Cmd
|
|
m.wanSSID, cmd = m.wanSSID.Update(msg)
|
|
return m, cmd
|
|
case wanFieldWiFiPass:
|
|
var cmd tea.Cmd
|
|
m.wanWiFiPass, cmd = m.wanWiFiPass.Update(msg)
|
|
return m, cmd
|
|
case wanFieldNIC:
|
|
if len(m.nics) == 0 {
|
|
var cmd tea.Cmd
|
|
m.wanNicManualInput, cmd = m.wanNicManualInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
case screenNetworkLAN:
|
|
switch m.lanFocus {
|
|
case lanFieldIP:
|
|
var cmd tea.Cmd
|
|
m.lanIP, cmd = m.lanIP.Update(msg)
|
|
return m, cmd
|
|
case lanFieldMask:
|
|
var cmd tea.Cmd
|
|
m.lanMask, cmd = m.lanMask.Update(msg)
|
|
return m, cmd
|
|
case lanFieldDNS:
|
|
var cmd tea.Cmd
|
|
m.lanDNS, cmd = m.lanDNS.Update(msg)
|
|
return m, cmd
|
|
case lanFieldSSID:
|
|
var cmd tea.Cmd
|
|
m.lanSSID, cmd = m.lanSSID.Update(msg)
|
|
return m, cmd
|
|
case lanFieldWiFiPass:
|
|
var cmd tea.Cmd
|
|
m.lanWiFiPass, cmd = m.lanWiFiPass.Update(msg)
|
|
return m, cmd
|
|
}
|
|
case screenIdentity:
|
|
if m.hostnameInput.Focused() {
|
|
var cmd tea.Cmd
|
|
m.hostnameInput, cmd = m.hostnameInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
case screenPassword:
|
|
var cmd tea.Cmd
|
|
switch m.credsFocus {
|
|
case 0:
|
|
m.emailInput, cmd = m.emailInput.Update(msg)
|
|
case 1:
|
|
m.passwordInput, cmd = m.passwordInput.Update(msg)
|
|
default:
|
|
m.passwordConfirmInput, cmd = m.passwordConfirmInput.Update(msg)
|
|
}
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ── View ──────────────────────────────────────────────────────────────────────
|
|
|
|
func (m model) View() string {
|
|
w := m.width
|
|
if w == 0 {
|
|
w = 80
|
|
}
|
|
|
|
var content string
|
|
switch m.screen {
|
|
case screenWelcome:
|
|
content = m.viewWelcome(w)
|
|
case screenDisk:
|
|
content = m.viewDisk(w)
|
|
case screenDiskConfirm:
|
|
content = m.viewDiskConfirm(w)
|
|
case screenNetworkWAN:
|
|
content = m.viewNetworkWAN(w)
|
|
case screenNetworkLAN:
|
|
content = m.viewNetworkLAN(w)
|
|
case screenIdentity:
|
|
content = m.viewIdentity(w)
|
|
case screenPassword:
|
|
content = m.viewPassword(w)
|
|
case screenJoinConfig:
|
|
content = m.viewJoinConfig(w)
|
|
case screenConfirm:
|
|
content = m.viewConfirm(w)
|
|
case screenDone:
|
|
content = m.viewDone(w)
|
|
}
|
|
|
|
return content
|
|
}
|
|
|
|
func (m model) viewWelcome(w int) string {
|
|
logo := styleLogo.Render(branding.Logo)
|
|
subtitle := styleSubtitle.Render(branding.Subtitle)
|
|
publisher := styleMuted.Render(branding.Publisher)
|
|
|
|
warning := styleWarning.Render("WARNING: Installation will erase the selected disk entirely.")
|
|
help := styleHelp.Render("Press Enter to begin")
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Center,
|
|
logo,
|
|
subtitle,
|
|
publisher,
|
|
"",
|
|
warning,
|
|
"",
|
|
help,
|
|
)
|
|
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 72)).Render(body),
|
|
)
|
|
}
|
|
|
|
func (m model) viewDisk(w int) string {
|
|
title := styleTitle.Render("Select Installation Disk")
|
|
subtitle := styleMuted.Render("All data on the selected disk will be permanently erased.")
|
|
|
|
var rows []string
|
|
for i, d := range m.disks {
|
|
line := fmt.Sprintf(" %-20s %-8s %s", d.Path, d.Size, d.Model)
|
|
if i == m.diskCursor {
|
|
line = styleSelected.Render("> " + line[2:])
|
|
} else {
|
|
line = styleMuted.Render(line)
|
|
}
|
|
rows = append(rows, line)
|
|
}
|
|
|
|
help := styleHelp.Render("↑/↓ to select • Enter to confirm")
|
|
body := lipgloss.JoinVertical(lipgloss.Left, append([]string{title, subtitle, ""}, append(rows, "", help)...)...)
|
|
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 72)).Render(body),
|
|
)
|
|
}
|
|
|
|
func (m model) viewDiskConfirm(w int) string {
|
|
title := styleTitle.Render("Confirm Disk Erasure")
|
|
disk := styleLabel.Render(m.disks[m.diskCursor].Path)
|
|
msg := fmt.Sprintf("All data on %s will be permanently erased.\nType 'yes' to confirm:", disk)
|
|
|
|
var lines []string
|
|
lines = append(lines, title, msg, "", m.eraseInput.View())
|
|
if m.validationErr != "" {
|
|
lines = append(lines, "", styleError.Render(m.validationErr))
|
|
}
|
|
lines = append(lines, styleHelp.Render("Enter to confirm • Esc to go back"))
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 64)).Render(body),
|
|
)
|
|
}
|
|
|
|
// renderNICPicker renders a horizontal NIC selector. disabledIdx is the index
|
|
// to show as greyed/unavailable (-1 to disable none).
|
|
func (m model) renderNICPicker(cursor, disabledIdx int, focused bool) string {
|
|
if len(m.nics) == 0 {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for i, nic := range m.nics {
|
|
tag := "[ETH]"
|
|
if nic.IsWiFi {
|
|
tag = "[WIFI]"
|
|
}
|
|
label := fmt.Sprintf("%s %s", tag, nic.Name)
|
|
switch {
|
|
case i == disabledIdx:
|
|
parts = append(parts, styleMuted.Render("("+label+")"))
|
|
case i == cursor && focused:
|
|
parts = append(parts, styleSelected.Render(" "+label+" "))
|
|
case i == cursor:
|
|
parts = append(parts, styleLabel.Render("["+label+"]"))
|
|
default:
|
|
parts = append(parts, styleMuted.Render(label))
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func (m model) viewNetworkWAN(w int) string {
|
|
title := styleTitle.Render("WAN Network (Step 1 of " + m.networkStepCount() + ")")
|
|
subtitle := styleMuted.Render("The management IP is assigned to a bridge (br-wan) over this NIC.")
|
|
|
|
var lines []string
|
|
lines = append(lines, title, subtitle, "")
|
|
|
|
// NIC picker
|
|
nicLabel := styleLabel.Render("WAN network interface")
|
|
var nicLine string
|
|
if len(m.nics) == 0 {
|
|
nicLine = m.wanNicManualInput.View()
|
|
} else {
|
|
nicLine = m.renderNICPicker(m.wanNicCursor, -1, m.wanFocus == wanFieldNIC)
|
|
}
|
|
lines = append(lines, nicLabel, nicLine, "")
|
|
|
|
// IP method toggle
|
|
methodLabel := styleLabel.Render("IP method")
|
|
methods := []string{"DHCP (automatic)", "Static"}
|
|
dhcpIdx := 0
|
|
if !m.wanDHCP {
|
|
dhcpIdx = 1
|
|
}
|
|
var methodParts []string
|
|
for i, s := range methods {
|
|
if i == dhcpIdx && m.wanFocus == wanFieldMethod {
|
|
methodParts = append(methodParts, styleSelected.Render(" "+s+" "))
|
|
} else if i == dhcpIdx {
|
|
methodParts = append(methodParts, styleLabel.Render("["+s+"]"))
|
|
} else {
|
|
methodParts = append(methodParts, styleMuted.Render(s))
|
|
}
|
|
}
|
|
lines = append(lines, methodLabel, strings.Join(methodParts, " "), "")
|
|
|
|
// Static fields
|
|
if !m.wanDHCP {
|
|
lines = append(lines,
|
|
styleLabel.Render("IP address"), m.wanIP.View(), "",
|
|
styleLabel.Render("Subnet mask"), m.wanMask.View(), "",
|
|
styleLabel.Render("Default gateway"), m.wanGateway.View(), "",
|
|
styleLabel.Render("DNS nameservers"), m.wanDNS.View(), "",
|
|
)
|
|
}
|
|
|
|
// WiFi fields
|
|
if len(m.nics) > 0 && m.nics[m.wanNicCursor].IsWiFi {
|
|
lines = append(lines,
|
|
styleLabel.Render("WiFi SSID"), m.wanSSID.View(), "",
|
|
styleLabel.Render("WiFi password"), m.wanWiFiPass.View(), "",
|
|
)
|
|
}
|
|
|
|
if m.validationErr != "" {
|
|
lines = append(lines, styleError.Render(m.validationErr), "")
|
|
}
|
|
lines = append(lines, styleHelp.Render("Tab/↑↓ to move • ←/→ to select • Enter to proceed"))
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 68)).Render(body),
|
|
)
|
|
}
|
|
|
|
func (m model) viewNetworkLAN(w int) string {
|
|
title := styleTitle.Render("LAN Network (Step 2 of " + m.networkStepCount() + ")")
|
|
subtitle := styleMuted.Render("Internal interface for EC2/VPC and Geneve tunnel traffic (br-lan).")
|
|
|
|
var lines []string
|
|
lines = append(lines, title, subtitle, "")
|
|
|
|
// NIC picker — WAN NIC shown greyed
|
|
nicLabel := styleLabel.Render("LAN network interface")
|
|
nicLine := m.renderNICPicker(m.lanNicCursor, m.wanNicCursor, m.lanFocus == lanFieldNIC)
|
|
lines = append(lines, nicLabel, nicLine, "")
|
|
|
|
// IP method toggle
|
|
methodLabel := styleLabel.Render("IP method")
|
|
methods := []string{"DHCP (automatic)", "Static"}
|
|
dhcpIdx := 0
|
|
if !m.lanDHCP {
|
|
dhcpIdx = 1
|
|
}
|
|
var methodParts []string
|
|
for i, s := range methods {
|
|
if i == dhcpIdx && m.lanFocus == lanFieldMethod {
|
|
methodParts = append(methodParts, styleSelected.Render(" "+s+" "))
|
|
} else if i == dhcpIdx {
|
|
methodParts = append(methodParts, styleLabel.Render("["+s+"]"))
|
|
} else {
|
|
methodParts = append(methodParts, styleMuted.Render(s))
|
|
}
|
|
}
|
|
lines = append(lines, methodLabel, strings.Join(methodParts, " "), "")
|
|
|
|
// Static fields (no gateway for LAN)
|
|
if !m.lanDHCP {
|
|
lines = append(lines,
|
|
styleLabel.Render("IP address"), m.lanIP.View(), "",
|
|
styleLabel.Render("Subnet mask"), m.lanMask.View(), "",
|
|
styleLabel.Render("DNS nameservers"), m.lanDNS.View(), "",
|
|
)
|
|
}
|
|
|
|
// WiFi fields
|
|
if len(m.nics) > m.lanNicCursor && m.nics[m.lanNicCursor].IsWiFi {
|
|
lines = append(lines,
|
|
styleLabel.Render("WiFi SSID"), m.lanSSID.View(), "",
|
|
styleLabel.Render("WiFi password"), m.lanWiFiPass.View(), "",
|
|
)
|
|
}
|
|
|
|
if m.validationErr != "" {
|
|
lines = append(lines, styleError.Render(m.validationErr), "")
|
|
}
|
|
lines = append(lines, styleHelp.Render("Tab/↑↓ to move • ←/→ to select • Enter to proceed"))
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 68)).Render(body),
|
|
)
|
|
}
|
|
|
|
func (m model) networkStepCount() string {
|
|
if len(m.nics) > 1 {
|
|
return "2"
|
|
}
|
|
return "1"
|
|
}
|
|
|
|
func (m model) viewIdentity(w int) string {
|
|
title := styleTitle.Render("Node Identity")
|
|
|
|
hostnameLabel := styleLabel.Render("Hostname")
|
|
|
|
roleLabel := styleLabel.Render("Cluster role")
|
|
roles := []string{"Initialize new cluster", "Join existing cluster"}
|
|
var roleParts []string
|
|
for i, r := range roles {
|
|
if i == m.clusterRole && !m.hostnameInput.Focused() {
|
|
roleParts = append(roleParts, styleSelected.Render(" "+r+" "))
|
|
} else if i == m.clusterRole {
|
|
roleParts = append(roleParts, styleLabel.Render("["+r+"]"))
|
|
} else {
|
|
roleParts = append(roleParts, styleMuted.Render(r))
|
|
}
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, title, "", hostnameLabel, m.hostnameInput.View(), "", roleLabel, strings.Join(roleParts, " "))
|
|
if m.validationErr != "" {
|
|
lines = append(lines, "", styleError.Render(m.validationErr))
|
|
}
|
|
lines = append(lines, styleHelp.Render("Tab to toggle focus • ←/→ to select role • Enter to proceed"))
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 64)).Render(body),
|
|
)
|
|
}
|
|
|
|
func (m model) viewPassword(w int) string {
|
|
title := styleTitle.Render("Administrator Credentials")
|
|
emailLabel := styleLabel.Render("Email")
|
|
emailHelp := styleHelp.Render("Used to notify of important system updates to Spinifex or security announcements")
|
|
passLabel := styleLabel.Render("Password")
|
|
confirmLabel := styleLabel.Render("Confirm password")
|
|
|
|
var lines []string
|
|
lines = append(lines,
|
|
title, "",
|
|
emailLabel, m.emailInput.View(), emailHelp, "",
|
|
passLabel, m.passwordInput.View(), "",
|
|
confirmLabel, m.passwordConfirmInput.View(),
|
|
)
|
|
if m.validationErr != "" {
|
|
lines = append(lines, "", styleError.Render(m.validationErr))
|
|
}
|
|
lines = append(lines, "", styleHelp.Render("Tab to move • Enter to proceed • Esc to go back"))
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 64)).Render(body),
|
|
)
|
|
}
|
|
|
|
func (m model) viewJoinConfig(w int) string {
|
|
title := styleTitle.Render("Join Existing Cluster")
|
|
ipLabel := styleLabel.Render("Primary node IP")
|
|
portLabel := styleLabel.Render("Formation port")
|
|
|
|
var lines []string
|
|
lines = append(lines, title, "", ipLabel, m.joinIPInput.View(), "", portLabel, m.joinPortInput.View())
|
|
if m.validationErr != "" {
|
|
lines = append(lines, "", styleError.Render(m.validationErr))
|
|
}
|
|
lines = append(lines, styleHelp.Render("Tab to move • Enter to proceed • Esc to go back"))
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 64)).Render(body),
|
|
)
|
|
}
|
|
|
|
func (m model) viewConfirm(w int) string {
|
|
title := styleTitle.Render("Confirm Installation")
|
|
|
|
cfg := m.buildConfig()
|
|
role := "Initialize new cluster"
|
|
if cfg.ClusterRole == "join" {
|
|
role = fmt.Sprintf("Join cluster at %s", cfg.JoinAddr)
|
|
}
|
|
|
|
wanNet := "DHCP"
|
|
if !cfg.WANDHCPMode {
|
|
wanNet = fmt.Sprintf("%s/%s via %s", cfg.WANAddress, cfg.WANMask, cfg.WANGateway)
|
|
}
|
|
|
|
summary := []struct{ k, v string }{
|
|
{"Disk", cfg.Disk},
|
|
{"WAN interface", fmt.Sprintf("%s → br-wan", cfg.WANInterface)},
|
|
{"WAN address", wanNet},
|
|
}
|
|
if cfg.LANInterface != "" {
|
|
lanNet := "DHCP"
|
|
if !cfg.LANDHCPMode {
|
|
lanNet = fmt.Sprintf("%s/%s", cfg.LANAddress, cfg.LANMask)
|
|
}
|
|
summary = append(summary,
|
|
struct{ k, v string }{"LAN interface", fmt.Sprintf("%s → br-lan", cfg.LANInterface)},
|
|
struct{ k, v string }{"LAN address", lanNet},
|
|
)
|
|
}
|
|
summary = append(summary,
|
|
struct{ k, v string }{"Hostname", cfg.Hostname},
|
|
struct{ k, v string }{"Cluster role", role},
|
|
)
|
|
if cfg.HasCACert {
|
|
summary = append(summary, struct{ k, v string }{"CA certificate", "provided"})
|
|
}
|
|
|
|
var rows []string
|
|
for _, s := range summary {
|
|
rows = append(rows, fmt.Sprintf(" %s%-20s%s %s",
|
|
styleLabel.Render(""), styleLabel.Render(s.k), "", s.v))
|
|
}
|
|
|
|
warning := styleWarning.Render("This will erase " + cfg.Disk + " and begin installation.")
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
title, "",
|
|
strings.Join(rows, "\n"), "",
|
|
warning, "",
|
|
styleHelp.Render("Enter/Y to install • N to cancel • Esc to go back"),
|
|
)
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 72)).Render(body),
|
|
)
|
|
}
|
|
|
|
func (m model) viewDone(w int) string {
|
|
body := lipgloss.JoinVertical(lipgloss.Center,
|
|
styleSuccess.Render("Installation complete."),
|
|
"",
|
|
styleMuted.Render("The system will reboot shortly."),
|
|
)
|
|
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
|
styleBox.Width(min(w-4, 48)).Render(body),
|
|
)
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
func (m model) buildConfig() *install.Config {
|
|
cfg := &install.Config{}
|
|
if len(m.disks) > m.diskCursor {
|
|
cfg.Disk = m.disks[m.diskCursor].Path
|
|
}
|
|
|
|
// WAN
|
|
if len(m.nics) > m.wanNicCursor {
|
|
cfg.WANInterface = m.nics[m.wanNicCursor].Name
|
|
if m.nics[m.wanNicCursor].IsWiFi {
|
|
cfg.WANWiFiSSID = strings.TrimSpace(m.wanSSID.Value())
|
|
cfg.WANWiFiPass = m.wanWiFiPass.Value()
|
|
}
|
|
} else {
|
|
cfg.WANInterface = strings.TrimSpace(m.wanNicManualInput.Value())
|
|
}
|
|
cfg.WANDHCPMode = m.wanDHCP
|
|
if !m.wanDHCP {
|
|
cfg.WANAddress = strings.TrimSpace(m.wanIP.Value())
|
|
cfg.WANMask = strings.TrimSpace(m.wanMask.Value())
|
|
cfg.WANGateway = strings.TrimSpace(m.wanGateway.Value())
|
|
cfg.WANDNS = parseDNS(m.wanDNS.Value())
|
|
}
|
|
|
|
// LAN (only when 2+ NICs)
|
|
if len(m.nics) > 1 && m.lanNicCursor < len(m.nics) {
|
|
cfg.LANInterface = m.nics[m.lanNicCursor].Name
|
|
cfg.LANDHCPMode = m.lanDHCP
|
|
if !m.lanDHCP {
|
|
cfg.LANAddress = strings.TrimSpace(m.lanIP.Value())
|
|
cfg.LANMask = strings.TrimSpace(m.lanMask.Value())
|
|
cfg.LANDNS = parseDNS(m.lanDNS.Value())
|
|
}
|
|
if m.nics[m.lanNicCursor].IsWiFi {
|
|
cfg.LANWiFiSSID = strings.TrimSpace(m.lanSSID.Value())
|
|
cfg.LANWiFiPass = m.lanWiFiPass.Value()
|
|
}
|
|
}
|
|
|
|
cfg.Hostname = strings.TrimSpace(m.hostnameInput.Value())
|
|
if m.clusterRole == 0 {
|
|
cfg.ClusterRole = "init"
|
|
} else {
|
|
cfg.ClusterRole = "join"
|
|
port := strings.TrimSpace(m.joinPortInput.Value())
|
|
if port == "" {
|
|
port = "4432"
|
|
}
|
|
cfg.JoinAddr = net.JoinHostPort(strings.TrimSpace(m.joinIPInput.Value()), port)
|
|
}
|
|
cfg.RootPassword = m.passwordInput.Value()
|
|
cfg.Email = strings.TrimSpace(m.emailInput.Value())
|
|
return cfg
|
|
}
|
|
|
|
// parseDNS splits a comma-separated DNS string into individual nameserver entries.
|
|
func parseDNS(raw string) []string {
|
|
var out []string
|
|
for s := range strings.SplitSeq(raw, ",") {
|
|
s = strings.TrimSpace(s)
|
|
if s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// diskInfo holds display info for a block device.
|
|
type diskInfo struct {
|
|
Path string
|
|
Size string
|
|
Model string
|
|
}
|
|
|
|
func availableDisks() ([]diskInfo, error) {
|
|
entries, err := os.ReadDir("/sys/block")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var disks []diskInfo
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") {
|
|
continue
|
|
}
|
|
d := diskInfo{Path: "/dev/" + name}
|
|
d.Size = readSysBlockFile(name, "size")
|
|
if d.Size != "" {
|
|
d.Size = formatSectors(d.Size)
|
|
}
|
|
d.Model = strings.TrimSpace(readSysBlockFile(name, "device/model"))
|
|
disks = append(disks, d)
|
|
}
|
|
return disks, nil
|
|
}
|
|
|
|
func readSysBlockFile(dev, file string) string {
|
|
data, err := os.ReadFile("/sys/block/" + dev + "/" + file)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func formatSectors(sectors string) string {
|
|
var n int64
|
|
if _, err := fmt.Sscan(sectors, &n); err != nil {
|
|
return ""
|
|
}
|
|
bytes := n * 512
|
|
switch {
|
|
case bytes >= 1<<40:
|
|
return fmt.Sprintf("%.1fT", float64(bytes)/(1<<40))
|
|
case bytes >= 1<<30:
|
|
return fmt.Sprintf("%.1fG", float64(bytes)/(1<<30))
|
|
case bytes >= 1<<20:
|
|
return fmt.Sprintf("%.1fM", float64(bytes)/(1<<20))
|
|
default:
|
|
return fmt.Sprintf("%dB", bytes)
|
|
}
|
|
}
|
|
|
|
// validSubnetMask accepts dotted-decimal (255.255.255.0) or CIDR prefix (/24 or 24).
|
|
func validSubnetMask(s string) bool {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.TrimPrefix(s, "/")
|
|
var prefix int
|
|
if _, err := fmt.Sscan(s, &prefix); err == nil && len(s) <= 2 {
|
|
return prefix >= 0 && prefix <= 32
|
|
}
|
|
return net.ParseIP(s) != nil
|
|
}
|
|
|
|
func availableNICs() ([]nicInfo, error) {
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var nics []nicInfo
|
|
for _, iface := range ifaces {
|
|
if iface.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
nics = append(nics, nicInfo{
|
|
Name: iface.Name,
|
|
IsWiFi: isWiFiNIC(iface.Name),
|
|
})
|
|
}
|
|
return nics, nil
|
|
}
|
|
|
|
// isWiFiNIC returns true if the interface has a wireless subdirectory in sysfs.
|
|
func isWiFiNIC(name string) bool {
|
|
_, err := os.Stat("/sys/class/net/" + name + "/wireless")
|
|
return err == nil
|
|
}
|