feat(verlauf): Filter, Wiederholen und Aufräumen
Verlauf filterbar (URL, API, proxdeploy history); Wiederholen füllt den Assistenten aus einem alten Auftrag; Aufräumen löscht nach Fehlschlag oder Abbruch den halb angelegten Gast – nur, wenn dessen Beschreibung auf den Auftrag verweist. Deshalb setzt das Klonen die Beschreibung jetzt sofort. Auf pve-demo geprüft (CT 109). Closes #014 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
package befehle
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
||||
)
|
||||
@@ -172,3 +175,146 @@ func (u *umgebung) cancelBefehl() *cobra.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Statustexte wie in der Web-GUI.
|
||||
var statusText = map[api.AuftragStatus]string{
|
||||
api.AuftragStatusWARTEND: "wartet", api.AuftragStatusLAEUFT: "läuft", api.AuftragStatusERFOLGREICH: "erfolgreich",
|
||||
api.AuftragStatusFEHLGESCHLAGEN: "fehlgeschlagen", api.AuftragStatusABGEBROCHEN: "abgebrochen",
|
||||
api.AuftragStatusAUFGERAEUMT: "aufgeräumt",
|
||||
}
|
||||
|
||||
// statusAusText nimmt „fehlgeschlagen“, „läuft“, „aufgeräumt“ oder den API-Wert.
|
||||
func statusAusText(text string) string {
|
||||
for wert, t := range statusText {
|
||||
if strings.EqualFold(t, text) || strings.EqualFold(string(wert), text) {
|
||||
return string(wert)
|
||||
}
|
||||
}
|
||||
return strings.ToUpper(text)
|
||||
}
|
||||
|
||||
func (u *umgebung) historyBefehl() *cobra.Command {
|
||||
var server, typ, distro, status string
|
||||
var seite int
|
||||
befehl := &cobra.Command{
|
||||
Use: "history",
|
||||
Aliases: []string{"verlauf"},
|
||||
Short: "Verlauf der Aufträge, neueste zuerst",
|
||||
Example: " proxdeploy history --type lxc --status FEHLGESCHLAGEN\n proxdeploy history --server rz1 --page 2",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter := map[string]string{"server": server, "typ": typ, "distribution": distro, "status": ""}
|
||||
if status != "" {
|
||||
filter["status"] = statusAusText(status)
|
||||
}
|
||||
if seite > 1 {
|
||||
filter["seite"] = fmt.Sprint(seite)
|
||||
}
|
||||
v, err := c.Verlauf(cmd.Context(), filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.opt.json {
|
||||
return u.jsonAusgeben(v)
|
||||
}
|
||||
s := u.stil()
|
||||
if len(v.Eintraege) == 0 {
|
||||
fmt.Fprintln(u.aus, s.Muted("Keine Aufträge."))
|
||||
return nil
|
||||
}
|
||||
symbole := map[api.AuftragStatus]string{
|
||||
api.AuftragStatusERFOLGREICH: "✓", api.AuftragStatusFEHLGESCHLAGEN: "✗", api.AuftragStatusABGEBROCHEN: "✗",
|
||||
api.AuftragStatusLAEUFT: "◐", api.AuftragStatusWARTEND: "○", api.AuftragStatusAUFGERAEUMT: "○",
|
||||
}
|
||||
zeilen := [][]string{}
|
||||
for _, a := range v.Eintraege {
|
||||
art := map[api.AuftragArt]string{api.AUSROLLEN: "VM", api.TEMPLATEBAUEN: "Template", api.AUFRAEUMEN: "Aufräumen"}[a.Art]
|
||||
if a.Art == api.AUSROLLEN && a.GastTyp != nil && *a.GastTyp == api.AuftragGastTypLXC {
|
||||
art = "LXC"
|
||||
}
|
||||
id := "–"
|
||||
if a.Vmid != nil {
|
||||
id = fmt.Sprint(*a.Vmid)
|
||||
}
|
||||
zustand := symbole[a.Status] + " " + statusText[a.Status]
|
||||
if s.ASCII {
|
||||
zustand = strings.NewReplacer("✓", "[OK]", "✗", "[!!]", "◐", "[..]", "○", "[--]").Replace(zustand)
|
||||
}
|
||||
zeit := a.ErstelltAm
|
||||
if t, ok := zeitAus(&a.ErstelltAm); ok {
|
||||
zeit = t.Local().Format("02.01. 15:04")
|
||||
}
|
||||
name := wert(a.Name)
|
||||
if name == "" {
|
||||
name = "–"
|
||||
}
|
||||
zeilen = append(zeilen, []string{a.KurzId, name, art, a.Server.Name, wert(a.DistributionId), id, zustand, zeit})
|
||||
}
|
||||
u.tabelle(s, []string{"AUFTRAG", "NAME", "TYP", "SERVER", "DISTRIBUTION", "ID", "STATUS", "ZEIT"}, zeilen)
|
||||
if v.Seiten > 1 {
|
||||
fmt.Fprintln(u.aus, s.Muted(fmt.Sprintf("Seite %d von %d · %d Aufträge · weiter mit --page %d", v.Seite, v.Seiten, v.Gesamt, min(v.Seite+1, v.Seiten))))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := befehl.Flags()
|
||||
f.StringVar(&server, "server", "", "nur dieser Server")
|
||||
f.StringVar(&typ, "type", "", "vm, lxc, template oder aufraeumen")
|
||||
f.StringVar(&distro, "distro", "", "nur diese Distribution")
|
||||
f.StringVar(&status, "status", "", "z. B. fehlgeschlagen, erfolgreich, laeuft")
|
||||
f.IntVar(&seite, "page", 1, "Seite (25 je Seite)")
|
||||
return befehl
|
||||
}
|
||||
|
||||
func (u *umgebung) cleanupBefehl() *cobra.Command {
|
||||
var ja, nichtWarten bool
|
||||
befehl := &cobra.Command{
|
||||
Use: "cleanup <job-id>",
|
||||
Short: "Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen",
|
||||
Long: "Stoppt und löscht die VM bzw. den Container, den ein fehlgeschlagener oder abgebrochener\n" +
|
||||
"Auftrag angelegt hat. Gelöscht wird nur, was laut Beschreibung zu diesem Auftrag gehört.",
|
||||
Args: eineID("cleanup"),
|
||||
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := c.Auftrag(cmd.Context(), argumente[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Aufraeumbar == nil {
|
||||
return bedienfehler("bei %s gibt es nichts aufzuräumen (Status %s)", a.KurzId, statusText[a.Status])
|
||||
}
|
||||
z := a.Aufraeumbar
|
||||
wort := map[api.AuftragAufraeumbarTyp]string{api.AuftragAufraeumbarTypVM: "VM", api.AuftragAufraeumbarTypLXC: "Container"}[z.Typ]
|
||||
ziel := fmt.Sprintf("%s %d", wort, z.Vmid)
|
||||
if z.Knoten != nil {
|
||||
ziel += " auf " + *z.Knoten
|
||||
}
|
||||
if !ja {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return bedienfehler("%s löschen? Ohne Terminal mit --yes bestätigen", ziel)
|
||||
}
|
||||
fmt.Fprintf(u.fehler, "%s stoppen und mit allen Disks löschen? [j/N] ", ziel)
|
||||
antwort, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if a := strings.ToLower(strings.TrimSpace(antwort)); a != "j" && a != "ja" && a != "y" && a != "yes" {
|
||||
fmt.Fprintln(u.fehler, u.stil().Muted("Nichts gelöscht."))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
angenommen, err := c.Aufraeumen(cmd.Context(), a.KurzId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.angenommen(cmd.Context(), c, angenommen, nichtWarten)
|
||||
},
|
||||
}
|
||||
befehl.Flags().BoolVarP(&ja, "yes", "y", false, "ohne Rückfrage")
|
||||
befehl.Flags().BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben")
|
||||
return befehl
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
|
||||
@@ -78,6 +79,8 @@ type fakeAPI struct {
|
||||
plan map[string]any
|
||||
status int
|
||||
antwort any
|
||||
verlauf map[string]any
|
||||
einzeln map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
||||
@@ -88,6 +91,11 @@ func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
||||
fmt.Fprint(w, `{"fehler":{"code":"nicht_angemeldet","nachricht":"Nicht angemeldet."}}`)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
if f.anfragen == nil {
|
||||
f.anfragen = map[string][]map[string]any{}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if r.Body != nil && r.Method == http.MethodPost {
|
||||
var rumpf map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&rumpf)
|
||||
@@ -112,6 +120,15 @@ func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
||||
schreiben(200, map[string]any{"benutzername": "florian", "anzeigename": "Florian", "rolle": "OPERATOR", "rechte": []string{}, "version": "0.4.0"})
|
||||
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodPost:
|
||||
schreiben(202, map[string]any{"id": "c1", "kurzId": "a-7f3k"})
|
||||
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodGet:
|
||||
f.mu.Lock()
|
||||
f.anfragen["GET "+r.URL.RawQuery] = nil
|
||||
f.mu.Unlock()
|
||||
schreiben(200, f.verlauf)
|
||||
case strings.HasSuffix(r.URL.Path, "/aufraeumen"):
|
||||
schreiben(202, map[string]any{"id": "c2", "kurzId": "c-9x9x9"})
|
||||
case r.URL.Path == "/api/v1/auftraege/d-kaputt":
|
||||
schreiben(200, f.einzeln)
|
||||
case r.URL.Path == "/api/v1/auftraege/plan":
|
||||
schreiben(200, f.plan)
|
||||
case strings.HasSuffix(r.URL.Path, "/ereignisse"):
|
||||
@@ -323,3 +340,48 @@ func TestLoginMitToken(t *testing.T) {
|
||||
t.Error("falsches Token hat die Konfiguration verändert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory(t *testing.T) {
|
||||
ok := auftrag("ERFOLGREICH", alle, nil)
|
||||
ok["erstelltAm"] = "2026-10-12T08:05:00.000Z"
|
||||
ct := auftrag("FEHLGESCHLAGEN", alle, nil)
|
||||
ct["kurzId"], ct["name"], ct["gastTyp"], ct["vmid"], ct["distributionId"] = "d-ct001", "dns01", "LXC", 121, "ubuntu-24.04"
|
||||
ct["erstelltAm"] = "2026-10-12T08:00:00.000Z"
|
||||
f := &fakeAPI{verlauf: map[string]any{"eintraege": []any{ok, ct}, "gesamt": 30, "seite": 1, "seiten": 2}}
|
||||
alt := time.Local
|
||||
time.Local = time.UTC
|
||||
t.Cleanup(func() { time.Local = alt })
|
||||
aus, fehler, code := lauf(t, f, "history", "--type", "lxc", "--status", "fehlgeschlagen")
|
||||
if code != 0 {
|
||||
t.Fatalf("Exit %d: %s", code, fehler)
|
||||
}
|
||||
golden(t, "history.golden", aus)
|
||||
if _, gesehen := f.anfragen["GET status=FEHLGESCHLAGEN&typ=lxc"]; !gesehen {
|
||||
t.Errorf("Filter nicht gesendet: %v", f.anfragen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanup(t *testing.T) {
|
||||
kaputt := auftrag("FEHLGESCHLAGEN", alle, nil)
|
||||
kaputt["kurzId"] = "d-kaputt"
|
||||
kaputt["aufraeumbar"] = map[string]any{"typ": "VM", "vmid": 120, "knoten": "pve01"}
|
||||
f := &fakeAPI{einzeln: kaputt}
|
||||
// Ohne Terminal keine Rückfrage möglich: ohne --yes wird nichts gelöscht.
|
||||
_, fehler, code := lauf(t, f, "cleanup", "d-kaputt")
|
||||
if code != ExitBedienung || !strings.Contains(fehler, "VM 120 auf pve01 löschen?") {
|
||||
t.Errorf("ohne --yes: Exit %d, %s", code, fehler)
|
||||
}
|
||||
if len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 0 {
|
||||
t.Error("ohne Bestätigung aufgeräumt")
|
||||
}
|
||||
aus, _, code := lauf(t, f, "cleanup", "d-kaputt", "--yes", "--no-wait")
|
||||
if code != 0 || strings.TrimSpace(aus) != "c-9x9x9" || len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 1 {
|
||||
t.Errorf("mit --yes: Exit %d, %q", code, aus)
|
||||
}
|
||||
|
||||
kaputt["aufraeumbar"] = nil
|
||||
_, fehler, code = lauf(t, f, "cleanup", "d-kaputt", "--yes")
|
||||
if code != ExitBedienung || !strings.Contains(fehler, "nichts aufzuräumen") {
|
||||
t.Errorf("nicht aufräumbar: Exit %d, %s", code, fehler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
AUFTRAG NAME TYP SERVER DISTRIBUTION ID STATUS ZEIT
|
||||
a-7f3k web01 VM rz1 debian-13 120 ✓ erfolgreich 12.10. 08:05
|
||||
d-ct001 dns01 LXC rz1 ubuntu-24.04 121 ✗ fehlgeschlagen 12.10. 08:00
|
||||
Seite 1 von 2 · 30 Aufträge · weiter mit --page 2
|
||||
@@ -140,6 +140,8 @@ func (u *umgebung) wurzel() *cobra.Command {
|
||||
u.statusBefehl(),
|
||||
u.logsBefehl(),
|
||||
u.cancelBefehl(),
|
||||
u.historyBefehl(),
|
||||
u.cleanupBefehl(),
|
||||
)
|
||||
hilfeEindeutschen(wurzel)
|
||||
return wurzel
|
||||
|
||||
Reference in New Issue
Block a user