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:
parent
bb8b9243b7
commit
26da5defe8
22 changed files with 4121 additions and 1 deletions
|
|
@ -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()})
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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
129
pkg/dashboard/api.go
Normal 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
141
pkg/dashboard/auth.go
Normal 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
213
pkg/dashboard/auth_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
457
pkg/dashboard/crud_agents.go
Normal file
457
pkg/dashboard/crud_agents.go
Normal 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
|
||||||
|
}
|
||||||
198
pkg/dashboard/crud_agents_test.go
Normal file
198
pkg/dashboard/crud_agents_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
342
pkg/dashboard/crud_channels.go
Normal file
342
pkg/dashboard/crud_channels.go
Normal 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>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
215
pkg/dashboard/crud_channels_test.go
Normal file
215
pkg/dashboard/crud_channels_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
pkg/dashboard/crud_helpers.go
Normal file
28
pkg/dashboard/crud_helpers.go
Normal 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)
|
||||||
|
}
|
||||||
249
pkg/dashboard/crud_models.go
Normal file
249
pkg/dashboard/crud_models.go
Normal 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>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
216
pkg/dashboard/crud_models_test.go
Normal file
216
pkg/dashboard/crud_models_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
266
pkg/dashboard/crud_settings.go
Normal file
266
pkg/dashboard/crud_settings.go
Normal 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>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
254
pkg/dashboard/crud_settings_test.go
Normal file
254
pkg/dashboard/crud_settings_test.go
Normal 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
402
pkg/dashboard/dashboard.go
Normal 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}}✓{{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"}}✓{{else}}✗{{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)
|
||||||
|
}
|
||||||
290
pkg/dashboard/dashboard_test.go
Normal file
290
pkg/dashboard/dashboard_test.go
Normal 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
6
pkg/dashboard/embed.go
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
package dashboard
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFiles embed.FS
|
||||||
83
pkg/dashboard/sse.go
Normal file
83
pkg/dashboard/sse.go
Normal 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)
|
||||||
|
}
|
||||||
492
pkg/dashboard/static/index.html
Normal file
492
pkg/dashboard/static/index.html
Normal 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 & 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">×</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>
|
||||||
105
pkg/dashboard/static/login.html
Normal file
105
pkg/dashboard/static/login.html
Normal 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>
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue