feat: email report, pre-flight checks, parallel winget installs
Email report: HTML summary sent via SMTP2Go (mail-eu.smtp2go.com) at the end of every deployment. Subject "xetup report HOSTNAME", body contains per-step status table with timestamps. Non-blocking (goroutine) so it doesn't delay the summary screen. Pre-flight checks: admin rights, winget availability, network connectivity (DNS resolve), and disk space verified before the config form. Results shown as colored status lines at the top of the GUI - red warnings tell the technician what's wrong before starting a 30-minute deployment. Parallel winget: 02-software.ps1 now launches all winget installs as background jobs (Start-Job) and waits for all to complete. 7-Zip, Acrobat, OpenVPN run simultaneously instead of sequentially, saving 3-5 minutes per deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af41dde33c
commit
64646f1b7f
5 changed files with 351 additions and 17 deletions
|
|
@ -27,7 +27,9 @@ import (
|
|||
. "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/state"
|
||||
)
|
||||
|
|
@ -152,12 +154,47 @@ func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formR
|
|||
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},
|
||||
|
|
@ -279,6 +316,17 @@ func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formR
|
|||
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
|
||||
}
|
||||
|
|
@ -548,14 +596,17 @@ func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {
|
|||
}
|
||||
|
||||
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
|
||||
|
|
@ -585,6 +636,14 @@ func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {
|
|||
|
||||
summaryText := fmt.Sprintf("OK: %d CHYBY: %d PRESKOCENO: %d", ok, errs, skipped)
|
||||
|
||||
// Send email report (non-blocking, best-effort)
|
||||
go func() {
|
||||
if err := report.Send(emailRows); err != nil {
|
||||
// Log but don't block - deployment is done
|
||||
_ = err
|
||||
}
|
||||
}()
|
||||
|
||||
cancelReboot := make(chan struct{})
|
||||
|
||||
if err := (MainWindow{
|
||||
|
|
|
|||
111
internal/preflight/preflight.go
Normal file
111
internal/preflight/preflight.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//go:build windows
|
||||
|
||||
// Package preflight runs quick environment checks before deployment starts.
|
||||
// Each check returns a human-readable result; failures are warnings, not blockers.
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Result is one pre-flight check outcome.
|
||||
type Result struct {
|
||||
Name string
|
||||
OK bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
// RunAll executes all pre-flight checks and returns results.
|
||||
func RunAll() []Result {
|
||||
return []Result{
|
||||
checkAdmin(),
|
||||
checkWinget(),
|
||||
checkNetwork(),
|
||||
checkDisk(),
|
||||
}
|
||||
}
|
||||
|
||||
func checkAdmin() Result {
|
||||
r := Result{Name: "Spusteno jako Administrator"}
|
||||
// If we got here via #Requires -RunAsAdministrator or manifested exe,
|
||||
// we're admin. Double-check via net session.
|
||||
cmd := exec.Command("net", "session")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
if err := cmd.Run(); err != nil {
|
||||
r.OK = false
|
||||
r.Detail = "Neni spusteno jako Administrator"
|
||||
return r
|
||||
}
|
||||
r.OK = true
|
||||
r.Detail = "OK"
|
||||
return r
|
||||
}
|
||||
|
||||
func checkWinget() Result {
|
||||
r := Result{Name: "Winget dostupny"}
|
||||
cmd := exec.Command("winget", "--version")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
r.OK = false
|
||||
r.Detail = "winget nenalezen - software se nenainstaluje"
|
||||
return r
|
||||
}
|
||||
ver := strings.TrimSpace(string(out))
|
||||
r.OK = true
|
||||
r.Detail = ver
|
||||
return r
|
||||
}
|
||||
|
||||
func checkNetwork() Result {
|
||||
r := Result{Name: "Pripojeni k internetu"}
|
||||
// Try to resolve a well-known hostname
|
||||
cmd := exec.Command("powershell.exe", "-NonInteractive", "-Command",
|
||||
"[System.Net.Dns]::GetHostEntry('www.google.com').AddressList[0].IPAddressToString")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil || strings.TrimSpace(string(out)) == "" {
|
||||
r.OK = false
|
||||
r.Detail = "DNS resolve selhal - Atera a winget nebudou fungovat"
|
||||
return r
|
||||
}
|
||||
r.OK = true
|
||||
r.Detail = "OK"
|
||||
return r
|
||||
}
|
||||
|
||||
func checkDisk() Result {
|
||||
r := Result{Name: "Volne misto na C:"}
|
||||
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW")
|
||||
|
||||
var freeBytesAvailable, totalBytes, totalFreeBytes uint64
|
||||
drive, _ := syscall.UTF16PtrFromString("C:\\")
|
||||
|
||||
ret, _, _ := getDiskFreeSpaceEx.Call(
|
||||
uintptr(unsafe.Pointer(drive)),
|
||||
uintptr(unsafe.Pointer(&freeBytesAvailable)),
|
||||
uintptr(unsafe.Pointer(&totalBytes)),
|
||||
uintptr(unsafe.Pointer(&totalFreeBytes)),
|
||||
)
|
||||
if ret == 0 {
|
||||
r.OK = false
|
||||
r.Detail = "Nelze zjistit volne misto"
|
||||
return r
|
||||
}
|
||||
|
||||
freeGB := float64(freeBytesAvailable) / (1024 * 1024 * 1024)
|
||||
if freeGB < 5 {
|
||||
r.OK = false
|
||||
r.Detail = fmt.Sprintf("%.1f GB - malo mista (min 5 GB)", freeGB)
|
||||
return r
|
||||
}
|
||||
r.OK = true
|
||||
r.Detail = fmt.Sprintf("%.1f GB volnych", freeGB)
|
||||
return r
|
||||
}
|
||||
13
internal/preflight/preflight_other.go
Normal file
13
internal/preflight/preflight_other.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//go:build !windows
|
||||
|
||||
package preflight
|
||||
|
||||
// Result is one pre-flight check outcome.
|
||||
type Result struct {
|
||||
Name string
|
||||
OK bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
// RunAll returns empty results on non-Windows platforms.
|
||||
func RunAll() []Result { return nil }
|
||||
131
internal/report/report.go
Normal file
131
internal/report/report.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Package report sends a deployment summary email via SMTP.
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SMTP2Go relay configuration for X9.cz deployment reports.
|
||||
const (
|
||||
smtpHost = "mail-eu.smtp2go.com"
|
||||
smtpPort = "2525"
|
||||
smtpUser = "xetup"
|
||||
smtpPass = "M9ahxHOnJ8fM0CEF"
|
||||
mailFrom = "xetup@x9.cz"
|
||||
mailTo = "net@x9.cz"
|
||||
)
|
||||
|
||||
// StepResult holds one row of the deployment report.
|
||||
type StepResult struct {
|
||||
Num string
|
||||
Name string
|
||||
Status string // OK, ERROR, SKIPPED, CANCELLED
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// Send emails the deployment report. Non-fatal: returns error but caller
|
||||
// should log it and continue (deployment is already done).
|
||||
func Send(results []StepResult) error {
|
||||
hostname, _ := os.Hostname()
|
||||
now := time.Now().Format("2006-01-02 15:04")
|
||||
|
||||
subject := fmt.Sprintf("xetup report %s", hostname)
|
||||
body := buildHTML(results, hostname, now)
|
||||
|
||||
msg := strings.Join([]string{
|
||||
"From: " + mailFrom,
|
||||
"To: " + mailTo,
|
||||
"Subject: " + subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
|
||||
auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost)
|
||||
return smtp.SendMail(
|
||||
smtpHost+":"+smtpPort,
|
||||
auth,
|
||||
mailFrom,
|
||||
[]string{mailTo},
|
||||
[]byte(msg),
|
||||
)
|
||||
}
|
||||
|
||||
func buildHTML(results []StepResult, hostname, dateTime string) string {
|
||||
var ok, errs, skipped int
|
||||
var rows strings.Builder
|
||||
|
||||
for _, r := range results {
|
||||
color := "#333"
|
||||
icon := ""
|
||||
switch r.Status {
|
||||
case "OK":
|
||||
ok++
|
||||
color = "#2e7d32"
|
||||
icon = "✓"
|
||||
case "ERROR":
|
||||
errs++
|
||||
color = "#c62828"
|
||||
icon = "✗"
|
||||
default:
|
||||
skipped++
|
||||
color = "#9e9e9e"
|
||||
icon = "–"
|
||||
}
|
||||
|
||||
elapsed := ""
|
||||
if r.Elapsed > 0 {
|
||||
elapsed = r.Elapsed.Round(time.Second).String()
|
||||
}
|
||||
|
||||
fmt.Fprintf(&rows,
|
||||
`<tr><td style="padding:4px 8px;color:%s;font-size:16px;text-align:center">%s</td>`+
|
||||
`<td style="padding:4px 8px;color:#666">%s</td>`+
|
||||
`<td style="padding:4px 8px">%s</td>`+
|
||||
`<td style="padding:4px 8px;color:%s;font-weight:bold">%s</td>`+
|
||||
`<td style="padding:4px 8px;color:#999;text-align:right">%s</td></tr>`,
|
||||
color, icon, r.Num, r.Name, color, r.Status, elapsed)
|
||||
}
|
||||
|
||||
summaryColor := "#2e7d32"
|
||||
summaryText := "Deployment OK"
|
||||
if errs > 0 {
|
||||
summaryColor = "#c62828"
|
||||
summaryText = fmt.Sprintf("Deployment finished with %d error(s)", errs)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"></head>
|
||||
<body style="font-family:Segoe UI,Arial,sans-serif;margin:0;padding:20px;background:#f5f5f5">
|
||||
<div style="max-width:640px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.1)">
|
||||
<div style="background:#223B47;padding:20px 24px;color:#fff">
|
||||
<h1 style="margin:0;font-size:20px">xetup report</h1>
|
||||
<p style="margin:6px 0 0;opacity:0.8">%s — %s</p>
|
||||
</div>
|
||||
<table style="width:100%%;border-collapse:collapse;margin:16px 0">
|
||||
<tr style="background:#f9f9f9">
|
||||
<th style="padding:6px 8px;text-align:center;width:30px"></th>
|
||||
<th style="padding:6px 8px;text-align:left;width:40px">Krok</th>
|
||||
<th style="padding:6px 8px;text-align:left">Nazev</th>
|
||||
<th style="padding:6px 8px;text-align:left;width:70px">Status</th>
|
||||
<th style="padding:6px 8px;text-align:right;width:60px">Cas</th>
|
||||
</tr>
|
||||
%s
|
||||
</table>
|
||||
<div style="padding:16px 24px;background:%s;color:#fff;text-align:center;font-weight:bold">
|
||||
%s — OK: %d CHYBY: %d PRESKOCENO: %d
|
||||
</div>
|
||||
</div>
|
||||
<p style="text-align:center;color:#999;font-size:12px;margin-top:16px">
|
||||
Odeslano z xetup.exe — log: C:\Windows\Setup\Scripts\Deploy.log
|
||||
</p>
|
||||
</body></html>`,
|
||||
hostname, dateTime,
|
||||
rows.String(),
|
||||
summaryColor, summaryText, ok, errs, skipped)
|
||||
}
|
||||
|
|
@ -61,26 +61,46 @@ if (Get-Feature $Config "software" "wingetInstalls") {
|
|||
if (-not $Config -or -not $Config.software -or -not $Config.software.install) {
|
||||
Write-Log "No software list in config - skipping installs" -Level WARN
|
||||
} else {
|
||||
foreach ($pkg in $Config.software.install) {
|
||||
Write-Log "Installing $($pkg.name) ($($pkg.wingetId))" -Level INFO
|
||||
$result = & winget install --id $pkg.wingetId `
|
||||
--silent `
|
||||
--accept-package-agreements `
|
||||
--accept-source-agreements `
|
||||
--disable-interactivity `
|
||||
2>&1
|
||||
$packages = @($Config.software.install)
|
||||
Write-Log "Installing $($packages.Count) packages in parallel" -Level INFO
|
||||
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Log " Installed OK: $($pkg.name)" -Level OK
|
||||
} elseif ($exitCode -eq -1978335189) {
|
||||
# 0x8A150011 = already installed
|
||||
Write-Log " Already installed: $($pkg.name)" -Level OK
|
||||
} else {
|
||||
Write-Log " Failed: $($pkg.name) (exit $exitCode)" -Level ERROR
|
||||
Write-Log " Output: $($result -join ' ')" -Level ERROR
|
||||
# Launch all winget installs as parallel background jobs
|
||||
$jobs = @()
|
||||
foreach ($pkg in $packages) {
|
||||
Write-Log " Starting: $($pkg.name) ($($pkg.wingetId))" -Level INFO
|
||||
$jobs += Start-Job -ArgumentList $pkg.wingetId, $pkg.name -ScriptBlock {
|
||||
param($wingetId, $name)
|
||||
$output = & winget install --id $wingetId `
|
||||
--silent `
|
||||
--accept-package-agreements `
|
||||
--accept-source-agreements `
|
||||
--disable-interactivity `
|
||||
2>&1
|
||||
[PSCustomObject]@{
|
||||
Name = $name
|
||||
WingetId = $wingetId
|
||||
ExitCode = $LASTEXITCODE
|
||||
Output = ($output -join "`n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Wait for all jobs and collect results
|
||||
Write-Log " Waiting for $($jobs.Count) installs to complete..." -Level INFO
|
||||
$jobs | Wait-Job | Out-Null
|
||||
|
||||
foreach ($job in $jobs) {
|
||||
$r = Receive-Job -Job $job
|
||||
if ($r.ExitCode -eq 0) {
|
||||
Write-Log " Installed OK: $($r.Name)" -Level OK
|
||||
} elseif ($r.ExitCode -eq -1978335189) {
|
||||
Write-Log " Already installed: $($r.Name)" -Level OK
|
||||
} else {
|
||||
Write-Log " Failed: $($r.Name) (exit $($r.ExitCode))" -Level ERROR
|
||||
Write-Log " Output: $($r.Output)" -Level ERROR
|
||||
}
|
||||
Remove-Job -Job $job
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Log "wingetInstalls feature disabled - skipping" -Level INFO
|
||||
|
|
|
|||
Loading…
Reference in a new issue