Phase 2: Web dashboard with SSE real-time push
- dashboard.go: SSE hub + HTTP server + price history ring buffer
- static.go: //go:embed for static files
- web/static/index.html: Full dashboard HTML (6 panels)
- web/static/app.js: SSE client, Chart.js price chart, live table updates
- web/static/style.css: GitHub-style dark theme
- main.go: Start dashboard on :8888 + wire price recording + scan results
Dashboard features:
- Real-time price table (6 coins × 4 exchanges)
- Arbitrage opportunities table
- Open positions view
- Historical trades table (from SQLite)
- Chart.js price chart with coin/exchange selector
- Stats summary (total/converged/diverged/flat)
- 🚫 Zero external Go dependencies (Chart.js loaded from CDN)
This commit is contained in:
+439
@@ -0,0 +1,439 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"exchange-monitor/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SSE Hub — manages connected browser clients
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type sseClient struct {
|
||||||
|
ch chan []byte
|
||||||
|
done chan struct{}
|
||||||
|
filter string // optional coin filter (empty = all)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SSEHub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[*sseClient]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSSEHub() *SSEHub {
|
||||||
|
return &SSEHub{
|
||||||
|
clients: make(map[*sseClient]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SSEHub) Subscribe(filter string) *sseClient {
|
||||||
|
c := &sseClient{
|
||||||
|
ch: make(chan []byte, 64),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
filter: filter,
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[c] = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
log.Printf("[Web] SSE client connected (clients=%d)", len(h.clients))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SSEHub) Unsubscribe(c *sseClient) {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.clients, c)
|
||||||
|
count := len(h.clients)
|
||||||
|
h.mu.Unlock()
|
||||||
|
close(c.done)
|
||||||
|
log.Printf("[Web] SSE client disconnected (clients=%d)", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SSEHub) Broadcast(event string, data interface{}) {
|
||||||
|
raw, err := json.Marshal(map[string]interface{}{
|
||||||
|
"event": event,
|
||||||
|
"data": data,
|
||||||
|
"ts": time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for c := range h.clients {
|
||||||
|
select {
|
||||||
|
case c.ch <- raw:
|
||||||
|
default:
|
||||||
|
// Client too slow, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Price History — ring buffer for charting
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const maxHistoryPoints = 500
|
||||||
|
|
||||||
|
type pricePoint struct {
|
||||||
|
T int64 `json:"t"` // unix ms
|
||||||
|
P float64 `json:"p"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type priceHistory struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
buffers map[string]map[string][]pricePoint // coin -> exchange -> points
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPriceHistory() *priceHistory {
|
||||||
|
return &priceHistory{
|
||||||
|
buffers: make(map[string]map[string][]pricePoint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ph *priceHistory) Record(coin, exchange string, price float64) {
|
||||||
|
ph.mu.Lock()
|
||||||
|
defer ph.mu.Unlock()
|
||||||
|
|
||||||
|
if ph.buffers[coin] == nil {
|
||||||
|
ph.buffers[coin] = make(map[string][]pricePoint)
|
||||||
|
}
|
||||||
|
buf := ph.buffers[coin][exchange]
|
||||||
|
buf = append(buf, pricePoint{T: time.Now().UnixMilli(), P: price})
|
||||||
|
if len(buf) > maxHistoryPoints {
|
||||||
|
buf = buf[len(buf)-maxHistoryPoints:]
|
||||||
|
}
|
||||||
|
ph.buffers[coin][exchange] = buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ph *priceHistory) GetHistory(coin, exchange string, limit int) []pricePoint {
|
||||||
|
ph.mu.RLock()
|
||||||
|
defer ph.mu.RUnlock()
|
||||||
|
|
||||||
|
buf := ph.buffers[coin][exchange]
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit >= len(buf) {
|
||||||
|
result := make([]pricePoint, len(buf))
|
||||||
|
copy(result, buf)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result := make([]pricePoint, limit)
|
||||||
|
copy(result, buf[len(buf)-limit:])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Dashboard — main orchestrator
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type Dashboard struct {
|
||||||
|
hub *SSEHub
|
||||||
|
history *priceHistory
|
||||||
|
store *PriceStore
|
||||||
|
trader *Trader
|
||||||
|
db *db.DB
|
||||||
|
addr string
|
||||||
|
|
||||||
|
// cached arb scan results — updated every tick
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastScan []*ArbOpportunity
|
||||||
|
scanTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string) *Dashboard {
|
||||||
|
return &Dashboard{
|
||||||
|
hub: NewSSEHub(),
|
||||||
|
history: newPriceHistory(),
|
||||||
|
store: store,
|
||||||
|
trader: trader,
|
||||||
|
db: database,
|
||||||
|
addr: addr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the HTTP server + SSE broadcaster goroutine.
|
||||||
|
func (d *Dashboard) Run() {
|
||||||
|
// SSE broadcaster — pushes data every ~1s
|
||||||
|
go d.broadcastLoop()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Static files — embed subdirectory
|
||||||
|
staticSub, err := fs.Sub(staticFS, "web/static")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Web] Failed to create static sub-fs: %v", err)
|
||||||
|
} else {
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main page
|
||||||
|
mux.HandleFunc("GET /", d.handleIndex)
|
||||||
|
|
||||||
|
// API endpoints
|
||||||
|
mux.HandleFunc("GET /api/status", d.handleStatus)
|
||||||
|
mux.HandleFunc("GET /api/history", d.handleHistory)
|
||||||
|
mux.HandleFunc("GET /api/trades", d.handleTrades)
|
||||||
|
mux.HandleFunc("GET /api/trade/", d.handleTradeDetail)
|
||||||
|
|
||||||
|
// SSE
|
||||||
|
mux.HandleFunc("GET /events", d.handleSSE)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: d.addr,
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 0, // SSE needs no write timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Web] Dashboard listening on http://%s", d.addr)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("[Web] Server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastLoop pushes data to SSE clients every 1 second.
|
||||||
|
func (d *Dashboard) broadcastLoop() {
|
||||||
|
tick := time.NewTicker(1 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
|
for range tick.C {
|
||||||
|
snap := d.store.GetAll()
|
||||||
|
if len(snap) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Prices
|
||||||
|
var prices []map[string]interface{}
|
||||||
|
for _, coin := range TrackedCoins {
|
||||||
|
exMap := snap[coin.Name]
|
||||||
|
if exMap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry := map[string]interface{}{
|
||||||
|
"coin": coin.Name,
|
||||||
|
}
|
||||||
|
for ex, p := range exMap {
|
||||||
|
entry[ex] = p
|
||||||
|
}
|
||||||
|
// Add bid-ask spreads
|
||||||
|
for ex := range exMap {
|
||||||
|
sp := d.store.GetSpread(coin.Name, ex)
|
||||||
|
if sp > 0 {
|
||||||
|
entry[ex+"_spread"] = sp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prices = append(prices, entry)
|
||||||
|
}
|
||||||
|
d.hub.Broadcast("prices", prices)
|
||||||
|
|
||||||
|
// 2. Open positions
|
||||||
|
positions := d.trader.GetOpenPositions()
|
||||||
|
if len(positions) > 0 {
|
||||||
|
posList := make([]map[string]interface{}, 0, len(positions))
|
||||||
|
for _, pos := range positions {
|
||||||
|
posList = append(posList, map[string]interface{}{
|
||||||
|
"coin": pos.Coin,
|
||||||
|
"direction": pos.Direction,
|
||||||
|
"amount_usd": pos.AmountUSD,
|
||||||
|
"entry_spread": pos.EntrySpread,
|
||||||
|
"scales": pos.ScaleLevels,
|
||||||
|
"duration": time.Since(pos.StartedAt).Round(time.Second).String(),
|
||||||
|
"started_at": pos.StartedAt.Format("15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
d.hub.Broadcast("positions", posList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Arb scan results
|
||||||
|
d.mu.RLock()
|
||||||
|
scanCopy := d.lastScan
|
||||||
|
d.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(scanCopy) > 0 {
|
||||||
|
scanList := make([]map[string]interface{}, 0, len(scanCopy))
|
||||||
|
for _, opp := range scanCopy {
|
||||||
|
scanList = append(scanList, map[string]interface{}{
|
||||||
|
"coin": opp.Coin,
|
||||||
|
"direction": opp.Direction,
|
||||||
|
"buy_ex": opp.BuyEx,
|
||||||
|
"sell_ex": opp.SellEx,
|
||||||
|
"buy_price": opp.BuyPrice,
|
||||||
|
"sell_price": opp.SellPrice,
|
||||||
|
"net_profit": opp.NetProfit,
|
||||||
|
"gross": opp.GrossBasis,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
d.hub.Broadcast("arb", scanList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Stats
|
||||||
|
converged, diverged, flat, total := d.trader.GetClosedStats()
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"total_trades": total,
|
||||||
|
"converged": converged,
|
||||||
|
"diverged": diverged,
|
||||||
|
"flat": flat,
|
||||||
|
"open_positions": len(positions),
|
||||||
|
"coins": len(prices),
|
||||||
|
}
|
||||||
|
d.hub.Broadcast("stats", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateScan caches the latest arb scan results.
|
||||||
|
func (d *Dashboard) UpdateScan(opps []*ArbOpportunity) {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.lastScan = opps
|
||||||
|
d.scanTime = time.Now()
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordPrice adds a price to the history buffer and optionally broadcasts.
|
||||||
|
func (d *Dashboard) RecordPrice(coin, exchange string, price float64) {
|
||||||
|
d.history.Record(coin, exchange, price)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HTTP Handlers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := staticFS.ReadFile("web/static/index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
snap := d.store.GetAll()
|
||||||
|
positions := d.trader.GetOpenPositions()
|
||||||
|
converged, diverged, flat, total := d.trader.GetClosedStats()
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"prices": snap,
|
||||||
|
"positions": len(positions),
|
||||||
|
"stats": map[string]int{"total": total, "converged": converged, "diverged": diverged, "flat": flat},
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
coin := r.URL.Query().Get("coin")
|
||||||
|
exchange := r.URL.Query().Get("exchange")
|
||||||
|
if coin == "" || exchange == "" {
|
||||||
|
// Return available coins/exchanges
|
||||||
|
snap := d.store.GetAll()
|
||||||
|
coins := make([]string, 0, len(snap))
|
||||||
|
for c := range snap {
|
||||||
|
coins = append(coins, c)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"coins": coins, "exchanges": []string{"Binance", "HyperLiquid", "Bitget", "dYdX"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
points := d.history.GetHistory(coin, exchange, 300)
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"coin": coin,
|
||||||
|
"exchange": exchange,
|
||||||
|
"points": points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if d.db == nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"trades": []interface{}{}, "total": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := 1
|
||||||
|
limit := 20
|
||||||
|
coin := r.URL.Query().Get("coin")
|
||||||
|
|
||||||
|
trades, total, err := d.db.GetTrades(page, limit, coin)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"trades": trades,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) handleTradeDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if d.db == nil {
|
||||||
|
http.Error(w, "DB not available", 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
if _, err := fmt.Sscanf(r.URL.Path, "/api/trade/%d", &id); err != nil {
|
||||||
|
http.Error(w, "Invalid trade ID", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trade, orders, err := d.db.GetTradeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"trade": trade,
|
||||||
|
"orders": orders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Streaming not supported", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
client := d.hub.Subscribe("")
|
||||||
|
defer d.hub.Unsubscribe(client)
|
||||||
|
|
||||||
|
// Send initial heartbeat
|
||||||
|
fmt.Fprintf(w, "event: connected\ndata: {\"status\":\"ok\"}\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case msg, ok := <-client.ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ func main() {
|
|||||||
|
|
||||||
// Initialize trader
|
// Initialize trader
|
||||||
trader := NewTrader(cfg, database)
|
trader := NewTrader(cfg, database)
|
||||||
|
|
||||||
|
// Initialize dashboard (web server + SSE)
|
||||||
|
dashboard := NewDashboard(store, trader, database, ":8888")
|
||||||
|
go dashboard.Run()
|
||||||
if trader.IsConfigured() {
|
if trader.IsConfigured() {
|
||||||
modeLabel := trader.ModeLabel()
|
modeLabel := trader.ModeLabel()
|
||||||
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/trade)",
|
log.Printf("[Trader] %s mode: automated trading ENABLED (threshold >= %.2f%%, $%.0f/trade)",
|
||||||
@@ -79,6 +83,7 @@ func main() {
|
|||||||
for {
|
for {
|
||||||
err := runner(func(coin string, price, bid, ask float64) {
|
err := runner(func(coin string, price, bid, ask float64) {
|
||||||
store.SetWithSpread(coin, name, price, bid, ask)
|
store.SetWithSpread(coin, name, price, bid, ask)
|
||||||
|
dashboard.RecordPrice(coin, name, price)
|
||||||
})
|
})
|
||||||
log.Printf("[%s] WS error: %v (reconnecting...)", name, err)
|
log.Printf("[%s] WS error: %v (reconnecting...)", name, err)
|
||||||
select {
|
select {
|
||||||
@@ -155,6 +160,7 @@ func main() {
|
|||||||
|
|
||||||
// Scan for arbitrage entries using maker fees (limit orders)
|
// Scan for arbitrage entries using maker fees (limit orders)
|
||||||
makerOpps := ScanArbWithFees(store, makerFees)
|
makerOpps := ScanArbWithFees(store, makerFees)
|
||||||
|
dashboard.UpdateScan(makerOpps)
|
||||||
t2 := time.Now()
|
t2 := time.Now()
|
||||||
|
|
||||||
for _, opp := range makerOpps {
|
for _, opp := range makerOpps {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed web/static/index.html web/static/app.js web/static/style.css
|
||||||
|
var staticFS embed.FS
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Exchange Monitor Dashboard — Frontend Logic
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---- DOM refs ----
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
clock: $('clock'),
|
||||||
|
connStatus: $('conn-status'),
|
||||||
|
priceBody: $('price-body'),
|
||||||
|
arbBody: $('arb-body'),
|
||||||
|
posBody: $('positions-body'),
|
||||||
|
tradesBody: $('trades-body'),
|
||||||
|
statTotal: $('stat-total'),
|
||||||
|
statConv: $('stat-converged'),
|
||||||
|
statDiv: $('stat-diverged'),
|
||||||
|
statFlat: $('stat-flat'),
|
||||||
|
statPos: $('stat-positions'),
|
||||||
|
statCoins: $('stat-coins'),
|
||||||
|
chartCoin: $('chart-coin'),
|
||||||
|
chartExch: $('chart-exchange'),
|
||||||
|
chartCanvas: $('priceChart'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Clock ----
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
els.clock.textContent = now.toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
updateClock();
|
||||||
|
|
||||||
|
// ---- Price table helpers ----
|
||||||
|
const EXCHANGES = ['Binance', 'HyperLiquid', 'Bitget', 'dYdX'];
|
||||||
|
const COINS = ['DOGE', 'LINK', 'ONDO', 'OP', 'WIF', 'ARB'];
|
||||||
|
|
||||||
|
function formatPrice(p) {
|
||||||
|
if (p == null || p <= 0) return '-';
|
||||||
|
if (p >= 100) return p.toFixed(2);
|
||||||
|
if (p >= 1) return p.toFixed(4);
|
||||||
|
return p.toFixed(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceClass(lastPrice, currentPrice) {
|
||||||
|
if (lastPrice == null || currentPrice == null) return '';
|
||||||
|
if (currentPrice > lastPrice) return 'text-green';
|
||||||
|
if (currentPrice < lastPrice) return 'text-red';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Price history for chart ----
|
||||||
|
const priceCache = {}; // coin.exchange -> { last: float, points: [{t,p}] }
|
||||||
|
|
||||||
|
// ---- SSE Connection ----
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource = new EventSource('/events');
|
||||||
|
|
||||||
|
eventSource.addEventListener('connected', () => {
|
||||||
|
els.connStatus.textContent = '● 已连接';
|
||||||
|
els.connStatus.className = 'status-online';
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
els.connStatus.textContent = '● 已断开 (重连中...)';
|
||||||
|
els.connStatus.className = 'status-offline';
|
||||||
|
setTimeout(connectSSE, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
const handler = eventHandlers[msg.event];
|
||||||
|
if (handler) handler(msg.data);
|
||||||
|
} catch(err) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Event Handlers ----
|
||||||
|
const eventHandlers = {};
|
||||||
|
|
||||||
|
eventHandlers.prices = (prices) => {
|
||||||
|
if (!prices || prices.length === 0) return;
|
||||||
|
|
||||||
|
// Build table rows
|
||||||
|
let html = '';
|
||||||
|
let coinsOnline = 0;
|
||||||
|
|
||||||
|
for (const coin of COINS) {
|
||||||
|
const row = prices.find(p => p.coin === coin);
|
||||||
|
if (!row) {
|
||||||
|
html += `<tr><td>${coin}</td>${EXCHANGES.map(() => '<td class="text-dim">-</td>').join('')}</tr>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coinsOnline++;
|
||||||
|
|
||||||
|
const cells = EXCHANGES.map(ex => {
|
||||||
|
const p = row[ex];
|
||||||
|
const sp = row[ex + '_spread'];
|
||||||
|
const key = coin + '.' + ex;
|
||||||
|
const prev = priceCache[key];
|
||||||
|
const curP = p || 0;
|
||||||
|
const cls = prev ? priceClass(prev.last, curP) : '';
|
||||||
|
|
||||||
|
// Store for directional arrows next time
|
||||||
|
if (prev) {
|
||||||
|
prev.last = curP;
|
||||||
|
} else {
|
||||||
|
priceCache[key] = { last: curP, points: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record for chart
|
||||||
|
if (p > 0) {
|
||||||
|
const pt = { t: Date.now(), p: p };
|
||||||
|
if (!priceCache[key]) priceCache[key] = { last: p, points: [] };
|
||||||
|
priceCache[key].points.push(pt);
|
||||||
|
if (priceCache[key].points.length > 500) {
|
||||||
|
priceCache[key].points = priceCache[key].points.slice(-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let display = formatPrice(p);
|
||||||
|
if (sp && sp > 0.01) {
|
||||||
|
display += `<span class="text-dim" style="font-size:10px"> (${sp.toFixed(3)}%)</span>`;
|
||||||
|
}
|
||||||
|
return `<td class="${cls}">${display}</td>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `<tr><td><strong>${coin}</strong></td>${cells.join('')}</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.priceBody.innerHTML = html;
|
||||||
|
|
||||||
|
// Update coin selector if needed
|
||||||
|
updateChartSelectors(prices);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventHandlers.arb = (opps) => {
|
||||||
|
if (!opps || opps.length === 0) {
|
||||||
|
els.arbBody.innerHTML = '<tr><td colspan="5" class="text-dim">暂无套利机会</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = opps.slice(0, 10).map(opp => {
|
||||||
|
const cls = opp.net_profit > 0.1 ? 'text-green' : opp.net_profit > 0.05 ? 'text-yellow' : '';
|
||||||
|
return `<tr>
|
||||||
|
<td>${opp.coin}</td>
|
||||||
|
<td>${opp.direction}</td>
|
||||||
|
<td class="text-right">${formatPrice(opp.buy_price)}</td>
|
||||||
|
<td class="text-right">${formatPrice(opp.sell_price)}</td>
|
||||||
|
<td class="text-right ${cls}"><strong>${opp.net_profit.toFixed(4)}</strong></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
els.arbBody.innerHTML = html;
|
||||||
|
};
|
||||||
|
|
||||||
|
eventHandlers.positions = (positions) => {
|
||||||
|
if (!positions || positions.length === 0) {
|
||||||
|
els.posBody.innerHTML = '<tr><td colspan="6" class="text-dim">无持仓</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = positions.map(p => `<tr>
|
||||||
|
<td><strong>${p.coin}</strong></td>
|
||||||
|
<td>${p.direction}</td>
|
||||||
|
<td class="text-right">$${p.amount_usd.toFixed(0)}</td>
|
||||||
|
<td class="text-right">${p.entry_spread.toFixed(4)}%</td>
|
||||||
|
<td class="text-right">${p.scales}</td>
|
||||||
|
<td>${p.duration}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
els.posBody.innerHTML = html;
|
||||||
|
};
|
||||||
|
|
||||||
|
eventHandlers.stats = (stats) => {
|
||||||
|
els.statTotal.textContent = stats.total_trades || 0;
|
||||||
|
els.statConv.textContent = stats.converged || 0;
|
||||||
|
els.statDiv.textContent = stats.diverged || 0;
|
||||||
|
els.statFlat.textContent = stats.flat || 0;
|
||||||
|
els.statPos.textContent = stats.open_positions || 0;
|
||||||
|
els.statCoins.textContent = stats.coins || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Chart ----
|
||||||
|
let chart = null;
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
const ctx = els.chartCanvas.getContext('2d');
|
||||||
|
chart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [{
|
||||||
|
label: 'Price',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#58a6ff',
|
||||||
|
backgroundColor: 'rgba(88, 166, 255, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.2,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 0 },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: (items) => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
const d = new Date(items[0].parsed.x);
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
label: (item) => item.parsed.y.toFixed(4),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
ticks: {
|
||||||
|
color: '#8b949e',
|
||||||
|
maxTicksLimit: 10,
|
||||||
|
callback: (val) => {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { color: 'rgba(48, 54, 61, 0.5)' },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
ticks: {
|
||||||
|
color: '#8b949e',
|
||||||
|
callback: (val) => val.toFixed(4),
|
||||||
|
},
|
||||||
|
grid: { color: 'rgba(48, 54, 61, 0.3)' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChartSelectors(prices) {
|
||||||
|
const coinSel = els.chartCoin;
|
||||||
|
const exSel = els.chartExch;
|
||||||
|
|
||||||
|
// Populate coins if empty
|
||||||
|
if (coinSel.options.length <= 1) {
|
||||||
|
const currentCoin = coinSel.value;
|
||||||
|
coinSel.innerHTML = '<option value="">-- 选择币种 --</option>';
|
||||||
|
for (const row of prices) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = row.coin;
|
||||||
|
opt.textContent = row.coin;
|
||||||
|
coinSel.appendChild(opt);
|
||||||
|
}
|
||||||
|
// Try to restore selection
|
||||||
|
if (currentCoin) {
|
||||||
|
coinSel.value = currentCoin;
|
||||||
|
} else if (prices.length > 0) {
|
||||||
|
coinSel.value = prices[0].coin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate exchanges if empty
|
||||||
|
if (exSel.options.length <= 1) {
|
||||||
|
exSel.innerHTML = '<option value="">-- 选择交易所 --</option>';
|
||||||
|
for (const ex of EXCHANGES) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = ex;
|
||||||
|
opt.textContent = ex;
|
||||||
|
exSel.appendChild(opt);
|
||||||
|
}
|
||||||
|
exSel.value = 'HyperLiquid';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart when selections change
|
||||||
|
const selectedCoin = coinSel.value;
|
||||||
|
const selectedEx = exSel.value;
|
||||||
|
if (selectedCoin && selectedEx) {
|
||||||
|
updateChart(selectedCoin, selectedEx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(coin, exchange) {
|
||||||
|
const key = coin + '.' + exchange;
|
||||||
|
const cache = priceCache[key];
|
||||||
|
if (!cache || !cache.points || cache.points.length < 2) {
|
||||||
|
if (chart) {
|
||||||
|
chart.data.datasets[0].data = [];
|
||||||
|
chart.data.datasets[0].label = `${coin} @ ${exchange}`;
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pts = cache.points;
|
||||||
|
const data = pts.map(p => ({ x: p.t, y: p.p }));
|
||||||
|
|
||||||
|
if (chart) {
|
||||||
|
chart.data.datasets[0].data = data;
|
||||||
|
chart.data.datasets[0].label = `${coin} @ ${exchange}`;
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Chart controls ----
|
||||||
|
els.chartCoin.addEventListener('change', () => {
|
||||||
|
const coin = els.chartCoin.value;
|
||||||
|
const ex = els.chartExch.value;
|
||||||
|
if (coin && ex) updateChart(coin, ex);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.chartExch.addEventListener('change', () => {
|
||||||
|
const coin = els.chartCoin.value;
|
||||||
|
const ex = els.chartExch.value;
|
||||||
|
if (coin && ex) updateChart(coin, ex);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Chart auto-refresh ----
|
||||||
|
let chartRefreshTimer = null;
|
||||||
|
let chartRefreshInterval = 2000; // refresh chart every 2s
|
||||||
|
|
||||||
|
function startChartRefresh() {
|
||||||
|
if (chartRefreshTimer) return;
|
||||||
|
chartRefreshTimer = setInterval(() => {
|
||||||
|
const coin = els.chartCoin.value;
|
||||||
|
const ex = els.chartExch.value;
|
||||||
|
if (coin && ex) updateChart(coin, ex);
|
||||||
|
}, chartRefreshInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Trades loading ----
|
||||||
|
async function loadTrades() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/trades');
|
||||||
|
const data = await resp.json();
|
||||||
|
const trades = data.trades || [];
|
||||||
|
|
||||||
|
if (trades.length === 0) {
|
||||||
|
els.tradesBody.innerHTML = '<tr><td colspan="8" class="text-dim">暂无交易记录</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = trades.slice(0, 20).map(t => {
|
||||||
|
const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : '';
|
||||||
|
const convCls = t.Convergence === '价差收敛' ? 'text-green' :
|
||||||
|
t.Convergence === '价差发散' ? 'text-red' : 'text-yellow';
|
||||||
|
return `<tr>
|
||||||
|
<td class="text-dim">${t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'}</td>
|
||||||
|
<td><strong>${t.Coin}</strong></td>
|
||||||
|
<td>${t.Direction}</td>
|
||||||
|
<td class="text-right">${t.EntrySpread ? t.EntrySpread.toFixed(4) : '-'}</td>
|
||||||
|
<td class="text-right">${t.ExitSpread ? t.ExitSpread.toFixed(4) : '-'}</td>
|
||||||
|
<td class="text-right ${pnlCls}"><strong>${t.NetPnl != null ? t.NetPnl.toFixed(4) : '-'}</strong></td>
|
||||||
|
<td class="${convCls}">${t.Convergence || '-'}</td>
|
||||||
|
<td>${t.ExitReason || '-'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
els.tradesBody.innerHTML = html;
|
||||||
|
} catch (err) {
|
||||||
|
els.tradesBody.innerHTML = '<tr><td colspan="8" class="text-red">加载失败</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Init ----
|
||||||
|
function init() {
|
||||||
|
connectSSE();
|
||||||
|
initChart();
|
||||||
|
startChartRefresh();
|
||||||
|
loadTrades();
|
||||||
|
|
||||||
|
// Refresh trades every 10s
|
||||||
|
setInterval(loadTrades, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start when DOM ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Exchange Monitor Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header>
|
||||||
|
<h1>⚡ 跨交易所套利监控</h1>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span id="clock">--:--:--</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span id="conn-status" class="status-offline">● 未连接</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Stats Summary -->
|
||||||
|
<section class="card" id="stats-card">
|
||||||
|
<h2>📊 统计数据</h2>
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat"><label>总交易</label><span id="stat-total">0</span></div>
|
||||||
|
<div class="stat"><label>收敛</label><span id="stat-converged" class="pct-green">0</span></div>
|
||||||
|
<div class="stat"><label>发散</label><span id="stat-diverged" class="pct-red">0</span></div>
|
||||||
|
<div class="stat"><label>持平</label><span id="stat-flat" class="pct-gray">0</span></div>
|
||||||
|
<div class="stat"><label>持仓</label><span id="stat-positions" class="pct-yellow">0</span></div>
|
||||||
|
<div class="stat"><label>币种</label><span id="stat-coins" class="pct-blue">0</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Price Table -->
|
||||||
|
<section class="card" id="prices-card">
|
||||||
|
<h2>💰 实时价格</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="price-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>币种</th>
|
||||||
|
<th>Binance</th>
|
||||||
|
<th>HyperLiquid</th>
|
||||||
|
<th>Bitget</th>
|
||||||
|
<th>dYdX</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="price-body">
|
||||||
|
<tr><td colspan="5" class="loading">等待数据...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Arbitrage Opportunities -->
|
||||||
|
<section class="card" id="arb-card">
|
||||||
|
<h2>🎯 套利机会</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="arb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>币种</th>
|
||||||
|
<th>方向</th>
|
||||||
|
<th>买价</th>
|
||||||
|
<th>卖价</th>
|
||||||
|
<th>净利%</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="arb-body">
|
||||||
|
<tr><td colspan="5" class="loading">等待数据...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Open Positions -->
|
||||||
|
<section class="card" id="positions-card">
|
||||||
|
<h2>🔒 当前持仓</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="positions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>币种</th>
|
||||||
|
<th>方向</th>
|
||||||
|
<th>规模</th>
|
||||||
|
<th>开仓价差</th>
|
||||||
|
<th>加仓</th>
|
||||||
|
<th>时长</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="positions-body">
|
||||||
|
<tr><td colspan="6" class="loading">等待数据...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Price Chart -->
|
||||||
|
<section class="card card-wide" id="chart-card">
|
||||||
|
<h2>📈 价格走势</h2>
|
||||||
|
<div class="chart-controls">
|
||||||
|
<select id="chart-coin"></select>
|
||||||
|
<select id="chart-exchange"></select>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="priceChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Trades -->
|
||||||
|
<section class="card card-wide" id="trades-card">
|
||||||
|
<h2>📋 历史交易</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="trades-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>币种</th>
|
||||||
|
<th>方向</th>
|
||||||
|
<th>入价差</th>
|
||||||
|
<th>出价差</th>
|
||||||
|
<th>净利%</th>
|
||||||
|
<th>结果</th>
|
||||||
|
<th>原因</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="trades-body">
|
||||||
|
<tr><td colspan="8" class="loading">等待数据...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Exchange Monitor Dashboard — Dark Theme
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--card: #161b22;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--text-dim: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--red: #f85149;
|
||||||
|
--yellow: #d29922;
|
||||||
|
--blue: #58a6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app { max-width: 1440px; margin: 0 auto; padding: 16px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 { font-size: 18px; font-weight: 600; }
|
||||||
|
.header-meta { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.sep { color: var(--border); }
|
||||||
|
.status-offline { color: var(--red); }
|
||||||
|
.status-online { color: var(--green); }
|
||||||
|
|
||||||
|
/* Grid layout */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card-wide { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats row */
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
.stat label { font-size: 11px; color: var(--text-dim); margin-bottom: 2px; }
|
||||||
|
.stat span { font-size: 20px; font-weight: 700; }
|
||||||
|
.pct-green { color: var(--green); }
|
||||||
|
.pct-red { color: var(--red); }
|
||||||
|
.pct-gray { color: var(--text-dim); }
|
||||||
|
.pct-yellow { color: var(--yellow); }
|
||||||
|
.pct-blue { color: var(--blue); }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tr:hover td { background: rgba(88, 166, 255, 0.05); }
|
||||||
|
.loading { text-align: center; color: var(--text-dim); padding: 20px !important; }
|
||||||
|
|
||||||
|
.text-green { color: var(--green); }
|
||||||
|
.text-red { color: var(--red); }
|
||||||
|
.text-yellow { color: var(--yellow); }
|
||||||
|
.text-dim { color: var(--text-dim); }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
|
||||||
|
/* Chart controls */
|
||||||
|
.chart-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.chart-controls select {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #484f58; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
header { flex-direction: column; gap: 8px; }
|
||||||
|
.stats-row { justify-content: center; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user