package main import ( "fmt" "html/template" "log" "net/http" "strconv" "strings" "time" ) type dashboardData struct { Presence RichPresenceConfig PresenceStart string PresenceEnd string PresenceNotice string Now time.Time Month int Year int Orders []Order ArchivedOrders []Order Inventory []InventoryItem TradingSummary *TradingSummary TradingUsers []TradingTraderSummary Token string TradingPayee string Error string } func StartDashboard(cfg Config, store *Store, bot *Bot) *http.Server { if strings.TrimSpace(cfg.DashboardAddr) == "" { return nil } mux := http.NewServeMux() h := &dashboardHandler{cfg: cfg, store: store, bot: bot} mux.HandleFunc("/", h.index) mux.HandleFunc("/presence", h.presence) mux.HandleFunc("/presence/invite", h.presenceInvite) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }) srv := &http.Server{Addr: cfg.DashboardAddr, Handler: mux} go func() { log.Println("Web-Dashboard läuft auf", cfg.DashboardAddr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Println("dashboard failed:", err) } }() return srv } type dashboardHandler struct { cfg Config store *Store bot *Bot } func (h *dashboardHandler) authorized(r *http.Request) bool { if strings.TrimSpace(h.cfg.DashboardToken) == "" { return true } token := r.URL.Query().Get("token") if token == "" { auth := r.Header.Get("Authorization") token = strings.TrimPrefix(auth, "Bearer ") } return token == h.cfg.DashboardToken } func (h *dashboardHandler) index(w http.ResponseWriter, r *http.Request) { if !h.authorized(r) { w.Header().Set("WWW-Authenticate", "Bearer") http.Error(w, "unauthorized", http.StatusUnauthorized) return } now := time.Now() month := intFromQuery(r, "monat", int(now.Month())) year := intFromQuery(r, "jahr", now.Year()) data := dashboardData{Now: now, Month: month, Year: year, Token: r.URL.Query().Get("token"), TradingPayee: h.cfg.TradingPayoutRecipient} if rp, err := h.store.GetRichPresenceConfig(); err == nil { data.Presence = rp data.PresenceStart = datetimeLocalFromUnixMillis(rp.StartTimestamp) data.PresenceEnd = datetimeLocalFromUnixMillis(rp.EndTimestamp) } var errs []string if orders, err := h.store.ListOrders("", 25); err == nil { data.Orders = orders } else { errs = append(errs, "Aufträge: "+err.Error()) } if archived, err := h.store.ListArchivedOrders("", 10); err == nil { data.ArchivedOrders = archived } else { errs = append(errs, "Archiv: "+err.Error()) } if inv, err := h.store.ListInventory("", 100); err == nil { data.Inventory = inv } else { errs = append(errs, "Lager: "+err.Error()) } if sum, err := h.store.SummarizeTradingRuns(month, year); err == nil { data.TradingSummary = sum } else { errs = append(errs, "Trading: "+err.Error()) } if users, err := h.store.SummarizeTradingByUser(month, year); err == nil { data.TradingUsers = users } else { errs = append(errs, "Abrechnung: "+err.Error()) } data.Error = strings.Join(errs, " | ") w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := dashboardTemplate.Execute(w, data); err != nil { log.Println("dashboard render failed:", err) } } func (h *dashboardHandler) presence(w http.ResponseWriter, r *http.Request) { if !h.authorized(r) { w.Header().Set("WWW-Authenticate", "Bearer") http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodPost { http.Redirect(w, r, dashboardURL("/", r), http.StatusSeeOther) return } if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } cfg := RichPresenceConfig{ Enabled: r.FormValue("enabled") == "on", Status: strings.TrimSpace(r.FormValue("status")), ActivityType: strings.TrimSpace(r.FormValue("activity_type")), Name: strings.TrimSpace(r.FormValue("name")), State: strings.TrimSpace(r.FormValue("state")), Details: strings.TrimSpace(r.FormValue("details")), StartTimestamp: unixMillisFromDatetimeLocal(r.FormValue("start")), EndTimestamp: unixMillisFromDatetimeLocal(r.FormValue("end")), LargeImage: strings.TrimSpace(r.FormValue("large_image")), LargeText: strings.TrimSpace(r.FormValue("large_text")), SmallImage: strings.TrimSpace(r.FormValue("small_image")), SmallText: strings.TrimSpace(r.FormValue("small_text")), InviteChannelID: strings.TrimSpace(r.FormValue("invite_channel_id")), InvitePostChannelID: strings.TrimSpace(r.FormValue("invite_post_channel_id")), InviteButtonLabel: strings.TrimSpace(r.FormValue("invite_button_label")), InvitePostTitle: strings.TrimSpace(r.FormValue("invite_post_title")), InvitePostText: strings.TrimSpace(r.FormValue("invite_post_text")), } old, _ := h.store.GetRichPresenceConfig() cfg.LastInviteChannelID = old.LastInviteChannelID cfg.LastInviteMessageID = old.LastInviteMessageID if cfg.Status == "" { cfg.Status = "online" } if cfg.ActivityType == "" { cfg.ActivityType = "watching" } if cfg.InviteButtonLabel == "" { cfg.InviteButtonLabel = "Channel beitreten" } if cfg.InvitePostTitle == "" { cfg.InvitePostTitle = "Gemeinsame Session" } if err := h.store.SaveRichPresenceConfig(cfg); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if h.bot != nil { if err := h.bot.SetRichPresence(cfg); err != nil { http.Error(w, "Gespeichert, aber Presence konnte nicht gesetzt werden: "+err.Error(), http.StatusInternalServerError) return } } http.Redirect(w, r, dashboardURL("/?presence=saved", r), http.StatusSeeOther) } func (h *dashboardHandler) presenceInvite(w http.ResponseWriter, r *http.Request) { if !h.authorized(r) { w.Header().Set("WWW-Authenticate", "Bearer") http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodPost { http.Redirect(w, r, dashboardURL("/", r), http.StatusSeeOther) return } cfg, err := h.store.GetRichPresenceConfig() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if h.bot == nil { http.Error(w, "Bot-Session nicht verfügbar", http.StatusInternalServerError) return } if _, err := h.bot.SendRichPresenceInvite(cfg); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, dashboardURL("/?presence=invite", r), http.StatusSeeOther) } func dashboardURL(path string, r *http.Request) string { token := r.URL.Query().Get("token") if token == "" { return path } sep := "?" if strings.Contains(path, "?") { sep = "&" } return path + sep + "token=" + token } func intFromQuery(r *http.Request, key string, fallback int) int { v := strings.TrimSpace(r.URL.Query().Get(key)) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n } var dashboardTemplate = template.Must(template.New("dashboard").Funcs(template.FuncMap{ "status": statusLabel, "purpose": tradingPurposeLabel, "amount": formatAmount, "auec": formatAUEC, "profit": tradingProfit, "org": tradingOrgShare, "totalSCU": func(r TradingRun) float64 { return r.SCU * float64(r.Repetitions) }, "location": func(v string) string { if strings.TrimSpace(v) == "" { return "-" } return v }, "monthName": func(m int) string { return time.Month(m).String() }, "formatPercent": func(v float64) string { return fmt.Sprintf("%.1f%%", v*100) }, }).Parse(` SF Trading Dashboard

SF Trading Dashboard

Stand: {{.Now.Format "02.01.2006 15:04"}}
{{if .Error}}
Hinweis: {{.Error}}
{{end}}
{{if .Token}}{{end}} Filter betrifft Trading-Auswertung und Abrechnung.

Rich Presence & Session-Einladung

Admin-Steuerung über das Webinterface. Hinweis: Discord erlaubt Bot-Activities offiziell nur eingeschränkt; der zuverlässige Einladungsbutton wird deshalb zusätzlich als Discord-Post mit Link-Button erzeugt.

{{if .Presence.LastInviteMessageID}}Letzter Post: #{{.Presence.LastInviteMessageID}}{{end}}
{{with .TradingSummary}}
Trading-Einträge
{{.Count}}
Gewinn gesamt
{{auec .GrossProfit}}
Orga-Anteil
{{auec .OrgShare}}
Transportierte SCU
{{amount .TotalSCU}}
Kosten gesamt
{{auec .TotalCosts}}
{{end}}

Abrechnung {{printf "%02d/%d" .Month .Year}}

Empfänger: {{.TradingPayee}}
{{range .TradingUsers}}{{else}}{{end}}
NutzerRunsGewinnSCUOrga-Anteil
{{.UserName}}{{.Runs}}{{auec .Profit}}{{amount .SCU}}{{auec .OrgShare}}
Keine Daten

Aktive Aufträge

{{range .Orders}}{{else}}{{end}}
IDWareMengeFristStatusEinreicher
#{{.ID}}{{.Commodity}}{{amount .QuantityAmount}} {{.QuantityUnit}}{{.Deadline}}{{status .Status}}{{.SubmitterName}}
Keine aktiven Aufträge

Lager

{{range .Inventory}}{{else}}{{end}}
IDWareQualitätMengeOrtNotiz
#{{.ID}}{{.Name}}{{.Quality}}{{amount .QuantityAmount}} {{.QuantityUnit}}{{location .Location}}{{.Note}}
Kein Lagerbestand

Archivierte Aufträge

{{range .ArchivedOrders}}{{else}}{{end}}
IDWareStatusAktualisiert
#{{.ID}}{{.Commodity}}{{status .Status}}{{.UpdatedAt.Format "02.01.2006 15:04"}}
Keine archivierten Aufträge
`))