Files
aitrade/pkg/app/web/server.go
T
kaedwen fe16cb55cb
Build and Push Docker Image / build-and-push (push) Successful in 3m43s
fix news page
Signed-off-by: kaedwen <kaedwen@heinrich.blue>
2026-07-09 22:54:40 +02:00

541 lines
15 KiB
Go

package web
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"sync"
"time"
"github.com/pheinrich/aitrade/pkg/app/trader"
"github.com/pheinrich/aitrade/pkg/config"
"github.com/pheinrich/aitrade/pkg/db"
"github.com/pheinrich/aitrade/pkg/model"
)
//go:embed templates/*.html static/*
var embeddedFS embed.FS
type Server struct {
cfg *config.WebConfig
tradingCfg *config.TradingConfig
auth *AuthMiddleware
tradeRepo *db.TradeRepository
balanceRepo *db.BalanceRepository
newsRepo *db.NewsRepository
whitelistRepo *db.WhitelistRepository
trader *trader.Trader
logger *slog.Logger
templates *template.Template
// SSE
sseClients map[chan string]bool
sseClientsMu sync.Mutex
}
func NewServer(
cfg *config.WebConfig,
tradingCfg *config.TradingConfig,
oidcCfg *config.OIDCConfig,
tradeRepo *db.TradeRepository,
balanceRepo *db.BalanceRepository,
newsRepo *db.NewsRepository,
whitelistRepo *db.WhitelistRepository,
trader *trader.Trader,
logger *slog.Logger,
) (*Server, error) {
auth, err := NewAuthMiddleware(oidcCfg, logger)
if err != nil {
return nil, fmt.Errorf("failed to create auth middleware: %w", err)
}
// Parse templates
tmpl, err := template.ParseFS(embeddedFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err)
}
return &Server{
cfg: cfg,
tradingCfg: tradingCfg,
auth: auth,
tradeRepo: tradeRepo,
balanceRepo: balanceRepo,
newsRepo: newsRepo,
whitelistRepo: whitelistRepo,
trader: trader,
logger: logger,
templates: tmpl,
sseClients: make(map[chan string]bool),
}, nil
}
func (s *Server) Run(ctx context.Context) error {
mux := http.NewServeMux()
// Static files
staticFS, err := fs.Sub(embeddedFS, "static")
if err != nil {
return fmt.Errorf("failed to get static filesystem: %w", err)
}
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Public endpoints (no auth required)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/oauth2/callback", s.auth.HandleCallback)
// Protected endpoints (auth required)
mux.Handle("/", s.auth.Middleware(http.HandlerFunc(s.handleOverview)))
mux.Handle("/overview", s.auth.Middleware(http.HandlerFunc(s.handleOverview)))
mux.Handle("/trades", s.auth.Middleware(http.HandlerFunc(s.handleTradesPage)))
mux.Handle("/whitelist", s.auth.Middleware(http.HandlerFunc(s.handleWhitelistPage)))
mux.Handle("/news", s.auth.Middleware(http.HandlerFunc(s.handleNewsPage)))
// Content endpoints (partial HTML for SPA)
mux.Handle("/content/overview", s.auth.Middleware(http.HandlerFunc(s.handleOverviewContent)))
mux.Handle("/content/trades", s.auth.Middleware(http.HandlerFunc(s.handleTradesContent)))
mux.Handle("/content/whitelist", s.auth.Middleware(http.HandlerFunc(s.handleWhitelistContent)))
mux.Handle("/content/news", s.auth.Middleware(http.HandlerFunc(s.handleNewsContent)))
mux.Handle("/events", s.auth.Middleware(http.HandlerFunc(s.handleSSE)))
mux.Handle("/api/balance", s.auth.Middleware(http.HandlerFunc(s.handleGetBalance)))
mux.Handle("/api/config", s.auth.Middleware(http.HandlerFunc(s.handleGetConfig)))
mux.Handle("/api/news", s.auth.Middleware(http.HandlerFunc(s.handleGetNews)))
mux.Handle("/api/news/recent", s.auth.Middleware(http.HandlerFunc(s.handleGetNewsRecent)))
mux.Handle("/api/news/count", s.auth.Middleware(http.HandlerFunc(s.handleGetNewsCount)))
mux.Handle("/api/trades", s.auth.Middleware(http.HandlerFunc(s.handleGetTrades)))
mux.Handle("/api/trades/", s.auth.Middleware(http.HandlerFunc(s.handleTradeAction)))
mux.Handle("/api/whitelist", s.auth.Middleware(http.HandlerFunc(s.handleWhitelist)))
mux.Handle("/api/whitelist/", s.auth.Middleware(http.HandlerFunc(s.handleWhitelistItem)))
addr := fmt.Sprintf("%s:%s", s.cfg.Host, s.cfg.Port)
server := &http.Server{
Addr: addr,
Handler: mux,
}
s.logger.Info("web server starting", slog.String("addr", addr))
// Start server in goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.logger.Error("web server error", slog.Any("error", err))
}
}()
// Wait for context cancellation
<-ctx.Done()
s.logger.Info("web server stopping")
// Shutdown gracefully
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return server.Shutdown(shutdownCtx)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "healthy",
})
}
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "Overview",
"CurrentPage": "overview",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "shell.html", data); err != nil {
s.logger.Error("failed to render template", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (s *Server) handleTradesPage(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "Trades",
"CurrentPage": "trades",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "shell.html", data); err != nil {
s.logger.Error("failed to render template", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (s *Server) handleWhitelistPage(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "Whitelist",
"CurrentPage": "whitelist",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "shell.html", data); err != nil {
s.logger.Error("failed to render template", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (s *Server) handleNewsPage(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "News Feed",
"CurrentPage": "news",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "shell.html", data); err != nil {
s.logger.Error("failed to render template", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// Content endpoints (return partial HTML for SPA)
func (s *Server) handleOverviewContent(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "overview-content", nil); err != nil {
s.logger.Error("failed to render content template", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (s *Server) handleTradesContent(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "trades-content", nil); err != nil {
s.logger.Error("failed to render content template", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (s *Server) handleWhitelistContent(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "whitelist-content", nil); err != nil {
s.logger.Error("failed to render content template", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (s *Server) handleNewsContent(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "news-content", nil); err != nil {
s.logger.Error("failed to render content template", slog.Any("error", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (s *Server) handleGetTrades(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get trades from last 7 days
since := time.Now().AddDate(0, 0, -7)
trades, err := s.tradeRepo.GetTradesSince(ctx, since)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(trades)
}
func (s *Server) handleGetBalance(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var balance *model.Balance
var err error
// Get live balance from IB Gateway via trader (if available)
if s.trader != nil {
balance, err = s.trader.GetLiveBalance(ctx)
if err != nil {
s.logger.Warn("failed to get live balance from IB, falling back to DB",
slog.Any("error", err))
balance = nil // Force fallback
}
}
// Fallback: read from DB if trader is unavailable or IB Gateway failed
if balance == nil {
balance, err = s.balanceRepo.GetLatest(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if balance == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]float64{"TotalValue": 0.0})
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(balance)
}
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"dry_run": s.tradingCfg.DryRun,
})
}
func (s *Server) handleGetNews(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
news, err := s.newsRepo.GetRecent(ctx, 20)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(news)
}
func (s *Server) handleGetNewsRecent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get limit and offset from query parameters
limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
offset := 0
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
fmt.Sscanf(offsetStr, "%d", &offset)
}
news, err := s.newsRepo.GetRecentWithOffset(ctx, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(news)
}
func (s *Server) handleGetNewsCount(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get hours parameter, default to 1 hour
hours := 1
if hoursStr := r.URL.Query().Get("hours"); hoursStr != "" {
fmt.Sscanf(hoursStr, "%d", &hours)
}
since := time.Now().Add(-time.Duration(hours) * time.Hour)
count, err := s.newsRepo.CountRecentByTime(ctx, since)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": count,
"hours": hours,
"since": since,
})
}
func (s *Server) handleTradeAction(w http.ResponseWriter, r *http.Request) {
// Check if trader is available
if s.trader == nil {
http.Error(w, "Trader not available (IB Gateway disabled)", http.StatusServiceUnavailable)
return
}
// Parse trade ID from URL: /api/trades/{id}/{action}
path := r.URL.Path[len("/api/trades/"):]
var tradeID int64
var action string
fmt.Sscanf(path, "%d/%s", &tradeID, &action)
ctx := r.Context()
switch action {
case "approve":
forceNow := r.URL.Query().Get("force") == "true"
if err := s.trader.ApproveTrade(ctx, tradeID, forceNow); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.BroadcastSSE("trade.approved")
case "reject":
var body struct {
Reason string `json:"reason"`
}
json.NewDecoder(r.Body).Decode(&body)
if err := s.trader.RejectTrade(ctx, tradeID, body.Reason); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.BroadcastSSE("trade.rejected")
default:
http.Error(w, "Invalid action", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleWhitelist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
switch r.Method {
case http.MethodGet:
// Get all whitelist entries
entries, err := s.whitelistRepo.GetAll(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
case http.MethodPost:
// Create new whitelist entry
var entry model.WhitelistEntry
if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := s.whitelistRepo.Create(ctx, &entry); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.BroadcastSSE("whitelist.updated")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entry)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWhitelistItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse ID from URL: /api/whitelist/{id}
path := r.URL.Path[len("/api/whitelist/"):]
var id int64
fmt.Sscanf(path, "%d", &id)
switch r.Method {
case http.MethodGet:
// Get single whitelist entry
entry, err := s.whitelistRepo.GetByID(ctx, id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entry)
case http.MethodPut:
// Update whitelist entry
var entry model.WhitelistEntry
if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
entry.ID = id
if err := s.whitelistRepo.Update(ctx, &entry); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.BroadcastSSE("whitelist.updated")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entry)
case http.MethodDelete:
// Delete whitelist entry
if err := s.whitelistRepo.Delete(ctx, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.BroadcastSSE("whitelist.updated")
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// Create client channel
clientChan := make(chan string)
s.sseClientsMu.Lock()
s.sseClients[clientChan] = true
s.sseClientsMu.Unlock()
// Remove client on disconnect
defer func() {
s.sseClientsMu.Lock()
delete(s.sseClients, clientChan)
s.sseClientsMu.Unlock()
close(clientChan)
}()
// Send keep-alive
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case msg := <-clientChan:
fmt.Fprintf(w, "data: %s\n\n", msg)
w.(http.Flusher).Flush()
case <-ticker.C:
fmt.Fprintf(w, ": keepalive\n\n")
w.(http.Flusher).Flush()
}
}
}
func (s *Server) BroadcastSSE(event string) {
s.sseClientsMu.Lock()
defer s.sseClientsMu.Unlock()
for client := range s.sseClients {
select {
case client <- event:
default:
// Client channel full, skip
}
}
}