package ausgabe import ( "fmt" "sort" "strings" "time" "github.com/mattn/go-runewidth" ) // Phase, wie sie gezeigt wird – Felder kommen unverändert aus der API. type Phase struct { Titel string Status string // OFFEN, LAEUFT, ERLEDIGT, FEHLER, UEBERSPRUNGEN Detail string Ursache string Hinweis string // Laufzeit der laufenden Phase (nur bei LAEUFT). Laufzeit time.Duration } // Box ist die Abschlussbox; Zeilen sind [Bezeichnung, Wert]. type Box struct { Ok bool Titel string Dauer string Zeilen [][2]string } // Aufruf ist ein geplanter Proxmox-Aufruf im Dry-Run. type Aufruf struct { Methode string Pfad string Parameter map[string]any } // Dauer wie in Web-GUI und Engine: „18s“, „1m 12s“, „1h 03m“. func Dauer(d time.Duration) string { s := int((d + 500*time.Millisecond) / time.Second) if s < 0 { s = 0 } switch { case s < 60: return fmt.Sprintf("%ds", s) case s < 3600: return fmt.Sprintf("%dm %02ds", s/60, s%60) default: return fmt.Sprintf("%dh %02dm", s/3600, (s%3600)/60) } } // Kopf: „⬡ ProxDeploy v1.0 · Server rz1 · …“ – Prox in Paper, Deploy fett in Teal, Rest Muted. func (s Stil) Kopf(version, kopf string, trocken bool) string { var b strings.Builder if !s.ASCII { b.WriteString(s.Muted("⬡ ")) } b.WriteString(s.Paper("Prox")) b.WriteString(s.TealFett("Deploy")) rest := " v" + version if kopf != "" { rest += " · " + kopf } b.WriteString(s.Muted(rest)) if trocken { b.WriteString(s.AmberFett(" · DRY-RUN")) } return b.String() } // Spaltenbreite der Phasennamen: längster Name + 2, mindestens 14 (wie die Web-GUI). func Spaltenbreite(phasen []Phase) int { breite := 12 for _, p := range phasen { if w := runewidth.StringWidth(p.Titel); w > breite { breite = w } } return breite + 2 } // PhasenZeilen setzt jede Phase als Zeile: Symbol, Name in fester Breite, Detail in Muted. Bei // Fehler Ursache in Rot und darunter der Hinweis. `takt` dreht den Spinner der laufenden Phase. func (s Stil) PhasenZeilen(phasen []Phase, takt int) []string { return s.PhasenZeilenMit(phasen, Spaltenbreite(phasen), takt) } // PhasenZeilenMit setzt mit fester Spaltenbreite – live bleibt sie so stabil, auch wenn noch nicht // alle Phasen sichtbar sind. func (s Stil) PhasenZeilenMit(phasen []Phase, breite, takt int) []string { einzug := strings.Repeat(" ", runewidth.StringWidth(s.symbol("OFFEN"))+1+breite) var zeilen []string for _, p := range phasen { symbol := s.symbol(p.Status) if p.Status == "LAEUFT" && !s.ASCII { symbol = spinner[takt%len(spinner)] } titel := p.Titel + strings.Repeat(" ", breite-runewidth.StringWidth(p.Titel)) var z strings.Builder switch p.Status { case "ERLEDIGT": z.WriteString(s.Teal(symbol) + " " + s.Paper(titel)) case "LAEUFT": z.WriteString(s.Amber(symbol) + " " + s.Paper(titel)) case "FEHLER": z.WriteString(s.Rot(symbol) + " " + s.Paper(titel)) default: z.WriteString(s.Muted(symbol) + " " + s.Muted(titel)) } switch p.Status { case "FEHLER": z.WriteString(s.Rot(erste(p.Ursache, p.Detail))) zeilen = append(zeilen, strings.TrimRight(z.String(), " ")) if p.Hinweis != "" { zeilen = append(zeilen, einzug+s.Muted("→ "+p.Hinweis)) } continue case "LAEUFT": text := Dauer(p.Laufzeit) if p.Detail != "" { text = p.Detail + " … " + text } z.WriteString(s.Muted(text)) default: z.WriteString(s.Muted(p.Detail)) } zeilen = append(zeilen, strings.TrimRight(z.String(), " ")) } return zeilen } func erste(werte ...string) string { for _, w := range werte { if w != "" { return w } } return "" } // Mindestbreite der Box innen (zwischen den Rahmenstrichen) – wie im Mockup. const boxMindestbreite = 38 // BoxZeilen setzt die Abschlussbox: Rahmen Teal (Erfolg) oder Rot, Breite aus dem Inhalt. func (s Stil) BoxZeilen(b Box) []string { rahmen := s.Teal kopfSymbol := "●" if !b.Ok { rahmen = s.Rot kopfSymbol = "✗" } if s.ASCII { kopfSymbol = map[bool]string{true: "[OK]", false: "[!!]"}[b.Ok] } type zeile struct{ roh, farbig string } kopf := kopfSymbol + " " + b.Titel kopfFarbig := rahmen(kopfSymbol) + " " + s.Fett(b.Titel) if b.Dauer != "" { kopf += " · " + b.Dauer kopfFarbig += s.Muted(" · " + b.Dauer) } inhalt := []zeile{{kopf, kopfFarbig}} label := 7 for _, z := range b.Zeilen { if w := runewidth.StringWidth(z[0]); w > label { label = w } } label += 2 for _, z := range b.Zeilen { name := z[0] + strings.Repeat(" ", label-runewidth.StringWidth(z[0])) inhalt = append(inhalt, zeile{name + z[1], s.Muted(name) + s.Paper(z[1])}) } breite := boxMindestbreite for _, z := range inhalt { if w := runewidth.StringWidth(z.roh) + 4; w > breite { breite = w } } ol, or, ul, ur, h, v := "┌", "┐", "└", "┘", "─", "│" if s.ASCII { ol, or, ul, ur, h, v = "+", "+", "+", "+", "-", "|" } zeilen := []string{rahmen(ol + strings.Repeat(h, breite) + or)} for _, z := range inhalt { luecke := breite - 1 - runewidth.StringWidth(z.roh) zeilen = append(zeilen, rahmen(v)+" "+z.farbig+strings.Repeat(" ", luecke)+rahmen(v)) } zeilen = append(zeilen, rahmen(ul+strings.Repeat(h, breite)+ur)) return zeilen } // AufrufZeilen: jeder geplante Aufruf als „→ POST /pfad k=v …“, lange Werte gekürzt wie im Web. func (s Stil) AufrufZeilen(aufrufe []Aufruf) []string { pfeil := "→ " if s.ASCII { pfeil = "-> " } var zeilen []string for _, a := range aufrufe { z := s.Amber(pfeil) + s.Paper(a.Methode+" "+a.Pfad) if len(a.Parameter) > 0 { namen := make([]string, 0, len(a.Parameter)) for n := range a.Parameter { namen = append(namen, n) } sort.Strings(namen) teile := make([]string, 0, len(namen)) for _, n := range namen { teile = append(teile, n+"="+kuerzen(fmt.Sprint(a.Parameter[n]), 60)) } z += " " + s.Muted(strings.Join(teile, " ")) } zeilen = append(zeilen, z) } return zeilen } func kuerzen(text string, hoechstens int) string { // Eine Zeile je Aufruf: Umbrüche (z. B. in der Beschreibung) sichtbar, aber einzeilig. text = strings.NewReplacer("\r\n", "⏎", "\n", "⏎").Replace(text) r := []rune(text) if len(r) <= hoechstens { return text } return string(r[:hoechstens-3]) + "…" }