diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 9a3b6aa19..29de28883 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -17,6 +17,7 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" + "github.com/sipeed/picoclaw/pkg/dashboard" "github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/heartbeat" @@ -187,6 +188,19 @@ func gatewayCmd() { } healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + if cfg.Dashboard.Enabled { + if cfg.Dashboard.Password == "" { + cfg.Dashboard.Password = dashboard.GeneratePassword() + configPath := getConfigPath() + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + logger.ErrorCF("dashboard", "Failed to save generated password", map[string]any{"error": saveErr.Error()}) + } + fmt.Printf("🔑 Dashboard password generated: %s\n", cfg.Dashboard.Password) + } + dashConfigPath := getConfigPath() + dashboard.Mount(healthServer, cfg, agentLoop, channelManager, dashConfigPath) + fmt.Printf("✓ Dashboard available at http://%s:%d/dashboard\n", cfg.Gateway.Host, cfg.Gateway.Port) + } go func() { if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()}) diff --git a/pkg/config/config.go b/pkg/config/config.go index 20556011a..928b2d233 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -54,6 +54,7 @@ type Config struct { Providers ProvidersConfig `json:"providers,omitempty"` ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway"` + Dashboard DashboardConfig `json:"dashboard"` Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` @@ -412,6 +413,11 @@ type GatewayConfig struct { Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` } +type DashboardConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_DASHBOARD_ENABLED"` + Password string `json:"password" env:"PICOCLAW_DASHBOARD_PASSWORD"` +} + type BraveConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7654326e7..79f819058 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -267,6 +267,10 @@ func DefaultConfig() *Config { Host: "0.0.0.0", Port: 18790, }, + Dashboard: DashboardConfig{ + Enabled: true, + Password: "", + }, Tools: ToolsConfig{ Web: WebToolsConfig{ Brave: BraveConfig{ diff --git a/pkg/dashboard/api.go b/pkg/dashboard/api.go new file mode 100644 index 000000000..9179f22cd --- /dev/null +++ b/pkg/dashboard/api.go @@ -0,0 +1,129 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func statusHandler(cfg *config.Config, al *agent.AgentLoop, cm *channels.Manager, startTime time.Time) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + info := al.GetStartupInfo() + channelStatus := cm.GetStatus() + + resp := map[string]any{ + "uptime": time.Since(startTime).String(), + "running": true, + "tools": info["tools"], + "skills": info["skills"], + "agents": info["agents"], + "channels": channelStatus, + "model": cfg.Agents.Defaults.Model, + } + writeJSON(w, resp) + } +} + +func configGetHandler(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + masked := maskConfig(cfg) + writeJSON(w, masked) + } +} + +func agentsHandler(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "defaults": cfg.Agents.Defaults, + "list": cfg.Agents.List, + } + writeJSON(w, resp) + } +} + +func modelsHandler(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + masked := make([]map[string]any, 0, len(cfg.ModelList)) + for _, m := range cfg.ModelList { + masked = append(masked, map[string]any{ + "model_name": m.ModelName, + "model": m.Model, + "api_base": m.APIBase, + "api_key": maskKey(m.APIKey), + }) + } + writeJSON(w, masked) + } +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func maskKey(key string) string { + if key == "" { + return "" + } + if len(key) <= 8 { + return "****" + } + return key[:3] + "..." + key[len(key)-4:] +} + +func maskConfig(cfg *config.Config) map[string]any { + models := make([]map[string]any, 0, len(cfg.ModelList)) + for _, m := range cfg.ModelList { + models = append(models, map[string]any{ + "model_name": m.ModelName, + "model": m.Model, + "api_base": m.APIBase, + "api_key": maskKey(m.APIKey), + }) + } + + channelMap := map[string]bool{ + "whatsapp": cfg.Channels.WhatsApp.Enabled, + "telegram": cfg.Channels.Telegram.Enabled, + "discord": cfg.Channels.Discord.Enabled, + "feishu": cfg.Channels.Feishu.Enabled, + "maixcam": cfg.Channels.MaixCam.Enabled, + "qq": cfg.Channels.QQ.Enabled, + "dingtalk": cfg.Channels.DingTalk.Enabled, + "slack": cfg.Channels.Slack.Enabled, + "line": cfg.Channels.LINE.Enabled, + "onebot": cfg.Channels.OneBot.Enabled, + "wecom": cfg.Channels.WeCom.Enabled, + "wecom_app": cfg.Channels.WeComApp.Enabled, + } + + return map[string]any{ + "agents": map[string]any{ + "defaults": map[string]any{ + "model": cfg.Agents.Defaults.Model, + "provider": cfg.Agents.Defaults.Provider, + "workspace": cfg.Agents.Defaults.Workspace, + "max_tokens": cfg.Agents.Defaults.MaxTokens, + }, + "list": cfg.Agents.List, + }, + "model_list": models, + "channels": channelMap, + "gateway": map[string]any{ + "host": cfg.Gateway.Host, + "port": cfg.Gateway.Port, + }, + } +} + +func extractProvider(model string) string { + if idx := strings.Index(model, "/"); idx >= 0 { + return model[:idx] + } + return model +} diff --git a/pkg/dashboard/auth.go b/pkg/dashboard/auth.go new file mode 100644 index 000000000..578c294b5 --- /dev/null +++ b/pkg/dashboard/auth.go @@ -0,0 +1,141 @@ +package dashboard + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + cookieName = "picoclaw_session" + sessionMaxAge = 24 * time.Hour + passwordLen = 16 +) + +var alphanumeric = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + +// GeneratePassword returns a random 16-character alphanumeric password. +func GeneratePassword() string { + b := make([]byte, passwordLen) + randomBytes := make([]byte, passwordLen) + if _, err := rand.Read(randomBytes); err != nil { + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + for i := range b { + b[i] = alphanumeric[int(randomBytes[i])%len(alphanumeric)] + } + return string(b) +} + +func authMiddleware(password string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if password == "" { + next(w, r) + return + } + cookie, err := r.Cookie(cookieName) + if err != nil || !verifySession(cookie.Value, password) { + http.Redirect(w, r, "/dashboard/login", http.StatusFound) + return + } + next(w, r) + } +} + +func loginPage(password string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if password == "" { + http.Redirect(w, r, "/dashboard", http.StatusFound) + return + } + serveLogin(w, "") + } +} + +func loginHandler(password string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/dashboard/login", http.StatusFound) + return + } + + submitted := r.FormValue("password") + if !hmac.Equal([]byte(submitted), []byte(password)) { + serveLogin(w, "Invalid password") + return + } + + value, expiry := signSession(password) + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: value, + Path: "/dashboard", + Expires: expiry, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/dashboard", http.StatusFound) + } +} + +func logoutHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/dashboard", + MaxAge: -1, + HttpOnly: true, + }) + http.Redirect(w, r, "/dashboard/login", http.StatusFound) + } +} + +func signSession(password string) (string, time.Time) { + expiry := time.Now().Add(sessionMaxAge) + expiryHex := fmt.Sprintf("%x", expiry.Unix()) + mac := hmac.New(sha256.New, []byte(password)) + mac.Write([]byte(expiryHex)) + sig := hex.EncodeToString(mac.Sum(nil)) + return sig + "." + expiryHex, expiry +} + +func verifySession(cookie, password string) bool { + parts := strings.SplitN(cookie, ".", 2) + if len(parts) != 2 { + return false + } + sig, expiryHex := parts[0], parts[1] + + var expiryUnix int64 + if _, err := fmt.Sscanf(expiryHex, "%x", &expiryUnix); err != nil { + return false + } + if time.Now().Unix() > expiryUnix { + return false + } + + mac := hmac.New(sha256.New, []byte(password)) + mac.Write([]byte(expiryHex)) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(sig), []byte(expected)) +} + +func serveLogin(w http.ResponseWriter, errMsg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + loginHTML, err := staticFiles.ReadFile("static/login.html") + if err != nil { + http.Error(w, "login.html not found", http.StatusInternalServerError) + return + } + html := string(loginHTML) + if errMsg != "" { + html = strings.Replace(html, ``, `

`+errMsg+`

`, 1) + } + w.Write([]byte(html)) +} diff --git a/pkg/dashboard/auth_test.go b/pkg/dashboard/auth_test.go new file mode 100644 index 000000000..361daeca2 --- /dev/null +++ b/pkg/dashboard/auth_test.go @@ -0,0 +1,213 @@ +package dashboard + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestGeneratePassword(t *testing.T) { + pw := GeneratePassword() + if len(pw) != 16 { + t.Fatalf("expected 16 chars, got %d: %q", len(pw), pw) + } + for _, c := range pw { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + t.Fatalf("non-alphanumeric char in password: %c", c) + } + } + // Two passwords should differ + pw2 := GeneratePassword() + if pw == pw2 { + t.Error("two generated passwords should not be identical") + } +} + +func TestSignVerifySession(t *testing.T) { + password := "testpassword123" + cookie, _ := signSession(password) + if !verifySession(cookie, password) { + t.Error("valid session should verify") + } +} + +func TestVerifyExpiredSession(t *testing.T) { + password := "testpassword123" + // Sign normally then replace expiry with a past timestamp (0 = epoch) + cookie, _ := signSession(password) + parts := strings.SplitN(cookie, ".", 2) + pastCookie := parts[0] + ".0" + if verifySession(pastCookie, password) { + t.Error("expired session should not verify") + } +} + +func TestVerifyTamperedSession(t *testing.T) { + password := "testpassword123" + cookie, _ := signSession(password) + + // Tamper with signature + tampered := "deadbeef" + cookie[8:] + if verifySession(tampered, password) { + t.Error("tampered session should not verify") + } +} + +func TestVerifyWrongPassword(t *testing.T) { + cookie, _ := signSession("correct") + if verifySession(cookie, "wrong") { + t.Error("session signed with different password should not verify") + } +} + +func TestVerifyInvalidFormats(t *testing.T) { + tests := []string{ + "", + "noseparator", + "abc.", + ".abc", + "abc.notahexnumber", + } + for _, cookie := range tests { + if verifySession(cookie, "password") { + t.Errorf("invalid cookie %q should not verify", cookie) + } + } +} + +func TestAuthMiddlewareRedirect(t *testing.T) { + called := false + handler := authMiddleware("secret", func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) + w := httptest.NewRecorder() + handler(w, req) + + if called { + t.Error("handler should not be called without auth cookie") + } + if w.Code != http.StatusFound { + t.Fatalf("expected 302, got %d", w.Code) + } + if loc := w.Header().Get("Location"); loc != "/dashboard/login" { + t.Fatalf("expected redirect to /dashboard/login, got %q", loc) + } +} + +func TestAuthMiddlewareValid(t *testing.T) { + password := "secret" + called := false + handler := authMiddleware(password, func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + cookie, expiry := signSession(password) + req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) + req.AddCookie(&http.Cookie{ + Name: cookieName, + Value: cookie, + Expires: expiry, + }) + w := httptest.NewRecorder() + handler(w, req) + + if !called { + t.Error("handler should be called with valid auth cookie") + } + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestNoAuthWhenNoPassword(t *testing.T) { + called := false + handler := authMiddleware("", func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) + w := httptest.NewRecorder() + handler(w, req) + + if !called { + t.Error("handler should be called when password is empty (no auth)") + } + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestLoginHandler(t *testing.T) { + password := "mypassword" + handler := loginHandler(password) + + form := url.Values{"password": {password}} + req := httptest.NewRequest(http.MethodPost, "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("expected 302, got %d", w.Code) + } + if loc := w.Header().Get("Location"); loc != "/dashboard" { + t.Fatalf("expected redirect to /dashboard, got %q", loc) + } + cookies := w.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == cookieName && c.Value != "" { + found = true + } + } + if !found { + t.Error("login should set session cookie") + } +} + +func TestLoginHandlerWrong(t *testing.T) { + password := "mypassword" + handler := loginHandler(password) + + form := url.Values{"password": {"wrongpassword"}} + req := httptest.NewRequest(http.MethodPost, "/dashboard/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 (re-serve login), got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "Invalid password") { + t.Error("wrong password should show error message") + } +} + +func TestLogoutHandler(t *testing.T) { + handler := logoutHandler() + + req := httptest.NewRequest(http.MethodGet, "/dashboard/logout", nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("expected 302, got %d", w.Code) + } + if loc := w.Header().Get("Location"); loc != "/dashboard/login" { + t.Fatalf("expected redirect to /dashboard/login, got %q", loc) + } + cookies := w.Result().Cookies() + for _, c := range cookies { + if c.Name == cookieName && c.MaxAge != -1 { + t.Error("logout should set cookie MaxAge to -1") + } + } +} diff --git a/pkg/dashboard/crud_agents.go b/pkg/dashboard/crud_agents.go new file mode 100644 index 000000000..1422fce67 --- /dev/null +++ b/pkg/dashboard/crud_agents.go @@ -0,0 +1,457 @@ +package dashboard + +import ( + "fmt" + "html/template" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/health" +) + +func registerAgentsCRUD(srv *health.Server, cfg *config.Config, configPath string, auth func(http.HandlerFunc) http.HandlerFunc) { + srv.HandleFunc("/dashboard/fragments/agent-edit", auth(fragmentAgentEdit(cfg))) + srv.HandleFunc("/dashboard/fragments/agent-add", auth(fragmentAgentAdd())) + srv.HandleFunc("/dashboard/crud/agents/create", auth(agentCreateHandler(cfg, configPath))) + srv.HandleFunc("/dashboard/crud/agents/update", auth(agentUpdateHandler(cfg, configPath))) + srv.HandleFunc("/dashboard/crud/agents/delete", auth(agentDeleteHandler(cfg, configPath))) + srv.HandleFunc("/dashboard/fragments/defaults-edit", auth(fragmentDefaultsEdit(cfg))) + srv.HandleFunc("/dashboard/crud/agents/defaults", auth(defaultsUpdateHandler(cfg, configPath))) +} + +const agentFormCSS = `` + +func fragmentAgentEdit(cfg *config.Config) http.HandlerFunc { + const tmpl = `{{.CSS}} +

Edit Agent

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
` + t := template.Must(template.New("agent-edit").Parse(tmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.URL.Query().Get("id") + if agentID == "" { + http.Error(w, "missing id parameter", http.StatusBadRequest) + return + } + + configMu.Lock() + var found *config.AgentConfig + for i := range cfg.Agents.List { + if cfg.Agents.List[i].ID == agentID { + found = &cfg.Agents.List[i] + break + } + } + configMu.Unlock() + + if found == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `

Agent %q not found

`, template.HTMLEscapeString(agentID)) + return + } + + model := "" + if found.Model != nil { + model = found.Model.Primary + } + + ws := resolveAgentWorkspace(cfg, found) + instructions := readAgentInstructions(ws) + + data := map[string]any{ + "CSS": template.HTML(agentFormCSS), + "ID": found.ID, + "Name": found.Name, + "Model": model, + "Skills": strings.Join(found.Skills, ", "), + "Workspace": found.Workspace, + "Default": found.Default, + "Instructions": instructions, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, data) + } +} + +func fragmentAgentAdd() http.HandlerFunc { + const tmpl = `{{.CSS}} +

Add Agent

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
` + t := template.Must(template.New("agent-add").Parse(tmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + data := map[string]any{ + "CSS": template.HTML(agentFormCSS), + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, data) + } +} + +func fragmentDefaultsEdit(cfg *config.Config) http.HandlerFunc { + const tmpl = `{{.CSS}} +

Agent Defaults

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
` + t := template.Must(template.New("defaults-edit").Parse(tmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + configMu.Lock() + data := map[string]any{ + "CSS": template.HTML(agentFormCSS), + "Model": cfg.Agents.Defaults.Model, + "MaxTokens": cfg.Agents.Defaults.MaxTokens, + "MaxToolIterations": cfg.Agents.Defaults.MaxToolIterations, + "Workspace": cfg.Agents.Defaults.Workspace, + "RestrictToWorkspace": cfg.Agents.Defaults.RestrictToWorkspace, + } + configMu.Unlock() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, data) + } +} + +func agentCreateHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonError(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimSpace(r.FormValue("id")) + if id == "" { + jsonError(w, "id is required", http.StatusBadRequest) + return + } + + configMu.Lock() + defer configMu.Unlock() + + for _, a := range cfg.Agents.List { + if a.ID == id { + jsonError(w, "agent with this ID already exists", http.StatusConflict) + return + } + } + + agent := config.AgentConfig{ + ID: id, + Name: strings.TrimSpace(r.FormValue("name")), + Default: r.FormValue("default") == "true", + } + + model := strings.TrimSpace(r.FormValue("model")) + if model != "" { + agent.Model = &config.AgentModelConfig{Primary: model} + } + + agent.Skills = parseSkills(r.FormValue("skills")) + + cfg.Agents.List = append(cfg.Agents.List, agent) + + if err := config.SaveConfig(configPath, cfg); err != nil { + jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError) + return + } + + if instructions := r.FormValue("instructions"); strings.TrimSpace(instructions) != "" { + ws := resolveAgentWorkspace(cfg, &agent) + if err := writeAgentInstructions(ws, instructions); err != nil { + jsonError(w, "agent created but failed to write AGENT.md: "+err.Error(), http.StatusInternalServerError) + return + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("HX-Trigger", "refreshAgents, closeModal") + fmt.Fprint(w, `
Agent created successfully
`) + } +} + +func agentUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonError(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimSpace(r.FormValue("id")) + if id == "" { + jsonError(w, "id is required", http.StatusBadRequest) + return + } + + configMu.Lock() + defer configMu.Unlock() + + var found *config.AgentConfig + for i := range cfg.Agents.List { + if cfg.Agents.List[i].ID == id { + found = &cfg.Agents.List[i] + break + } + } + if found == nil { + jsonError(w, "agent not found", http.StatusNotFound) + return + } + + found.Name = strings.TrimSpace(r.FormValue("name")) + found.Default = r.FormValue("default") == "true" + found.Workspace = strings.TrimSpace(r.FormValue("workspace")) + found.Skills = parseSkills(r.FormValue("skills")) + + model := strings.TrimSpace(r.FormValue("model")) + if model != "" { + if found.Model == nil { + found.Model = &config.AgentModelConfig{} + } + found.Model.Primary = model + } else { + found.Model = nil + } + + if err := config.SaveConfig(configPath, cfg); err != nil { + jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError) + return + } + + instructions := r.FormValue("instructions") + ws := resolveAgentWorkspace(cfg, found) + if err := writeAgentInstructions(ws, instructions); err != nil { + jsonError(w, "agent updated but failed to write AGENT.md: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("HX-Trigger", "refreshAgents, closeModal") + fmt.Fprint(w, `
Agent updated successfully
`) + } +} + +func agentDeleteHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonError(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimSpace(r.FormValue("id")) + if id == "" { + jsonError(w, "id is required", http.StatusBadRequest) + return + } + + configMu.Lock() + defer configMu.Unlock() + + idx := -1 + for i := range cfg.Agents.List { + if cfg.Agents.List[i].ID == id { + idx = i + break + } + } + if idx == -1 { + jsonError(w, "agent not found", http.StatusNotFound) + return + } + + cfg.Agents.List = append(cfg.Agents.List[:idx], cfg.Agents.List[idx+1:]...) + + if err := config.SaveConfig(configPath, cfg); err != nil { + jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("HX-Trigger", "refreshAgents, closeModal") + fmt.Fprint(w, `
Agent deleted successfully
`) + } +} + +func defaultsUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonError(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + configMu.Lock() + defer configMu.Unlock() + + cfg.Agents.Defaults.Model = strings.TrimSpace(r.FormValue("model")) + cfg.Agents.Defaults.Workspace = strings.TrimSpace(r.FormValue("workspace")) + cfg.Agents.Defaults.RestrictToWorkspace = r.FormValue("restrict_to_workspace") == "true" + + if v := strings.TrimSpace(r.FormValue("max_tokens")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.Agents.Defaults.MaxTokens = n + } + } + if v := strings.TrimSpace(r.FormValue("max_tool_iterations")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.Agents.Defaults.MaxToolIterations = n + } + } + + if err := config.SaveConfig(configPath, cfg); err != nil { + jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("HX-Trigger", "refreshAgents, closeModal") + fmt.Fprint(w, `
Defaults updated successfully
`) + } +} + +func resolveAgentWorkspace(cfg *config.Config, agent *config.AgentConfig) string { + if strings.TrimSpace(agent.Workspace) != "" { + ws := strings.TrimSpace(agent.Workspace) + if strings.HasPrefix(ws, "~/") { + home, _ := os.UserHomeDir() + ws = filepath.Join(home, ws[2:]) + } + return ws + } + if agent.Default || agent.ID == "" || agent.ID == "main" { + ws := cfg.Agents.Defaults.Workspace + if strings.HasPrefix(ws, "~/") { + home, _ := os.UserHomeDir() + ws = filepath.Join(home, ws[2:]) + } + return ws + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".picoclaw", "workspace-"+agent.ID) +} + +func readAgentInstructions(workspace string) string { + data, err := os.ReadFile(filepath.Join(workspace, "AGENT.md")) + if err != nil { + return "" + } + return string(data) +} + +func writeAgentInstructions(workspace, content string) error { + if err := os.MkdirAll(workspace, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(workspace, "AGENT.md"), []byte(content), 0o644) +} + +func parseSkills(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + var skills []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + skills = append(skills, p) + } + } + return skills +} diff --git a/pkg/dashboard/crud_agents_test.go b/pkg/dashboard/crud_agents_test.go new file mode 100644 index 000000000..a0c247afe --- /dev/null +++ b/pkg/dashboard/crud_agents_test.go @@ -0,0 +1,198 @@ +package dashboard + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestAgentCreateHandler(t *testing.T) { + cfg := config.DefaultConfig() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + handler := agentCreateHandler(cfg, configPath) + + form := url.Values{ + "id": {"test-agent"}, + "name": {"Test Agent"}, + } + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if len(cfg.Agents.List) != 1 { + t.Fatalf("expected 1 agent, got %d", len(cfg.Agents.List)) + } + if cfg.Agents.List[0].ID != "test-agent" { + t.Errorf("expected id 'test-agent', got %q", cfg.Agents.List[0].ID) + } + if cfg.Agents.List[0].Name != "Test Agent" { + t.Errorf("expected name 'Test Agent', got %q", cfg.Agents.List[0].Name) + } + + // Verify config file was written + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Error("config file should have been created") + } +} + +func TestAgentCreateHandlerDuplicateID(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.List = []config.AgentConfig{ + {ID: "existing", Name: "Existing Agent"}, + } + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + handler := agentCreateHandler(cfg, configPath) + + form := url.Values{ + "id": {"existing"}, + "name": {"Duplicate"}, + } + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) + } + + if len(cfg.Agents.List) != 1 { + t.Fatalf("agent list should still have 1 agent, got %d", len(cfg.Agents.List)) + } +} + +func TestAgentCreateHandlerMissingID(t *testing.T) { + cfg := config.DefaultConfig() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + handler := agentCreateHandler(cfg, configPath) + + form := url.Values{ + "name": {"No ID Agent"}, + } + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAgentUpdateHandler(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.List = []config.AgentConfig{ + {ID: "agent-1", Name: "Old Name"}, + } + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + handler := agentUpdateHandler(cfg, configPath) + + form := url.Values{ + "id": {"agent-1"}, + "name": {"New Name"}, + } + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/update", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if cfg.Agents.List[0].Name != "New Name" { + t.Errorf("expected name 'New Name', got %q", cfg.Agents.List[0].Name) + } +} + +func TestAgentDeleteHandler(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.List = []config.AgentConfig{ + {ID: "agent-1", Name: "Agent 1"}, + {ID: "agent-2", Name: "Agent 2"}, + } + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + handler := agentDeleteHandler(cfg, configPath) + + form := url.Values{ + "id": {"agent-1"}, + } + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/delete", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if len(cfg.Agents.List) != 1 { + t.Fatalf("expected 1 agent after delete, got %d", len(cfg.Agents.List)) + } + if cfg.Agents.List[0].ID != "agent-2" { + t.Errorf("expected remaining agent to be 'agent-2', got %q", cfg.Agents.List[0].ID) + } +} + +func TestDefaultsUpdateHandler(t *testing.T) { + cfg := config.DefaultConfig() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + handler := defaultsUpdateHandler(cfg, configPath) + + form := url.Values{ + "model": {"gpt-4o"}, + "max_tokens": {"8192"}, + "max_tool_iterations": {"25"}, + "workspace": {"~/workspace"}, + } + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/defaults", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if cfg.Agents.Defaults.Model != "gpt-4o" { + t.Errorf("expected model 'gpt-4o', got %q", cfg.Agents.Defaults.Model) + } + if cfg.Agents.Defaults.MaxTokens != 8192 { + t.Errorf("expected max_tokens 8192, got %d", cfg.Agents.Defaults.MaxTokens) + } + if cfg.Agents.Defaults.MaxToolIterations != 25 { + t.Errorf("expected max_tool_iterations 25, got %d", cfg.Agents.Defaults.MaxToolIterations) + } + if cfg.Agents.Defaults.Workspace != "~/workspace" { + t.Errorf("expected workspace '~/workspace', got %q", cfg.Agents.Defaults.Workspace) + } +} diff --git a/pkg/dashboard/crud_channels.go b/pkg/dashboard/crud_channels.go new file mode 100644 index 000000000..e4b7c2d50 --- /dev/null +++ b/pkg/dashboard/crud_channels.go @@ -0,0 +1,342 @@ +package dashboard + +import ( + "fmt" + "html/template" + "net/http" + "strconv" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/health" +) + +func registerChannelsCRUD(srv *health.Server, cfg *config.Config, configPath string, auth func(http.HandlerFunc) http.HandlerFunc) { + srv.HandleFunc("/dashboard/fragments/channel-edit", auth(fragmentChannelEdit(cfg))) + srv.HandleFunc("/dashboard/crud/channels/update", auth(channelUpdateHandler(cfg, configPath))) + srv.HandleFunc("/dashboard/crud/channels/toggle", auth(channelToggleHandler(cfg, configPath))) +} + +const channelFormCSS = `` + +func channelEditFormHTML(name string, cfg *config.Config) string { + var enabled bool + var allowFrom []string + var fields string + + switch name { + case "telegram": + ch := cfg.Channels.Telegram + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("token", "Token", ch.Token) + + textField("proxy", "Proxy", ch.Proxy) + case "discord": + ch := cfg.Channels.Discord + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("token", "Token", ch.Token) + + checkboxField("mention_only", "Mention Only", ch.MentionOnly) + case "slack": + ch := cfg.Channels.Slack + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("bot_token", "Bot Token", ch.BotToken) + + textField("app_token", "App Token", ch.AppToken) + case "whatsapp": + ch := cfg.Channels.WhatsApp + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("bridge_url", "Bridge URL", ch.BridgeURL) + case "feishu": + ch := cfg.Channels.Feishu + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("app_id", "App ID", ch.AppID) + + textField("app_secret", "App Secret", ch.AppSecret) + + textField("encrypt_key", "Encrypt Key", ch.EncryptKey) + + textField("verification_token", "Verification Token", ch.VerificationToken) + case "dingtalk": + ch := cfg.Channels.DingTalk + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("client_id", "Client ID", ch.ClientID) + + textField("client_secret", "Client Secret", ch.ClientSecret) + case "qq": + ch := cfg.Channels.QQ + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("app_id", "App ID", ch.AppID) + + textField("app_secret", "App Secret", ch.AppSecret) + case "line": + ch := cfg.Channels.LINE + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("channel_secret", "Channel Secret", ch.ChannelSecret) + + textField("channel_access_token", "Channel Access Token", ch.ChannelAccessToken) + + textField("webhook_host", "Webhook Host", ch.WebhookHost) + + textField("webhook_port", "Webhook Port", strconv.Itoa(ch.WebhookPort)) + + textField("webhook_path", "Webhook Path", ch.WebhookPath) + case "maixcam": + ch := cfg.Channels.MaixCam + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("host", "Host", ch.Host) + + textField("port", "Port", strconv.Itoa(ch.Port)) + case "onebot": + ch := cfg.Channels.OneBot + enabled = ch.Enabled + allowFrom = ch.AllowFrom + fields = textField("ws_url", "WebSocket URL", ch.WSUrl) + + textField("access_token", "Access Token", ch.AccessToken) + + textField("reconnect_interval", "Reconnect Interval", strconv.Itoa(ch.ReconnectInterval)) + default: + return "" + } + + checkedAttr := "" + if enabled { + checkedAttr = " checked" + } + + allowFromStr := strings.Join(allowFrom, ", ") + + return channelFormCSS + `
+

Edit Channel: ` + template.HTMLEscapeString(name) + `

+
+ +
+ + +
` + + fields + ` +
+ + +
+
+ + +
+
+
` +} + +func textField(name, label, value string) string { + return ` +
+ + +
` +} + +func checkboxField(name, label string, checked bool) string { + checkedAttr := "" + if checked { + checkedAttr = " checked" + } + return ` +
+ + +
` +} + +func fragmentChannelEdit(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + html := channelEditFormHTML(name, cfg) + if html == "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `

Unknown channel: %s

`, template.HTMLEscapeString(name)) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) + } +} + +func parseAllowFrom(s string) config.FlexibleStringSlice { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + result := make(config.FlexibleStringSlice, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +func channelUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `

Method not allowed

`) + return + } + + name := r.FormValue("name") + enabled := r.FormValue("enabled") == "true" + allowFrom := parseAllowFrom(r.FormValue("allow_from")) + + configMu.Lock() + switch name { + case "telegram": + cfg.Channels.Telegram.Enabled = enabled + cfg.Channels.Telegram.Token = r.FormValue("token") + cfg.Channels.Telegram.Proxy = r.FormValue("proxy") + cfg.Channels.Telegram.AllowFrom = allowFrom + case "discord": + cfg.Channels.Discord.Enabled = enabled + cfg.Channels.Discord.Token = r.FormValue("token") + cfg.Channels.Discord.MentionOnly = r.FormValue("mention_only") == "true" + cfg.Channels.Discord.AllowFrom = allowFrom + case "slack": + cfg.Channels.Slack.Enabled = enabled + cfg.Channels.Slack.BotToken = r.FormValue("bot_token") + cfg.Channels.Slack.AppToken = r.FormValue("app_token") + cfg.Channels.Slack.AllowFrom = allowFrom + case "whatsapp": + cfg.Channels.WhatsApp.Enabled = enabled + cfg.Channels.WhatsApp.BridgeURL = r.FormValue("bridge_url") + cfg.Channels.WhatsApp.AllowFrom = allowFrom + case "feishu": + cfg.Channels.Feishu.Enabled = enabled + cfg.Channels.Feishu.AppID = r.FormValue("app_id") + cfg.Channels.Feishu.AppSecret = r.FormValue("app_secret") + cfg.Channels.Feishu.EncryptKey = r.FormValue("encrypt_key") + cfg.Channels.Feishu.VerificationToken = r.FormValue("verification_token") + cfg.Channels.Feishu.AllowFrom = allowFrom + case "dingtalk": + cfg.Channels.DingTalk.Enabled = enabled + cfg.Channels.DingTalk.ClientID = r.FormValue("client_id") + cfg.Channels.DingTalk.ClientSecret = r.FormValue("client_secret") + cfg.Channels.DingTalk.AllowFrom = allowFrom + case "qq": + cfg.Channels.QQ.Enabled = enabled + cfg.Channels.QQ.AppID = r.FormValue("app_id") + cfg.Channels.QQ.AppSecret = r.FormValue("app_secret") + cfg.Channels.QQ.AllowFrom = allowFrom + case "line": + cfg.Channels.LINE.Enabled = enabled + cfg.Channels.LINE.ChannelSecret = r.FormValue("channel_secret") + cfg.Channels.LINE.ChannelAccessToken = r.FormValue("channel_access_token") + cfg.Channels.LINE.WebhookHost = r.FormValue("webhook_host") + if p, err := strconv.Atoi(r.FormValue("webhook_port")); err == nil { + cfg.Channels.LINE.WebhookPort = p + } + cfg.Channels.LINE.WebhookPath = r.FormValue("webhook_path") + cfg.Channels.LINE.AllowFrom = allowFrom + case "maixcam": + cfg.Channels.MaixCam.Enabled = enabled + cfg.Channels.MaixCam.Host = r.FormValue("host") + if p, err := strconv.Atoi(r.FormValue("port")); err == nil { + cfg.Channels.MaixCam.Port = p + } + cfg.Channels.MaixCam.AllowFrom = allowFrom + case "onebot": + cfg.Channels.OneBot.Enabled = enabled + cfg.Channels.OneBot.WSUrl = r.FormValue("ws_url") + cfg.Channels.OneBot.AccessToken = r.FormValue("access_token") + if ri, err := strconv.Atoi(r.FormValue("reconnect_interval")); err == nil { + cfg.Channels.OneBot.ReconnectInterval = ri + } + cfg.Channels.OneBot.AllowFrom = allowFrom + default: + configMu.Unlock() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `

Unknown channel: %s

`, template.HTMLEscapeString(name)) + return + } + configMu.Unlock() + + if configPath != "" { + if err := saveConfig(configPath, cfg); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `

Failed to save: %s

`, template.HTMLEscapeString(err.Error())) + return + } + } + + w.Header().Set("HX-Trigger", "refreshChannels, closeModal") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Channel updated

`) + } +} + +func channelToggleHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `

Method not allowed

`) + return + } + + name := r.FormValue("name") + enabled := r.FormValue("enabled") == "true" + + configMu.Lock() + switch name { + case "telegram": + cfg.Channels.Telegram.Enabled = enabled + case "discord": + cfg.Channels.Discord.Enabled = enabled + case "slack": + cfg.Channels.Slack.Enabled = enabled + case "whatsapp": + cfg.Channels.WhatsApp.Enabled = enabled + case "feishu": + cfg.Channels.Feishu.Enabled = enabled + case "dingtalk": + cfg.Channels.DingTalk.Enabled = enabled + case "qq": + cfg.Channels.QQ.Enabled = enabled + case "line": + cfg.Channels.LINE.Enabled = enabled + case "maixcam": + cfg.Channels.MaixCam.Enabled = enabled + case "onebot": + cfg.Channels.OneBot.Enabled = enabled + default: + configMu.Unlock() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `

Unknown channel: %s

`, template.HTMLEscapeString(name)) + return + } + configMu.Unlock() + + if configPath != "" { + if err := saveConfig(configPath, cfg); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `

Failed to save: %s

`, template.HTMLEscapeString(err.Error())) + return + } + } + + w.Header().Set("HX-Trigger", "refreshChannels") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Channel toggled

`) + } +} diff --git a/pkg/dashboard/crud_channels_test.go b/pkg/dashboard/crud_channels_test.go new file mode 100644 index 000000000..035de967e --- /dev/null +++ b/pkg/dashboard/crud_channels_test.go @@ -0,0 +1,215 @@ +package dashboard + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestChannelToggle(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels.Telegram.Enabled = false + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + handler := channelToggleHandler(cfg, configPath) + + form := url.Values{} + form.Set("name", "telegram") + form.Set("enabled", "true") + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/channels/toggle", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if !cfg.Channels.Telegram.Enabled { + t.Error("expected Telegram to be enabled after toggle") + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Error("config file should have been saved") + } +} + +func TestChannelUpdateTelegram(t *testing.T) { + cfg := config.DefaultConfig() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + handler := channelUpdateHandler(cfg, configPath) + + form := url.Values{} + form.Set("name", "telegram") + form.Set("enabled", "true") + form.Set("token", "bot123456:ABC-DEF") + form.Set("proxy", "socks5://proxy:1080") + form.Set("allow_from", "user1, user2, user3") + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/channels/update", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if !cfg.Channels.Telegram.Enabled { + t.Error("expected Telegram enabled") + } + if cfg.Channels.Telegram.Token != "bot123456:ABC-DEF" { + t.Errorf("expected token 'bot123456:ABC-DEF', got %q", cfg.Channels.Telegram.Token) + } + if cfg.Channels.Telegram.Proxy != "socks5://proxy:1080" { + t.Errorf("expected proxy 'socks5://proxy:1080', got %q", cfg.Channels.Telegram.Proxy) + } + if len(cfg.Channels.Telegram.AllowFrom) != 3 { + t.Fatalf("expected 3 allow_from entries, got %d", len(cfg.Channels.Telegram.AllowFrom)) + } + if cfg.Channels.Telegram.AllowFrom[0] != "user1" { + t.Errorf("expected allow_from[0] = 'user1', got %q", cfg.Channels.Telegram.AllowFrom[0]) + } +} + +func TestChannelUpdateDiscord(t *testing.T) { + cfg := config.DefaultConfig() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + handler := channelUpdateHandler(cfg, configPath) + + form := url.Values{} + form.Set("name", "discord") + form.Set("enabled", "true") + form.Set("token", "discord-bot-token-xyz") + form.Set("mention_only", "true") + form.Set("allow_from", "guild1") + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/channels/update", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if cfg.Channels.Discord.Token != "discord-bot-token-xyz" { + t.Errorf("expected discord token, got %q", cfg.Channels.Discord.Token) + } + if !cfg.Channels.Discord.MentionOnly { + t.Error("expected MentionOnly to be true") + } +} + +func TestChannelUpdateUnknown(t *testing.T) { + cfg := config.DefaultConfig() + + handler := channelUpdateHandler(cfg, "") + + form := url.Values{} + form.Set("name", "nonexistent") + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/channels/update", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unknown channel, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "Unknown channel") { + t.Error("expected 'Unknown channel' error message") + } +} + +func TestChannelEditFragment(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels.Telegram.Token = "test-token-123" + cfg.Channels.Telegram.Enabled = true + + handler := fragmentChannelEdit(cfg) + + req := httptest.NewRequest(http.MethodGet, "/dashboard/fragments/channel-edit?name=telegram", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !strings.Contains(body, " +.form-group { margin-bottom: 12px; } +.form-group label { display: block; font-size: 12px; color: var(--fg2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } +.form-group input, .form-group select { width: 100%; padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); font-family: inherit; font-size: 13px; box-sizing: border-box; } +.form-group input:focus { border-color: var(--blue); outline: none; } +.form-actions { display: flex; gap: 8px; margin-top: 16px; } +.btn-primary { padding: 8px 16px; background: var(--blue); color: var(--bg); border: none; border-radius: 4px; cursor: pointer; font-family: inherit; } +.btn-secondary { padding: 8px 16px; background: var(--bg3); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-family: inherit; } +.btn-danger { padding: 8px 16px; background: var(--red); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-family: inherit; } +.success { color: var(--green, #3fb950); text-align: center; padding: 16px; } +.error { color: var(--red); text-align: center; padding: 16px; } +` + +const modelEditTmpl = modelFormCSS + `
+

Edit Model

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
` + +const modelAddTmpl = modelFormCSS + `
+

Add Model

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
` + +func fragmentModelEdit(cfg *config.Config) http.HandlerFunc { + t := template.Must(template.New("model-edit").Parse(modelEditTmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + idxStr := r.URL.Query().Get("idx") + idx, err := strconv.Atoi(idxStr) + if err != nil || idx < 0 || idx >= len(cfg.ModelList) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Invalid model index

`) + return + } + + m := cfg.ModelList[idx] + data := map[string]any{ + "Idx": idx, + "ModelName": m.ModelName, + "Model": m.Model, + "APIBase": m.APIBase, + "APIKey": m.APIKey, + "Proxy": m.Proxy, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, data) + } +} + +func fragmentModelAdd() http.HandlerFunc { + t := template.Must(template.New("model-add").Parse(modelAddTmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, nil) + } +} + +func modelCreateHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `

Method not allowed

`) + return + } + + modelName := r.FormValue("model_name") + model := r.FormValue("model") + if modelName == "" || model == "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `

model_name and model are required

`) + return + } + + configMu.Lock() + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: modelName, + Model: model, + APIBase: r.FormValue("api_base"), + APIKey: r.FormValue("api_key"), + Proxy: r.FormValue("proxy"), + }) + configMu.Unlock() + + if configPath != "" { + if err := saveConfig(configPath, cfg); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `

Failed to save: %s

`, template.HTMLEscapeString(err.Error())) + return + } + } + + w.Header().Set("HX-Trigger", "refreshModels, closeModal") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Model added

`) + } +} + +func modelUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `

Method not allowed

`) + return + } + + idxStr := r.FormValue("idx") + idx, err := strconv.Atoi(idxStr) + if err != nil || idx < 0 || idx >= len(cfg.ModelList) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `

Invalid model index

`) + return + } + + configMu.Lock() + cfg.ModelList[idx].ModelName = r.FormValue("model_name") + cfg.ModelList[idx].Model = r.FormValue("model") + cfg.ModelList[idx].APIBase = r.FormValue("api_base") + cfg.ModelList[idx].APIKey = r.FormValue("api_key") + cfg.ModelList[idx].Proxy = r.FormValue("proxy") + configMu.Unlock() + + if configPath != "" { + if err := saveConfig(configPath, cfg); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `

Failed to save: %s

`, template.HTMLEscapeString(err.Error())) + return + } + } + + w.Header().Set("HX-Trigger", "refreshModels, closeModal") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Model updated

`) + } +} + +func modelDeleteHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `

Method not allowed

`) + return + } + + idxStr := r.FormValue("idx") + idx, err := strconv.Atoi(idxStr) + if err != nil || idx < 0 || idx >= len(cfg.ModelList) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `

Invalid model index

`) + return + } + + configMu.Lock() + cfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...) + configMu.Unlock() + + if configPath != "" { + if err := saveConfig(configPath, cfg); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `

Failed to save: %s

`, template.HTMLEscapeString(err.Error())) + return + } + } + + w.Header().Set("HX-Trigger", "refreshModels, closeModal") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Model deleted

`) + } +} diff --git a/pkg/dashboard/crud_models_test.go b/pkg/dashboard/crud_models_test.go new file mode 100644 index 000000000..be8513ec3 --- /dev/null +++ b/pkg/dashboard/crud_models_test.go @@ -0,0 +1,216 @@ +package dashboard + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestModelCreateHandler(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + initialLen := len(cfg.ModelList) + + handler := modelCreateHandler(cfg, configPath) + + form := url.Values{} + form.Set("model_name", "test-model") + form.Set("model", "openai/gpt-4o") + form.Set("api_base", "https://api.openai.com/v1") + form.Set("api_key", "sk-test-key") + form.Set("proxy", "") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/models/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if len(cfg.ModelList) != initialLen+1 { + t.Fatalf("expected ModelList to grow by 1, got %d (was %d)", len(cfg.ModelList), initialLen) + } + + added := cfg.ModelList[len(cfg.ModelList)-1] + if added.ModelName != "test-model" { + t.Errorf("expected model_name 'test-model', got %q", added.ModelName) + } + if added.Model != "openai/gpt-4o" { + t.Errorf("expected model 'openai/gpt-4o', got %q", added.Model) + } + + body := w.Body.String() + if !strings.Contains(body, "Model added") { + t.Error("response should contain success message") + } + if w.Header().Get("HX-Trigger") == "" { + t.Error("response should have HX-Trigger header") + } +} + +func TestModelCreateHandlerMissing(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := modelCreateHandler(cfg, configPath) + + form := url.Values{} + form.Set("model", "openai/gpt-4o") + // model_name is missing + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/models/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "required") { + t.Error("response should mention required fields") + } +} + +func TestModelUpdateHandler(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + + // Ensure at least one model exists + if len(cfg.ModelList) == 0 { + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "original", + Model: "openai/gpt-3.5", + }) + } + + handler := modelUpdateHandler(cfg, configPath) + + form := url.Values{} + form.Set("idx", "0") + form.Set("model_name", "updated-name") + form.Set("model", "openai/gpt-4o") + form.Set("api_base", "https://new-base.com/v1") + form.Set("api_key", "sk-new-key") + form.Set("proxy", "http://proxy:8080") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/models/update", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if cfg.ModelList[0].ModelName != "updated-name" { + t.Errorf("expected model_name 'updated-name', got %q", cfg.ModelList[0].ModelName) + } + if cfg.ModelList[0].Model != "openai/gpt-4o" { + t.Errorf("expected model 'openai/gpt-4o', got %q", cfg.ModelList[0].Model) + } + if cfg.ModelList[0].Proxy != "http://proxy:8080" { + t.Errorf("expected proxy 'http://proxy:8080', got %q", cfg.ModelList[0].Proxy) + } +} + +func TestModelUpdateHandlerBadIdx(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := modelUpdateHandler(cfg, configPath) + + form := url.Values{} + form.Set("idx", "999") + form.Set("model_name", "test") + form.Set("model", "test") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/models/update", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "Invalid model index") { + t.Error("response should mention invalid index") + } +} + +func TestModelDeleteHandler(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + + // Ensure at least one model exists + if len(cfg.ModelList) == 0 { + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "to-delete", + Model: "openai/gpt-3.5", + }) + } + initialLen := len(cfg.ModelList) + + handler := modelDeleteHandler(cfg, configPath) + + form := url.Values{} + form.Set("idx", "0") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/models/delete", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if len(cfg.ModelList) != initialLen-1 { + t.Fatalf("expected ModelList to shrink by 1, got %d (was %d)", len(cfg.ModelList), initialLen) + } + + body := w.Body.String() + if !strings.Contains(body, "Model deleted") { + t.Error("response should contain success message") + } +} + +func TestModelDeleteHandlerBadIdx(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := modelDeleteHandler(cfg, configPath) + + form := url.Values{} + form.Set("idx", "-1") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/models/delete", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "Invalid model index") { + t.Error("response should mention invalid index") + } +} diff --git a/pkg/dashboard/crud_settings.go b/pkg/dashboard/crud_settings.go new file mode 100644 index 000000000..aed5924b9 --- /dev/null +++ b/pkg/dashboard/crud_settings.go @@ -0,0 +1,266 @@ +package dashboard + +import ( + "crypto/hmac" + "fmt" + "net/http" + "strconv" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/health" +) + +func registerSettingsCRUD(srv *health.Server, cfg *config.Config, configPath string, currentPassword string, auth func(http.HandlerFunc) http.HandlerFunc) { + srv.HandleFunc("/dashboard/fragments/settings", auth(fragmentSettings(cfg, currentPassword))) + srv.HandleFunc("/dashboard/crud/settings/password", auth(passwordChangeHandler(cfg, configPath))) + srv.HandleFunc("/dashboard/crud/settings/gateway", auth(gatewayUpdateHandler(cfg, configPath))) + srv.HandleFunc("/dashboard/crud/settings/heartbeat", auth(heartbeatUpdateHandler(cfg, configPath))) + srv.HandleFunc("/dashboard/crud/settings/devices", auth(devicesUpdateHandler(cfg, configPath))) +} + +const settingsCSS = `` + +func fragmentSettings(cfg *config.Config, currentPassword string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + checkedAttr := func(b bool) string { + if b { + return " checked" + } + return "" + } + + html := settingsCSS + `
+
+ +
+

Dashboard Password

+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Gateway

+
+
+ + +
+
+ + +
+
+ +
+

Changes take effect on restart

+
+
+ +
+

Heartbeat

+
+
+ +
+
+ + +
+
+ +
+
+
+ +
+

Devices

+
+
+ +
+
+ +
+
+ +
+
+
+ +
` + fmt.Fprint(w, html) + } +} + +func passwordChangeHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `

Method not allowed

`) + return + } + + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + + if !hmac.Equal([]byte(currentPassword), []byte(cfg.Dashboard.Password)) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `

Current password is incorrect

`) + return + } + + if len(newPassword) < 8 { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `

New password must be at least 8 characters

`) + return + } + + cfg.Dashboard.Password = newPassword + + if configPath != "" { + if err := saveConfig(configPath, cfg); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `

Failed to save config

`) + return + } + } + + value, expiry := signSession(newPassword) + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: value, + Path: "/dashboard", + Expires: expiry, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Password changed successfully

`) + } +} + +func gatewayUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `

Method not allowed

`) + return + } + + host := r.FormValue("host") + portStr := r.FormValue("port") + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 || port >= 65536 { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `

Port must be between 1 and 65535

`) + return + } + + cfg.Gateway.Host = host + cfg.Gateway.Port = port + + if configPath != "" { + if err := saveConfig(configPath, cfg); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `

Failed to save config

`) + return + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Gateway settings saved (restart required)

`) + } +} + +func heartbeatUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `

Method not allowed

`) + return + } + + enabled := r.FormValue("enabled") == "on" + intervalStr := r.FormValue("interval") + interval, err := strconv.Atoi(intervalStr) + if err != nil || interval < 1 { + interval = cfg.Heartbeat.Interval + } + + cfg.Heartbeat.Enabled = enabled + cfg.Heartbeat.Interval = interval + + if configPath != "" { + if err := saveConfig(configPath, cfg); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `

Failed to save config

`) + return + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Heartbeat settings saved

`) + } +} + +func devicesUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, `

Method not allowed

`) + return + } + + cfg.Devices.Enabled = r.FormValue("enabled") == "on" + cfg.Devices.MonitorUSB = r.FormValue("monitor_usb") == "on" + + if configPath != "" { + if err := saveConfig(configPath, cfg); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `

Failed to save config

`) + return + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Devices settings saved

`) + } +} diff --git a/pkg/dashboard/crud_settings_test.go b/pkg/dashboard/crud_settings_test.go new file mode 100644 index 000000000..3f7ffd041 --- /dev/null +++ b/pkg/dashboard/crud_settings_test.go @@ -0,0 +1,254 @@ +package dashboard + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestPasswordChange(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Dashboard.Password = "oldpassword" + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := passwordChangeHandler(cfg, configPath) + + form := url.Values{} + form.Set("current_password", "oldpassword") + form.Set("new_password", "newpassword123") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/settings/password", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if cfg.Dashboard.Password != "newpassword123" { + t.Errorf("expected password to be updated, got %q", cfg.Dashboard.Password) + } + + body := w.Body.String() + if !strings.Contains(body, "Password changed successfully") { + t.Error("response should contain success message") + } + + cookies := w.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == cookieName { + found = true + if c.Value == "" { + t.Error("session cookie should not be empty") + } + break + } + } + if !found { + t.Error("response should set a new session cookie") + } +} + +func TestPasswordChangeWrongCurrent(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Dashboard.Password = "oldpassword" + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := passwordChangeHandler(cfg, configPath) + + form := url.Values{} + form.Set("current_password", "wrongpassword") + form.Set("new_password", "newpassword123") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/settings/password", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !strings.Contains(body, "incorrect") { + t.Error("response should mention incorrect password") + } + + if cfg.Dashboard.Password != "oldpassword" { + t.Error("password should not have changed") + } +} + +func TestPasswordChangeTooShort(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Dashboard.Password = "oldpassword" + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := passwordChangeHandler(cfg, configPath) + + form := url.Values{} + form.Set("current_password", "oldpassword") + form.Set("new_password", "short") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/settings/password", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !strings.Contains(body, "at least 8") { + t.Error("response should mention minimum length") + } + + if cfg.Dashboard.Password != "oldpassword" { + t.Error("password should not have changed") + } +} + +func TestGatewayUpdate(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := gatewayUpdateHandler(cfg, configPath) + + form := url.Values{} + form.Set("host", "127.0.0.1") + form.Set("port", "9090") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/settings/gateway", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if cfg.Gateway.Host != "127.0.0.1" { + t.Errorf("expected host '127.0.0.1', got %q", cfg.Gateway.Host) + } + if cfg.Gateway.Port != 9090 { + t.Errorf("expected port 9090, got %d", cfg.Gateway.Port) + } + + body := w.Body.String() + if !strings.Contains(body, "Gateway settings saved") { + t.Error("response should contain success message") + } +} + +func TestGatewayUpdateBadPort(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := gatewayUpdateHandler(cfg, configPath) + + tests := []struct { + name string + port string + }{ + {"zero", "0"}, + {"too_high", "99999"}, + {"negative", "-1"}, + {"not_a_number", "abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + form := url.Values{} + form.Set("host", "localhost") + form.Set("port", tt.port) + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/settings/gateway", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for port=%s, got %d: %s", tt.port, w.Code, w.Body.String()) + } + }) + } +} + +func TestHeartbeatUpdate(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := heartbeatUpdateHandler(cfg, configPath) + + form := url.Values{} + form.Set("enabled", "on") + form.Set("interval", "15") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/settings/heartbeat", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if !cfg.Heartbeat.Enabled { + t.Error("expected heartbeat enabled") + } + if cfg.Heartbeat.Interval != 15 { + t.Errorf("expected interval 15, got %d", cfg.Heartbeat.Interval) + } + + body := w.Body.String() + if !strings.Contains(body, "Heartbeat settings saved") { + t.Error("response should contain success message") + } +} + +func TestDevicesUpdate(t *testing.T) { + cfg := config.DefaultConfig() + configPath := filepath.Join(t.TempDir(), "config.json") + + handler := devicesUpdateHandler(cfg, configPath) + + form := url.Values{} + form.Set("enabled", "on") + form.Set("monitor_usb", "on") + + req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/settings/devices", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if !cfg.Devices.Enabled { + t.Error("expected devices enabled") + } + if !cfg.Devices.MonitorUSB { + t.Error("expected monitor_usb enabled") + } + + body := w.Body.String() + if !strings.Contains(body, "Devices settings saved") { + t.Error("response should contain success message") + } +} diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go new file mode 100644 index 000000000..5b5e40b2a --- /dev/null +++ b/pkg/dashboard/dashboard.go @@ -0,0 +1,402 @@ +package dashboard + +import ( + "encoding/json" + "fmt" + "html/template" + "io/fs" + "net/http" + "time" + + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/health" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func Mount(srv *health.Server, cfg *config.Config, al *agent.AgentLoop, cm *channels.Manager, configPath ...string) { + startTime := time.Now() + broker := NewBroker() + password := cfg.Dashboard.Password + + cfgPath := "" + if len(configPath) > 0 { + cfgPath = configPath[0] + } + + // Start background polling for SSE events + go pollStatus(broker, cfg, al, cm) + + // Static files (public — needed for login page) + staticFS, err := fs.Sub(staticFiles, "static") + if err != nil { + logger.ErrorCF("dashboard", "Failed to create sub FS", map[string]any{"error": err.Error()}) + return + } + srv.Handle("/dashboard/static/", http.StripPrefix("/dashboard/static/", http.FileServer(http.FS(staticFS)))) + + // Auth routes (public) + srv.HandleFunc("/dashboard/login", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + loginHandler(password)(w, r) + return + } + loginPage(password)(w, r) + }) + srv.HandleFunc("/dashboard/logout", logoutHandler()) + + // Protected: wrap with authMiddleware + auth := func(h http.HandlerFunc) http.HandlerFunc { + return authMiddleware(password, h) + } + + // Main page + srv.HandleFunc("/dashboard", auth(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + indexHTML, err := staticFiles.ReadFile("static/index.html") + if err != nil { + http.Error(w, "index.html not found", http.StatusInternalServerError) + return + } + w.Write(indexHTML) + })) + + // SSE endpoint + srv.HandleFunc("/dashboard/events", auth(broker.Subscribe)) + + // JSON API + srv.HandleFunc("/dashboard/api/status", auth(statusHandler(cfg, al, cm, startTime))) + srv.HandleFunc("/dashboard/api/config", auth(configGetHandler(cfg))) + srv.HandleFunc("/dashboard/api/agents", auth(agentsHandler(cfg))) + srv.HandleFunc("/dashboard/api/models", auth(modelsHandler(cfg))) + + // HTMX fragments + srv.HandleFunc("/dashboard/fragments/status", auth(fragmentStatus(cfg, al, cm, startTime))) + srv.HandleFunc("/dashboard/fragments/agents", auth(fragmentAgents(cfg, al))) + srv.HandleFunc("/dashboard/fragments/agent-detail", auth(fragmentAgentDetail(cfg))) + srv.HandleFunc("/dashboard/fragments/tools", auth(fragmentTools(al))) + srv.HandleFunc("/dashboard/fragments/channels", auth(fragmentChannels(cm))) + srv.HandleFunc("/dashboard/fragments/models", auth(fragmentModels(cfg))) + + // CRUD routes (require configPath for saving) + registerModelsCRUD(srv, cfg, cfgPath, auth) + registerAgentsCRUD(srv, cfg, cfgPath, auth) + registerChannelsCRUD(srv, cfg, cfgPath, auth) + registerSettingsCRUD(srv, cfg, cfgPath, password, auth) + + logger.InfoC("dashboard", "Dashboard mounted at /dashboard") +} + +func pollStatus(broker *SSEBroker, cfg *config.Config, al *agent.AgentLoop, cm *channels.Manager) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for range ticker.C { + if broker.ClientCount() == 0 { + continue + } + info := al.GetStartupInfo() + channelStatus := cm.GetStatus() + data := map[string]any{ + "tools": info["tools"], + "agents": info["agents"], + "channels": channelStatus, + "model": cfg.Agents.Defaults.Model, + } + jsonData, err := json.Marshal(data) + if err == nil { + broker.Publish("status", string(jsonData)) + } + } +} + +var funcMap = template.FuncMap{ + "maskKey": maskKey, + "extractProvider": extractProvider, +} + +func fragmentStatus(cfg *config.Config, al *agent.AgentLoop, cm *channels.Manager, startTime time.Time) http.HandlerFunc { + const tmpl = `
+ + {{.Model}} + | + Uptime: {{.Uptime}} + | + Tools: {{.ToolCount}} + | + Agents: {{.AgentCount}} + | + Channels: {{.ChannelCount}} +
` + t := template.Must(template.New("status").Parse(tmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + info := al.GetStartupInfo() + toolsInfo, _ := info["tools"].(map[string]any) + agentsInfo, _ := info["agents"].(map[string]any) + channelStatus := cm.GetStatus() + + toolCount := 0 + if tc, ok := toolsInfo["count"]; ok { + toolCount, _ = tc.(int) + } + agentCount := 0 + if ac, ok := agentsInfo["count"]; ok { + agentCount, _ = ac.(int) + } + + data := map[string]any{ + "Running": true, + "Model": cfg.Agents.Defaults.Model, + "Uptime": formatUptime(time.Since(startTime)), + "ToolCount": toolCount, + "AgentCount": agentCount, + "ChannelCount": len(channelStatus), + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, data) + } +} + +func fragmentAgents(cfg *config.Config, al *agent.AgentLoop) http.HandlerFunc { + const tmpl = `
+
+ + +
+
+ + + + + + {{range .Agents}} + + + + + + + + + {{end}} + {{if not .Agents}} + + {{end}} + +
IDNameModelDefaultSkillsActions
{{.ID}}{{.Name}}{{if .Model}}{{.Model.Primary}}{{else}}default{{end}}{{if .Default}}✓{{end}}{{range .Skills}}{{.}} {{end}} + + +
No agents configured (using defaults)
+
+

Default model: {{.DefaultModel}} | Max tokens: {{.MaxTokens}} | Click a row for details

+
` + t := template.Must(template.New("agents").Parse(tmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + data := map[string]any{ + "Agents": cfg.Agents.List, + "DefaultModel": cfg.Agents.Defaults.Model, + "MaxTokens": cfg.Agents.Defaults.MaxTokens, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, data) + } +} + +func fragmentAgentDetail(cfg *config.Config) http.HandlerFunc { + const tmpl = `
+

Agent: {{.Name}}

+
+ ID + {{.ID}} + Name + {{if .Name}}{{.Name}}{{else}}unnamed{{end}} + Default + {{if .Default}}Yes{{else}}No{{end}} + Model + {{if .Model}}{{.Model.Primary}}{{if .Model.Fallbacks}} (fallbacks: {{range $i, $f := .Model.Fallbacks}}{{if $i}}, {{end}}{{$f}}{{end}}){{end}}{{else}}inherited{{end}} + Workspace + {{if .Workspace}}{{.Workspace}}{{else}}default{{end}} +
+ {{if .Skills}} +
+

Skills

+
+ {{range .Skills}}{{.}}{{end}} +
+
+ {{end}} + {{if .Subagents}} +
+

Allowed Subagents

+
+ {{range .Subagents.AllowAgents}}{{.}}{{end}} +
+
+ {{end}} +
` + t := template.Must(template.New("agent-detail").Parse(tmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.URL.Query().Get("id") + if agentID == "" { + http.Error(w, "missing id parameter", http.StatusBadRequest) + return + } + for _, a := range cfg.Agents.List { + if a.ID == agentID { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, a) + return + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `

Agent not found

No agent with ID "%s"

`, template.HTMLEscapeString(agentID)) + } +} + +func fragmentTools(al *agent.AgentLoop) http.HandlerFunc { + const tmpl = `
+
+

Tools ({{.ToolCount}})

+
+ {{range .Tools}}{{.}}{{end}} + {{if not .Tools}}No tools loaded{{end}} +
+
+
+

Skills ({{.SkillAvailable}}/{{.SkillTotal}})

+
+ {{range .Skills}}{{.}}{{end}} + {{if not .Skills}}No skills available{{end}} +
+
+
` + t := template.Must(template.New("tools").Parse(tmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + info := al.GetStartupInfo() + + toolsInfo, _ := info["tools"].(map[string]any) + skillsInfo, _ := info["skills"].(map[string]any) + + toolNames, _ := toolsInfo["names"].([]string) + toolCount := 0 + if tc, ok := toolsInfo["count"]; ok { + toolCount, _ = tc.(int) + } + + skillNames, _ := skillsInfo["names"].([]string) + skillAvailable := 0 + if sa, ok := skillsInfo["available"]; ok { + skillAvailable, _ = sa.(int) + } + skillTotal := 0 + if st, ok := skillsInfo["total"]; ok { + skillTotal, _ = st.(int) + } + + data := map[string]any{ + "Tools": toolNames, + "ToolCount": toolCount, + "Skills": skillNames, + "SkillAvailable": skillAvailable, + "SkillTotal": skillTotal, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, data) + } +} + +func fragmentChannels(cm *channels.Manager) http.HandlerFunc { + const tmpl = `
+
+ + + + + + {{range $name, $status := .}} + + + + + + + {{end}} + {{if not .}} + + {{end}} + +
ChannelEnabledRunningActions
{{$name}}{{if index $status "enabled"}}✓{{else}}✗{{end}} + {{if index $status "running"}} + Running + {{else}} + Stopped + {{end}} + + +
No channels configured
+
+
` + t := template.Must(template.New("channels").Parse(tmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + status := cm.GetStatus() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, status) + } +} + +func fragmentModels(cfg *config.Config) http.HandlerFunc { + const tmpl = `
+
+ +
+
+ + + + + + {{range $i, $m := .}} + + + + + + + + + {{end}} + {{if not .}} + + {{end}} + +
NameProviderModelAPI BaseAPI KeyActions
{{$m.ModelName}}{{extractProvider $m.Model}}{{$m.Model}}{{if $m.APIBase}}{{$m.APIBase}}{{else}}default{{end}}{{maskKey $m.APIKey}} + + +
No models configured
+
+
` + t := template.Must(template.New("models").Funcs(funcMap).Parse(tmpl)) + + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, cfg.ModelList) + } +} + +func formatUptime(d time.Duration) string { + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + if hours > 0 { + return fmt.Sprintf("%dh%02dm%02ds", hours, minutes, seconds) + } + if minutes > 0 { + return fmt.Sprintf("%dm%02ds", minutes, seconds) + } + return fmt.Sprintf("%ds", seconds) +} diff --git a/pkg/dashboard/dashboard_test.go b/pkg/dashboard/dashboard_test.go new file mode 100644 index 000000000..c1e17c1fb --- /dev/null +++ b/pkg/dashboard/dashboard_test.go @@ -0,0 +1,290 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/health" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func testSetup(t *testing.T) (*health.Server, *config.Config, *agent.AgentLoop, *channels.Manager) { + t.Helper() + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = 0 + + msgBus := bus.NewMessageBus() + + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + // Use a nil-safe approach: create loop without provider for testing + t.Logf("Provider creation failed (expected in test): %v", err) + } + + al := agent.NewAgentLoop(cfg, msgBus, provider) + + cm, err := channels.NewManager(cfg, msgBus) + if err != nil { + t.Fatalf("Failed to create channel manager: %v", err) + } + + srv := health.NewServer("127.0.0.1", 0) + return srv, cfg, al, cm +} + +func TestMount(t *testing.T) { + srv, cfg, al, cm := testSetup(t) + Mount(srv, cfg, al, cm) + // Mount should not panic — that's the main test +} + +func TestStatusAPI(t *testing.T) { + _, cfg, al, cm := testSetup(t) + startTime := time.Now() + + handler := statusHandler(cfg, al, cm, startTime) + req := httptest.NewRequest(http.MethodGet, "/dashboard/api/status", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if _, ok := resp["uptime"]; !ok { + t.Error("missing uptime field") + } + if _, ok := resp["running"]; !ok { + t.Error("missing running field") + } + if _, ok := resp["channels"]; !ok { + t.Error("missing channels field") + } +} + +func TestConfigAPI(t *testing.T) { + cfg := config.DefaultConfig() + // Set a fake key to test masking + if len(cfg.ModelList) > 0 { + cfg.ModelList[0].APIKey = "sk-1234567890abcdef" + } + + handler := configGetHandler(cfg) + req := httptest.NewRequest(http.MethodGet, "/dashboard/api/config", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if strings.Contains(body, "1234567890abcdef") { + t.Error("API key should be masked in config response") + } +} + +func TestAgentsAPI(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.List = []config.AgentConfig{ + {ID: "test-agent", Name: "Test Agent", Default: true}, + } + + handler := agentsHandler(cfg) + req := httptest.NewRequest(http.MethodGet, "/dashboard/api/agents", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + list, ok := resp["list"].([]any) + if !ok { + t.Fatal("missing list field") + } + if len(list) != 1 { + t.Fatalf("expected 1 agent, got %d", len(list)) + } +} + +func TestModelsAPI(t *testing.T) { + cfg := config.DefaultConfig() + if len(cfg.ModelList) > 0 { + cfg.ModelList[0].APIKey = "sk-supersecretkey12345" + } + + handler := modelsHandler(cfg) + req := httptest.NewRequest(http.MethodGet, "/dashboard/api/models", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if strings.Contains(body, "supersecretkey") { + t.Error("API key should be masked in models response") + } +} + +func TestAPIKeyMasking(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", ""}, + {"short", "****"}, + {"12345678", "****"}, + {"sk-1234567890abcdef", "sk-...cdef"}, + {"sk-ant-api03-very-long-key-here-xxxx", "sk-...xxxx"}, + } + + for _, tt := range tests { + got := maskKey(tt.input) + if got != tt.expected { + t.Errorf("maskKey(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +func TestSSEBroker(t *testing.T) { + broker := NewBroker() + + if broker.ClientCount() != 0 { + t.Fatalf("expected 0 clients, got %d", broker.ClientCount()) + } + + // Test publish with no clients doesn't panic + broker.Publish("test", `{"hello":"world"}`) +} + +func TestFragmentStatus(t *testing.T) { + _, cfg, al, cm := testSetup(t) + startTime := time.Now() + + handler := fragmentStatus(cfg, al, cm, startTime) + req := httptest.NewRequest(http.MethodGet, "/dashboard/fragments/status", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "status-bar") { + t.Error("status fragment should contain status-bar div") + } + if !strings.Contains(body, "Uptime:") { + t.Error("status fragment should contain uptime") + } +} + +func TestFragmentAgents(t *testing.T) { + cfg := config.DefaultConfig() + msgBus := bus.NewMessageBus() + al := agent.NewAgentLoop(cfg, msgBus, nil) + + handler := fragmentAgents(cfg, al) + req := httptest.NewRequest(http.MethodGet, "/dashboard/fragments/agents", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "agents-table") { + t.Error("agents fragment should contain agents-table div") + } +} + +func TestFragmentModels(t *testing.T) { + cfg := config.DefaultConfig() + if len(cfg.ModelList) > 0 { + cfg.ModelList[0].APIKey = "sk-should-be-masked-key" + } + + handler := fragmentModels(cfg) + req := httptest.NewRequest(http.MethodGet, "/dashboard/fragments/models", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "models-table") { + t.Error("models fragment should contain models-table div") + } + if strings.Contains(body, "should-be-masked") { + t.Error("API key should be masked in models fragment") + } +} + +func TestFormatUptime(t *testing.T) { + tests := []struct { + duration time.Duration + expected string + }{ + {5 * time.Second, "5s"}, + {65 * time.Second, "1m05s"}, + {3661 * time.Second, "1h01m01s"}, + } + + for _, tt := range tests { + got := formatUptime(tt.duration) + if got != tt.expected { + t.Errorf("formatUptime(%v) = %q, want %q", tt.duration, got, tt.expected) + } + } +} + +func TestExtractProvider(t *testing.T) { + tests := []struct { + model string + expected string + }{ + {"openai/gpt-4o", "openai"}, + {"anthropic/claude-3", "anthropic"}, + {"glm-4.7", "glm-4.7"}, + {"", ""}, + } + + for _, tt := range tests { + got := extractProvider(tt.model) + if got != tt.expected { + t.Errorf("extractProvider(%q) = %q, want %q", tt.model, got, tt.expected) + } + } +} diff --git a/pkg/dashboard/embed.go b/pkg/dashboard/embed.go new file mode 100644 index 000000000..251e586fe --- /dev/null +++ b/pkg/dashboard/embed.go @@ -0,0 +1,6 @@ +package dashboard + +import "embed" + +//go:embed static/* +var staticFiles embed.FS diff --git a/pkg/dashboard/sse.go b/pkg/dashboard/sse.go new file mode 100644 index 000000000..f83755084 --- /dev/null +++ b/pkg/dashboard/sse.go @@ -0,0 +1,83 @@ +package dashboard + +import ( + "fmt" + "net/http" + "sync" + "time" +) + +type SSEBroker struct { + clients map[chan string]struct{} + mu sync.RWMutex +} + +func NewBroker() *SSEBroker { + return &SSEBroker{ + clients: make(map[chan string]struct{}), + } +} + +func (b *SSEBroker) Subscribe(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") + w.Header().Set("Access-Control-Allow-Origin", "*") + + ch := make(chan string, 16) + b.mu.Lock() + b.clients[ch] = struct{}{} + b.mu.Unlock() + + defer func() { + b.mu.Lock() + delete(b.clients, ch) + b.mu.Unlock() + close(ch) + }() + + // Send initial heartbeat + fmt.Fprintf(w, ": heartbeat\n\n") + flusher.Flush() + + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + + for { + select { + case <-r.Context().Done(): + return + case msg := <-ch: + fmt.Fprint(w, msg) + flusher.Flush() + case <-heartbeat.C: + fmt.Fprintf(w, ": heartbeat\n\n") + flusher.Flush() + } + } +} + +func (b *SSEBroker) Publish(event, data string) { + msg := fmt.Sprintf("event: %s\ndata: %s\n\n", event, data) + b.mu.RLock() + defer b.mu.RUnlock() + for ch := range b.clients { + select { + case ch <- msg: + default: + // Drop message if client is too slow + } + } +} + +func (b *SSEBroker) ClientCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.clients) +} diff --git a/pkg/dashboard/static/index.html b/pkg/dashboard/static/index.html new file mode 100644 index 000000000..5cab81801 --- /dev/null +++ b/pkg/dashboard/static/index.html @@ -0,0 +1,492 @@ + + + + + + PicoClaw Dashboard + + + + +
+
+

PicoClaw Dashboard

+
+
+ loading... +
+ Logout +
+
+ +
+ Changes saved to config. Restart the gateway for them to take effect. +
+ +
+

Agents

+
+ loading... +
+
+ +
+

Tools & Skills

+
+ loading... +
+
+ +
+

Channels

+
+ loading... +
+
+ +
+

Models

+
+ loading... +
+
+ +
+

Settings

+
+ loading... +
+
+ +
+

Live Feed

+
+
+
+
--:--:-- Waiting for events...
+
+
+
+ + + + + + + diff --git a/pkg/dashboard/static/login.html b/pkg/dashboard/static/login.html new file mode 100644 index 000000000..1b7e8e7f1 --- /dev/null +++ b/pkg/dashboard/static/login.html @@ -0,0 +1,105 @@ + + + + + + PicoClaw — Login + + + +
+

PicoClaw Dashboard

+

Authentication required

+
+ + + +
+ +
+ + diff --git a/pkg/health/server.go b/pkg/health/server.go index 77b36034d..d888095a6 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -11,6 +11,7 @@ import ( type Server struct { server *http.Server + mux *http.ServeMux mu sync.RWMutex ready bool checks map[string]Check @@ -33,6 +34,7 @@ type StatusResponse struct { func NewServer(host string, port int) *Server { mux := http.NewServeMux() s := &Server{ + mux: mux, ready: false, checks: make(map[string]Check), startTime: time.Now(), @@ -46,7 +48,7 @@ func NewServer(host string, port int) *Server { Addr: addr, Handler: mux, ReadTimeout: 5 * time.Second, - WriteTimeout: 5 * time.Second, + WriteTimeout: 30 * time.Second, } return s @@ -90,6 +92,14 @@ func (s *Server) SetReady(ready bool) { s.mu.Unlock() } +func (s *Server) HandleFunc(pattern string, handler http.HandlerFunc) { + s.mux.HandleFunc(pattern, handler) +} + +func (s *Server) Handle(pattern string, handler http.Handler) { + s.mux.Handle(pattern, handler) +} + func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) { s.mu.Lock() defer s.mu.Unlock()