Befehle login, servers, distros, profiles, presets, vm, lxc, template list|build, batch, status, logs --follow, cancel. Renderer im Aufbau der Vorgabe (zeichengenau per Golden-File), 24-bit/256/ohne Farbe, --ascii, --quiet, --json, --dry-run; live im Terminal, zeilenweise ohne. SSE mit Wiederverbinden und Polling als Rückfall. Typen aus openapi.json per oapi-codegen; Release-Builds per cli/bauen.sh. Refs #013 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
6.2 KiB
Go
232 lines
6.2 KiB
Go
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]) + "…"
|
||
}
|