package befehle import ( "context" "encoding/json" "errors" "fmt" "os" "strings" "time" "github.com/mattn/go-runewidth" "golang.org/x/term" "git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api" "git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/ausgabe" ) var endzustaende = map[api.AuftragStatus]bool{ api.AuftragStatusERFOLGREICH: true, api.AuftragStatusFEHLGESCHLAGEN: true, api.AuftragStatusABGEBROCHEN: true, api.AuftragStatusAUFGERAEUMT: true, } func wert(p *string) string { if p == nil { return "" } return *p } func zeitAus(p *string) (time.Time, bool) { if p == nil { return time.Time{}, false } t, err := time.Parse(time.RFC3339Nano, *p) return t, err == nil } // phasenAus übernimmt die Phasen der API unverändert; nur die Laufzeit rechnet die CLI selbst. func phasenAus(a api.Auftrag, jetzt time.Time) []ausgabe.Phase { liste := make([]ausgabe.Phase, 0, len(a.Phasen)) for _, p := range a.Phasen { phase := ausgabe.Phase{ Titel: p.Titel, Status: string(p.Status), Detail: wert(p.Detail), Ursache: wert(p.Ursache), Hinweis: wert(p.Hinweis), } if seit, ok := zeitAus(p.GestartetAm); ok && p.Status == api.PhaseStatusLAEUFT { phase.Laufzeit = jetzt.Sub(seit) } liste = append(liste, phase) } return liste } func boxAus(a *api.Abschluss) ausgabe.Box { box := ausgabe.Box{Ok: a.Ok, Titel: a.Titel, Dauer: wert(a.Dauer)} for _, z := range a.Zeilen { if len(z) == 2 { box.Zeilen = append(box.Zeilen, [2]string{z[0], z[1]}) } } return box } // darsteller zeichnet einen Auftrag: live im Terminal (an Ort und Stelle neu), sonst zeilenweise. type darsteller struct { u *umgebung s ausgabe.Stil live bool gezeichnet int // Bildschirmzeilen der letzten Zeichnung (live) kopf bool gedruckt int // Anzahl schon gedruckter Phasen (zeilenweise) takt int } func (u *umgebung) neuerDarsteller() *darsteller { return &darsteller{u: u, s: u.stil(), live: u.terminal && !u.opt.still && !u.opt.json} } func (d *darsteller) bildschirmzeilen(zeilen []string) int { spalten := 0 if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil { spalten = w } n := 0 for _, z := range zeilen { breite := runewidth.StringWidth(ohneFarben(z)) if spalten > 0 && breite > spalten { n += (breite + spalten - 1) / spalten } else { n++ } } return n } func ohneFarben(text string) string { var b strings.Builder in := false for _, r := range text { switch { case r == '\x1b': in = true case in && r == 'm': in = false case !in: b.WriteRune(r) } } return b.String() } // zeichnen gibt den Stand aus. `fertig`: der Auftrag ist beendet, die Box kommt dazu. func (d *darsteller) zeichnen(a api.Auftrag, fertig bool) { if d.u.opt.json { return } phasen := phasenAus(a, time.Now()) if d.u.opt.still { if fertig && a.Abschluss != nil { fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n")) } return } breite := ausgabe.Spaltenbreite(phasen) if d.live { sichtbar := make([]ausgabe.Phase, 0, len(phasen)) for _, p := range phasen { if p.Status != "OFFEN" || fertig { sichtbar = append(sichtbar, p) } } zeilen := []string{d.s.Kopf(Version, a.Kopf, false), ""} zeilen = append(zeilen, d.s.PhasenZeilenMit(sichtbar, breite, d.takt)...) if fertig && a.Abschluss != nil { zeilen = append(zeilen, "") zeilen = append(zeilen, d.s.BoxZeilen(boxAus(a.Abschluss))...) } var b strings.Builder if d.gezeichnet > 0 { fmt.Fprintf(&b, "\x1b[%dF\x1b[J", d.gezeichnet) } b.WriteString(strings.Join(zeilen, "\n")) b.WriteString("\n") fmt.Fprint(d.u.aus, b.String()) d.gezeichnet = d.bildschirmzeilen(zeilen) return } // Zeilenweise (Pipe, Datei, CI): jede Phase einmal, sobald sie abgeschlossen ist. Die Kopfzeile // kommt nach der ersten Phase – dann stehen Node und Template fest. abgeschlossen := 0 for _, p := range phasen { if p.Status == "OFFEN" || p.Status == "LAEUFT" { break } abgeschlossen++ } if !d.kopf && (abgeschlossen > 0 || fertig) { fmt.Fprintln(d.u.aus, d.s.Kopf(Version, a.Kopf, false)) fmt.Fprintln(d.u.aus) d.kopf = true } if abgeschlossen > d.gedruckt { for _, z := range d.s.PhasenZeilenMit(phasen[d.gedruckt:abgeschlossen], breite, 0) { fmt.Fprintln(d.u.aus, z) } d.gedruckt = abgeschlossen } if fertig { if rest := phasen[d.gedruckt:]; len(rest) > 0 { for _, z := range d.s.PhasenZeilenMit(rest, breite, 0) { fmt.Fprintln(d.u.aus, z) } } if a.Abschluss != nil { fmt.Fprintln(d.u.aus) fmt.Fprintln(d.u.aus, strings.Join(d.s.BoxZeilen(boxAus(a.Abschluss)), "\n")) } } } // folgen hängt sich an einen Auftrag, bis er fertig ist. Fehlgeschlagen → Exit 1. func (u *umgebung) folgen(ctx context.Context, c *api.Client, id string) (api.Auftrag, error) { d := u.neuerDarsteller() staende := make(chan api.Auftrag, 16) stromFehler := make(chan error, 1) strCtx, stopp := context.WithCancel(ctx) defer stopp() go func() { defer close(staende) stromFehler <- u.stroemen(strCtx, c, id, staende) }() var letzter *api.Auftrag uhr := time.NewTicker(150 * time.Millisecond) defer uhr.Stop() for { select { case <-ctx.Done(): if d.live && letzter != nil { d.zeichnen(*letzter, false) } fmt.Fprintln(u.fehler, d.s.Muted(fmt.Sprintf("\nAbgehängt – der Auftrag läuft weiter: proxdeploy status %s", id))) return api.Auftrag{}, &exitFehler{code: 130, gemeldet: true} case a, offen := <-staende: if !offen { if err := <-stromFehler; err != nil { return api.Auftrag{}, err } if letzter == nil { return api.Auftrag{}, errors.New("kein Stand erhalten") } return u.abschliessen(d, *letzter) } letzter = &a if endzustaende[a.Status] { return u.abschliessen(d, a) } d.zeichnen(a, false) case <-uhr.C: if d.live && letzter != nil { d.takt++ d.zeichnen(*letzter, false) } } } } func (u *umgebung) abschliessen(d *darsteller, a api.Auftrag) (api.Auftrag, error) { d.zeichnen(a, true) if u.opt.json { if err := u.jsonAusgeben(a); err != nil { return a, err } } if a.Status != api.AuftragStatusERFOLGREICH { return a, &exitFehler{code: ExitFehlschlag, gemeldet: true} } return a, nil } // stroemen liefert Stände per SSE; bricht der Strom ab, wird mit der letzten Event-ID neu // verbunden. Klappt SSE dreimal hintereinander nicht (z. B. ein puffernder Proxy), wird alle 2 s // abgefragt. func (u *umgebung) stroemen(ctx context.Context, c *api.Client, id string, staende chan<- api.Auftrag) error { letzteID := "" fehlschlaege := 0 for fehlschlaege < 3 { erhalten := false err := c.Ereignisse(ctx, id, letzteID, func(e api.Ereignis) error { if e.ID != "" { letzteID = e.ID } switch e.Art { case "stand": var a api.Auftrag if err := json.Unmarshal(e.Daten, &a); err != nil { return fmt.Errorf("Stand nicht lesbar: %w", err) } erhalten = true fehlschlaege = 0 select { case staende <- a: case <-ctx.Done(): return ctx.Err() } case "ende": return api.Ende } return nil }) switch { case errors.Is(err, api.Ende): return nil case ctx.Err() != nil: return nil case api.IstAnmeldefehler(err): return err } var apiFehler *api.Fehler if errors.As(err, &apiFehler) { return err } if !erhalten { fehlschlaege++ } select { case <-time.After(time.Duration(fehlschlaege+1) * time.Second): case <-ctx.Done(): return nil } } for { a, err := c.Auftrag(ctx, id) if err != nil { if ctx.Err() != nil { return nil } var verbindung *api.VerbindungsFehler if !errors.As(err, &verbindung) { return err } } else { select { case staende <- a: case <-ctx.Done(): return nil } if endzustaende[a.Status] { return nil } } select { case <-time.After(2 * time.Second): case <-ctx.Done(): return nil } } }