spaxel/mothership/internal/dashboard/server.go
jedarden aaa622d410 feat(ui): implement command palette (Component 34) with tests
- commandpalette.js: CommandPaletteManager with fuzzy scorer, time parsing,
  command registry (20 commands), recent history, entity search, mode gating
- commandpalette.css: modal overlay, search input, result list styles
- commandpalette.test.js: 64 tests covering fuzzy match, time parsing, commands
  completeness, keyboard nav, recent history, expert-mode gating, positioning
- app.js: spaxelGetState() exposure and Ctrl+K fallback listener
- index.html: includes commandpalette.css and commandpalette.js
- explainability.js + explain.go: detection explainability backend/frontend
- hub.go + server.go: dashboard WebSocket and API updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:51:16 -04:00

335 lines
8.1 KiB
Go

package dashboard
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/spaxel/mothership/internal/replay"
)
// parseISO8601 parses an ISO8601 timestamp string and returns Unix milliseconds
func parseISO8601(s string) (int64, error) {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
// Try alternative formats
t, err = time.Parse("2006-01-02T15:04:05", s)
if err != nil {
t, err = time.Parse("2006-01-02T15:04:05Z", s)
if err != nil {
t, err = time.Parse("2006-01-02T15:04:05.999Z", s)
if err != nil {
return 0, err
}
}
}
}
return t.UnixMilli(), nil
}
const (
// Dashboard ping/pong timing
dashboardPingInterval = 30 * time.Second
dashboardReadDeadline = 60 * time.Second
// Send buffer size per client
sendBufferSize = 1024
)
// Server handles WebSocket connections from dashboard clients
type Server struct {
hub *Hub
upgrader websocket.Upgrader
}
// NewServer creates a new dashboard server
func NewServer(hub *Hub) *Server {
return &Server{
hub: hub,
upgrader: websocket.Upgrader{
// Allow all origins for development
CheckOrigin: func(r *http.Request) bool {
return true
},
ReadBufferSize: 256,
WriteBufferSize: 4096,
},
}
}
// HandleDashboardWS handles WebSocket connections at /ws/dashboard
func (s *Server) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
// Upgrade HTTP connection to WebSocket
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[WARN] Dashboard WebSocket upgrade failed: %v", err)
return
}
// Create client
client := &Client{
hub: s.hub,
send: make(chan []byte, sendBufferSize),
}
// Register with hub
s.hub.Register(client)
// Start write goroutine
go s.writePump(conn, client)
// Run read pump in this goroutine
s.readPump(conn, client)
}
// readPump handles incoming messages from the dashboard client
func (s *Server) readPump(conn *websocket.Conn, client *Client) {
defer func() {
conn.Close()
s.hub.Unregister(client)
}()
// Set read deadline
conn.SetReadDeadline(time.Now().Add(dashboardReadDeadline))
// Set pong handler to reset deadline
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(dashboardReadDeadline))
return nil
})
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("[WARN] Dashboard read error: %v", err)
}
break
}
// Handle WebSocket commands from dashboard
s.handleCommand(message, client)
}
}
// handleCommand processes WebSocket commands from the dashboard client
func (s *Server) handleCommand(data []byte, client *Client) {
var cmd map[string]interface{}
if err := json.Unmarshal(data, &cmd); err != nil {
log.Printf("[DEBUG] Failed to parse WebSocket command: %v", err)
return
}
cmdType, ok := cmd["type"].(string)
if !ok {
return
}
switch cmdType {
case "replay_seek":
s.handleReplaySeek(cmd)
case "replay_play":
s.handleReplayPlay(cmd)
case "replay_pause":
s.handleReplayPause(cmd)
case "replay_set_params":
s.handleReplaySetParams(cmd)
case "replay_apply_to_live":
s.handleReplayApplyToLive(cmd)
case "replay_set_speed":
s.handleReplaySetSpeed(cmd)
case "request_explain":
s.handleRequestExplain(cmd)
default:
// Unknown command type - ignore
log.Printf("[DEBUG] Unknown WebSocket command type: %s", cmdType)
}
}
// handleReplaySeek handles replay_seek commands
func (s *Server) handleReplaySeek(cmd map[string]interface{}) {
targetISO, ok := cmd["timestamp_iso8601"].(string)
if !ok {
log.Printf("[WARN] replay_seek missing timestamp_iso8601")
return
}
targetMS, err := parseISO8601(targetISO)
if err != nil {
log.Printf("[WARN] replay_seek invalid timestamp: %v", err)
return
}
// Forward to replay handler if available
if s.hub.replayHandler != nil {
s.hub.replayHandler.SeekTo(targetMS)
}
}
// handleReplayPlay handles replay_play commands
func (s *Server) handleReplayPlay(cmd map[string]interface{}) {
speedVal, ok := cmd["speed"]
var speed float64 = 1.0
if ok {
switch v := speedVal.(type) {
case float64:
speed = v
case int:
speed = float64(v)
}
}
// Forward to replay handler if available
if s.hub.replayHandler != nil {
s.hub.replayHandler.Play(speed)
}
}
// handleReplayPause handles replay_pause commands
func (s *Server) handleReplayPause(cmd map[string]interface{}) {
// Forward to replay handler if available
if s.hub.replayHandler != nil {
s.hub.replayHandler.Pause()
}
}
// handleReplaySetParams handles replay_set_params commands
func (s *Server) handleReplaySetParams(cmd map[string]interface{}) {
params := &replay.TunableParams{}
if val, ok := cmd["delta_rms_threshold"]; ok {
if f, ok := val.(float64); ok {
params.DeltaRMSThreshold = &f
}
}
if val, ok := cmd["tau_s"]; ok {
if f, ok := val.(float64); ok {
params.TauS = &f
}
}
if val, ok := cmd["fresnel_decay"]; ok {
if f, ok := val.(float64); ok {
params.FresnelDecay = &f
}
}
if val, ok := cmd["n_subcarriers"]; ok {
if i, ok := val.(float64); ok {
ival := int(i)
params.NSubcarriers = &ival
}
}
if val, ok := cmd["breathing_sensitivity"]; ok {
if f, ok := val.(float64); ok {
params.BreathingSensitivity = &f
}
}
// Forward to replay handler if available
if s.hub.replayHandler != nil {
s.hub.replayHandler.SetParams(params)
}
}
// handleReplayApplyToLive handles replay_apply_to_live commands
func (s *Server) handleReplayApplyToLive(cmd map[string]interface{}) {
// This would copy replay parameters to live configuration
// Requires confirmation from user (handled on frontend)
log.Printf("[INFO] Apply replay parameters to live requested")
// Forward to replay handler if available
if s.hub.replayHandler != nil {
s.hub.replayHandler.ApplyToLive()
}
}
// handleReplaySetSpeed handles replay_set_speed commands
func (s *Server) handleReplaySetSpeed(cmd map[string]interface{}) {
speedVal, ok := cmd["speed"]
var speed float64 = 1.0
if ok {
switch v := speedVal.(type) {
case float64:
speed = v
case int:
speed = float64(v)
}
}
// Forward to replay handler if available
if s.hub.replayHandler != nil {
s.hub.replayHandler.SetSpeed(speed)
}
}
// writePump handles outgoing messages to the dashboard client
func (s *Server) writePump(conn *websocket.Conn, client *Client) {
ticker := time.NewTicker(dashboardPingInterval)
defer func() {
ticker.Stop()
conn.Close()
}()
for {
select {
case message, ok := <-client.send:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
// Hub closed the channel
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
// Determine message type (binary vs text)
if len(message) > 0 && message[0] == '{' {
// Looks like JSON, send as text
err := conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
log.Printf("[WARN] Dashboard write error: %v", err)
return
}
} else {
// Binary CSI frame
err := conn.WriteMessage(websocket.BinaryMessage, message)
if err != nil {
log.Printf("[WARN] Dashboard write error: %v", err)
return
}
}
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// handleRequestExplain handles "request_explain" commands from the dashboard.
// The client sends {"type":"request_explain","blob_id":N} to request that the
// server emit a "blob_explain" message on the next fusion tick.
func (s *Server) handleRequestExplain(cmd map[string]interface{}) {
var blobID int
switch v := cmd["blob_id"].(type) {
case float64:
blobID = int(v)
case int:
blobID = v
default:
log.Printf("[WARN] request_explain: missing or invalid blob_id field")
return
}
s.hub.RequestExplain(blobID)
log.Printf("[DEBUG] request_explain queued for blob %d", blobID)
}
// Hub returns the server's hub for external use
func (s *Server) Hub() *Hub {
return s.hub
}
// Client represents a dashboard WebSocket client
// (redeclared here for documentation; defined in hub.go)
type dashboardClient = Client