package court import ( "context" "database/sql" "encoding/json" "fmt" "net/http" "time" ) // SeerHub broadcasts court and LOTL events over SSE. type SeerHub struct { DB *sql.DB } func NewSeerHub(db *sql.DB) *SeerHub { return &SeerHub{DB: db} } // SeerEvent is one SSE payload line. type SeerEvent struct { ID string `json:"id"` HostID string `json:"host_id,omitempty"` EventType string `json:"event_type"` Payload json.RawMessage `json:"payload"` CreatedAt time.Time `json:"created_at"` } // StreamHandler serves GET /seer as Server-Sent Events. func (h *SeerHub) StreamHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") since := r.URL.Query().Get("since") ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { select { case <-r.Context().Done(): return case <-ticker.C: events, err := h.fetchEvents(r.Context(), since) if err != nil { fmt.Fprintf(w, "event: error\ndata: %q\n\n", err.Error()) flusher.Flush() continue } for _, ev := range events { b, _ := json.Marshal(ev) fmt.Fprintf(w, "event: seer\ndata: %s\n\n", b) since = ev.CreatedAt.Format("2006-01-02 15:04:05") } flusher.Flush() } } } } func (h *SeerHub) fetchEvents(ctx context.Context, since string) ([]SeerEvent, error) { q := `SELECT id, COALESCE(host_id,''), event_type, payload_json, created_at FROM seer_events` var rows *sql.Rows var err error if since != "" { rows, err = h.DB.QueryContext(ctx, q+` WHERE created_at > ? ORDER BY created_at ASC LIMIT 50`, since) } else { rows, err = h.DB.QueryContext(ctx, q+` ORDER BY created_at DESC LIMIT 20`) } if err != nil { return nil, err } defer rows.Close() var events []SeerEvent for rows.Next() { var ev SeerEvent var created string var payload string if err := rows.Scan(&ev.ID, &ev.HostID, &ev.EventType, &payload, &created); err != nil { return nil, err } ev.Payload = json.RawMessage(payload) ev.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) events = append(events, ev) } return events, rows.Err() } // TimelineHandler serves LOTL timeline merged with court events for a host. func TimelineHandler(c *Court) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { hostID := r.PathValue("id") if hostID == "" { http.Error(w, "host_id required", http.StatusBadRequest) return } type entry struct { Kind string `json:"kind"` Timestamp time.Time `json:"timestamp"` Data interface{} `json:"data"` } var timeline []entry if c.Fleet != nil { lotl, err := c.Fleet.ListLOTL(r.Context(), hostID, 100) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } for _, a := range lotl { timeline = append(timeline, entry{Kind: "lotl", Timestamp: a.CreatedAt, Data: a}) } } rows, err := c.DB.QueryContext(r.Context(), ` SELECT id, status, COALESCE(verdict,''), created_at, COALESCE(resolved_at,'') FROM court_sessions WHERE host_id = ? ORDER BY created_at DESC LIMIT 20`, hostID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() for rows.Next() { var id, status, verdict, created, resolved string if err := rows.Scan(&id, &status, &verdict, &created, &resolved); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } ts, _ := time.Parse("2006-01-02 15:04:05", created) timeline = append(timeline, entry{ Kind: "court", Timestamp: ts, Data: map[string]string{"session_id": id, "status": status, "verdict": verdict}, }) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "host_id": hostID, "timeline": timeline, }) } }