package main import ( "embed" "encoding/json" "flag" "fmt" "io/fs" "net/http" "os" "path/filepath" "sync" "time" "github.com/gorilla/websocket" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" ) //go:embed web var webFiles embed.FS var ( // Command-line flags port = flag.Int("port", 8080, "Port for web server") host = flag.String("host", "localhost", "Host to bind to") dbPath = flag.String("db", "", "Path to beads database (optional, will auto-detect)") socketPath = flag.String("socket", "", "Path to daemon socket (optional, will auto-detect)") // WebSocket upgrader upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { // Allow all origins for simplicity (consider restricting in production) return true }, } // WebSocket client management wsClients = make(map[*websocket.Conn]bool) wsClientsMu sync.Mutex wsBroadcast = make(chan []byte, 256) // RPC client for daemon communication daemonClient *rpc.Client ) func main() { defer func() { if r := recover(); r != nil { fmt.Fprintf(os.Stderr, "PANIC in main: %v\n", r) } fmt.Println("Main function exiting") }() flag.Parse() // Find database path if not specified dbPathResolved := *dbPath if dbPathResolved == "" { if foundDB := beads.FindDatabasePath(); foundDB != "" { dbPathResolved = foundDB } else { fmt.Fprintf(os.Stderr, "Error: no beads database found\n") fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to create a database in the current directory\n") fmt.Fprintf(os.Stderr, "Or specify database path with -db flag\n") os.Exit(1) } } // Resolve socket path socketPathResolved := *socketPath if socketPathResolved == "" { socketPathResolved = getSocketPath(dbPathResolved) } // Connect to daemon if err := connectToDaemon(socketPathResolved, dbPathResolved); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Start WebSocket broadcaster go handleWebSocketBroadcast() // Start mutation polling go pollMutations() // Set up HTTP routes http.HandleFunc("/", handleIndex) http.HandleFunc("/api/issues", handleAPIIssues) http.HandleFunc("/api/issues/", handleAPIIssueDetail) http.HandleFunc("/api/ready", handleAPIReady) http.HandleFunc("/api/stats", handleAPIStats) http.HandleFunc("/ws", handleWebSocket) // Serve static files webFS, err := fs.Sub(webFiles, "web") if err != nil { fmt.Fprintf(os.Stderr, "Error accessing web files: %v\n", err) os.Exit(1) } http.Handle("/static/", http.StripPrefix("/", http.FileServer(http.FS(webFS)))) addr := fmt.Sprintf("%s:%d", *host, *port) fmt.Printf("🖥️ bd monitor-webui starting on http://%s\n", addr) fmt.Printf("📊 Open your browser to view real-time issue tracking\n") fmt.Printf("🔌 WebSocket endpoint available at ws://%s/ws\n", addr) fmt.Printf("Press Ctrl+C to stop\n\n") if err := http.ListenAndServe(addr, nil); err != nil { fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) os.Exit(1) } } // getSocketPath returns the Unix socket path for the daemon func getSocketPath(dbPath string) string { // The daemon always creates the socket as "bd.sock" in the same directory as the database dbDir := filepath.Dir(dbPath) return filepath.Join(dbDir, "bd.sock") } // connectToDaemon establishes connection to the daemon func connectToDaemon(socketPath, dbPath string) error { client, err := rpc.TryConnect(socketPath) if err != nil || client == nil { return fmt.Errorf("bd monitor-webui requires the daemon to be running\n\n"+ "The monitor uses the daemon's RPC interface to avoid database locking conflicts.\n"+ "Please start the daemon first:\n\n"+ " bd daemon\n\n"+ "Then start the monitor:\n\n"+ " %s\n", os.Args[0]) } // Check daemon health health, err := client.Health() if err != nil || health.Status != "healthy" { _ = client.Close() if err != nil { return fmt.Errorf("daemon health check failed: %v", err) } errMsg := fmt.Sprintf("daemon is not healthy (status: %s)", health.Status) if health.Error != "" { errMsg += fmt.Sprintf("\nError: %s", health.Error) } return fmt.Errorf("%s\n\nTry restarting the daemon:\n bd daemon --stop\n bd daemon", errMsg) } // Set database path absDBPath, _ := filepath.Abs(dbPath) client.SetDatabasePath(absDBPath) daemonClient = client fmt.Printf("✓ Connected to daemon (version %s)\n", health.Version) return nil } // handleIndex serves the main HTML page func handleIndex(w http.ResponseWriter, r *http.Request) { // Only serve index for root path if r.URL.Path != "/" { http.NotFound(w, r) return } webFS, err := fs.Sub(webFiles, "web") if err != nil { http.Error(w, "Error accessing web files", http.StatusInternalServerError) return } data, err := fs.ReadFile(webFS, "index.html") if err != nil { http.Error(w, "Error reading index.html", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(data) } // handleAPIIssues returns all issues as JSON func handleAPIIssues(w http.ResponseWriter, r *http.Request) { var issues []*types.Issue if daemonClient == nil { http.Error(w, "Daemon client not initialized", http.StatusInternalServerError) return } // Use RPC to get issues from daemon resp, err := daemonClient.List(&rpc.ListArgs{}) if err != nil { http.Error(w, fmt.Sprintf("Error fetching issues via RPC: %v", err), http.StatusInternalServerError) return } if err := json.Unmarshal(resp.Data, &issues); err != nil { http.Error(w, fmt.Sprintf("Error unmarshaling issues: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(issues) } // handleAPIIssueDetail returns a single issue's details func handleAPIIssueDetail(w http.ResponseWriter, r *http.Request) { // Extract issue ID from URL path (e.g., /api/issues/bd-1) issueID := r.URL.Path[len("/api/issues/"):] if issueID == "" { http.Error(w, "Issue ID required", http.StatusBadRequest) return } if daemonClient == nil { http.Error(w, "Daemon client not initialized", http.StatusInternalServerError) return } var issue *types.Issue // Use RPC to get issue from daemon resp, err := daemonClient.Show(&rpc.ShowArgs{ID: issueID}) if err != nil { http.Error(w, fmt.Sprintf("Issue not found: %v", err), http.StatusNotFound) return } if err := json.Unmarshal(resp.Data, &issue); err != nil { http.Error(w, fmt.Sprintf("Error unmarshaling issue: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(issue) } // handleAPIReady returns ready work (no blockers) func handleAPIReady(w http.ResponseWriter, r *http.Request) { var issues []*types.Issue if daemonClient == nil { http.Error(w, "Daemon client not initialized", http.StatusInternalServerError) return } // Use RPC to get ready work from daemon resp, err := daemonClient.Ready(&rpc.ReadyArgs{}) if err != nil { http.Error(w, fmt.Sprintf("Error fetching ready work via RPC: %v", err), http.StatusInternalServerError) return } if err := json.Unmarshal(resp.Data, &issues); err != nil { http.Error(w, fmt.Sprintf("Error unmarshaling issues: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(issues) } // handleAPIStats returns issue statistics func handleAPIStats(w http.ResponseWriter, r *http.Request) { var stats *types.Statistics if daemonClient == nil { http.Error(w, "Daemon client not initialized", http.StatusInternalServerError) return } // Use RPC to get stats from daemon resp, err := daemonClient.Stats() if err != nil { http.Error(w, fmt.Sprintf("Error fetching statistics via RPC: %v", err), http.StatusInternalServerError) return } if err := json.Unmarshal(resp.Data, &stats); err != nil { http.Error(w, fmt.Sprintf("Error unmarshaling statistics: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stats) } // handleWebSocket upgrades HTTP connection to WebSocket and manages client lifecycle func handleWebSocket(w http.ResponseWriter, r *http.Request) { // Upgrade connection to WebSocket conn, err := upgrader.Upgrade(w, r, nil) if err != nil { fmt.Fprintf(os.Stderr, "Error upgrading to WebSocket: %v\n", err) return } // Register client wsClientsMu.Lock() wsClients[conn] = true wsClientsMu.Unlock() fmt.Printf("WebSocket client connected (total: %d)\n", len(wsClients)) // Handle client disconnection defer func() { wsClientsMu.Lock() delete(wsClients, conn) wsClientsMu.Unlock() conn.Close() fmt.Printf("WebSocket client disconnected (total: %d)\n", len(wsClients)) }() // Keep connection alive and handle client messages for { _, _, err := conn.ReadMessage() if err != nil { break } } } // handleWebSocketBroadcast sends messages to all connected WebSocket clients func handleWebSocketBroadcast() { defer func() { if r := recover(); r != nil { fmt.Fprintf(os.Stderr, "PANIC in handleWebSocketBroadcast: %v\n", r) } }() for { // Wait for message to broadcast message := <-wsBroadcast // Send to all connected clients wsClientsMu.Lock() for client := range wsClients { err := client.WriteMessage(websocket.TextMessage, message) if err != nil { // Client disconnected, will be cleaned up by handleWebSocket fmt.Fprintf(os.Stderr, "Error writing to WebSocket client: %v\n", err) client.Close() delete(wsClients, client) } } wsClientsMu.Unlock() } } // pollMutations polls the daemon for mutations and broadcasts them to WebSocket clients func pollMutations() { defer func() { if r := recover(); r != nil { fmt.Fprintf(os.Stderr, "PANIC in pollMutations: %v\n", r) } }() lastPollTime := int64(0) // Start from beginning ticker := time.NewTicker(2 * time.Second) // Poll every 2 seconds defer ticker.Stop() for range ticker.C { if daemonClient == nil { continue } // Call GetMutations RPC resp, err := daemonClient.GetMutations(&rpc.GetMutationsArgs{ Since: lastPollTime, }) if err != nil { // Daemon might be down or restarting, just skip this poll continue } var mutations []rpc.MutationEvent if err := json.Unmarshal(resp.Data, &mutations); err != nil { fmt.Fprintf(os.Stderr, "Error unmarshaling mutations: %v\n", err) continue } // Broadcast each mutation to WebSocket clients for _, mutation := range mutations { data, _ := json.Marshal(mutation) wsBroadcast <- data // Update last poll time to this mutation's timestamp mutationTimeMillis := mutation.Timestamp.UnixMilli() if mutationTimeMillis > lastPollTime { lastPollTime = mutationTimeMillis } } } }