feat: add HTMX dashboard with auth and CRUD

Ref #101 — built-in web dashboard with password auth and config management.

- Optional dashboard at /dashboard (dashboard.enabled: false to disable)
- HMAC-SHA256 signed cookie sessions (24h expiry)
- Auto-generated password on first launch, saved to config.json
- HTMX + SSE real-time status, CRUD for agents/models/channels/settings
- Agent instructions editing (AGENT.md per workspace)
- 55 tests, E2E tested (login, CRUD, persistence, logout)
This commit is contained in:
Edouard CLAUDE 2026-02-21 15:32:02 +04:00
parent bb8b9243b7
commit 26da5defe8
22 changed files with 4121 additions and 1 deletions

View file

@ -17,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/dashboard"
"github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/devices"
"github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/heartbeat"
@ -187,6 +188,19 @@ func gatewayCmd() {
} }
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) 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() { go func() {
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()}) logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})

View file

@ -54,6 +54,7 @@ type Config struct {
Providers ProvidersConfig `json:"providers,omitempty"` Providers ProvidersConfig `json:"providers,omitempty"`
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"` Gateway GatewayConfig `json:"gateway"`
Dashboard DashboardConfig `json:"dashboard"`
Tools ToolsConfig `json:"tools"` Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"` Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"` Devices DevicesConfig `json:"devices"`
@ -412,6 +413,11 @@ type GatewayConfig struct {
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` 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 { type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`

View file

@ -267,6 +267,10 @@ func DefaultConfig() *Config {
Host: "0.0.0.0", Host: "0.0.0.0",
Port: 18790, Port: 18790,
}, },
Dashboard: DashboardConfig{
Enabled: true,
Password: "",
},
Tools: ToolsConfig{ Tools: ToolsConfig{
Web: WebToolsConfig{ Web: WebToolsConfig{
Brave: BraveConfig{ Brave: BraveConfig{

129
pkg/dashboard/api.go Normal file
View file

@ -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
}

141
pkg/dashboard/auth.go Normal file
View file

@ -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, `<!--ERROR-->`, `<p class="error">`+errMsg+`</p>`, 1)
}
w.Write([]byte(html))
}

213
pkg/dashboard/auth_test.go Normal file
View file

@ -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")
}
}
}

View file

@ -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 = `<style>
.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, .form-group textarea { 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; }
.form-group textarea { min-height: 120px; resize: vertical; line-height: 1.5; }
.form-group input:focus, .form-group textarea:focus { border-color: var(--blue); outline: none; }
.form-group input[type="checkbox"] { width: auto; }
.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; }
.success { color: #3fb950; text-align: center; padding: 16px; }
</style>`
func fragmentAgentEdit(cfg *config.Config) http.HandlerFunc {
const tmpl = `{{.CSS}}
<h3>Edit Agent</h3>
<form hx-post="/dashboard/crud/agents/update" hx-target="#modal-content" hx-swap="innerHTML">
<div class="form-group">
<label>ID</label>
<input type="text" name="id" value="{{.ID}}" readonly>
</div>
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="{{.Name}}">
</div>
<div class="form-group">
<label>Model (primary)</label>
<input type="text" name="model" value="{{.Model}}" placeholder="inherited from defaults">
</div>
<div class="form-group">
<label>Skills (comma-separated)</label>
<input type="text" name="skills" value="{{.Skills}}">
</div>
<div class="form-group">
<label>Workspace</label>
<input type="text" name="workspace" value="{{.Workspace}}" placeholder="inherited from defaults">
</div>
<div class="form-group">
<label>Instructions (AGENT.md)</label>
<textarea name="instructions" placeholder="You are an expert in...">{{.Instructions}}</textarea>
</div>
<div class="form-group">
<label><input type="checkbox" name="default" value="true" {{if .Default}}checked{{end}}> Default agent</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>`
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, `<p>Agent %q not found</p>`, 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}}
<h3>Add Agent</h3>
<form hx-post="/dashboard/crud/agents/create" hx-target="#modal-content" hx-swap="innerHTML">
<div class="form-group">
<label>ID (required)</label>
<input type="text" name="id" required placeholder="my-agent">
</div>
<div class="form-group">
<label>Name</label>
<input type="text" name="name" placeholder="My Agent">
</div>
<div class="form-group">
<label>Model (primary)</label>
<input type="text" name="model" placeholder="inherited from defaults">
</div>
<div class="form-group">
<label>Skills (comma-separated)</label>
<input type="text" name="skills" placeholder="search, code">
</div>
<div class="form-group">
<label>Instructions (AGENT.md)</label>
<textarea name="instructions" placeholder="You are an expert in..."></textarea>
</div>
<div class="form-group">
<label><input type="checkbox" name="default" value="true"> Default agent</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Create</button>
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>`
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}}
<h3>Agent Defaults</h3>
<form hx-post="/dashboard/crud/agents/defaults" hx-target="#modal-content" hx-swap="innerHTML">
<div class="form-group">
<label>Model</label>
<input type="text" name="model" value="{{.Model}}">
</div>
<div class="form-group">
<label>Max Tokens</label>
<input type="number" name="max_tokens" value="{{.MaxTokens}}">
</div>
<div class="form-group">
<label>Max Tool Iterations</label>
<input type="number" name="max_tool_iterations" value="{{.MaxToolIterations}}">
</div>
<div class="form-group">
<label>Workspace</label>
<input type="text" name="workspace" value="{{.Workspace}}">
</div>
<div class="form-group">
<label><input type="checkbox" name="restrict_to_workspace" value="true" {{if .RestrictToWorkspace}}checked{{end}}> Restrict to workspace</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>`
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, `<div class="success">Agent created successfully</div>`)
}
}
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, `<div class="success">Agent updated successfully</div>`)
}
}
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, `<div class="success">Agent deleted successfully</div>`)
}
}
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, `<div class="success">Defaults updated successfully</div>`)
}
}
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
}

View file

@ -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)
}
}

View file

@ -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 = `<style>
.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-group input[type="checkbox"] { width: auto; }
.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; }
.success { color: var(--green, #3fb950); text-align: center; padding: 16px; }
.error { color: var(--red); text-align: center; padding: 16px; }
</style>`
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 + `<div>
<h3>Edit Channel: ` + template.HTMLEscapeString(name) + `</h3>
<form hx-post="/dashboard/crud/channels/update" hx-target="#modal-content" hx-swap="innerHTML">
<input type="hidden" name="name" value="` + template.HTMLEscapeString(name) + `">
<div class="form-group">
<label>Enabled</label>
<input type="checkbox" name="enabled" value="true"` + checkedAttr + `>
</div>` +
fields + `
<div class="form-group">
<label>Allow From (comma-separated)</label>
<input type="text" name="allow_from" value="` + template.HTMLEscapeString(allowFromStr) + `">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>
</div>`
}
func textField(name, label, value string) string {
return `
<div class="form-group">
<label>` + template.HTMLEscapeString(label) + `</label>
<input type="text" name="` + template.HTMLEscapeString(name) + `" value="` + template.HTMLEscapeString(value) + `">
</div>`
}
func checkboxField(name, label string, checked bool) string {
checkedAttr := ""
if checked {
checkedAttr = " checked"
}
return `
<div class="form-group">
<label>` + template.HTMLEscapeString(label) + `</label>
<input type="checkbox" name="` + template.HTMLEscapeString(name) + `" value="true"` + checkedAttr + `>
</div>`
}
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, `<p class="error">Unknown channel: %s</p>`, 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, `<p class="error">Method not allowed</p>`)
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, `<p class="error">Unknown channel: %s</p>`, 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, `<p class="error">Failed to save: %s</p>`, 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, `<p class="success">Channel updated</p>`)
}
}
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, `<p class="error">Method not allowed</p>`)
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, `<p class="error">Unknown channel: %s</p>`, 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, `<p class="error">Failed to save: %s</p>`, template.HTMLEscapeString(err.Error()))
return
}
}
w.Header().Set("HX-Trigger", "refreshChannels")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<p class="success">Channel toggled</p>`)
}
}

View file

@ -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") {
t.Error("expected HTML form in response")
}
if !strings.Contains(body, "test-token-123") {
t.Error("expected token value in form")
}
if !strings.Contains(body, "checked") {
t.Error("expected checked attribute for enabled channel")
}
}
func TestChannelEditFragmentUnknown(t *testing.T) {
cfg := config.DefaultConfig()
handler := fragmentChannelEdit(cfg)
req := httptest.NewRequest(http.MethodGet, "/dashboard/fragments/channel-edit?name=unknown", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestChannelToggleUnknown(t *testing.T) {
cfg := config.DefaultConfig()
handler := channelToggleHandler(cfg, "")
form := url.Values{}
form.Set("name", "nonexistent")
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.StatusBadRequest {
t.Fatalf("expected 400 for unknown channel, got %d", w.Code)
}
}
func TestChannelUpdateMethodNotAllowed(t *testing.T) {
cfg := config.DefaultConfig()
handler := channelUpdateHandler(cfg, "")
req := httptest.NewRequest(http.MethodGet, "/dashboard/crud/channels/update", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", w.Code)
}
}

View file

@ -0,0 +1,28 @@
package dashboard
import (
"encoding/json"
"net/http"
"sync"
"github.com/sipeed/picoclaw/pkg/config"
)
var configMu sync.Mutex
func saveConfig(configPath string, cfg *config.Config) error {
configMu.Lock()
defer configMu.Unlock()
return config.SaveConfig(configPath, cfg)
}
func jsonError(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func hxRedirect(w http.ResponseWriter, url string) {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,249 @@
package dashboard
import (
"fmt"
"html/template"
"net/http"
"strconv"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/health"
)
func registerModelsCRUD(srv *health.Server, cfg *config.Config, configPath string, auth func(http.HandlerFunc) http.HandlerFunc) {
srv.HandleFunc("/dashboard/fragments/model-edit", auth(fragmentModelEdit(cfg)))
srv.HandleFunc("/dashboard/fragments/model-add", auth(fragmentModelAdd()))
srv.HandleFunc("/dashboard/crud/models/create", auth(modelCreateHandler(cfg, configPath)))
srv.HandleFunc("/dashboard/crud/models/update", auth(modelUpdateHandler(cfg, configPath)))
srv.HandleFunc("/dashboard/crud/models/delete", auth(modelDeleteHandler(cfg, configPath)))
}
const modelFormCSS = `<style>
.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; }
</style>`
const modelEditTmpl = modelFormCSS + `<div>
<h3>Edit Model</h3>
<form hx-post="/dashboard/crud/models/update" hx-target="#modal-content" hx-swap="innerHTML">
<input type="hidden" name="idx" value="{{.Idx}}">
<div class="form-group">
<label>Model Name</label>
<input type="text" name="model_name" value="{{.ModelName}}" required>
</div>
<div class="form-group">
<label>Model</label>
<input type="text" name="model" value="{{.Model}}" required>
</div>
<div class="form-group">
<label>API Base</label>
<input type="text" name="api_base" value="{{.APIBase}}">
</div>
<div class="form-group">
<label>API Key</label>
<input type="text" name="api_key" value="{{.APIKey}}">
</div>
<div class="form-group">
<label>Proxy</label>
<input type="text" name="proxy" value="{{.Proxy}}">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>
</div>`
const modelAddTmpl = modelFormCSS + `<div>
<h3>Add Model</h3>
<form hx-post="/dashboard/crud/models/create" hx-target="#modal-content" hx-swap="innerHTML">
<div class="form-group">
<label>Model Name</label>
<input type="text" name="model_name" value="" required>
</div>
<div class="form-group">
<label>Model</label>
<input type="text" name="model" value="" required>
</div>
<div class="form-group">
<label>API Base</label>
<input type="text" name="api_base" value="">
</div>
<div class="form-group">
<label>API Key</label>
<input type="text" name="api_key" value="">
</div>
<div class="form-group">
<label>Proxy</label>
<input type="text" name="proxy" value="">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Add</button>
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>
</div>`
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, `<p class="error">Invalid model index</p>`)
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, `<p class="error">Method not allowed</p>`)
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, `<p class="error">model_name and model are required</p>`)
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, `<p class="error">Failed to save: %s</p>`, 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, `<p class="success">Model added</p>`)
}
}
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, `<p class="error">Method not allowed</p>`)
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, `<p class="error">Invalid model index</p>`)
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, `<p class="error">Failed to save: %s</p>`, 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, `<p class="success">Model updated</p>`)
}
}
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, `<p class="error">Method not allowed</p>`)
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, `<p class="error">Invalid model index</p>`)
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, `<p class="error">Failed to save: %s</p>`, 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, `<p class="success">Model deleted</p>`)
}
}

View file

@ -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")
}
}

View file

@ -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 = `<style>
.settings-section { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
.settings-section h4 { font-size: 13px; color: var(--fg2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
.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 { 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; }
.form-group input:focus { border-color: var(--blue); outline: none; }
.form-group input[type="checkbox"], .form-group input[type="number"] { width: auto; }
.form-actions { display: flex; gap: 8px; margin-top: 12px; }
.btn-primary { padding: 8px 16px; background: var(--blue); color: var(--bg); border: none; border-radius: 4px; cursor: pointer; font-family: inherit; }
.success { color: #3fb950; padding: 8px; font-size: 13px; }
.error { color: #f85149; padding: 8px; font-size: 13px; }
.note { color: var(--fg2); font-size: 11px; margin-top: 4px; }
</style>`
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 + `<div id="settings-content">
<div id="settings-result"></div>
<div class="settings-section">
<h4>Dashboard Password</h4>
<form hx-post="/dashboard/crud/settings/password" hx-target="#settings-result" hx-swap="innerHTML">
<div class="form-group">
<label>Current Password</label>
<input type="password" name="current_password" required>
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" name="new_password" required minlength="8">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Change Password</button>
</div>
</form>
</div>
<div class="settings-section">
<h4>Gateway</h4>
<form hx-post="/dashboard/crud/settings/gateway" hx-target="#settings-result" hx-swap="innerHTML">
<div class="form-group">
<label>Host</label>
<input type="text" name="host" value="` + cfg.Gateway.Host + `">
</div>
<div class="form-group">
<label>Port</label>
<input type="number" name="port" value="` + strconv.Itoa(cfg.Gateway.Port) + `">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save Gateway</button>
</div>
<p class="note">Changes take effect on restart</p>
</form>
</div>
<div class="settings-section">
<h4>Heartbeat</h4>
<form hx-post="/dashboard/crud/settings/heartbeat" hx-target="#settings-result" hx-swap="innerHTML">
<div class="form-group">
<label><input type="checkbox" name="enabled"` + checkedAttr(cfg.Heartbeat.Enabled) + `> Enabled</label>
</div>
<div class="form-group">
<label>Interval (minutes)</label>
<input type="number" name="interval" value="` + strconv.Itoa(cfg.Heartbeat.Interval) + `" min="1">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save Heartbeat</button>
</div>
</form>
</div>
<div class="settings-section">
<h4>Devices</h4>
<form hx-post="/dashboard/crud/settings/devices" hx-target="#settings-result" hx-swap="innerHTML">
<div class="form-group">
<label><input type="checkbox" name="enabled"` + checkedAttr(cfg.Devices.Enabled) + `> Enabled</label>
</div>
<div class="form-group">
<label><input type="checkbox" name="monitor_usb"` + checkedAttr(cfg.Devices.MonitorUSB) + `> Monitor USB</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save Devices</button>
</div>
</form>
</div>
</div>`
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, `<p class="error">Method not allowed</p>`)
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, `<p class="error">Current password is incorrect</p>`)
return
}
if len(newPassword) < 8 {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `<p class="error">New password must be at least 8 characters</p>`)
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, `<p class="error">Failed to save config</p>`)
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, `<p class="success">Password changed successfully</p>`)
}
}
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, `<p class="error">Method not allowed</p>`)
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, `<p class="error">Port must be between 1 and 65535</p>`)
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, `<p class="error">Failed to save config</p>`)
return
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<p class="success">Gateway settings saved (restart required)</p>`)
}
}
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, `<p class="error">Method not allowed</p>`)
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, `<p class="error">Failed to save config</p>`)
return
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<p class="success">Heartbeat settings saved</p>`)
}
}
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, `<p class="error">Method not allowed</p>`)
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, `<p class="error">Failed to save config</p>`)
return
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<p class="success">Devices settings saved</p>`)
}
}

View file

@ -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")
}
}

402
pkg/dashboard/dashboard.go Normal file
View file

@ -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 = `<div id="status-bar" class="status-bar">
<span class="indicator {{if .Running}}running{{else}}stopped{{end}}"></span>
<span>{{.Model}}</span>
<span class="sep">|</span>
<span>Uptime: {{.Uptime}}</span>
<span class="sep">|</span>
<span>Tools: {{.ToolCount}}</span>
<span class="sep">|</span>
<span>Agents: {{.AgentCount}}</span>
<span class="sep">|</span>
<span>Channels: {{.ChannelCount}}</span>
</div>`
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 = `<div id="agents-table" hx-trigger="refreshAgents from:body" hx-get="/dashboard/fragments/agents" hx-swap="outerHTML">
<div style="display:flex;justify-content:flex-end;gap:8px;margin-bottom:8px">
<button class="action-btn edit" hx-get="/dashboard/fragments/defaults-edit" hx-target="#modal-content" hx-swap="innerHTML">Defaults</button>
<button class="action-btn add" hx-get="/dashboard/fragments/agent-add" hx-target="#modal-content" hx-swap="innerHTML">+ Add Agent</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Model</th><th>Default</th><th>Skills</th><th>Actions</th></tr>
</thead>
<tbody>
{{range .Agents}}
<tr>
<td>{{.ID}}</td>
<td>{{.Name}}</td>
<td>{{if .Model}}{{.Model.Primary}}{{else}}<em>default</em>{{end}}</td>
<td>{{if .Default}}&#10003;{{end}}</td>
<td>{{range .Skills}}<span class="tag">{{.}}</span> {{end}}</td>
<td class="actions">
<button class="action-btn edit" hx-get="/dashboard/fragments/agent-edit?id={{.ID}}" hx-target="#modal-content" hx-swap="innerHTML">Edit</button>
<button class="action-btn delete" hx-post="/dashboard/crud/agents/delete" hx-vals='{"id":"{{.ID}}"}' hx-target="#modal-content" hx-swap="innerHTML" hx-confirm="Delete agent {{.ID}}?">Del</button>
</td>
</tr>
{{end}}
{{if not .Agents}}
<tr><td colspan="6" class="empty">No agents configured (using defaults)</td></tr>
{{end}}
</tbody>
</table>
</div>
<p class="note">Default model: {{.DefaultModel}} | Max tokens: {{.MaxTokens}} | Click a row for details</p>
</div>`
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 = `<div>
<h3>Agent: {{.Name}}</h3>
<div class="detail-grid">
<span class="detail-label">ID</span>
<span class="detail-value">{{.ID}}</span>
<span class="detail-label">Name</span>
<span class="detail-value">{{if .Name}}{{.Name}}{{else}}<em>unnamed</em>{{end}}</span>
<span class="detail-label">Default</span>
<span class="detail-value">{{if .Default}}Yes{{else}}No{{end}}</span>
<span class="detail-label">Model</span>
<span class="detail-value">{{if .Model}}{{.Model.Primary}}{{if .Model.Fallbacks}} (fallbacks: {{range $i, $f := .Model.Fallbacks}}{{if $i}}, {{end}}{{$f}}{{end}}){{end}}{{else}}<em>inherited</em>{{end}}</span>
<span class="detail-label">Workspace</span>
<span class="detail-value">{{if .Workspace}}{{.Workspace}}{{else}}<em>default</em>{{end}}</span>
</div>
{{if .Skills}}
<div class="detail-section">
<h4>Skills</h4>
<div class="tool-list">
{{range .Skills}}<span class="tool-tag">{{.}}</span>{{end}}
</div>
</div>
{{end}}
{{if .Subagents}}
<div class="detail-section">
<h4>Allowed Subagents</h4>
<div class="tool-list">
{{range .Subagents.AllowAgents}}<span class="tool-tag">{{.}}</span>{{end}}
</div>
</div>
{{end}}
</div>`
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, `<div><h3>Agent not found</h3><p>No agent with ID "%s"</p></div>`, template.HTMLEscapeString(agentID))
}
}
func fragmentTools(al *agent.AgentLoop) http.HandlerFunc {
const tmpl = `<div id="tools-section">
<div class="detail-section" style="margin-top:0;padding-top:0;border-top:none">
<h4>Tools ({{.ToolCount}})</h4>
<div class="tool-list">
{{range .Tools}}<span class="tool-tag">{{.}}</span>{{end}}
{{if not .Tools}}<span class="empty">No tools loaded</span>{{end}}
</div>
</div>
<div class="detail-section">
<h4>Skills ({{.SkillAvailable}}/{{.SkillTotal}})</h4>
<div class="tool-list">
{{range .Skills}}<span class="tool-tag">{{.}}</span>{{end}}
{{if not .Skills}}<span class="empty">No skills available</span>{{end}}
</div>
</div>
</div>`
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 = `<div id="channels-table" hx-trigger="refreshChannels from:body" hx-get="/dashboard/fragments/channels" hx-swap="outerHTML">
<div class="table-wrap">
<table>
<thead>
<tr><th>Channel</th><th>Enabled</th><th>Running</th><th>Actions</th></tr>
</thead>
<tbody>
{{range $name, $status := .}}
<tr>
<td>{{$name}}</td>
<td>{{if index $status "enabled"}}&#10003;{{else}}&#10007;{{end}}</td>
<td>
{{if index $status "running"}}
<span class="indicator running"></span> Running
{{else}}
<span class="indicator stopped"></span> Stopped
{{end}}
</td>
<td class="actions">
<button class="action-btn edit" hx-get="/dashboard/fragments/channel-edit?name={{$name}}" hx-target="#modal-content" hx-swap="innerHTML">Edit</button>
</td>
</tr>
{{end}}
{{if not .}}
<tr><td colspan="4" class="empty">No channels configured</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>`
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 = `<div id="models-table" hx-trigger="refreshModels from:body" hx-get="/dashboard/fragments/models" hx-swap="outerHTML">
<div style="display:flex;justify-content:flex-end;margin-bottom:8px">
<button class="action-btn add" hx-get="/dashboard/fragments/model-add" hx-target="#modal-content" hx-swap="innerHTML">+ Add Model</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Name</th><th>Provider</th><th>Model</th><th>API Base</th><th>API Key</th><th>Actions</th></tr>
</thead>
<tbody>
{{range $i, $m := .}}
<tr>
<td>{{$m.ModelName}}</td>
<td>{{extractProvider $m.Model}}</td>
<td>{{$m.Model}}</td>
<td>{{if $m.APIBase}}{{$m.APIBase}}{{else}}<em>default</em>{{end}}</td>
<td>{{maskKey $m.APIKey}}</td>
<td class="actions">
<button class="action-btn edit" hx-get="/dashboard/fragments/model-edit?idx={{$i}}" hx-target="#modal-content" hx-swap="innerHTML">Edit</button>
<button class="action-btn delete" hx-post="/dashboard/crud/models/delete" hx-vals='{"idx":"{{$i}}"}' hx-target="#modal-content" hx-swap="innerHTML" hx-confirm="Delete model {{$m.ModelName}}?">Del</button>
</td>
</tr>
{{end}}
{{if not .}}
<tr><td colspan="6" class="empty">No models configured</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>`
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)
}

View file

@ -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)
}
}
}

6
pkg/dashboard/embed.go Normal file
View file

@ -0,0 +1,6 @@
package dashboard
import "embed"
//go:embed static/*
var staticFiles embed.FS

83
pkg/dashboard/sse.go Normal file
View file

@ -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)
}

View file

@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PicoClaw Dashboard</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<style>
:root {
--bg: #0d1117;
--bg2: #161b22;
--bg3: #21262d;
--fg: #c9d1d9;
--fg2: #8b949e;
--green: #3fb950;
--red: #f85149;
--blue: #58a6ff;
--yellow: #d29922;
--border: #30363d;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
background: var(--bg);
color: var(--fg);
font-size: 14px;
line-height: 1.6;
}
.container { max-width: 1200px; margin: 0 auto; padding: 16px; }
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 24px;
}
header h1 { font-size: 20px; color: var(--blue); }
header h1 span { color: var(--fg2); font-weight: normal; font-size: 14px; }
.status-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
}
.sep { color: var(--border); }
.indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--red);
}
.indicator.running { background: var(--green); }
.indicator.stopped { background: var(--red); }
section { margin-bottom: 32px; }
section h2 {
font-size: 16px;
color: var(--blue);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
th {
text-align: left;
padding: 10px 14px;
background: var(--bg3);
color: var(--fg2);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
td {
padding: 8px 14px;
border-top: 1px solid var(--border);
}
tr:hover td { background: var(--bg3); }
.empty { color: var(--fg2); text-align: center; font-style: italic; }
.tag {
display: inline-block;
padding: 1px 8px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 11px;
color: var(--fg2);
margin: 1px 2px;
}
.note {
color: var(--fg2);
font-size: 12px;
margin-top: 8px;
}
#live-feed {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
max-height: 200px;
overflow-y: auto;
font-size: 12px;
color: var(--fg2);
}
#live-feed .event {
padding: 2px 0;
border-bottom: 1px solid var(--border);
}
#live-feed .event:last-child { border-bottom: none; }
.event-time { color: var(--yellow); }
.event-type { color: var(--blue); }
.restart-banner {
display: none;
padding: 10px 16px;
background: rgba(210, 153, 34, 0.15);
border: 1px solid var(--yellow);
border-radius: 6px;
color: var(--yellow);
font-size: 13px;
margin-bottom: 16px;
text-align: center;
}
.restart-banner.visible { display: block; }
.htmx-indicator {
display: none;
color: var(--yellow);
font-size: 12px;
}
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
.actions { white-space: nowrap; }
.action-btn {
padding: 3px 10px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--fg2);
font-family: inherit;
font-size: 11px;
cursor: pointer;
margin: 0 2px;
}
.action-btn:hover { color: var(--fg); border-color: var(--fg2); }
.action-btn.add { color: var(--green); border-color: var(--green); }
.action-btn.add:hover { background: var(--green); color: var(--bg); }
.action-btn.delete:hover { color: var(--red); border-color: var(--red); }
.logout-btn {
padding: 6px 14px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--fg2);
font-family: inherit;
font-size: 12px;
text-decoration: none;
white-space: nowrap;
}
.logout-btn:hover { color: var(--red); border-color: var(--red); }
/* Clickable rows */
tr[hx-get] { cursor: pointer; }
tr[hx-get]:hover td { background: #1c2333; }
/* Modal overlay */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
z-index: 100;
justify-content: center;
align-items: center;
padding: 16px;
}
.modal-overlay.active { display: flex; }
.modal {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 8px;
width: 100%;
max-width: 600px;
max-height: 85vh;
overflow-y: auto;
padding: 24px;
position: relative;
}
.modal h3 {
font-size: 16px;
color: var(--blue);
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.modal-close {
position: absolute;
top: 12px;
right: 16px;
background: none;
border: none;
color: var(--fg2);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.modal-close:hover { background: var(--bg3); color: var(--fg); }
.detail-grid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px 16px;
font-size: 13px;
}
.detail-label { color: var(--fg2); font-weight: 600; }
.detail-value { color: var(--fg); word-break: break-all; }
.detail-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.detail-section h4 {
font-size: 13px;
color: var(--fg2);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tool-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tool-tag {
display: inline-block;
padding: 3px 10px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
color: var(--fg);
}
/* Responsive tables: horizontal scroll wrapper */
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--border);
border-radius: 6px;
}
.table-wrap table { border: none; border-radius: 0; }
/* Tablet */
@media (max-width: 960px) {
.container { padding: 12px; }
header { flex-direction: column; align-items: flex-start; gap: 12px; }
.status-bar { flex-wrap: wrap; gap: 8px; font-size: 12px; width: 100%; }
.sep { display: none; }
.status-bar span:not(.indicator) {
padding: 2px 8px;
background: var(--bg3);
border-radius: 4px;
}
}
/* Mobile */
@media (max-width: 600px) {
body { font-size: 13px; }
.container { padding: 8px; }
header h1 { font-size: 17px; }
header h1 span { font-size: 12px; }
section { margin-bottom: 24px; }
section h2 { font-size: 14px; margin-bottom: 8px; }
.status-bar { padding: 6px 10px; font-size: 11px; }
table { font-size: 12px; }
th { padding: 8px 10px; font-size: 11px; }
td { padding: 6px 10px; }
.tag { font-size: 10px; padding: 1px 6px; }
.note { font-size: 11px; }
#live-feed { font-size: 11px; padding: 8px; max-height: 160px; }
}
/* Very small screens */
@media (max-width: 380px) {
.container { padding: 4px; }
header h1 { font-size: 15px; }
.status-bar { gap: 4px; font-size: 10px; padding: 4px 6px; }
th { padding: 6px; font-size: 10px; }
td { padding: 4px 6px; font-size: 11px; }
}
/* Modal responsive */
@media (max-width: 600px) {
.modal { padding: 16px; max-height: 90vh; }
.modal h3 { font-size: 14px; }
.detail-grid { grid-template-columns: 100px 1fr; gap: 6px 10px; font-size: 12px; }
.modal-close { top: 8px; right: 10px; }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>PicoClaw <span>Dashboard</span></h1>
<div style="display:flex;align-items:center;gap:12px">
<div
hx-get="/dashboard/fragments/status"
hx-trigger="load, every 5s"
hx-swap="innerHTML"
>
<span class="htmx-indicator">loading...</span>
</div>
<a href="/dashboard/logout" class="logout-btn">Logout</a>
</div>
</header>
<div id="restart-banner" class="restart-banner">
Changes saved to config. Restart the gateway for them to take effect.
</div>
<section>
<h2>Agents</h2>
<div
hx-get="/dashboard/fragments/agents"
hx-trigger="load"
hx-swap="innerHTML"
>
<span class="htmx-indicator">loading...</span>
</div>
</section>
<section>
<h2>Tools &amp; Skills</h2>
<div
hx-get="/dashboard/fragments/tools"
hx-trigger="load"
hx-swap="innerHTML"
>
<span class="htmx-indicator">loading...</span>
</div>
</section>
<section>
<h2>Channels</h2>
<div
hx-get="/dashboard/fragments/channels"
hx-trigger="load, every 5s"
hx-swap="innerHTML"
>
<span class="htmx-indicator">loading...</span>
</div>
</section>
<section>
<h2>Models</h2>
<div
hx-get="/dashboard/fragments/models"
hx-trigger="load"
hx-swap="innerHTML"
>
<span class="htmx-indicator">loading...</span>
</div>
</section>
<section>
<h2>Settings</h2>
<div
hx-get="/dashboard/fragments/settings"
hx-trigger="load"
hx-swap="innerHTML"
>
<span class="htmx-indicator">loading...</span>
</div>
</section>
<section>
<h2>Live Feed</h2>
<div id="live-feed" hx-ext="sse" sse-connect="/dashboard/events">
<div sse-swap="status" hx-swap="afterbegin">
</div>
<div class="event"><span class="event-time">--:--:--</span> Waiting for events...</div>
</div>
</section>
</div>
<!-- Modal container -->
<div id="modal-overlay" class="modal-overlay">
<div class="modal">
<button class="modal-close" id="modal-close-btn">&times;</button>
<div id="modal-content"></div>
</div>
</div>
<script>
// Modal logic
function openModal() {
document.getElementById('modal-overlay').classList.add('active');
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('active');
document.getElementById('modal-content').textContent = '';
}
document.getElementById('modal-close-btn').addEventListener('click', closeModal);
document.getElementById('modal-overlay').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// Close modal via HX-Trigger: closeModal from CRUD responses
document.body.addEventListener('closeModal', function() {
closeModal();
});
// Show restart banner when config changes are saved
['refreshAgents', 'refreshModels', 'refreshChannels', 'refreshSettings'].forEach(function(evt) {
document.body.addEventListener(evt, function() {
document.getElementById('restart-banner').classList.add('visible');
});
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
// HTMX: open modal when fragment is loaded into #modal-content
document.body.addEventListener('htmx:afterSwap', function(e) {
if (e.detail.target.id === 'modal-content') {
openModal();
}
});
document.body.addEventListener('sse:status', function(e) {
var feed = document.getElementById('live-feed');
if (!feed) return;
try {
var data = JSON.parse(e.detail.data);
var now = new Date().toLocaleTimeString();
var channels = data.channels || {};
var running = Object.keys(channels).filter(function(k) {
return channels[k] && channels[k].running;
});
var div = document.createElement('div');
div.className = 'event';
var timeSpan = document.createElement('span');
timeSpan.className = 'event-time';
timeSpan.textContent = now;
div.appendChild(timeSpan);
div.appendChild(document.createTextNode(' '));
var typeSpan = document.createElement('span');
typeSpan.className = 'event-type';
typeSpan.textContent = 'status';
div.appendChild(typeSpan);
div.appendChild(document.createTextNode(
' model=' + (data.model || 'n/a') +
' channels=' + running.join(',')
));
var sseDiv = feed.querySelector('[sse-swap="status"]');
if (sseDiv && sseDiv.nextSibling) {
feed.insertBefore(div, sseDiv.nextSibling);
} else {
feed.appendChild(div);
}
// Keep max 50 events
var events = feed.querySelectorAll('.event');
while (events.length > 50) {
events[events.length - 1].remove();
events = feed.querySelectorAll('.event');
}
} catch(err) {
// Ignore parse errors from SSE data
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PicoClaw — Login</title>
<style>
:root {
--bg: #0d1117;
--bg2: #161b22;
--bg3: #21262d;
--fg: #c9d1d9;
--fg2: #8b949e;
--green: #3fb950;
--red: #f85149;
--blue: #58a6ff;
--border: #30363d;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
background: var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 32px;
width: 100%;
max-width: 380px;
margin: 16px;
}
h1 {
font-size: 20px;
color: var(--blue);
margin-bottom: 4px;
}
h1 span { color: var(--fg2); font-weight: normal; font-size: 14px; }
.subtitle {
color: var(--fg2);
font-size: 13px;
margin-bottom: 24px;
}
label {
display: block;
font-size: 12px;
color: var(--fg2);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
input[type="password"] {
width: 100%;
padding: 10px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--fg);
font-family: inherit;
font-size: 14px;
outline: none;
}
input[type="password"]:focus {
border-color: var(--blue);
}
button {
width: 100%;
margin-top: 16px;
padding: 10px;
background: var(--blue);
color: var(--bg);
border: none;
border-radius: 6px;
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button:hover { opacity: 0.9; }
.error {
color: var(--red);
font-size: 13px;
margin-top: 12px;
text-align: center;
}
</style>
</head>
<body>
<div class="login-card">
<h1>PicoClaw <span>Dashboard</span></h1>
<p class="subtitle">Authentication required</p>
<form method="POST" action="/dashboard/login">
<label for="password">Password</label>
<input type="password" id="password" name="password" autofocus required>
<button type="submit">Sign in</button>
</form>
<!--ERROR-->
</div>
</body>
</html>

View file

@ -11,6 +11,7 @@ import (
type Server struct { type Server struct {
server *http.Server server *http.Server
mux *http.ServeMux
mu sync.RWMutex mu sync.RWMutex
ready bool ready bool
checks map[string]Check checks map[string]Check
@ -33,6 +34,7 @@ type StatusResponse struct {
func NewServer(host string, port int) *Server { func NewServer(host string, port int) *Server {
mux := http.NewServeMux() mux := http.NewServeMux()
s := &Server{ s := &Server{
mux: mux,
ready: false, ready: false,
checks: make(map[string]Check), checks: make(map[string]Check),
startTime: time.Now(), startTime: time.Now(),
@ -46,7 +48,7 @@ func NewServer(host string, port int) *Server {
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,
ReadTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second, WriteTimeout: 30 * time.Second,
} }
return s return s
@ -90,6 +92,14 @@ func (s *Server) SetReady(ready bool) {
s.mu.Unlock() 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)) { func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()