kairos-agent/internal/agent/TUIinstallProcessPage.go
Itxaka 6d74cdc4b6
Bring over the TUI to interactive installer (#845)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-23 10:54:27 +02:00

246 lines
6.8 KiB
Go

package agent
import (
"bytes"
"encoding/json"
"fmt"
"github.com/kairos-io/kairos-sdk/types"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Install Process Page
type installProcessPage struct {
progress int
step string
steps []string
done chan bool // Channel to signal when installation is complete
output chan string // Channel to receive output from the installer
cmd *exec.Cmd // Reference to the running installer command
}
func newInstallProcessPage() *installProcessPage {
return &installProcessPage{
progress: 0,
step: InstallDefaultStep,
steps: []string{
InstallDefaultStep,
InstallPartitionStep,
InstallBeforeInstallStep,
InstallActiveStep,
InstallBootloaderStep,
InstallRecoveryStep,
InstallPassiveStep,
InstallAfterInstallStep,
InstallCompleteStep,
},
done: make(chan bool),
output: make(chan string),
}
}
func (p *installProcessPage) Init() tea.Cmd {
oldLog := mainModel.log
cc := NewInteractiveInstallConfig(&mainModel)
// Create a new logger to track the install process output
// TODO: Maybe do a dual logger or something? So we can still see the output in the old logger decently
logBuffer := bytes.Buffer{}
bufferLog := types.NewBufferLogger(&logBuffer)
cc.Logger = bufferLog
// Start the installer in a goroutine
go func() {
defer close(p.done)
err := RunInstall(cc)
if err != nil {
return
}
}()
// Track the log buffer and send mapped steps to p.output
go func() {
lastLen := 0
for {
time.Sleep(100 * time.Millisecond)
buf := logBuffer.Bytes()
if len(buf) > lastLen {
newLogs := buf[lastLen:]
lines := bytes.Split(newLogs, []byte("\n"))
for _, line := range lines {
strLine := string(line)
if len(strLine) == 0 {
continue
}
oldLog.Print(strLine)
// Parse log line as JSON and extract the message field
var logEntry map[string]interface{}
msg := strLine
if err := json.Unmarshal([]byte(strLine), &logEntry); err == nil {
// Log the message to the old logger still so we have it there
if m, ok := logEntry["message"].(string); ok {
msg = m
}
}
if strings.Contains(msg, AgentPartitionLog) {
p.output <- StepPrefix + InstallPartitionStep
} else if strings.Contains(msg, AgentBeforeInstallLog) {
p.output <- StepPrefix + InstallBeforeInstallStep
} else if strings.Contains(msg, AgentActiveLog) {
p.output <- StepPrefix + InstallActiveStep
} else if strings.Contains(msg, AgentBootloaderLog) {
p.output <- StepPrefix + InstallBootloaderStep
} else if strings.Contains(msg, AgentRecoveryLog) {
p.output <- StepPrefix + InstallRecoveryStep
} else if strings.Contains(msg, AgentPassiveLog) {
p.output <- StepPrefix + InstallPassiveStep
} else if strings.Contains(msg, AgentAfterInstallLog) && !strings.Contains(msg, "chroot") {
p.output <- StepPrefix + InstallAfterInstallStep
} else if strings.Contains(msg, AgentCompleteLog) {
p.output <- StepPrefix + InstallCompleteStep
}
}
lastLen = len(buf)
}
select {
case <-p.done:
return
default:
}
}
}()
// Return a command that will check for output from the installer
return func() tea.Msg {
return CheckInstallerMsg{}
}
}
// CheckInstallerMsg Message type to check for installer output
type CheckInstallerMsg struct{}
func (p *installProcessPage) Update(msg tea.Msg) (Page, tea.Cmd) {
switch msg.(type) {
case CheckInstallerMsg:
// Check for new output from the installer
select {
case output, ok := <-p.output:
if !ok {
// Channel closed, installer is done
return p, nil
}
// Process the output
if strings.HasPrefix(output, StepPrefix) {
// This is a step change notification
stepName := strings.TrimPrefix(output, StepPrefix)
// Find the index of the step
for i, s := range p.steps {
if s == stepName {
p.progress = i
p.step = stepName
break
}
}
} else if strings.HasPrefix(output, ErrorPrefix) {
// Handle error
errorMsg := strings.TrimPrefix(output, ErrorPrefix)
p.step = "Error: " + errorMsg
return p, nil
}
// Continue checking for output
return p, func() tea.Msg { return CheckInstallerMsg{} }
case <-p.done:
// Installer is finished
p.progress = len(p.steps) - 1
p.step = p.steps[len(p.steps)-1]
return p, nil
default:
// No new output yet, check again after a short delay
return p, tea.Tick(time.Millisecond*100, func(_ time.Time) tea.Msg {
return CheckInstallerMsg{}
})
}
}
return p, nil
}
func (p *installProcessPage) View() string {
s := "Installation in Progress\n\n"
// Progress bar
totalSteps := len(p.steps)
progressPercent := (p.progress * 100) / (totalSteps - 1)
barWidth := 40 // Make progress bar wider
filled := barWidth * progressPercent / 100
progressBar := lipgloss.NewStyle().Foreground(kairosHighlight2).Background(kairosBg).Render(strings.Repeat("█", filled)) +
lipgloss.NewStyle().Foreground(kairosBorder).Background(kairosBg).Render(strings.Repeat("░", barWidth-filled))
s += "Progress:" + progressBar + lipgloss.NewStyle().Background(kairosBg).Render(" ")
s += lipgloss.NewStyle().Foreground(kairosText).Background(kairosBg).Bold(true).Render(fmt.Sprintf("%d%%", progressPercent))
s += "\n\n"
s += fmt.Sprintf("Current step: %s\n\n", p.step)
// Show completed steps
s += "Completed steps:\n"
tick := lipgloss.NewStyle().Foreground(kairosAccent).Render(checkMark)
for i := 0; i < p.progress; i++ {
s += fmt.Sprintf("%s %s\n", tick, p.steps[i])
}
if p.progress < len(p.steps)-1 {
// Make the warning message red
warning := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Bold(true).Render("[!] Do not power off the system during installation!")
s += "\n" + warning
} else {
// Make the completion message green
complete := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")).Bold(true).Render("Installation completed successfully!\nYou can now reboot your system.")
s += "\n" + complete
}
return s
}
func (p *installProcessPage) Title() string {
return "Installing"
}
func (p *installProcessPage) Help() string {
if p.progress >= len(p.steps)-1 {
return "Press any key to exit"
}
return "Installation in progress - Use ctrl+c to abort"
}
func (p *installProcessPage) ID() string { return "install_process" }
// Abort aborts the running installer process and cleans up
func (p *installProcessPage) Abort() {
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
mainModel.log.Info("Installer process aborted by user")
}
// Close output channel if not already closed
select {
case <-p.done:
// already closed
default:
close(p.done)
}
// Optionally, send a message to output channel
select {
case p.output <- ErrorPrefix + "Installation aborted by user":
default:
}
}