Adds a StepRunUpdater step that starts app-dono-updater after the app container comes up: a poll loop (docker pull, compare image IDs, recreate on change) baked into the official docker:cli image via `sh -c`, reusing the same docker run argv as the initial container start so the two can't drift. Not built on Watchtower: containrrr/watchtower was archived upstream in Dec 2025 with no maintained successor recommended for production use, so this avoids taking on that dependency.
331 lines
8.5 KiB
Go
331 lines
8.5 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
)
|
|
|
|
const (
|
|
header = "App do Dono - Instalador Cliente"
|
|
anyKeyOutMsg = "qualquer tecla: sair"
|
|
retryMsg = "r: tentar novamente • q: sair"
|
|
fixMsg = "qualquer tecla: voltar e corrigir"
|
|
|
|
formBase = "tab: próximo campo • enter: confirmar"
|
|
ipBase = "↑/↓: navegar • enter: selecionar"
|
|
reviewBase = "enter: confirmar e instalar"
|
|
)
|
|
|
|
// helpFor builds the footer for an input step, appending "esc: voltar" when back
|
|
// navigation is available and always ending with "ctrl+c: sair".
|
|
func (m Model) helpFor(base string) string {
|
|
parts := []string{}
|
|
if base != "" {
|
|
parts = append(parts, base)
|
|
}
|
|
if m.canGoBack() {
|
|
parts = append(parts, "esc: voltar")
|
|
}
|
|
parts = append(parts, "ctrl+c: sair")
|
|
return strings.Join(parts, " • ")
|
|
}
|
|
|
|
func (m Model) View() tea.View {
|
|
pad := strings.Repeat(" ", padding)
|
|
var body string
|
|
helpMsg := m.helpFor("")
|
|
|
|
switch m.currentStep {
|
|
// Docker stuff
|
|
case StepCheckDocker:
|
|
body = m.viewCheckDocker()
|
|
case StepDockerInstall:
|
|
body = m.viewDockerInstall()
|
|
helpMsg = anyKeyOutMsg
|
|
case StepDockerLogin:
|
|
body = m.loginForm.View()
|
|
helpMsg = m.helpFor(formBase)
|
|
case StepDownloadImage:
|
|
body = m.viewDownloadImage()
|
|
if m.downloadDone && m.downloadError != nil {
|
|
helpMsg = retryMsg
|
|
}
|
|
|
|
// IP Stuff
|
|
case StepIPQuestion:
|
|
body = m.viewIPQuestion()
|
|
helpMsg = m.helpFor(ipBase)
|
|
case StepWireguardConfig:
|
|
body = m.wireguardForm.View()
|
|
helpMsg = m.helpFor(formBase)
|
|
case StepGenerateWireguardFile:
|
|
body = m.viewGenerateFile()
|
|
if m.finishedFile && m.configFileError != nil {
|
|
helpMsg = fixMsg
|
|
}
|
|
case StepDownloadWireguard:
|
|
body = m.viewDownloadImage()
|
|
if m.downloadDone && m.downloadError != nil {
|
|
helpMsg = retryMsg
|
|
}
|
|
case StepRunWireguard:
|
|
body = m.viewDockerRun()
|
|
if m.finishedDockerRun && m.dockerRunError != nil {
|
|
helpMsg = retryMsg
|
|
}
|
|
|
|
// App Config Stuff
|
|
case StepAppConfig:
|
|
body = m.appForm.View()
|
|
helpMsg = m.helpFor(formBase)
|
|
case StepServerConfig:
|
|
body = m.serverForm.View()
|
|
helpMsg = m.helpFor(formBase)
|
|
case StepDatabaseConfig:
|
|
body = m.dbForm.View()
|
|
helpMsg = m.helpFor(formBase)
|
|
case StepCertConfig:
|
|
body = m.certForm.View()
|
|
helpMsg = m.helpFor(formBase)
|
|
|
|
// Review
|
|
case StepReview:
|
|
body = m.viewReview()
|
|
helpMsg = m.helpFor(reviewBase)
|
|
|
|
// Finalize
|
|
case StepGenerateFile:
|
|
body = m.viewGenerateFile()
|
|
if m.finishedFile && m.configFileError != nil {
|
|
helpMsg = fixMsg
|
|
}
|
|
case StepRunDocker:
|
|
body = m.viewDockerRun()
|
|
if m.finishedDockerRun && m.dockerRunError != nil {
|
|
helpMsg = retryMsg
|
|
}
|
|
case StepRunUpdater:
|
|
body = m.viewDockerRun()
|
|
if m.finishedDockerRun && m.dockerRunError != nil {
|
|
helpMsg = retryMsg
|
|
}
|
|
case StepDone:
|
|
body = m.viewDoneMessage()
|
|
helpMsg = anyKeyOutMsg
|
|
}
|
|
|
|
help := HelpStyle.Render(helpMsg)
|
|
|
|
title := TitleStyle.Render(header) + HelpStyle.Render(" v"+Version)
|
|
|
|
v := tea.NewView(fmt.Sprintf("\n%s%s\n\n%s\n\n%s%s\n",
|
|
pad, title,
|
|
body,
|
|
pad, help,
|
|
))
|
|
v.AltScreen = true
|
|
|
|
return v
|
|
}
|
|
|
|
func (m Model) viewCheckDocker() string {
|
|
pad := strings.Repeat(" ", padding)
|
|
|
|
barWidth := 40
|
|
filled := int(m.checkProgress * float64(barWidth))
|
|
empty := barWidth - filled
|
|
|
|
bar := ProgressFillStyle.Render(strings.Repeat("█", filled)) +
|
|
ProgressEmptyStyle.Render(strings.Repeat("░", empty))
|
|
|
|
percent := fmt.Sprintf(" %d%%", int(m.checkProgress*100))
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(pad + "Avaliando instalação do Docker...\n\n")
|
|
sb.WriteString(pad + bar + HelpStyle.Render(percent))
|
|
|
|
if m.checkDockerDone && m.checkProgress == 1 {
|
|
found := ""
|
|
|
|
if m.dockerInstalled {
|
|
found = "encontrado"
|
|
} else {
|
|
found = "não encontrado"
|
|
}
|
|
|
|
sb.WriteString("\n\n" + pad + fmt.Sprintf("Docker %s na máquina. Pressione qualquer tecla para seguir.", found))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m Model) viewDockerInstall() string {
|
|
pad := strings.Repeat(" ", padding)
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(pad + "Nenhuma versão do Docker encontrada no sistema.\n")
|
|
sb.WriteString(pad + "Instale o Docker manualmente e execute este instalador novamente para concluir a configuração.")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m Model) viewDownloadImage() string {
|
|
pad := strings.Repeat(" ", padding)
|
|
|
|
var sb strings.Builder
|
|
|
|
if !m.downloadDone {
|
|
sb.WriteString(pad + fmt.Sprintf("%s Baixando imagem do container...\n\n", m.spinner.View()))
|
|
return sb.String()
|
|
}
|
|
|
|
if m.downloadError == nil {
|
|
sb.WriteString(pad + "Instalação concluída com êxito.\n\n")
|
|
sb.WriteString(pad + "Pressione qualquer tecla para continuar.")
|
|
} else {
|
|
sb.WriteString(pad + "Instalação finalizada com erros.\n\n")
|
|
|
|
dualPad := pad + pad
|
|
errText := dualPad + m.downloadMessage + "\n" + dualPad + m.downloadError.Error()
|
|
sb.WriteString(ErrorStyle.Width(m.width - (padding * 2)).Align(lipgloss.Left).Render(errText))
|
|
|
|
sb.WriteString("\n\n" + pad + "Pressione 'r' para tentar novamente ou 'q' para sair.")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m Model) viewIPQuestion() string {
|
|
pad := strings.Repeat(" ", padding)
|
|
|
|
var options = []string{"Sim", "Não"}
|
|
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(pad + "Existe IP público disponível para a máquina?\n")
|
|
|
|
for i, opt := range options {
|
|
cursor := " "
|
|
text := ""
|
|
isSelected := m.cursor == i
|
|
|
|
if isSelected {
|
|
cursor = CursorStyle.Render("> ")
|
|
text = CursorStyle.Render(opt)
|
|
} else {
|
|
text = opt
|
|
}
|
|
sb.WriteString("\n" + pad + cursor + text)
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m Model) viewReview() string {
|
|
pad := strings.Repeat(" ", padding)
|
|
cv := m.configValues
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(pad + "Revise as configurações antes de instalar:\n")
|
|
|
|
section := func(title string, rows [][2]string) {
|
|
sb.WriteString("\n" + pad + TitleStyle.Render(title) + "\n")
|
|
for _, r := range rows {
|
|
sb.WriteString(pad + HelpStyle.Render(r[0]+": ") + r[1] + "\n")
|
|
}
|
|
}
|
|
|
|
if len(cv.Wireguard) > 0 {
|
|
section("vproxy", [][2]string{
|
|
{"IP Virtual", cv.Wireguard["vip"]},
|
|
{"Proxy EDPS", cv.Wireguard["proxy_edps"]},
|
|
{"MTU", cv.Wireguard["mtu"]},
|
|
{"Protocolo", cv.Wireguard["proto"]},
|
|
})
|
|
}
|
|
|
|
section("Aplicação", [][2]string{
|
|
{"Servidor Central", cv.Application["central_server_url"]},
|
|
{"Token de Inscrição", cv.Application["enrollment_token"]},
|
|
})
|
|
section("Servidor", [][2]string{
|
|
{"Porta (host)", cv.Server["port"]},
|
|
{"Timeout", cv.Server["timeout"]},
|
|
{"Ambiente", cv.Server["environment"]},
|
|
{"Modo Compatibilidade", cv.Server["seccomp_unconfined"]},
|
|
})
|
|
section("Banco de Dados", [][2]string{
|
|
{"Tipo", cv.Database["database_type"]},
|
|
{"URL", cv.Database["database_url"]},
|
|
{"Conexões (máx/mín)", cv.Database["max_conns"] + "/" + cv.Database["min_conns"]},
|
|
})
|
|
section("Certificado", [][2]string{
|
|
{"Diretório", cv.Cert["cert_dir_path"]},
|
|
})
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m Model) viewGenerateFile() string {
|
|
pad := strings.Repeat(" ", padding)
|
|
var sb strings.Builder
|
|
|
|
if !m.finishedFile {
|
|
sb.WriteString(pad + m.spinner.View() + "Gerando arquivo. Aguarde...")
|
|
} else {
|
|
sb.WriteString(pad + "Geração do arquivo finalizada.\n\n")
|
|
|
|
if m.configFileError == nil {
|
|
sb.WriteString(pad + "Arquivo gerado com sucesso. Pressione qualquer tecla para seguir.")
|
|
} else {
|
|
sb.WriteString(pad + "Ocorreu um erro ao gerar o arquivo.\n\n")
|
|
|
|
dualPad := pad + pad
|
|
errText := dualPad + m.configFileError.Error()
|
|
sb.WriteString(ErrorStyle.Width(m.width - (padding * 2)).Align(lipgloss.Left).Render(errText))
|
|
|
|
sb.WriteString("\n\n" + pad + "Pressione qualquer tecla para voltar e corrigir os dados.")
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m Model) viewDockerRun() string {
|
|
pad := strings.Repeat(" ", padding)
|
|
var sb strings.Builder
|
|
|
|
if !m.finishedDockerRun {
|
|
sb.WriteString(pad + m.spinner.View() + "Tentando iniciar o container Docker. Aguarde...")
|
|
} else {
|
|
sb.WriteString(pad + "Inicialização finalizada.\n\n")
|
|
|
|
if m.dockerRunError == nil {
|
|
sb.WriteString(pad + "Container Docker iniciado com sucesso. Pressione qualquer tecla para seguir.")
|
|
} else {
|
|
sb.WriteString(pad + "Ocorreu um erro ao iniciar o container.\n\n")
|
|
|
|
dualPad := pad + pad
|
|
errText := dualPad + m.dockerRunError.Error()
|
|
sb.WriteString(ErrorStyle.Width(m.width - (padding * 2)).Align(lipgloss.Left).Render(errText))
|
|
|
|
sb.WriteString("\n\n" + pad + "Pressione 'r' para tentar novamente ou 'q' para sair.")
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m Model) viewDoneMessage() string {
|
|
pad := strings.Repeat(" ", padding)
|
|
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(pad + "Instalação realizada com sucesso!")
|
|
|
|
return sb.String()
|
|
}
|