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
{{if .Error}}{{end}}
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.
Session-Invite posten {{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}}
`))