517 lines
17 KiB
Go
517 lines
17 KiB
Go
package cli
|
||
|
||
import (
|
||
"bytes"
|
||
"cmp"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"text/tabwriter"
|
||
"time"
|
||
|
||
"sultan/pkg/accountant"
|
||
"sultan/pkg/crypto"
|
||
"sultan/pkg/ledger"
|
||
)
|
||
|
||
const accountantUsage = `sultan accountant – Zentrale für Inventar und Abrechnung (Spec 5.3).
|
||
Sultan-Server (Payees) melden ihr Inventar per signiertem Bericht.
|
||
|
||
sultan accountant [--addr :8444] [--public-url https://…] [--metrics-addr 127.0.0.1:8082] [--data-dir ./accdata]
|
||
Accountant starten (nur TLS; Adresse wird gemerkt und steckt in Einladungen;
|
||
Checkmk: curl http://127.0.0.1:8082/metrics/checkmk)
|
||
sultan accountant invite --payee ID [--ttl 24h|7d] [--server-url https://…]
|
||
Einladung für einen Sultan-Server (Payee) erzeugen
|
||
sultan accountant where <host|mandant> Störfall: wo läuft eine URL (Backend, Container,
|
||
Compose-Verzeichnis), seit wann, wo vorher? Teiltreffer genügen.
|
||
sultan accountant routes [--tenant ID] [--payee ID] alle aktuellen Routen
|
||
sultan accountant customer add --id ID --name NAME [--group G] [--note TEXT] [--cycle m|q|j|offen] [--flat-rate 89,00]
|
||
sultan accountant customer set --id ID [dieselben Felder] [--no-flat-rate] (nur angegebene Felder ändern)
|
||
sultan accountant customer list [--group G] [--unclassified]
|
||
sultan accountant customer map --tenant T --customer ID [--payee P] Mandant (z. B. Tippfehler) zuordnen
|
||
sultan accountant customer unmap --tenant T [--payee P]
|
||
sultan accountant customer delete --id ID
|
||
Kunden entstehen automatisch aus den Mandanten-IDs der Berichte.
|
||
sultan accountant product add --id ID --name NAME --class included|billable|free|internal [--price 49,00]
|
||
sultan accountant product set --id ID --from JJJJ-MM [--class …] [--price … | --no-price] (gilt ab Monat)
|
||
sultan accountant product price --id ID --customer ID --price 39,00 [--from JJJJ-MM] Kundenpreis
|
||
sultan accountant product list | product delete --id ID
|
||
Preise sind optional und nicht buchhaltungsrelevant (Lexware führt).
|
||
sultan accountant assign (--host H | --tenant T --router R [--protocol tcp]) [--product P] [--class C] [--start JJJJ-MM] [--clear]
|
||
Posten zuordnen – Vorrang vor dem Label sultan.accountant.billing
|
||
sultan accountant assignments
|
||
sultan accountant billing --month JJJJ-MM [--out datei.csv] Monatsabrechnung als CSV (Semikolon, Dezimalkomma)
|
||
sultan accountant payee list
|
||
sultan accountant payee report --id ID (letzten Bericht als JSON ausgeben)
|
||
sultan accountant payee delete --id ID (z. B. vor neuer Einladung nach Schlüsselverlust)
|
||
sultan accountant dump-checkmk [--force] Checkmk-Vorlage nach <data-dir>/checkmk exportieren
|
||
(anpassen – wirkt sofort, ohne Neustart)
|
||
|
||
Verwaltungsbefehle arbeiten direkt auf dem Datenverzeichnis (auch während er läuft).
|
||
Alle Befehle: --data-dir DIR (Default ./accdata)
|
||
`
|
||
|
||
var accountantCmds = map[string]tokenCmd{
|
||
"invite": accountantInvite,
|
||
"payee list": payeeList,
|
||
"payee report": payeeReport,
|
||
"payee delete": payeeDelete,
|
||
"dump-checkmk": dumpCheckmk,
|
||
"where": accountantWhere,
|
||
"routes": accountantRoutes,
|
||
"customer add": customerAdd,
|
||
"customer set": customerSet,
|
||
"customer list": customerList,
|
||
"customer delete": customerDelete,
|
||
"customer map": customerMap,
|
||
"customer unmap": customerUnmap,
|
||
"product add": productAdd,
|
||
"product set": productSet,
|
||
"product price": productPrice,
|
||
"product list": productList,
|
||
"product delete": productDelete,
|
||
"assign": assign,
|
||
"assignments": assignments,
|
||
"billing": billing,
|
||
}
|
||
|
||
func runAccountant(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||
// Ohne Unterbefehl (bzw. nur Flags): Accountant starten.
|
||
if len(args) == 0 || len(args[0]) > 0 && args[0][0] == '-' {
|
||
return serveAccountant(ctx, args, stderr)
|
||
}
|
||
if args[0] == "help" {
|
||
fmt.Fprint(stdout, accountantUsage)
|
||
return 0
|
||
}
|
||
name, rest := args[0], args[1:]
|
||
if (name == "payee" || name == "customer" || name == "product") && len(rest) > 0 {
|
||
name, rest = name+" "+rest[0], rest[1:]
|
||
}
|
||
cmd, ok := accountantCmds[name]
|
||
if !ok {
|
||
fmt.Fprintf(stderr, "unbekannter Befehl \"accountant %s\"\n\n%s", name, accountantUsage)
|
||
return 2
|
||
}
|
||
err := cmd(tokenEnv{ctx: ctx, stdout: stdout, stderr: stderr}, rest)
|
||
var ue usageError
|
||
switch {
|
||
case err == nil, errors.Is(err, flag.ErrHelp):
|
||
return 0
|
||
case errors.As(err, &ue):
|
||
fmt.Fprintln(stderr, err)
|
||
return 2
|
||
default:
|
||
fmt.Fprintln(stderr, "Fehler:", err)
|
||
return 1
|
||
}
|
||
}
|
||
|
||
func serveAccountant(ctx context.Context, args []string, stderr io.Writer) int {
|
||
cfg, err := parseAccountantFlags(args, stderr, os.Getenv)
|
||
if errors.Is(err, flag.ErrHelp) {
|
||
return 0
|
||
}
|
||
if err != nil {
|
||
fmt.Fprintln(stderr, err)
|
||
return 2
|
||
}
|
||
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
|
||
a, err := accountant.New(ctx, cfg)
|
||
if err != nil {
|
||
fmt.Fprintln(stderr, "Start fehlgeschlagen:", err)
|
||
return 1
|
||
}
|
||
if err := a.Run(ctx); err != nil {
|
||
fmt.Fprintln(stderr, "Accountant beendet mit Fehler:", err)
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// parseAccountantFlags liest die Flags von `sultan accountant`; jedes Flag ist
|
||
// auch als SULTAN_<FLAG> setzbar (Flags gewinnen).
|
||
func parseAccountantFlags(args []string, output io.Writer, getenv func(string) string) (accountant.Config, error) {
|
||
fs := flag.NewFlagSet("sultan accountant", flag.ContinueOnError)
|
||
fs.SetOutput(output)
|
||
var cfg accountant.Config
|
||
fs.StringVar(&cfg.DataDir, "data-dir", "./accdata", "Datenverzeichnis (accountant.db, server.key/.crt)")
|
||
fs.StringVar(&cfg.Addr, "addr", ":8444", "Adresse für Payees (nur TLS)")
|
||
fs.Var((*stringList)(&cfg.PublicURLs), "public-url", "Adresse, unter der Payees den Accountant erreichen (z. B. https://accountant.example.de:8444); mehrfach angebbar; wird gemerkt, die erste steckt in Einladungen")
|
||
fs.StringVar(&cfg.MetricsAddr, "metrics-addr", "127.0.0.1:8082", `Checkmk-Ausgabe /metrics/checkmk (Klartext, nur Loopback; "none" = aus)`)
|
||
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
|
||
fs.DurationVar(&cfg.StaleWarn, "stale-warn", 2*time.Hour, "Checkmk WARN, wenn ein Payee so lange nicht berichtet hat")
|
||
fs.DurationVar(&cfg.StaleCrit, "stale-crit", 6*time.Hour, "Checkmk CRIT, wenn ein Payee so lange nicht berichtet hat")
|
||
annotateEnv(fs)
|
||
if err := fs.Parse(args); err != nil {
|
||
return accountant.Config{}, err
|
||
}
|
||
if err := applyEnv(fs, getenv); err != nil {
|
||
return accountant.Config{}, err
|
||
}
|
||
if fs.NArg() > 0 {
|
||
return accountant.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
func accountantFlags(name string, env tokenEnv) (*flag.FlagSet, *string) {
|
||
fs := flag.NewFlagSet("sultan accountant "+name, flag.ContinueOnError)
|
||
fs.SetOutput(env.stderr)
|
||
dir := fs.String("data-dir", "./accdata", "Datenverzeichnis des Accountants")
|
||
return fs, dir
|
||
}
|
||
|
||
// openLedger öffnet die DB eines bereits initialisierten Datenverzeichnisses
|
||
// (nie neu anlegen: ein Tippfehler würde sonst eine neue Identität erzeugen).
|
||
func openLedger(env tokenEnv, dir string) (*ledger.Ledger, error) {
|
||
for _, f := range []string{"accountant.db", "server.crt"} {
|
||
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||
return nil, fmt.Errorf("Datenverzeichnis %s nicht initialisiert (%s fehlt) – zuerst `sultan accountant` starten oder --data-dir prüfen", dir, f)
|
||
}
|
||
}
|
||
return ledger.Open(env.ctx, filepath.Join(dir, "accountant.db"))
|
||
}
|
||
|
||
func accountantInvite(env tokenEnv, args []string) error {
|
||
fs, dir := accountantFlags("invite", env)
|
||
payee := fs.String("payee", "", "ID des Sultan-Servers (a–z, 0–9, - und _), z. B. webproxy11")
|
||
ttlRaw := fs.String("ttl", "24h", "Gültigkeit der Einladung (z. B. 2h, 24h, 7d)")
|
||
serverURL := fs.String("server-url", "", "Accountant-Adresse für diese Einladung (Default: --public-url)")
|
||
if err := parse(fs, args, "payee"); err != nil {
|
||
return err
|
||
}
|
||
ttl, err := parseTTL(*ttlRaw)
|
||
if err != nil {
|
||
return usageError{err}
|
||
}
|
||
if *serverURL != "" {
|
||
u, err := crypto.ValidateServerURL(*serverURL)
|
||
if err != nil {
|
||
return usageError{err}
|
||
}
|
||
*serverURL = u
|
||
}
|
||
l, err := openLedger(env, *dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer l.Close()
|
||
if *serverURL == "" {
|
||
urls, err := accountant.LoadPublicURLs(env.ctx, l)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(urls) > 0 {
|
||
*serverURL = urls[0]
|
||
}
|
||
}
|
||
cert, err := crypto.LoadOrCreateServerCert(*dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tok, err := crypto.NewEnrollmentToken(cert.Fingerprint, *serverURL)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
expires := time.Now().Add(ttl)
|
||
if err := l.AddInvite(env.ctx, tok.Secret, *payee, expires); err != nil {
|
||
return err
|
||
}
|
||
fmt.Fprintln(env.stdout, tok.String())
|
||
fmt.Fprintf(env.stderr, "Einladung für Payee %s, einmalig gültig bis %s.\n", *payee, expires.Format(time.DateTime))
|
||
if tok.ServerURL != "" {
|
||
fmt.Fprintf(env.stderr, "Accountant-Adresse %s ist enthalten. Auf dem Sultan-Server:\n SULTAN_ACCOUNTANT_TOKEN=<token> sultan server …\n", tok.ServerURL)
|
||
} else {
|
||
fmt.Fprint(env.stderr, "Auf dem Sultan-Server: SULTAN_ACCOUNTANT_TOKEN=<token> sultan server --accountant=https://<accountant>:8444 …\n"+
|
||
"Tipp: Mit `sultan accountant --public-url=https://…` (oder invite --server-url) steckt die Adresse in der Einladung.\n")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func payeeList(env tokenEnv, args []string) error {
|
||
fs, dir := accountantFlags("payee list", env)
|
||
if err := parse(fs, args); err != nil {
|
||
return err
|
||
}
|
||
l, err := openLedger(env, *dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer l.Close()
|
||
payees, err := l.ListPayees(env.ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||
fmt.Fprintln(tw, "ID\tHOSTNAME\tIP\tENROLLED\tLAST_REPORT")
|
||
for _, p := range payees {
|
||
last := "nie"
|
||
if !p.LastReport.IsZero() {
|
||
last = p.LastReport.Local().Format(time.DateTime)
|
||
}
|
||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Hostname, p.IPAddress, p.EnrolledAt.Local().Format(time.DateTime), last)
|
||
}
|
||
return tw.Flush()
|
||
}
|
||
|
||
func payeeReport(env tokenEnv, args []string) error {
|
||
fs, dir := accountantFlags("payee report", env)
|
||
id := fs.String("id", "", "Payee-ID")
|
||
if err := parse(fs, args, "id"); err != nil {
|
||
return err
|
||
}
|
||
l, err := openLedger(env, *dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer l.Close()
|
||
body, at, err := l.LatestReport(env.ctx, *id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := json.Indent(&buf, body, "", " "); err != nil {
|
||
return err
|
||
}
|
||
fmt.Fprintln(env.stdout, buf.String())
|
||
fmt.Fprintf(env.stderr, "Bericht von %s, empfangen %s\n", *id, at.Local().Format(time.DateTime))
|
||
return nil
|
||
}
|
||
|
||
func payeeDelete(env tokenEnv, args []string) error {
|
||
fs, dir := accountantFlags("payee delete", env)
|
||
id := fs.String("id", "", "Payee-ID")
|
||
if err := parse(fs, args, "id"); err != nil {
|
||
return err
|
||
}
|
||
l, err := openLedger(env, *dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer l.Close()
|
||
if err := l.DeletePayee(env.ctx, *id); err != nil {
|
||
return err
|
||
}
|
||
fmt.Fprintf(env.stdout, "Payee %s gelöscht\n", *id)
|
||
return nil
|
||
}
|
||
|
||
func dumpCheckmk(env tokenEnv, args []string) error {
|
||
fs, dir := accountantFlags("dump-checkmk", env)
|
||
force := fs.Bool("force", false, "vorhandene Vorlage überschreiben")
|
||
if err := parse(fs, args); err != nil {
|
||
return err
|
||
}
|
||
l, err := openLedger(env, *dir) // nur prüfen, ob das Verzeichnis initialisiert ist
|
||
if err != nil {
|
||
return err
|
||
}
|
||
l.Close()
|
||
path, err := accountant.DumpCheckmk(*dir, *force)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fmt.Fprintln(env.stdout, path)
|
||
fmt.Fprintln(env.stderr, "Vorlage geschrieben – Änderungen wirken sofort, ohne Neustart. Fehlerhafte Vorlage: eingebaute greift, Checkmk meldet CRIT.")
|
||
return nil
|
||
}
|
||
|
||
const shortTime = "2006-01-02 15:04"
|
||
|
||
func localTime(t time.Time) string {
|
||
if t.IsZero() {
|
||
return "?"
|
||
}
|
||
return t.Local().Format(shortTime)
|
||
}
|
||
|
||
// splitQuery trennt den Suchbegriff (erstes Argument ohne -) von den Flags;
|
||
// ein Flag ohne "=" nimmt das folgende Argument als Wert.
|
||
func splitQuery(args []string) (query string, flags []string) {
|
||
for i := 0; i < len(args); i++ {
|
||
a := args[i]
|
||
switch {
|
||
case strings.HasPrefix(a, "-"):
|
||
flags = append(flags, a)
|
||
if !strings.Contains(a, "=") && a != "-h" && a != "--help" && i+1 < len(args) {
|
||
i++
|
||
flags = append(flags, args[i])
|
||
}
|
||
case query == "":
|
||
query = a
|
||
default:
|
||
flags = append(flags, a) // parse meldet das überzählige Argument
|
||
}
|
||
}
|
||
return query, flags
|
||
}
|
||
|
||
// routeProblems fasst den Zustand einer Route knapp zusammen ("ok" ohne Befund).
|
||
func routeProblems(r accountant.ReportRoute, n accountant.ReportNode) string {
|
||
var p []string
|
||
if !r.Active {
|
||
p = append(p, "verworfen")
|
||
}
|
||
if r.Down {
|
||
p = append(p, "gestoppt")
|
||
}
|
||
if r.Health == "unhealthy" {
|
||
p = append(p, "unhealthy")
|
||
}
|
||
switch {
|
||
case n.Maintenance:
|
||
p = append(p, "Wartung")
|
||
case !n.Connected:
|
||
p = append(p, "getrennt")
|
||
}
|
||
if len(p) == 0 {
|
||
return "ok"
|
||
}
|
||
return strings.Join(p, ",")
|
||
}
|
||
|
||
func accountantWhere(env tokenEnv, args []string) error {
|
||
query, rest := splitQuery(args)
|
||
fs, dir := accountantFlags("where", env)
|
||
if err := parse(fs, rest); err != nil {
|
||
return err
|
||
}
|
||
if query == "" {
|
||
return usageError{errors.New("Suchbegriff fehlt: sultan accountant where <host|mandant>")}
|
||
}
|
||
l, err := openLedger(env, *dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer l.Close()
|
||
res, err := accountant.Where(env.ctx, l, query)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(res.Current) == 0 && len(res.Gone) == 0 {
|
||
return fmt.Errorf("nichts gefunden für %q", query)
|
||
}
|
||
out := env.stdout
|
||
for i, c := range res.Current {
|
||
if i > 0 {
|
||
fmt.Fprintln(out)
|
||
}
|
||
r, n := c.Route, c.Node
|
||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||
fmt.Fprintf(tw, "URL\t%s (Router %s, %s)\n", strings.Join(r.Hosts, ", "), r.Router, r.Protocol)
|
||
tenant := r.Tenant
|
||
if c.TenantName != "" {
|
||
tenant += " (" + c.TenantName + ")"
|
||
}
|
||
fmt.Fprintf(tw, "Mandant\t%s\n", tenant)
|
||
if c.Customer != nil {
|
||
cust := c.Customer.ID + " " + c.Customer.Name + " (" + cycleLabel(c.Customer.Cycle)
|
||
if c.Customer.Group != "" {
|
||
cust += ", Gruppe " + c.Customer.Group
|
||
}
|
||
fmt.Fprintf(tw, "Kunde\t%s)\n", cust)
|
||
} else {
|
||
fmt.Fprintf(tw, "Kunde\tnicht zugeordnet\n")
|
||
}
|
||
fmt.Fprintf(tw, "Ingress\t%s (Bericht %s)\n", c.Payee, localTime(c.ReportAt))
|
||
backend := r.Backend
|
||
if n.NetBirdIP != "" {
|
||
backend += ", NetBird " + n.NetBirdIP
|
||
}
|
||
fmt.Fprintf(tw, "Backend\t%s (%s)\n", cmp.Or(r.BackendFQDN, r.Backend), backend)
|
||
container := r.Workload
|
||
if r.ComposeDir != "" {
|
||
container += ", compose " + r.ComposeDir
|
||
if r.ComposeProject != "" {
|
||
container += " (Projekt " + r.ComposeProject + ")"
|
||
}
|
||
}
|
||
fmt.Fprintf(tw, "Container\t%s\n", container)
|
||
state := []string{routeProblems(r, n)}
|
||
if r.State != "" {
|
||
s := r.State
|
||
if !r.StartedAt.IsZero() {
|
||
s += " seit " + localTime(r.StartedAt)
|
||
}
|
||
state = append(state, s)
|
||
}
|
||
if r.Health != "" {
|
||
state = append(state, r.Health)
|
||
}
|
||
if r.Product != "" {
|
||
state = append(state, "Produkt "+r.Product)
|
||
}
|
||
fmt.Fprintf(tw, "Zustand\t%s\n", strings.Join(state, ", "))
|
||
node := r.Node
|
||
if n.Connected {
|
||
node += " verbunden"
|
||
} else {
|
||
node += " getrennt"
|
||
}
|
||
for _, v := range []string{n.OS, prefixed("Docker ", n.DockerVersion)} {
|
||
if v != "" {
|
||
node += ", " + v
|
||
}
|
||
}
|
||
fmt.Fprintf(tw, "Knoten\t%s\n", node)
|
||
fmt.Fprintf(tw, "Seit\t%s\n", localTime(c.Since))
|
||
for _, p := range c.Previous {
|
||
fmt.Fprintf(tw, "Vorher\t%s (%s) %s – %s\n", p.Node, p.Payee, localTime(p.FirstSeen), localTime(p.Ended))
|
||
}
|
||
tw.Flush()
|
||
}
|
||
if len(res.Gone) > 0 {
|
||
if len(res.Current) > 0 {
|
||
fmt.Fprintln(out)
|
||
}
|
||
fmt.Fprintln(out, "Nicht mehr vorhanden:")
|
||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||
for _, g := range res.Gone {
|
||
fmt.Fprintf(tw, " %s\t%s\t%s (%s)\t%s – %s\n", strings.Join(g.Hosts, ", "), g.Tenant, g.Node, g.Payee,
|
||
localTime(g.FirstSeen), localTime(g.Ended))
|
||
}
|
||
tw.Flush()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func prefixed(prefix, v string) string {
|
||
if v == "" {
|
||
return ""
|
||
}
|
||
return prefix + v
|
||
}
|
||
|
||
func accountantRoutes(env tokenEnv, args []string) error {
|
||
fs, dir := accountantFlags("routes", env)
|
||
tenant := fs.String("tenant", "", "nur Routen dieses Mandanten")
|
||
payee := fs.String("payee", "", "nur Routen dieses Payees")
|
||
if err := parse(fs, args); err != nil {
|
||
return err
|
||
}
|
||
l, err := openLedger(env, *dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer l.Close()
|
||
routes, err := accountant.Routes(env.ctx, l, accountant.RoutesFilter{Tenant: *tenant, Payee: *payee})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
|
||
fmt.Fprintln(tw, "HOST\tTENANT\tPAYEE\tNODE\tBACKEND\tSTATE\tSINCE")
|
||
for _, c := range routes {
|
||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", strings.Join(c.Route.Hosts, ","), c.Route.Tenant, c.Payee,
|
||
c.Route.Node, cmp.Or(c.Route.BackendFQDN, c.Route.Backend), routeProblems(c.Route, c.Node), localTime(c.Since))
|
||
}
|
||
return tw.Flush()
|
||
}
|