239 lines
8.0 KiB
Go
239 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/atotto/clipboard"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
// summaryModel is the terminal screen of the setup wizard. On a first run it
|
|
// confirms what was just configured and points the operator at the panel for
|
|
// everything else. On a re-run (an Owner already exists) it doubles as a thin
|
|
// status landing screen — the whole point of the redesign is that setup runs
|
|
// once and the rest of administration happens in the panel.
|
|
type summaryModel struct {
|
|
panelURL string
|
|
ownerUsername string
|
|
setupTokenURL string // one-time first-login URL; shown once
|
|
auditWarning string
|
|
accessLabel string
|
|
storageLabel string // build-context storage backend recap; empty to omit
|
|
routedHosts []string
|
|
alreadySetUp bool // re-run: Owner pre-existed
|
|
localHint bool // show the self-signed-cert note
|
|
// alerts is where the watchdog's alerts go; nil leaves the rows out.
|
|
alerts *alertRoute
|
|
copyText func(string) error
|
|
copyNotice string
|
|
copyFailed bool
|
|
}
|
|
|
|
type summaryCopiedMsg struct{ err error }
|
|
|
|
func (m *summaryModel) Init() tea.Cmd { return nil }
|
|
|
|
// arrowNavOK lets the root repurpose ←/→ to walk back through completed steps;
|
|
// the summary takes no text input, so the horizontal arrows are free.
|
|
func (m *summaryModel) arrowNavOK() bool { return true }
|
|
|
|
func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if copied, ok := msg.(summaryCopiedMsg); ok {
|
|
m.copyFailed = copied.err != nil
|
|
switch {
|
|
case copied.err != nil:
|
|
m.copyNotice = "Could not copy: " + copied.err.Error()
|
|
case os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "":
|
|
m.copyNotice = "Copy sent to terminal. If it does not paste, your terminal needs OSC 52 support."
|
|
default:
|
|
m.copyNotice = "Link copied to clipboard."
|
|
}
|
|
return m, nil
|
|
}
|
|
if key, ok := msg.(tea.KeyMsg); ok {
|
|
switch key.String() {
|
|
case "c", "C":
|
|
link := m.panelURL
|
|
if m.setupTokenURL != "" {
|
|
link = m.setupTokenURL
|
|
}
|
|
if link == "" {
|
|
m.copyNotice, m.copyFailed = "No link to copy.", true
|
|
return m, nil
|
|
}
|
|
copyText := m.copyText
|
|
if copyText == nil {
|
|
copyText = copyTerminalText
|
|
}
|
|
return m, func() tea.Msg { return summaryCopiedMsg{err: copyText(link)} }
|
|
case "n", "N":
|
|
return m, func() tea.Msg { return reconfigureConnectMsg{} }
|
|
case "s", "S":
|
|
return m, func() tea.Msg { return reconfigureStorageMsg{} }
|
|
case "e", "E":
|
|
return m, func() tea.Msg { return reconfigureSMTPMsg{} }
|
|
case "ctrl+c", "esc", "enter", "q":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *summaryModel) View() string {
|
|
var b strings.Builder
|
|
|
|
switch {
|
|
case m.setupTokenURL != "":
|
|
b.WriteString(tuiOK.Render("✓ Deployment ready. Finish Owner login in your browser.") + "\n\n")
|
|
case m.alreadySetUp:
|
|
b.WriteString(tuiOK.Render("✓ Felis is already set up.") + "\n\n")
|
|
default:
|
|
b.WriteString(tuiOK.Render("✓ Setup complete.") + "\n\n")
|
|
}
|
|
|
|
var card strings.Builder
|
|
if m.ownerUsername != "" {
|
|
card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n")
|
|
}
|
|
if m.setupTokenURL != "" {
|
|
card.WriteString(tuiLabel.Render("setup URL ") + tuiPassword.Render(m.setupTokenURL) + "\n")
|
|
card.WriteString(" " + tuiWarn.Render("one-time link — open it to finish login setup") + "\n")
|
|
}
|
|
if m.accessLabel != "" {
|
|
card.WriteString(tuiLabel.Render("access ") + m.accessLabel + "\n")
|
|
}
|
|
if m.storageLabel != "" {
|
|
card.WriteString(tuiLabel.Render("storage ") + m.storageLabel + "\n")
|
|
}
|
|
if len(m.routedHosts) > 0 {
|
|
card.WriteString(tuiLabel.Render("routed ") + strings.Join(m.routedHosts, ", ") + "\n")
|
|
}
|
|
if m.panelURL != "" {
|
|
card.WriteString(tuiLabel.Render("panel ") + m.panelURL + "\n")
|
|
}
|
|
if m.alerts != nil {
|
|
line, ok := m.alerts.alertsLine()
|
|
card.WriteString(routeRow("alerts ", line, ok))
|
|
line, ok = m.alerts.heartbeatLine()
|
|
card.WriteString(routeRow("heartbeat ", line, ok))
|
|
}
|
|
b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n")
|
|
|
|
if m.setupTokenURL != "" {
|
|
b.WriteString(tuiHint.Render("Open the setup link, record your email, and create a passkey.\nMinecraft can be linked later from Account; it is not required for panel access.") + "\n")
|
|
} else {
|
|
b.WriteString(tuiHint.Render("Manage servers, users, and Minecraft identities in the panel.") + "\n")
|
|
}
|
|
if m.auditWarning != "" {
|
|
b.WriteString(tuiWarn.Render("Audit warning: "+m.auditWarning) + "\n")
|
|
}
|
|
if m.localHint {
|
|
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")
|
|
}
|
|
|
|
if m.copyNotice != "" {
|
|
style := tuiOK
|
|
if m.copyFailed {
|
|
style = tuiWarn
|
|
}
|
|
b.WriteString("\n" + style.Render(m.copyNotice) + "\n")
|
|
}
|
|
copyLabel := "copy panel link"
|
|
if m.setupTokenURL != "" {
|
|
copyLabel = "copy setup link"
|
|
}
|
|
b.WriteString("\n" + tuiAction("c", copyLabel, "n", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit"))
|
|
return b.String()
|
|
}
|
|
|
|
// SSH copies through the terminal; the remote host's desktop clipboard is unrelated.
|
|
// /dev/tty keeps the one-time link out of redirected stdout and install logs.
|
|
func copyTerminalText(text string) error {
|
|
if os.Getenv("SSH_TTY") == "" && os.Getenv("SSH_CONNECTION") == "" {
|
|
return clipboard.WriteAll(text)
|
|
}
|
|
tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tty.Close()
|
|
sequence := ansi.SetSystemClipboard(text)
|
|
if os.Getenv("TMUX") != "" {
|
|
sequence = ansi.TmuxPassthrough(sequence)
|
|
}
|
|
_, err = io.WriteString(tty, sequence)
|
|
return err
|
|
}
|
|
|
|
// alertRoute is where this host's watchdog alerts go, as the summary shows it:
|
|
// by mail through the [smtp] relay to the Owners' verified addresses, and the
|
|
// heartbeat that notices the host itself going down (docs/troubleshooting.md
|
|
// §14). Setup runs mail-less by design, so a fresh install has neither; the
|
|
// summary says so where the Owner can press e.
|
|
type alertRoute struct {
|
|
relay string // "host:port", "" with no [smtp] relay
|
|
recipients []string // enabled Owners' verified addresses
|
|
lookupErr error // the recipients could not be read
|
|
heartbeat string // the heartbeat URL's scheme and host, "" with none
|
|
heartbeatErr error // the heartbeat file does not read
|
|
}
|
|
|
|
// hostAlertRoute reads the route from the host config at cfgPath, the database
|
|
// at dbURL and the heartbeat file at heartbeatPath: what the next watchdog run
|
|
// uses.
|
|
func hostAlertRoute(ctx context.Context, cfgPath, dbURL, heartbeatPath string) alertRoute {
|
|
var r alertRoute
|
|
if in := smtpInputsFrom(cfgPath); in.host != "" {
|
|
r.relay = in.host + ":" + in.port
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
r.recipients, r.lookupErr = ownerEmails(ctx, dbURL)
|
|
u, err := readHeartbeatURL(heartbeatPath)
|
|
switch {
|
|
case err != nil:
|
|
r.heartbeatErr = err
|
|
case u != "":
|
|
r.heartbeat = redactURL(u)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// alertsLine is the summary's alerts row; ok is false when the alerts reach no one.
|
|
func (r alertRoute) alertsLine() (line string, ok bool) {
|
|
switch {
|
|
case r.relay == "":
|
|
return "only logged: no email relay (press e)", false
|
|
case r.lookupErr != nil:
|
|
return "via " + r.relay + "; could not read the Owner addresses", false
|
|
case len(r.recipients) == 0:
|
|
return "only logged: no verified Owner email (panel → Account)", false
|
|
}
|
|
return "mailed to " + strings.Join(r.recipients, ", ") + " via " + r.relay, true
|
|
}
|
|
|
|
// heartbeatLine is the summary's heartbeat row; ok is false with no heartbeat.
|
|
func (r alertRoute) heartbeatLine() (line string, ok bool) {
|
|
switch {
|
|
case r.heartbeatErr != nil:
|
|
return "unreadable: " + r.heartbeatErr.Error(), false
|
|
case r.heartbeat == "":
|
|
return "none: no outside check (troubleshooting.md §14)", false
|
|
}
|
|
return r.heartbeat + " every 2 minutes", true
|
|
}
|
|
|
|
// routeRow renders one alert row, marked and in the warning style when it
|
|
// needs action: the mark reads without colour too.
|
|
func routeRow(label, line string, ok bool) string {
|
|
if !ok {
|
|
line = tuiWarn.Render("⚠ " + line)
|
|
}
|
|
return tuiLabel.Render(label) + line + "\n"
|
|
}
|