james-dashboard/server.go

1066 lines
26 KiB
Go

package main
import (
"context"
"encoding/json"
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/google/uuid"
)
// ============================================
// TASKS
// ============================================
type Task struct {
ID string `json:"id"`
Text string `json:"text,omitempty"`
Title string `json:"title,omitempty"`
Priority string `json:"priority"`
Status string `json:"status,omitempty"`
Owner string `json:"owner,omitempty"` // "johan" or "james"
Domain string `json:"domain,omitempty"` // Kaseya, inou, Sophia, ClawdNode, Infrastructure, etc.
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
Updated string `json:"updated,omitempty"`
}
type TaskStore struct {
Tasks []Task `json:"tasks"`
mu sync.RWMutex
path string
}
func NewTaskStore(path string) (*TaskStore, error) {
store := &TaskStore{path: path, Tasks: []Task{}}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return store, store.save()
}
return nil, err
}
var wrapper struct {
Tasks []Task `json:"tasks"`
}
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Tasks != nil {
store.Tasks = wrapper.Tasks
return store, nil
}
if err := json.Unmarshal(data, &store.Tasks); err != nil {
store.Tasks = []Task{}
return store, store.save()
}
return store, nil
}
func (s *TaskStore) save() error {
wrapper := struct {
Tasks []Task `json:"tasks"`
}{Tasks: s.Tasks}
data, err := json.MarshalIndent(wrapper, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}
func (s *TaskStore) Add(task Task) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
task.ID = uuid.New().String()[:8]
task.CreatedAt = time.Now()
if task.Priority == "" {
task.Priority = "medium"
}
if task.Status == "" {
task.Status = "pending"
}
if task.Owner == "" {
task.Owner = "james"
}
s.Tasks = append(s.Tasks, task)
return task, s.save()
}
func (s *TaskStore) Update(id string, updates Task) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, task := range s.Tasks {
if task.ID == id {
if updates.Title != "" {
s.Tasks[i].Title = updates.Title
}
if updates.Text != "" {
s.Tasks[i].Text = updates.Text
}
if updates.Priority != "" {
s.Tasks[i].Priority = updates.Priority
}
if updates.Status != "" {
s.Tasks[i].Status = updates.Status
}
if updates.Owner != "" {
s.Tasks[i].Owner = updates.Owner
}
if updates.Domain != "" {
s.Tasks[i].Domain = updates.Domain
}
if updates.Notes != "" {
s.Tasks[i].Notes = updates.Notes
}
s.Tasks[i].Updated = time.Now().Format(time.RFC3339)
s.save()
return s.Tasks[i], true
}
}
return Task{}, false
}
func (s *TaskStore) List() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
return s.Tasks
}
func (s *TaskStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, task := range s.Tasks {
if task.ID == id {
s.Tasks = append(s.Tasks[:i], s.Tasks[i+1:]...)
s.save()
return true
}
}
return false
}
// ============================================
// NEWS
// ============================================
type NewsItem struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"` // info, success, warning, error
Source string `json:"source,omitempty"`
URL string `json:"url,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type NewsStore struct {
Items []NewsItem `json:"items"`
mu sync.RWMutex
path string
}
func NewNewsStore(path string) (*NewsStore, error) {
store := &NewsStore{path: path, Items: []NewsItem{}}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return store, store.save()
}
return nil, err
}
var wrapper struct {
Items []NewsItem `json:"items"`
}
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Items != nil {
store.Items = wrapper.Items
return store, nil
}
return store, store.save()
}
func (s *NewsStore) save() error {
wrapper := struct {
Items []NewsItem `json:"items"`
}{Items: s.Items}
data, err := json.MarshalIndent(wrapper, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}
func (s *NewsStore) Add(item NewsItem) (NewsItem, error) {
s.mu.Lock()
defer s.mu.Unlock()
item.ID = uuid.New().String()[:8]
item.Timestamp = time.Now()
if item.Type == "" {
item.Type = "info"
}
// Prepend (newest first)
s.Items = append([]NewsItem{item}, s.Items...)
// Keep only last 50 items
if len(s.Items) > 50 {
s.Items = s.Items[:50]
}
return item, s.save()
}
func (s *NewsStore) List(limit int) []NewsItem {
s.mu.RLock()
defer s.mu.RUnlock()
if limit > 0 && limit < len(s.Items) {
return s.Items[:limit]
}
return s.Items
}
func (s *NewsStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, item := range s.Items {
if item.ID == id {
s.Items = append(s.Items[:i], s.Items[i+1:]...)
s.save()
return true
}
}
return false
}
func (s *NewsStore) Clear() error {
s.mu.Lock()
defer s.mu.Unlock()
s.Items = []NewsItem{}
return s.save()
}
// ============================================
// BRIEFINGS
// ============================================
type Briefing struct {
ID string `json:"id"`
Date string `json:"date"`
Title string `json:"title"`
Weather string `json:"weather,omitempty"`
Markets string `json:"markets,omitempty"`
News string `json:"news,omitempty"`
Tasks string `json:"tasks,omitempty"`
Summary string `json:"summary,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type BriefingStore struct {
Briefings []Briefing `json:"briefings"`
mu sync.RWMutex
path string
}
func NewBriefingStore(path string) (*BriefingStore, error) {
store := &BriefingStore{path: path, Briefings: []Briefing{}}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return store, store.save()
}
return nil, err
}
var wrapper struct {
Briefings []Briefing `json:"briefings"`
}
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Briefings != nil {
store.Briefings = wrapper.Briefings
return store, nil
}
return store, store.save()
}
func (s *BriefingStore) save() error {
wrapper := struct {
Briefings []Briefing `json:"briefings"`
}{Briefings: s.Briefings}
data, err := json.MarshalIndent(wrapper, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}
func (s *BriefingStore) Add(b Briefing) (Briefing, error) {
s.mu.Lock()
defer s.mu.Unlock()
b.ID = uuid.New().String()[:8]
b.CreatedAt = time.Now()
if b.Date == "" {
b.Date = time.Now().Format("2006-01-02")
}
if b.Title == "" {
b.Title = "Morning Brief"
}
s.Briefings = append(s.Briefings, b)
return b, s.save()
}
func (s *BriefingStore) List(limit int) []Briefing {
s.mu.RLock()
defer s.mu.RUnlock()
// Sort by date descending
sorted := make([]Briefing, len(s.Briefings))
copy(sorted, s.Briefings)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].CreatedAt.After(sorted[j].CreatedAt)
})
if limit > 0 && limit < len(sorted) {
return sorted[:limit]
}
return sorted
}
func (s *BriefingStore) Get(id string) (Briefing, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, b := range s.Briefings {
if b.ID == id {
return b, true
}
}
return Briefing{}, false
}
func (s *BriefingStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, b := range s.Briefings {
if b.ID == id {
s.Briefings = append(s.Briefings[:i], s.Briefings[i+1:]...)
s.save()
return true
}
}
return false
}
// ============================================
// DELIVERIES
// ============================================
type Delivery struct {
ID string `json:"id"`
Carrier string `json:"carrier,omitempty"` // UPS, FedEx, USPS, etc.
Retailer string `json:"retailer,omitempty"` // Amazon, Nordstrom, etc.
Description string `json:"description"` // What's in the package
TrackingNumber string `json:"tracking_number,omitempty"`
TrackingURL string `json:"tracking_url,omitempty"`
ExpectedDate string `json:"expected_date,omitempty"` // "2026-02-03" or "Monday"
Status string `json:"status"` // shipped, in_transit, out_for_delivery, delayed, delivered
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type DeliveryStore struct {
Deliveries []Delivery `json:"deliveries"`
mu sync.RWMutex
path string
}
func NewDeliveryStore(path string) (*DeliveryStore, error) {
store := &DeliveryStore{path: path, Deliveries: []Delivery{}}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return store, store.save()
}
return nil, err
}
var wrapper struct {
Deliveries []Delivery `json:"deliveries"`
}
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Deliveries != nil {
store.Deliveries = wrapper.Deliveries
return store, nil
}
return store, store.save()
}
func (s *DeliveryStore) save() error {
wrapper := struct {
Deliveries []Delivery `json:"deliveries"`
}{Deliveries: s.Deliveries}
data, err := json.MarshalIndent(wrapper, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}
func (s *DeliveryStore) Add(d Delivery) (Delivery, error) {
s.mu.Lock()
defer s.mu.Unlock()
d.ID = uuid.New().String()[:8]
d.CreatedAt = time.Now()
d.UpdatedAt = time.Now()
if d.Status == "" {
d.Status = "shipped"
}
s.Deliveries = append(s.Deliveries, d)
return d, s.save()
}
func (s *DeliveryStore) Update(id string, updates Delivery) (Delivery, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, d := range s.Deliveries {
if d.ID == id {
if updates.Carrier != "" {
s.Deliveries[i].Carrier = updates.Carrier
}
if updates.Retailer != "" {
s.Deliveries[i].Retailer = updates.Retailer
}
if updates.Description != "" {
s.Deliveries[i].Description = updates.Description
}
if updates.TrackingNumber != "" {
s.Deliveries[i].TrackingNumber = updates.TrackingNumber
}
if updates.TrackingURL != "" {
s.Deliveries[i].TrackingURL = updates.TrackingURL
}
if updates.ExpectedDate != "" {
s.Deliveries[i].ExpectedDate = updates.ExpectedDate
}
if updates.Status != "" {
s.Deliveries[i].Status = updates.Status
}
if updates.Notes != "" {
s.Deliveries[i].Notes = updates.Notes
}
s.Deliveries[i].UpdatedAt = time.Now()
s.save()
return s.Deliveries[i], true
}
}
return Delivery{}, false
}
func (s *DeliveryStore) List() []Delivery {
s.mu.RLock()
defer s.mu.RUnlock()
// Return non-delivered items, sorted by expected date
var active []Delivery
for _, d := range s.Deliveries {
if d.Status != "delivered" {
active = append(active, d)
}
}
sort.Slice(active, func(i, j int) bool {
return active[i].ExpectedDate < active[j].ExpectedDate
})
return active
}
func (s *DeliveryStore) ListAll() []Delivery {
s.mu.RLock()
defer s.mu.RUnlock()
return s.Deliveries
}
func (s *DeliveryStore) Get(id string) (Delivery, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, d := range s.Deliveries {
if d.ID == id {
return d, true
}
}
return Delivery{}, false
}
func (s *DeliveryStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, d := range s.Deliveries {
if d.ID == id {
s.Deliveries = append(s.Deliveries[:i], s.Deliveries[i+1:]...)
s.save()
return true
}
}
return false
}
// Find by tracking number or description match
func (s *DeliveryStore) FindByTracking(tracking string) (Delivery, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, d := range s.Deliveries {
if d.TrackingNumber == tracking {
return d, true
}
}
return Delivery{}, false
}
// ============================================
// AGENTS (reads from OpenClaw config)
// ============================================
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Emoji string `json:"emoji"`
URL string `json:"url"`
Default bool `json:"default,omitempty"`
}
func getAgents(gatewayIP string, gatewayPort string) []Agent {
// Read OpenClaw config
home, _ := os.UserHomeDir()
configPath := filepath.Join(home, ".openclaw", "openclaw.json")
data, err := os.ReadFile(configPath)
if err != nil {
log.Printf("Failed to read openclaw config: %v", err)
return []Agent{}
}
var config struct {
Agents struct {
List []struct {
ID string `json:"id"`
Name string `json:"name"`
Identity struct {
Name string `json:"name"`
Emoji string `json:"emoji"`
} `json:"identity"`
} `json:"list"`
} `json:"agents"`
}
if err := json.Unmarshal(data, &config); err != nil {
log.Printf("Failed to parse openclaw config: %v", err)
return []Agent{}
}
var agents []Agent
baseURL := "http://" + gatewayIP + ":" + gatewayPort
for _, agentConfig := range config.Agents.List {
agent := Agent{
ID: agentConfig.ID,
Name: agentConfig.Identity.Name,
Emoji: agentConfig.Identity.Emoji,
}
// Set defaults if missing
if agent.Name == "" {
agent.Name = agentConfig.Name
}
if agent.Name == "" {
agent.Name = agentConfig.ID
}
if agent.Emoji == "" {
agent.Emoji = "🤖"
}
// Build URL - main is default
if agentConfig.ID == "main" {
agent.Default = true
agent.URL = baseURL + "/"
} else {
agent.URL = baseURL + "/agents/" + agentConfig.ID
}
agents = append(agents, agent)
}
// Sort: default first, then alphabetically
sort.Slice(agents, func(i, j int) bool {
if agents[i].Default != agents[j].Default {
return agents[i].Default
}
return agents[i].ID < agents[j].ID
})
return agents
}
// ============================================
// MAIN
// ============================================
func main() {
port := flag.String("port", "9200", "port to listen on")
dir := flag.String("dir", ".", "directory to serve")
gatewayIP := flag.String("gateway-ip", "192.168.1.16", "OpenClaw gateway IP")
gatewayPort := flag.String("gateway-port", "18789", "OpenClaw gateway port")
flag.Parse()
// Initialize stores
taskStore, err := NewTaskStore(filepath.Join(*dir, "data", "tasks.json"))
if err != nil {
log.Fatalf("Failed to initialize task store: %v", err)
}
briefingStore, err := NewBriefingStore(filepath.Join(*dir, "data", "briefings.json"))
if err != nil {
log.Fatalf("Failed to initialize briefing store: %v", err)
}
newsStore, err := NewNewsStore(filepath.Join(*dir, "data", "news.json"))
if err != nil {
log.Fatalf("Failed to initialize news store: %v", err)
}
deliveryStore, err := NewDeliveryStore(filepath.Join(*dir, "data", "deliveries.json"))
if err != nil {
log.Fatalf("Failed to initialize delivery store: %v", err)
}
mux := http.NewServeMux()
// CORS middleware helper
cors := func(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
// ========== TASKS API ==========
mux.HandleFunc("/api/tasks", func(w http.ResponseWriter, r *http.Request) {
cors(w)
if r.Method == "OPTIONS" {
return
}
switch r.Method {
case "GET":
json.NewEncoder(w).Encode(map[string]interface{}{
"tasks": taskStore.List(),
})
case "POST":
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
return
}
if task.Text == "" && task.Title == "" {
http.Error(w, `{"error": "text or title required"}`, http.StatusBadRequest)
return
}
newTask, err := taskStore.Add(task)
if err != nil {
http.Error(w, `{"error": "failed to save task"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(newTask)
default:
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
}
})
// Task by ID (DELETE, PATCH)
mux.HandleFunc("/api/tasks/", func(w http.ResponseWriter, r *http.Request) {
cors(w)
if r.Method == "OPTIONS" {
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
if id == "" {
http.Error(w, `{"error": "task id required"}`, http.StatusBadRequest)
return
}
switch r.Method {
case "DELETE":
if taskStore.Delete(id) {
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id})
} else {
http.Error(w, `{"error": "task not found"}`, http.StatusNotFound)
}
case "PATCH", "PUT":
var updates Task
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
return
}
if updated, ok := taskStore.Update(id, updates); ok {
json.NewEncoder(w).Encode(updated)
} else {
http.Error(w, `{"error": "task not found"}`, http.StatusNotFound)
}
default:
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
}
})
// ========== NEWS API ==========
mux.HandleFunc("/api/news", func(w http.ResponseWriter, r *http.Request) {
cors(w)
if r.Method == "OPTIONS" {
return
}
switch r.Method {
case "GET":
json.NewEncoder(w).Encode(map[string]interface{}{
"items": newsStore.List(20),
})
case "POST":
var item NewsItem
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
return
}
if item.Title == "" {
http.Error(w, `{"error": "title required"}`, http.StatusBadRequest)
return
}
newItem, err := newsStore.Add(item)
if err != nil {
http.Error(w, `{"error": "failed to save news"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(newItem)
case "DELETE":
// DELETE /api/news clears all news
if err := newsStore.Clear(); err != nil {
http.Error(w, `{"error": "failed to clear news"}`, http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "cleared"})
default:
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
}
})
// News by ID
mux.HandleFunc("/api/news/", func(w http.ResponseWriter, r *http.Request) {
cors(w)
if r.Method == "OPTIONS" {
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/news/")
if id == "" {
http.Error(w, `{"error": "news id required"}`, http.StatusBadRequest)
return
}
switch r.Method {
case "DELETE":
if newsStore.Delete(id) {
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id})
} else {
http.Error(w, `{"error": "news item not found"}`, http.StatusNotFound)
}
default:
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
}
})
// ========== BRIEFINGS API ==========
mux.HandleFunc("/api/briefings", func(w http.ResponseWriter, r *http.Request) {
cors(w)
if r.Method == "OPTIONS" {
return
}
switch r.Method {
case "GET":
limit := 30 // default
json.NewEncoder(w).Encode(map[string]interface{}{
"briefings": briefingStore.List(limit),
})
case "POST":
var b Briefing
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
return
}
newBriefing, err := briefingStore.Add(b)
if err != nil {
http.Error(w, `{"error": "failed to save briefing"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(newBriefing)
default:
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
}
})
// Briefing by ID
mux.HandleFunc("/api/briefings/", func(w http.ResponseWriter, r *http.Request) {
cors(w)
if r.Method == "OPTIONS" {
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/briefings/")
if id == "" {
http.Error(w, `{"error": "briefing id required"}`, http.StatusBadRequest)
return
}
switch r.Method {
case "GET":
if b, ok := briefingStore.Get(id); ok {
json.NewEncoder(w).Encode(b)
} else {
http.Error(w, `{"error": "briefing not found"}`, http.StatusNotFound)
}
case "DELETE":
if briefingStore.Delete(id) {
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id})
} else {
http.Error(w, `{"error": "briefing not found"}`, http.StatusNotFound)
}
default:
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
}
})
// ========== DELIVERIES API ==========
mux.HandleFunc("/api/deliveries", func(w http.ResponseWriter, r *http.Request) {
cors(w)
if r.Method == "OPTIONS" {
return
}
switch r.Method {
case "GET":
// ?all=true returns everything including delivered
if r.URL.Query().Get("all") == "true" {
json.NewEncoder(w).Encode(map[string]interface{}{
"deliveries": deliveryStore.ListAll(),
})
} else {
json.NewEncoder(w).Encode(map[string]interface{}{
"deliveries": deliveryStore.List(),
})
}
case "POST":
var d Delivery
if err := json.NewDecoder(r.Body).Decode(&d); err != nil {
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
return
}
if d.Description == "" {
http.Error(w, `{"error": "description required"}`, http.StatusBadRequest)
return
}
newDelivery, err := deliveryStore.Add(d)
if err != nil {
http.Error(w, `{"error": "failed to save delivery"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(newDelivery)
default:
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
}
})
// Delivery by ID
mux.HandleFunc("/api/deliveries/", func(w http.ResponseWriter, r *http.Request) {
cors(w)
if r.Method == "OPTIONS" {
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/deliveries/")
if id == "" {
http.Error(w, `{"error": "delivery id required"}`, http.StatusBadRequest)
return
}
switch r.Method {
case "GET":
if d, ok := deliveryStore.Get(id); ok {
json.NewEncoder(w).Encode(d)
} else {
http.Error(w, `{"error": "delivery not found"}`, http.StatusNotFound)
}
case "PATCH", "PUT":
var updates Delivery
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
return
}
if updated, ok := deliveryStore.Update(id, updates); ok {
json.NewEncoder(w).Encode(updated)
} else {
http.Error(w, `{"error": "delivery not found"}`, http.StatusNotFound)
}
case "DELETE":
if deliveryStore.Delete(id) {
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "id": id})
} else {
http.Error(w, `{"error": "delivery not found"}`, http.StatusNotFound)
}
default:
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
}
})
// ========== AGENTS API ==========
mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) {
cors(w)
if r.Method == "OPTIONS" {
return
}
if r.Method == "GET" {
agents := getAgents(*gatewayIP, *gatewayPort)
json.NewEncoder(w).Encode(map[string]interface{}{
"agents": agents,
"gateway_ip": *gatewayIP,
"gateway_port": *gatewayPort,
})
} else {
http.Error(w, `{"error": "method not allowed"}`, http.StatusMethodNotAllowed)
}
})
// Static files (fallback)
fs := http.FileServer(http.Dir(*dir))
mux.Handle("/", fs)
server := &http.Server{
Addr: "0.0.0.0:" + *port,
Handler: mux,
}
// Graceful shutdown
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Println("Shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)
}()
log.Printf("James Dashboard serving %s on http://0.0.0.0:%s\n", *dir, *port)
log.Printf("API endpoints:\n")
log.Printf(" Tasks:\n")
log.Printf(" GET /api/tasks - list all tasks\n")
log.Printf(" POST /api/tasks - add task {title, priority, status, owner, notes}\n")
log.Printf(" PATCH /api/tasks/:id - update task\n")
log.Printf(" DELETE /api/tasks/:id - remove task\n")
log.Printf(" News:\n")
log.Printf(" GET /api/news - list news (newest first, max 20)\n")
log.Printf(" POST /api/news - add news {title, body, type, source, url}\n")
log.Printf(" DELETE /api/news - clear all news\n")
log.Printf(" DELETE /api/news/:id - remove news item\n")
log.Printf(" Briefings:\n")
log.Printf(" GET /api/briefings - list briefings (newest first)\n")
log.Printf(" POST /api/briefings - add briefing {title, date, weather, markets, news, summary}\n")
log.Printf(" GET /api/briefings/:id - get single briefing\n")
log.Printf(" DELETE /api/briefings/:id - remove briefing\n")
log.Printf(" Deliveries:\n")
log.Printf(" GET /api/deliveries - list active deliveries (excludes delivered)\n")
log.Printf(" GET /api/deliveries?all=true - list all deliveries\n")
log.Printf(" POST /api/deliveries - add delivery {description, carrier, retailer, tracking_number, expected_date, status}\n")
log.Printf(" GET /api/deliveries/:id - get single delivery\n")
log.Printf(" PATCH /api/deliveries/:id - update delivery\n")
log.Printf(" DELETE /api/deliveries/:id - remove delivery\n")
log.Printf(" Agents:\n")
log.Printf(" GET /api/agents - list OpenClaw agents (reads from config)\n")
log.Printf(" Gateway: http://%s:%s\n", *gatewayIP, *gatewayPort)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}