Fixes status filter bug, adds multi-select priority filter, find-as-you-type search, and interactive stats cards. Includes visual improvements for hover effects and alignment. Co-authored-by: maphew <matt.wilkie@yukon.ca>
404 lines
11 KiB
Go
404 lines
11 KiB
Go
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)")
|
||
devMode = flag.Bool("dev", false, "Run in development mode (serve web files from disk)")
|
||
|
||
// 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
|
||
|
||
// File system for web files
|
||
webFS fs.FS
|
||
)
|
||
|
||
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()
|
||
|
||
// Set up web file system
|
||
if *devMode {
|
||
fmt.Println("⚠️ Running in DEVELOPMENT mode: serving web files from disk")
|
||
webFS = os.DirFS("web")
|
||
} else {
|
||
var err error
|
||
webFS, err = fs.Sub(webFiles, "web")
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error accessing embedded web files: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|