feat(cli): proxdeploy in Go
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>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user