All checks were successful
release / build-and-release (push) Successful in 43s
On any ERROR step, xetup now files an issue in the private x9/xetup-runs tracker (title "FAILED <host> - <version> - <date>", label "failed", body = step table + tail-capped Deploy.log) via the xetup-bot write:issue token, baked in through -ldflags. Best-effort and non-blocking; successful runs stay silent and the email is the fallback. The deployment email now attaches the zipped Deploy.log and links to this run's issue plus the run history filtered to this machine. - internal/buildinfo: ldflags-injected Version + RunsToken - internal/runreport: issue filing, retries, tail-capped log - internal/report: multipart/mixed with zip attachment, tracker links; buildMessage split out and covered by report_test.go - release.yml: inject Version (tag/SHA) + FORGEJO_RUNS_TOKEN via ldflags Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
843 lines
22 KiB
Go
843 lines
22 KiB
Go
//go:build windows
|
||
|
||
// Package gui implements the Walk-based graphical interface for xetup.
|
||
//
|
||
// Three sequential phases, each with its own window:
|
||
// 1. Config form – PC name, product key, profile, step checkboxes,
|
||
// load/save config buttons for per-client presets
|
||
// 2. Live run – real-time log streamed from PowerShell scripts
|
||
// 3. Summary – per-step OK / ERROR / SKIPPED with elapsed time
|
||
//
|
||
// Resume mode (state file present): skips the config form and runs only
|
||
// the pending steps recorded in the state file. On completion, disables
|
||
// autologon and removes the X9-Resume scheduled task.
|
||
package gui
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/lxn/walk"
|
||
. "github.com/lxn/walk/declarative"
|
||
|
||
"git.xetup.x9.cz/x9/xetup/internal/config"
|
||
"git.xetup.x9.cz/x9/xetup/internal/preflight"
|
||
"git.xetup.x9.cz/x9/xetup/internal/prereboot"
|
||
"git.xetup.x9.cz/x9/xetup/internal/report"
|
||
"git.xetup.x9.cz/x9/xetup/internal/runner"
|
||
"git.xetup.x9.cz/x9/xetup/internal/runreport"
|
||
"git.xetup.x9.cz/x9/xetup/internal/state"
|
||
)
|
||
|
||
// deployLogPath is the Deploy.log location, captured at Run() so the summary
|
||
// phase can attach it to the email and to the Forgejo run report.
|
||
var deployLogPath string
|
||
|
||
// Run opens the xetup window and blocks until the user closes it.
|
||
func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
|
||
deployLogPath = runCfg.LogFile
|
||
|
||
// Resume mode: state file present from a previous interrupted run
|
||
if st, err := state.Load(); err == nil {
|
||
resumePhase(st, runCfg)
|
||
return
|
||
}
|
||
|
||
// Normal flow: config form → run → summary
|
||
for {
|
||
res := formPhase(cfg, runCfg, cfgPath)
|
||
switch res.action {
|
||
case "quit":
|
||
return
|
||
case "reload":
|
||
cfg = res.cfg
|
||
cfgPath = res.cfgPath
|
||
case "run":
|
||
runCfg.ProfileType = res.cfg.Deployment.ProfileType
|
||
// Update runtime config so scripts see the user's GUI selections
|
||
_ = config.Save(res.cfg, runCfg.ConfigPath)
|
||
results, needsReboot := runPhase(runCfg, res.steps, false)
|
||
if needsReboot {
|
||
prepareRebootAndRestart(res.cfg, res.steps, results, cfgPath, runCfg)
|
||
return
|
||
}
|
||
donePhase(results, nil)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Resume mode
|
||
// --------------------------------------------------------------------------
|
||
|
||
// maxStepRetries is the maximum number of reboot cycles a single step is
|
||
// allowed before it is marked as ERROR and skipped.
|
||
const maxStepRetries = 5
|
||
|
||
func resumePhase(st *state.State, runCfg runner.RunConfig) {
|
||
runCfg.LogFile = st.LogFile
|
||
|
||
// Initialise retry counts (backward-compat with older state files)
|
||
if st.RetryCounts == nil {
|
||
st.RetryCounts = make(map[string]int)
|
||
}
|
||
|
||
steps := runner.StepsByIDs(st.PendingSteps)
|
||
// StepsByIDs returns Enabled=false; resume steps must be enabled.
|
||
for i := range steps {
|
||
steps[i].Enabled = true
|
||
}
|
||
|
||
// Check retry limits — skip steps that have been retried too many times
|
||
for i := range steps {
|
||
if st.RetryCounts[steps[i].ID] >= maxStepRetries {
|
||
steps[i].Enabled = false
|
||
st.Results = append(st.Results, state.StepResult{
|
||
StepID: steps[i].ID,
|
||
Num: steps[i].Num,
|
||
Name: steps[i].Name,
|
||
Status: "ERROR",
|
||
})
|
||
}
|
||
}
|
||
|
||
results, needsReboot := runPhase(runCfg, steps, true)
|
||
|
||
// Accumulate completed results (NeedsReboot step excluded – runs again)
|
||
newResults := append(st.Results, toStateResults(results)...) //nolint:gocritic
|
||
|
||
if needsReboot {
|
||
// Increment retry counts for steps that requested reboot
|
||
for _, r := range results {
|
||
if r.NeedsReboot {
|
||
st.RetryCounts[r.Step.ID]++
|
||
}
|
||
}
|
||
|
||
// Update state and reboot – infrastructure already in place (running as adminx9)
|
||
pending := pendingStepIDs(steps, results)
|
||
newSt := &state.State{
|
||
Config: st.Config,
|
||
ConfigPath: st.ConfigPath,
|
||
LogFile: st.LogFile,
|
||
PendingSteps: pending,
|
||
Results: newResults,
|
||
RetryCounts: st.RetryCounts,
|
||
}
|
||
if err := state.Save(newSt); err != nil {
|
||
walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)
|
||
return // do NOT reboot if state was not persisted
|
||
}
|
||
reboot()
|
||
return
|
||
}
|
||
|
||
// All steps complete
|
||
prereboot.Cleanup()
|
||
_ = state.Delete()
|
||
donePhase(results, st.Results)
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Phase 1 – Config form
|
||
// --------------------------------------------------------------------------
|
||
|
||
type formResult struct {
|
||
action string // "run", "reload", "quit"
|
||
cfg config.Config
|
||
cfgPath string
|
||
steps []runner.Step
|
||
}
|
||
|
||
func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formResult {
|
||
var mw *walk.MainWindow
|
||
result := formResult{action: "quit", cfgPath: cfgPath}
|
||
|
||
var (
|
||
pcNameLE *walk.LineEdit
|
||
pcDescLE *walk.LineEdit
|
||
productKeyLE *walk.LineEdit
|
||
profileCB *walk.ComboBox
|
||
)
|
||
|
||
items := runner.AllSelectableItems()
|
||
checkPtrs := make([]*walk.CheckBox, len(items))
|
||
checkWidgets := make([]Widget, len(items))
|
||
for i, item := range items {
|
||
i := i // capture for closure
|
||
checkWidgets[i] = CheckBox{
|
||
AssignTo: &checkPtrs[i],
|
||
Text: item.Label,
|
||
Checked: itemEnabled(cfg, item),
|
||
}
|
||
}
|
||
|
||
collectCfg := func() config.Config {
|
||
out := cfg
|
||
out.Deployment.PCName = pcNameLE.Text()
|
||
out.Deployment.PCDescription = pcDescLE.Text()
|
||
out.Activation.ProductKey = productKeyLE.Text()
|
||
profiles := []string{"default", "admin", "user"}
|
||
if idx := profileCB.CurrentIndex(); idx >= 0 && idx < len(profiles) {
|
||
out.Deployment.ProfileType = profiles[idx]
|
||
}
|
||
selected := make(map[string]bool, len(items))
|
||
for i, item := range items {
|
||
selected[item.Key] = checkPtrs[i].Checked()
|
||
}
|
||
_, features := buildStepsAndFeatures(selected)
|
||
out.Features = features
|
||
return out
|
||
}
|
||
|
||
// Run pre-flight checks and build status widgets
|
||
pfResults := preflight.RunAll()
|
||
var pfWidgets []Widget
|
||
allOK := true
|
||
for _, r := range pfResults {
|
||
color := walk.RGB(0, 140, 0)
|
||
prefix := "\u2713 "
|
||
if !r.OK {
|
||
color = walk.RGB(200, 40, 40)
|
||
prefix = "\u2717 "
|
||
allOK = false
|
||
}
|
||
text := prefix + r.Name + ": " + r.Detail
|
||
lbl := Label{Text: text, Font: Font{PointSize: 9}}
|
||
_ = color // applied after window creation
|
||
pfWidgets = append(pfWidgets, lbl)
|
||
}
|
||
_ = allOK
|
||
|
||
// Collect label pointers to set color after creation
|
||
pfLabels := make([]*walk.Label, len(pfResults))
|
||
for i := range pfWidgets {
|
||
pfWidgets[i] = Label{
|
||
AssignTo: &pfLabels[i],
|
||
Text: pfWidgets[i].(Label).Text,
|
||
Font: Font{PointSize: 9},
|
||
}
|
||
}
|
||
|
||
if err := (MainWindow{
|
||
AssignTo: &mw,
|
||
Title: "xetup \u2014 Windows deployment",
|
||
Size: Size{Width: 760, Height: 740},
|
||
Layout: VBox{},
|
||
Children: []Widget{
|
||
// ── Pre-flight checks ────────────────────────────────────────────
|
||
Composite{
|
||
Layout: VBox{MarginsZero: true},
|
||
Children: pfWidgets,
|
||
},
|
||
HSeparator{},
|
||
// ── Form fields ──────────────────────────────────────────────────
|
||
Composite{
|
||
Layout: Grid{Columns: 2},
|
||
Children: []Widget{
|
||
Label{Text: "PC jmeno:"},
|
||
LineEdit{
|
||
AssignTo: &pcNameLE,
|
||
Text: cfg.Deployment.PCName,
|
||
CueBanner: "napr. NB-KLIENT-01 (prazdne = neprejmenovat)",
|
||
},
|
||
Label{Text: "Popis PC:"},
|
||
LineEdit{
|
||
AssignTo: &pcDescLE,
|
||
Text: cfg.Deployment.PCDescription,
|
||
CueBanner: "napr. PC recepce",
|
||
},
|
||
Label{Text: "Product Key:"},
|
||
LineEdit{
|
||
AssignTo: &productKeyLE,
|
||
Text: cfg.Activation.ProductKey,
|
||
CueBanner: "prazdne = OA3 / GVLK fallback",
|
||
},
|
||
Label{Text: "Profil:"},
|
||
ComboBox{
|
||
AssignTo: &profileCB,
|
||
Model: []string{"default", "admin", "user"},
|
||
CurrentIndex: profileIndex(cfg.Deployment.ProfileType),
|
||
},
|
||
},
|
||
},
|
||
HSeparator{},
|
||
// ── Step checkboxes ──────────────────────────────────────────────
|
||
Label{Text: "Kroky a nastaveni (odskrtnete co nechcete spustit):"},
|
||
ScrollView{
|
||
MinSize: Size{Height: 400},
|
||
Layout: VBox{MarginsZero: true},
|
||
Children: checkWidgets,
|
||
},
|
||
HSeparator{},
|
||
// ── Buttons ──────────────────────────────────────────────────────
|
||
Composite{
|
||
Layout: HBox{MarginsZero: true},
|
||
Children: []Widget{
|
||
PushButton{
|
||
Text: "Nacist config...",
|
||
OnClicked: func() {
|
||
dlg := new(walk.FileDialog)
|
||
dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
|
||
dlg.Title = "Nacist konfiguraci"
|
||
ok, err := dlg.ShowOpen(mw)
|
||
if err != nil || !ok {
|
||
return
|
||
}
|
||
data, err := os.ReadFile(dlg.FilePath)
|
||
if err != nil {
|
||
walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
|
||
return
|
||
}
|
||
newCfg := config.DefaultConfig()
|
||
if err := json.Unmarshal(data, &newCfg); err != nil {
|
||
walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
|
||
return
|
||
}
|
||
result = formResult{
|
||
action: "reload",
|
||
cfg: newCfg,
|
||
cfgPath: dlg.FilePath,
|
||
}
|
||
mw.Close()
|
||
},
|
||
},
|
||
PushButton{
|
||
Text: "Ulozit config...",
|
||
OnClicked: func() {
|
||
dlg := new(walk.FileDialog)
|
||
dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
|
||
dlg.Title = "Ulozit konfiguraci"
|
||
dlg.FilePath = "config.json"
|
||
ok, err := dlg.ShowSave(mw)
|
||
if err != nil || !ok {
|
||
return
|
||
}
|
||
data, err := json.MarshalIndent(collectCfg(), "", " ")
|
||
if err != nil {
|
||
walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
|
||
return
|
||
}
|
||
if err := os.WriteFile(dlg.FilePath, data, 0644); err != nil {
|
||
walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
|
||
}
|
||
},
|
||
},
|
||
HSpacer{},
|
||
PushButton{
|
||
Text: " SPUSTIT ",
|
||
OnClicked: func() {
|
||
finalCfg := collectCfg()
|
||
selected := make(map[string]bool, len(items))
|
||
for i, item := range items {
|
||
selected[item.Key] = checkPtrs[i].Checked()
|
||
}
|
||
steps, features := buildStepsAndFeatures(selected)
|
||
finalCfg.Features = features
|
||
_ = config.Save(finalCfg, cfgPath)
|
||
result = formResult{
|
||
action: "run",
|
||
cfg: finalCfg,
|
||
cfgPath: cfgPath,
|
||
steps: steps,
|
||
}
|
||
mw.Close()
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}.Create()); err != nil {
|
||
walk.MsgBox(nil, "Chyba", fmt.Sprintf("Nelze vytvorit okno: %v", err), walk.MsgBoxIconError)
|
||
return result
|
||
}
|
||
|
||
// Apply colors to pre-flight labels (must be done after window creation)
|
||
for i, r := range pfResults {
|
||
if pfLabels[i] != nil {
|
||
if r.OK {
|
||
pfLabels[i].SetTextColor(walk.RGB(0, 140, 0))
|
||
} else {
|
||
pfLabels[i].SetTextColor(walk.RGB(200, 40, 40))
|
||
}
|
||
}
|
||
}
|
||
|
||
mw.Run()
|
||
return result
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Phase 2 – Live run view
|
||
// --------------------------------------------------------------------------
|
||
|
||
// stepIndicator tracks a step's label and current status.
|
||
type stepIndicator struct {
|
||
label *walk.Label
|
||
stepID string
|
||
status string // "pending", "running", "ok", "error", "skipped", "cancelled"
|
||
}
|
||
|
||
// runPhase runs steps and streams live log output.
|
||
// resuming=true changes the title and initial status message.
|
||
// Returns (results, needsReboot).
|
||
func runPhase(runCfg runner.RunConfig, steps []runner.Step, resuming bool) ([]runner.Result, bool) {
|
||
var (
|
||
mw *walk.MainWindow
|
||
statusLbl *walk.Label
|
||
logTE *walk.TextEdit
|
||
cancelFn context.CancelFunc
|
||
)
|
||
|
||
// Build step indicator labels (one per step, in order)
|
||
indicators := make([]stepIndicator, len(steps))
|
||
stepIndex := make(map[string]int, len(steps))
|
||
indWidgets := make([]Widget, len(steps))
|
||
for i, s := range steps {
|
||
indicators[i] = stepIndicator{stepID: s.ID, status: "pending"}
|
||
stepIndex[s.ID] = i
|
||
indWidgets[i] = Label{
|
||
AssignTo: &indicators[i].label,
|
||
Text: s.Num + " \u00b7",
|
||
Font: Font{Family: "Consolas", PointSize: 9},
|
||
MinSize: Size{Width: 38},
|
||
}
|
||
}
|
||
|
||
title := "xetup \u2014 probiha instalace"
|
||
initialStatus := "Spoustim..."
|
||
if resuming {
|
||
title = "xetup \u2014 pokracuji po restartu"
|
||
initialStatus = "Pokracuji po restartu..."
|
||
}
|
||
|
||
if err := (MainWindow{
|
||
AssignTo: &mw,
|
||
Title: title,
|
||
Size: Size{Width: 760, Height: 740},
|
||
Layout: VBox{},
|
||
Children: []Widget{
|
||
Label{AssignTo: &statusLbl, Text: initialStatus},
|
||
// Step progress strip
|
||
Composite{
|
||
Layout: HBox{MarginsZero: true},
|
||
Children: indWidgets,
|
||
},
|
||
HSeparator{},
|
||
TextEdit{
|
||
AssignTo: &logTE,
|
||
ReadOnly: true,
|
||
VScroll: true,
|
||
Font: Font{Family: "Consolas", PointSize: 9},
|
||
MinSize: Size{Height: 530},
|
||
},
|
||
HSeparator{},
|
||
Composite{
|
||
Layout: HBox{MarginsZero: true},
|
||
Children: []Widget{
|
||
HSpacer{},
|
||
PushButton{
|
||
Text: " ZASTAVIT ",
|
||
OnClicked: func() {
|
||
if cancelFn != nil {
|
||
cancelFn()
|
||
}
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}.Create()); err != nil {
|
||
return nil, false
|
||
}
|
||
|
||
// updateIndicator refreshes a step label's text and color — call inside Synchronize.
|
||
updateIndicator := func(idx int) {
|
||
ind := &indicators[idx]
|
||
s := steps[idx]
|
||
switch ind.status {
|
||
case "running":
|
||
ind.label.SetText(s.Num + " \u25ba") // ►
|
||
ind.label.SetTextColor(walk.RGB(30, 144, 255))
|
||
case "ok":
|
||
ind.label.SetText(s.Num + " \u2713") // ✓
|
||
ind.label.SetTextColor(walk.RGB(0, 180, 0))
|
||
case "error":
|
||
ind.label.SetText(s.Num + " \u2717") // ✗
|
||
ind.label.SetTextColor(walk.RGB(220, 50, 50))
|
||
case "skipped", "cancelled":
|
||
ind.label.SetText(s.Num + " \u2013") // –
|
||
ind.label.SetTextColor(walk.RGB(140, 140, 140))
|
||
default:
|
||
ind.label.SetText(s.Num + " \u00b7") // ·
|
||
ind.label.SetTextColor(walk.RGB(180, 180, 180))
|
||
}
|
||
}
|
||
|
||
var (
|
||
mu sync.Mutex
|
||
results []runner.Result
|
||
done = make(chan struct{})
|
||
)
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancelFn = cancel
|
||
|
||
mw.Closing().Attach(func(_ *bool, _ walk.CloseReason) {
|
||
cancelFn()
|
||
})
|
||
|
||
r := runner.New(
|
||
runCfg,
|
||
func(l runner.LogLine) {
|
||
mw.Synchronize(func() {
|
||
logTE.AppendText(l.Text + "\r\n")
|
||
})
|
||
},
|
||
func(step runner.Step) {
|
||
mw.Synchronize(func() {
|
||
statusLbl.SetText(fmt.Sprintf(
|
||
"Krok %s \u2013 %s...", step.Num, step.Name,
|
||
))
|
||
if idx, ok := stepIndex[step.ID]; ok {
|
||
indicators[idx].status = "running"
|
||
updateIndicator(idx)
|
||
}
|
||
})
|
||
},
|
||
func(res runner.Result) {
|
||
mw.Synchronize(func() {
|
||
statusLbl.SetText(fmt.Sprintf(
|
||
"Krok %s \u2013 %s: %s", res.Step.Num, res.Step.Name, res.Status,
|
||
))
|
||
if idx, ok := stepIndex[res.Step.ID]; ok {
|
||
indicators[idx].status = strings.ToLower(res.Status)
|
||
updateIndicator(idx)
|
||
}
|
||
})
|
||
},
|
||
)
|
||
|
||
go func() {
|
||
defer close(done)
|
||
res := r.Run(ctx, steps)
|
||
mu.Lock()
|
||
results = res
|
||
mu.Unlock()
|
||
mw.Synchronize(func() {
|
||
mw.Close()
|
||
})
|
||
}()
|
||
|
||
mw.Run()
|
||
cancel()
|
||
<-done
|
||
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
needsReboot := len(results) > 0 && results[len(results)-1].NeedsReboot
|
||
return results, needsReboot
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Pre-reboot helper
|
||
// --------------------------------------------------------------------------
|
||
|
||
// prepareRebootAndRestart saves state, sets up resume infrastructure, and reboots.
|
||
func prepareRebootAndRestart(
|
||
cfg config.Config,
|
||
allSteps []runner.Step,
|
||
completedResults []runner.Result,
|
||
cfgPath string,
|
||
runCfg runner.RunConfig,
|
||
) {
|
||
pending := pendingStepIDs(allSteps, completedResults)
|
||
st := &state.State{
|
||
Config: cfg,
|
||
ConfigPath: cfgPath,
|
||
LogFile: runCfg.LogFile,
|
||
PendingSteps: pending,
|
||
Results: toStateResults(completedResults),
|
||
}
|
||
if err := state.Save(st); err != nil {
|
||
walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)
|
||
return
|
||
}
|
||
if err := prereboot.Prepare(); err != nil {
|
||
walk.MsgBox(nil, "Chyba", "Prereboot setup selhal: "+err.Error(), walk.MsgBoxIconError)
|
||
return
|
||
}
|
||
reboot()
|
||
}
|
||
|
||
// pendingStepIDs returns IDs of steps that still need to run.
|
||
// Steps with NeedsReboot=true (re-run after reboot) and steps not yet
|
||
// reached (absent from completedResults) are both included.
|
||
func pendingStepIDs(allSteps []runner.Step, completedResults []runner.Result) []string {
|
||
done := make(map[string]bool, len(completedResults))
|
||
for _, r := range completedResults {
|
||
if !r.NeedsReboot {
|
||
done[r.Step.ID] = true
|
||
}
|
||
}
|
||
var pending []string
|
||
for _, s := range allSteps {
|
||
if !done[s.ID] {
|
||
pending = append(pending, s.ID)
|
||
}
|
||
}
|
||
return pending
|
||
}
|
||
|
||
// toStateResults converts runner results to state.StepResult for persistence.
|
||
// Steps with NeedsReboot=true are excluded (they will run again next round).
|
||
func toStateResults(results []runner.Result) []state.StepResult {
|
||
var sr []state.StepResult
|
||
for _, r := range results {
|
||
if r.NeedsReboot {
|
||
continue
|
||
}
|
||
sr = append(sr, state.StepResult{
|
||
StepID: r.Step.ID,
|
||
Num: r.Step.Num,
|
||
Name: r.Step.Name,
|
||
Status: r.Status,
|
||
Elapsed: r.Elapsed,
|
||
})
|
||
}
|
||
return sr
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Phase 3 – Summary with auto-reboot countdown
|
||
// --------------------------------------------------------------------------
|
||
|
||
const rebootCountdown = 60 // seconds before automatic reboot
|
||
|
||
// donePhase shows the final summary screen.
|
||
// currentResults are from the last run; prevResults accumulate earlier rounds.
|
||
func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {
|
||
var (
|
||
mw *walk.MainWindow
|
||
countdownLbl *walk.Label
|
||
)
|
||
|
||
// Combined ordered list: previous rounds first, then current round
|
||
type displayRow struct {
|
||
Num string
|
||
Name string
|
||
Status string
|
||
Elapsed time.Duration
|
||
}
|
||
|
||
var rows []displayRow
|
||
var emailRows []report.StepResult
|
||
for _, r := range prevResults {
|
||
rows = append(rows, displayRow{r.Num, r.Name, r.Status, r.Elapsed})
|
||
emailRows = append(emailRows, report.StepResult{Num: r.Num, Name: r.Name, Status: r.Status, Elapsed: r.Elapsed})
|
||
}
|
||
for _, r := range currentResults {
|
||
if r.NeedsReboot {
|
||
continue
|
||
}
|
||
rows = append(rows, displayRow{r.Step.Num, r.Step.Name, r.Status, r.Elapsed})
|
||
emailRows = append(emailRows, report.StepResult{Num: r.Step.Num, Name: r.Step.Name, Status: r.Status, Elapsed: r.Elapsed})
|
||
}
|
||
|
||
ok, errs, skipped := 0, 0, 0
|
||
widgets := make([]Widget, 0, len(rows))
|
||
for _, row := range rows {
|
||
var icon string
|
||
switch row.Status {
|
||
case "OK":
|
||
ok++
|
||
icon = "OK "
|
||
case "ERROR":
|
||
errs++
|
||
icon = "ERR "
|
||
default:
|
||
skipped++
|
||
icon = "\u2013 "
|
||
}
|
||
text := icon + row.Num + " \u2013 " + row.Name
|
||
if row.Elapsed > 0 {
|
||
text += fmt.Sprintf(" (%s)", row.Elapsed.Round(time.Second))
|
||
}
|
||
widgets = append(widgets, Label{
|
||
Text: text,
|
||
Font: Font{Family: "Consolas", PointSize: 9},
|
||
})
|
||
}
|
||
|
||
summaryText := fmt.Sprintf("OK: %d CHYBY: %d PRESKOCENO: %d", ok, errs, skipped)
|
||
|
||
// Report the run (non-blocking). On failure, runreport files a Forgejo
|
||
// issue and returns its URL; the email then links to it and attaches the
|
||
// zipped log. Both steps retry internally and never block completion.
|
||
go func() {
|
||
issueURL := runreport.Report(emailRows, deployLogPath)
|
||
_ = report.Send(emailRows, issueURL, deployLogPath)
|
||
}()
|
||
|
||
cancelReboot := make(chan struct{})
|
||
|
||
if err := (MainWindow{
|
||
AssignTo: &mw,
|
||
Title: "xetup \u2014 hotovo",
|
||
Size: Size{Width: 760, Height: 740},
|
||
Layout: VBox{},
|
||
Children: []Widget{
|
||
Label{
|
||
Text: "Hotovo",
|
||
Alignment: AlignHCenterVNear,
|
||
Font: Font{Bold: true, PointSize: 12},
|
||
},
|
||
HSeparator{},
|
||
ScrollView{
|
||
MinSize: Size{Height: 510},
|
||
Layout: VBox{MarginsZero: true},
|
||
Children: widgets,
|
||
},
|
||
HSeparator{},
|
||
Label{
|
||
Text: summaryText,
|
||
Alignment: AlignHCenterVNear,
|
||
Font: Font{Bold: true},
|
||
},
|
||
Label{
|
||
AssignTo: &countdownLbl,
|
||
Text: fmt.Sprintf("Restart za %ds...", rebootCountdown),
|
||
Alignment: AlignHCenterVNear,
|
||
},
|
||
Composite{
|
||
Layout: HBox{MarginsZero: true},
|
||
Children: []Widget{
|
||
HSpacer{},
|
||
PushButton{
|
||
Text: " Restartovat ted ",
|
||
OnClicked: func() {
|
||
close(cancelReboot)
|
||
reboot()
|
||
mw.Close()
|
||
},
|
||
},
|
||
PushButton{
|
||
Text: " Zrusit restart ",
|
||
OnClicked: func() {
|
||
select {
|
||
case <-cancelReboot:
|
||
default:
|
||
close(cancelReboot)
|
||
}
|
||
countdownLbl.SetText("Restart zrusen.")
|
||
},
|
||
},
|
||
HSpacer{},
|
||
},
|
||
},
|
||
},
|
||
}.Create()); err != nil {
|
||
return
|
||
}
|
||
|
||
// Countdown goroutine
|
||
go func() {
|
||
ticker := time.NewTicker(time.Second)
|
||
defer ticker.Stop()
|
||
remaining := rebootCountdown
|
||
for {
|
||
select {
|
||
case <-cancelReboot:
|
||
return
|
||
case <-ticker.C:
|
||
remaining--
|
||
r := remaining
|
||
mw.Synchronize(func() {
|
||
if r > 0 {
|
||
countdownLbl.SetText(fmt.Sprintf("Restart za %ds...", r))
|
||
} else {
|
||
countdownLbl.SetText("Restartuji...")
|
||
}
|
||
})
|
||
if remaining <= 0 {
|
||
reboot()
|
||
mw.Synchronize(func() { mw.Close() })
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
|
||
mw.Run()
|
||
}
|
||
|
||
// reboot issues an immediate Windows restart.
|
||
func reboot() {
|
||
exec.Command("shutdown", "/r", "/t", "0").Run() //nolint:errcheck
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Helpers
|
||
// --------------------------------------------------------------------------
|
||
|
||
func itemEnabled(cfg config.Config, item runner.SelectableItem) bool {
|
||
if item.FeatureID == "" {
|
||
if v, ok := cfg.Steps[item.StepID]; ok {
|
||
return v
|
||
}
|
||
return true
|
||
}
|
||
if feats, ok := cfg.Features[item.StepID]; ok {
|
||
if v, ok2 := feats[item.FeatureID]; ok2 {
|
||
return v
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) {
|
||
items := runner.AllSelectableItems()
|
||
features := make(config.Features)
|
||
stepOn := make(map[string]bool)
|
||
|
||
for _, item := range items {
|
||
if item.FeatureID == "" {
|
||
stepOn[item.StepID] = selected[item.Key]
|
||
} else {
|
||
if features[item.StepID] == nil {
|
||
features[item.StepID] = make(map[string]bool)
|
||
}
|
||
features[item.StepID][item.FeatureID] = selected[item.Key]
|
||
if selected[item.Key] {
|
||
stepOn[item.StepID] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
allSteps := runner.AllSteps()
|
||
steps := make([]runner.Step, len(allSteps))
|
||
for i, s := range allSteps {
|
||
s.Enabled = stepOn[s.ID]
|
||
steps[i] = s
|
||
}
|
||
return steps, features
|
||
}
|
||
|
||
func profileIndex(p string) int {
|
||
switch p {
|
||
case "admin":
|
||
return 1
|
||
case "user":
|
||
return 2
|
||
default:
|
||
return 0
|
||
}
|
||
}
|