Files
sftrading/web_dashboard.go
jbergner 25b42c8335
All checks were successful
release-tag / release-image (push) Successful in 2m8s
Update mit Presence-Einstellungen
2026-05-31 08:34:07 +02:00

309 lines
16 KiB
Go

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(`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SF Trading Dashboard</title>
<style>
:root{--bg:#0f172a;--panel:#111827;--card:#1f2937;--text:#e5e7eb;--muted:#94a3b8;--accent:#38bdf8;--green:#22c55e;--red:#ef4444;--yellow:#f59e0b}*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--text)}header{padding:24px 28px;border-bottom:1px solid #334155;background:#020617;position:sticky;top:0;z-index:1}h1{margin:0;font-size:24px}main{padding:24px;display:grid;gap:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.card{background:var(--card);border:1px solid #334155;border-radius:16px;padding:16px;box-shadow:0 10px 24px rgba(0,0,0,.22)}.metric{font-size:28px;font-weight:700}.muted{color:var(--muted)}table{width:100%;border-collapse:collapse;font-size:14px}th,td{padding:9px 8px;border-bottom:1px solid #334155;text-align:left;vertical-align:top}th{color:#cbd5e1;background:#111827}.scroll{overflow:auto}.badge{display:inline-block;border-radius:999px;padding:2px 8px;background:#334155;color:#dbeafe;font-size:12px}.err{background:#7f1d1d;border-color:#fecaca}.filters{display:flex;gap:8px;align-items:end;flex-wrap:wrap}.filters input{background:#020617;color:var(--text);border:1px solid #475569;border-radius:10px;padding:8px}.filters button,.btn{background:var(--accent);border:0;border-radius:10px;padding:9px 14px;font-weight:700;color:#00111c;text-decoration:none;cursor:pointer}.formgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}.formgrid input,.formgrid select,.formgrid textarea{width:100%;background:#020617;color:var(--text);border:1px solid #475569;border-radius:10px;padding:8px}.formgrid textarea{min-height:76px}.actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}.hint{font-size:13px;color:var(--muted);line-height:1.4}a{color:#7dd3fc}</style>
</head>
<body>
<header><h1>SF Trading Dashboard</h1><div class="muted">Stand: {{.Now.Format "02.01.2006 15:04"}}</div></header>
<main>
{{if .Error}}<section class="card err"><strong>Hinweis:</strong> {{.Error}}</section>{{end}}
<section class="card">
<form class="filters" method="get">
{{if .Token}}<input type="hidden" name="token" value="{{.Token}}">{{end}}
<label>Monat<br><input name="monat" type="number" min="1" max="12" value="{{.Month}}"></label>
<label>Jahr<br><input name="jahr" type="number" min="2000" max="2200" value="{{.Year}}"></label>
<button type="submit">Aktualisieren</button>
<span class="muted">Filter betrifft Trading-Auswertung und Abrechnung.</span>
</form>
</section>
<section class="card"><h2>Rich Presence & Session-Einladung</h2>
<p class="hint">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.</p>
<form method="post" action="/presence{{if .Token}}?token={{.Token}}{{end}}">
<div class="formgrid">
<label><input type="checkbox" name="enabled" {{if .Presence.Enabled}}checked{{end}}> Presence aktiv</label>
<label>Status<br><select name="status"><option value="online" {{if eq .Presence.Status "online"}}selected{{end}}>online</option><option value="idle" {{if eq .Presence.Status "idle"}}selected{{end}}>idle</option><option value="dnd" {{if eq .Presence.Status "dnd"}}selected{{end}}>nicht stören</option><option value="invisible" {{if eq .Presence.Status "invisible"}}selected{{end}}>unsichtbar</option></select></label>
<label>Activity-Typ<br><select name="activity_type"><option value="playing" {{if eq .Presence.ActivityType "playing"}}selected{{end}}>Spielt</option><option value="watching" {{if eq .Presence.ActivityType "watching"}}selected{{end}}>Schaut</option><option value="listening" {{if eq .Presence.ActivityType "listening"}}selected{{end}}>Hört</option><option value="competing" {{if eq .Presence.ActivityType "competing"}}selected{{end}}>Tritt an</option><option value="custom" {{if eq .Presence.ActivityType "custom"}}selected{{end}}>Custom</option></select></label>
<label>Name<br><input name="name" value="{{.Presence.Name}}" placeholder="Star Citizen"></label>
<label>State<br><input name="state" value="{{.Presence.State}}" placeholder="Mining Session"></label>
<label>Details<br><input name="details" value="{{.Presence.Details}}" placeholder="Join im Voice"></label>
<label>Start<br><input type="datetime-local" name="start" value="{{.PresenceStart}}"></label>
<label>Ende<br><input type="datetime-local" name="end" value="{{.PresenceEnd}}"></label>
<label>Large Image Asset-Key<br><input name="large_image" value="{{.Presence.LargeImage}}" placeholder="sc_logo"></label>
<label>Large Image Text<br><input name="large_text" value="{{.Presence.LargeText}}"></label>
<label>Small Image Asset-Key<br><input name="small_image" value="{{.Presence.SmallImage}}"></label>
<label>Small Image Text<br><input name="small_text" value="{{.Presence.SmallText}}"></label>
<label>Discord-Zielchannel-ID<br><input name="invite_channel_id" value="{{.Presence.InviteChannelID}}" placeholder="Voice/Text Channel ID"></label>
<label>Post-Channel-ID<br><input name="invite_post_channel_id" value="{{.Presence.InvitePostChannelID}}" placeholder="Channel für Einladungs-Post"></label>
<label>Button-Label<br><input name="invite_button_label" value="{{.Presence.InviteButtonLabel}}" placeholder="Channel beitreten"></label>
<label>Post-Titel<br><input name="invite_post_title" value="{{.Presence.InvitePostTitle}}"></label>
<label style="grid-column:1/-1">Post-Text<br><textarea name="invite_post_text">{{.Presence.InvitePostText}}</textarea></label>
</div>
<div class="actions"><button class="btn" type="submit">Speichern & Presence setzen</button></div>
</form>
<form method="post" action="/presence/invite{{if .Token}}?token={{.Token}}{{end}}" class="actions"><button class="btn" type="submit">Session-Invite posten</button>{{if .Presence.LastInviteMessageID}}<span class="muted">Letzter Post: #{{.Presence.LastInviteMessageID}}</span>{{end}}</form>
</section>
{{with .TradingSummary}}
<section class="grid">
<div class="card"><div class="muted">Trading-Einträge</div><div class="metric">{{.Count}}</div></div>
<div class="card"><div class="muted">Gewinn gesamt</div><div class="metric">{{auec .GrossProfit}}</div></div>
<div class="card"><div class="muted">Orga-Anteil</div><div class="metric">{{auec .OrgShare}}</div></div>
<div class="card"><div class="muted">Transportierte SCU</div><div class="metric">{{amount .TotalSCU}}</div></div>
<div class="card"><div class="muted">Kosten gesamt</div><div class="metric">{{auec .TotalCosts}}</div></div>
</section>
{{end}}
<section class="card"><h2>Abrechnung {{printf "%02d/%d" .Month .Year}}</h2><div class="muted">Empfänger: {{.TradingPayee}}</div><div class="scroll"><table><thead><tr><th>Nutzer</th><th>Runs</th><th>Gewinn</th><th>SCU</th><th>Orga-Anteil</th></tr></thead><tbody>{{range .TradingUsers}}<tr><td>{{.UserName}}</td><td>{{.Runs}}</td><td>{{auec .Profit}}</td><td>{{amount .SCU}}</td><td><strong>{{auec .OrgShare}}</strong></td></tr>{{else}}<tr><td colspan="5" class="muted">Keine Daten</td></tr>{{end}}</tbody></table></div></section>
<section class="card"><h2>Aktive Aufträge</h2><div class="scroll"><table><thead><tr><th>ID</th><th>Ware</th><th>Menge</th><th>Frist</th><th>Status</th><th>Einreicher</th></tr></thead><tbody>{{range .Orders}}<tr><td>#{{.ID}}</td><td>{{.Commodity}}</td><td>{{amount .QuantityAmount}} {{.QuantityUnit}}</td><td>{{.Deadline}}</td><td><span class="badge">{{status .Status}}</span></td><td>{{.SubmitterName}}</td></tr>{{else}}<tr><td colspan="6" class="muted">Keine aktiven Aufträge</td></tr>{{end}}</tbody></table></div></section>
<section class="card"><h2>Lager</h2><div class="scroll"><table><thead><tr><th>ID</th><th>Ware</th><th>Qualität</th><th>Menge</th><th>Ort</th><th>Notiz</th></tr></thead><tbody>{{range .Inventory}}<tr><td>#{{.ID}}</td><td>{{.Name}}</td><td>{{.Quality}}</td><td>{{amount .QuantityAmount}} {{.QuantityUnit}}</td><td>{{location .Location}}</td><td>{{.Note}}</td></tr>{{else}}<tr><td colspan="6" class="muted">Kein Lagerbestand</td></tr>{{end}}</tbody></table></div></section>
<section class="card"><h2>Archivierte Aufträge</h2><div class="scroll"><table><thead><tr><th>ID</th><th>Ware</th><th>Status</th><th>Aktualisiert</th></tr></thead><tbody>{{range .ArchivedOrders}}<tr><td>#{{.ID}}</td><td>{{.Commodity}}</td><td><span class="badge">{{status .Status}}</span></td><td>{{.UpdatedAt.Format "02.01.2006 15:04"}}</td></tr>{{else}}<tr><td colspan="4" class="muted">Keine archivierten Aufträge</td></tr>{{end}}</tbody></table></div></section>
</main>
</body>
</html>`))