feat(web): add visual system config and launcher/autostart controls
- add launcher config model and persistence (`launcher-config.json`) for port/public/CIDR settings - add system APIs for launch-at-login and launcher parameters - apply CIDR-based access-control middleware to backend HTTP routes - split config routing into visual config and raw JSON config pages - add frontend system API client and visual config sections for runtime/devices/launcher - expand i18n strings (en/zh) for new config UI - improve sidebar active matching and session ID generation fallback
This commit is contained in:
parent
7593526b1f
commit
b06e9e3707
23 changed files with 2436 additions and 268 deletions
85
web/backend/api/launcher_config.go
Normal file
85
web/backend/api/launcher_config.go
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type launcherConfigPayload struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/system/launcher-config", h.handleGetLauncherConfig)
|
||||||
|
mux.HandleFunc("PUT /api/system/launcher-config", h.handleUpdateLauncherConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) launcherConfigPath() string {
|
||||||
|
return launcherconfig.PathForAppConfig(h.configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) launcherFallbackConfig() launcherconfig.Config {
|
||||||
|
port := h.serverPort
|
||||||
|
if port <= 0 {
|
||||||
|
port = launcherconfig.DefaultPort
|
||||||
|
}
|
||||||
|
return launcherconfig.Config{
|
||||||
|
Port: port,
|
||||||
|
Public: h.serverPublic,
|
||||||
|
AllowedCIDRs: append([]string(nil), h.serverCIDRs...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loadLauncherConfig() (launcherconfig.Config, error) {
|
||||||
|
return launcherconfig.Load(h.launcherConfigPath(), h.launcherFallbackConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := h.loadLauncherConfig()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||||
|
Port: cfg.Port,
|
||||||
|
Public: cfg.Public,
|
||||||
|
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload launcherConfigPayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := launcherconfig.Config{
|
||||||
|
Port: payload.Port,
|
||||||
|
Public: payload.Public,
|
||||||
|
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
|
||||||
|
}
|
||||||
|
if err := launcherconfig.Validate(cfg); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := launcherconfig.Save(h.launcherConfigPath(), cfg); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to save launcher config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||||
|
Port: cfg.Port,
|
||||||
|
Public: cfg.Public,
|
||||||
|
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||||
|
})
|
||||||
|
}
|
||||||
115
web/backend/api/launcher_config_test.go
Normal file
115
web/backend/api/launcher_config_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
h.SetServerOptions(19999, true, []string{"192.168.1.0/24"})
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/system/launcher-config", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var got launcherConfigPayload
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if got.Port != 19999 || !got.Public {
|
||||||
|
t.Fatalf("response = %+v, want port=19999 public=true", got)
|
||||||
|
}
|
||||||
|
if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" {
|
||||||
|
t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutLauncherConfigPersists(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodPut,
|
||||||
|
"/api/system/launcher-config",
|
||||||
|
strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`),
|
||||||
|
)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
path := launcherconfig.PathForAppConfig(configPath)
|
||||||
|
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("launcherconfig.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Port != 18080 || !cfg.Public {
|
||||||
|
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
|
||||||
|
}
|
||||||
|
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
|
||||||
|
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutLauncherConfigRejectsInvalidPort(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodPut,
|
||||||
|
"/api/system/launcher-config",
|
||||||
|
strings.NewReader(`{"port":70000,"public":false}`),
|
||||||
|
)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodPut,
|
||||||
|
"/api/system/launcher-config",
|
||||||
|
strings.NewReader(`{"port":18080,"public":false,"allowed_cidrs":["bad-cidr"]}`),
|
||||||
|
)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,16 @@ package api
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler serves HTTP API requests.
|
// Handler serves HTTP API requests.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
configPath string
|
configPath string
|
||||||
|
serverPort int
|
||||||
|
serverPublic bool
|
||||||
|
serverCIDRs []string
|
||||||
oauthMu sync.Mutex
|
oauthMu sync.Mutex
|
||||||
oauthFlows map[string]*oauthFlow
|
oauthFlows map[string]*oauthFlow
|
||||||
oauthState map[string]string
|
oauthState map[string]string
|
||||||
|
|
@ -17,11 +22,19 @@ type Handler struct {
|
||||||
func NewHandler(configPath string) *Handler {
|
func NewHandler(configPath string) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
|
serverPort: launcherconfig.DefaultPort,
|
||||||
oauthFlows: make(map[string]*oauthFlow),
|
oauthFlows: make(map[string]*oauthFlow),
|
||||||
oauthState: make(map[string]string),
|
oauthState: make(map[string]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetServerOptions stores current backend listen options for fallback behavior.
|
||||||
|
func (h *Handler) SetServerOptions(port int, public bool, allowedCIDRs []string) {
|
||||||
|
h.serverPort = port
|
||||||
|
h.serverPublic = public
|
||||||
|
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
|
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
|
||||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Config CRUD
|
// Config CRUD
|
||||||
|
|
@ -44,4 +57,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
|
||||||
// Channel catalog (for frontend navigation/config pages)
|
// Channel catalog (for frontend navigation/config pages)
|
||||||
h.registerChannelRoutes(mux)
|
h.registerChannelRoutes(mux)
|
||||||
|
|
||||||
|
// OS startup / launch-at-login
|
||||||
|
h.registerStartupRoutes(mux)
|
||||||
|
|
||||||
|
// Launcher service parameters (port/public)
|
||||||
|
h.registerLauncherConfigRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
305
web/backend/api/startup.go
Normal file
305
web/backend/api/startup.go
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
autoStartEntryName = "PicoClawLauncher"
|
||||||
|
launchAgentLabel = "io.picoclaw.launcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
type autoStartRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type autoStartResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Supported bool `json:"supported"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var errAutoStartUnsupported = errors.New("autostart is not supported on this platform")
|
||||||
|
|
||||||
|
func (h *Handler) registerStartupRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/system/autostart", h.handleGetAutoStart)
|
||||||
|
mux.HandleFunc("PUT /api/system/autostart", h.handleSetAutoStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleGetAutoStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enabled, supported, message, err := h.getAutoStartStatus()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to read startup setting: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(autoStartResponse{
|
||||||
|
Enabled: enabled,
|
||||||
|
Supported: supported,
|
||||||
|
Platform: runtime.GOOS,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleSetAutoStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req autoStartRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.setAutoStart(req.Enabled); err != nil {
|
||||||
|
if errors.Is(err, errAutoStartUnsupported) {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to update startup setting: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled, supported, message, err := h.getAutoStartStatus()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to verify startup setting: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(autoStartResponse{
|
||||||
|
Enabled: enabled,
|
||||||
|
Supported: supported,
|
||||||
|
Platform: runtime.GOOS,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) resolveLaunchCommand() (string, []string, error) {
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"-no-browser"}
|
||||||
|
if h.configPath != "" {
|
||||||
|
args = append(args, h.configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return exePath, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getAutoStartStatus() (enabled bool, supported bool, message string, err error) {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
exists, err := fileExists(macLaunchAgentPath())
|
||||||
|
return exists, true, "Changes apply on next login.", err
|
||||||
|
case "linux":
|
||||||
|
exists, err := fileExists(linuxAutoStartPath())
|
||||||
|
return exists, true, "Changes apply on next login.", err
|
||||||
|
case "windows":
|
||||||
|
exists, err := windowsRunKeyExists()
|
||||||
|
return exists, true, "Changes apply on next login.", err
|
||||||
|
default:
|
||||||
|
return false, false, "Current platform does not support launch at login.", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setAutoStart(enabled bool) error {
|
||||||
|
exePath, args, err := h.resolveLaunchCommand()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return setDarwinAutoStart(enabled, exePath, args)
|
||||||
|
case "linux":
|
||||||
|
return setLinuxAutoStart(enabled, exePath, args)
|
||||||
|
case "windows":
|
||||||
|
return setWindowsAutoStart(enabled, exePath, args)
|
||||||
|
default:
|
||||||
|
return errAutoStartUnsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) (bool, error) {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func macLaunchAgentPath() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDarwinAutoStart(enabled bool, exePath string, args []string) error {
|
||||||
|
plistPath := macLaunchAgentPath()
|
||||||
|
if enabled {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := buildDarwinPlist(exePath, args)
|
||||||
|
return os.WriteFile(plistPath, []byte(content), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmlEscape(s string) string {
|
||||||
|
var b bytes.Buffer
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '&':
|
||||||
|
b.WriteString("&")
|
||||||
|
case '<':
|
||||||
|
b.WriteString("<")
|
||||||
|
case '>':
|
||||||
|
b.WriteString(">")
|
||||||
|
case '"':
|
||||||
|
b.WriteString(""")
|
||||||
|
case '\'':
|
||||||
|
b.WriteString("'")
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDarwinPlist(exePath string, args []string) string {
|
||||||
|
programArgs := make([]string, 0, len(args)+1)
|
||||||
|
programArgs = append(programArgs, exePath)
|
||||||
|
programArgs = append(programArgs, args...)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
||||||
|
b.WriteString(
|
||||||
|
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n",
|
||||||
|
)
|
||||||
|
b.WriteString(`<plist version="1.0">` + "\n")
|
||||||
|
b.WriteString(`<dict>` + "\n")
|
||||||
|
b.WriteString(` <key>Label</key>` + "\n")
|
||||||
|
b.WriteString(` <string>` + launchAgentLabel + `</string>` + "\n")
|
||||||
|
b.WriteString(` <key>ProgramArguments</key>` + "\n")
|
||||||
|
b.WriteString(` <array>` + "\n")
|
||||||
|
for _, arg := range programArgs {
|
||||||
|
b.WriteString(` <string>` + xmlEscape(arg) + `</string>` + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString(` </array>` + "\n")
|
||||||
|
b.WriteString(` <key>RunAtLoad</key>` + "\n")
|
||||||
|
b.WriteString(` <true/>` + "\n")
|
||||||
|
b.WriteString(` <key>ProcessType</key>` + "\n")
|
||||||
|
b.WriteString(` <string>Background</string>` + "\n")
|
||||||
|
b.WriteString(`</dict>` + "\n")
|
||||||
|
b.WriteString(`</plist>` + "\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func linuxAutoStartPath() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".config", "autostart", "picoclaw-web.desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "''"
|
||||||
|
}
|
||||||
|
if !strings.ContainsAny(s, " \t\n'\"\\$`") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLinuxExecLine(exePath string, args []string) string {
|
||||||
|
parts := make([]string, 0, len(args)+1)
|
||||||
|
parts = append(parts, shellQuote(exePath))
|
||||||
|
for _, arg := range args {
|
||||||
|
parts = append(parts, shellQuote(arg))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLinuxAutoStart(enabled bool, exePath string, args []string) error {
|
||||||
|
desktopPath := linuxAutoStartPath()
|
||||||
|
if enabled {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := strings.Join([]string{
|
||||||
|
"[Desktop Entry]",
|
||||||
|
"Type=Application",
|
||||||
|
"Version=1.0",
|
||||||
|
"Name=PicoClaw Web",
|
||||||
|
"Comment=Start PicoClaw Web on login",
|
||||||
|
"Exec=" + buildLinuxExecLine(exePath, args),
|
||||||
|
"Terminal=false",
|
||||||
|
"X-GNOME-Autostart-enabled=true",
|
||||||
|
"NoDisplay=true",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
return os.WriteFile(desktopPath, []byte(content), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsCommandLine(exePath string, args []string) string {
|
||||||
|
parts := make([]string, 0, len(args)+1)
|
||||||
|
parts = append(parts, fmt.Sprintf("%q", exePath))
|
||||||
|
for _, arg := range args {
|
||||||
|
parts = append(parts, fmt.Sprintf("%q", arg))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsRunKeyExists() (bool, error) {
|
||||||
|
cmd := exec.Command("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autoStartEntryName)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWindowsAutoStart(enabled bool, exePath string, args []string) error {
|
||||||
|
key := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
|
||||||
|
if enabled {
|
||||||
|
commandLine := windowsCommandLine(exePath, args)
|
||||||
|
cmd := exec.Command("reg", "add", key, "/v", autoStartEntryName, "/t", "REG_SZ", "/d", commandLine, "/f")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("reg", "delete", key, "/v", autoStartEntryName, "/f")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
56
web/backend/api/startup_test.go
Normal file
56
web/backend/api/startup_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
// Persist non-default launcher options to ensure resolveLaunchCommand does not
|
||||||
|
// pin them into autostart args.
|
||||||
|
launcherPath := launcherconfig.PathForAppConfig(configPath)
|
||||||
|
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
|
||||||
|
Port: 19999,
|
||||||
|
Public: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("launcherconfig.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exePath, args, err := h.resolveLaunchCommand()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveLaunchCommand() error = %v", err)
|
||||||
|
}
|
||||||
|
if exePath == "" {
|
||||||
|
t.Fatal("resolveLaunchCommand() returned empty executable path")
|
||||||
|
}
|
||||||
|
if len(args) != 2 {
|
||||||
|
t.Fatalf("args len = %d, want 2 (got %v)", len(args), args)
|
||||||
|
}
|
||||||
|
if args[0] != "-no-browser" {
|
||||||
|
t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser")
|
||||||
|
}
|
||||||
|
if args[1] != configPath {
|
||||||
|
t.Fatalf("args[1] = %q, want %q", args[1], configPath)
|
||||||
|
}
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-port" || arg == "-public" {
|
||||||
|
t.Fatalf("autostart args should not pin network flags, got %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
|
||||||
|
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
|
||||||
|
if !strings.Contains(plist, "<key>RunAtLoad</key>") {
|
||||||
|
t.Fatalf("plist missing RunAtLoad key:\n%s", plist)
|
||||||
|
}
|
||||||
|
if !strings.Contains(plist, "<true/>") {
|
||||||
|
t.Fatalf("plist missing RunAtLoad true value:\n%s", plist)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
web/backend/launcherconfig/config.go
Normal file
113
web/backend/launcherconfig/config.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package launcherconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FileName is the launcher-specific settings file name.
|
||||||
|
FileName = "launcher-config.json"
|
||||||
|
// DefaultPort is the default port for the web launcher.
|
||||||
|
DefaultPort = 18800
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config stores launch parameters for the web backend service.
|
||||||
|
type Config struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns default launcher settings.
|
||||||
|
func Default() Config {
|
||||||
|
return Config{Port: DefaultPort, Public: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if launcher settings are valid.
|
||||||
|
func Validate(cfg Config) error {
|
||||||
|
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||||
|
return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port)
|
||||||
|
}
|
||||||
|
for _, cidr := range cfg.AllowedCIDRs {
|
||||||
|
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||||
|
return fmt.Errorf("invalid CIDR %q", cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
|
||||||
|
func NormalizeCIDRs(cidrs []string) []string {
|
||||||
|
if len(cidrs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(cidrs))
|
||||||
|
seen := make(map[string]struct{}, len(cidrs))
|
||||||
|
for _, raw := range cidrs {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[trimmed]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathForAppConfig returns launcher-config path near the app config file.
|
||||||
|
func PathForAppConfig(appConfigPath string) string {
|
||||||
|
dir := filepath.Dir(appConfigPath)
|
||||||
|
if dir == "" || dir == "." {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, FileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads launcher settings; fallback is returned when file does not exist.
|
||||||
|
func Load(path string, fallback Config) (Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := fallback
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||||
|
if err := Validate(cfg); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes launcher settings to disk.
|
||||||
|
func Save(path string, cfg Config) error {
|
||||||
|
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
|
||||||
|
if err := Validate(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
89
web/backend/launcherconfig/config_test.go
Normal file
89
web/backend/launcherconfig/config_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package launcherconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "launcher-config.json")
|
||||||
|
fallback := Config{Port: 19999, Public: true}
|
||||||
|
|
||||||
|
got, err := Load(path, fallback)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Port != fallback.Port || got.Public != fallback.Public {
|
||||||
|
t.Fatalf("Load() = %+v, want %+v", got, fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "launcher-config.json")
|
||||||
|
want := Config{
|
||||||
|
Port: 18080,
|
||||||
|
Public: true,
|
||||||
|
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Save(path, want); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
got, err := Load(path, Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Port != want.Port || got.Public != want.Public {
|
||||||
|
t.Fatalf("Load() = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
|
||||||
|
t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs))
|
||||||
|
}
|
||||||
|
for i := range want.AllowedCIDRs {
|
||||||
|
if got.AllowedCIDRs[i] != want.AllowedCIDRs[i] {
|
||||||
|
t.Fatalf("allowed_cidrs[%d] = %q, want %q", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat() error = %v", err)
|
||||||
|
}
|
||||||
|
if perm := stat.Mode().Perm(); perm != 0o600 {
|
||||||
|
t.Fatalf("file perm = %o, want 600", perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsInvalidPort(t *testing.T) {
|
||||||
|
if err := Validate(Config{Port: 0, Public: false}); err == nil {
|
||||||
|
t.Fatal("Validate() expected error for port 0")
|
||||||
|
}
|
||||||
|
if err := Validate(Config{Port: 65536, Public: false}); err == nil {
|
||||||
|
t.Fatal("Validate() expected error for port 65536")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsInvalidCIDR(t *testing.T) {
|
||||||
|
err := Validate(Config{
|
||||||
|
Port: 18800,
|
||||||
|
AllowedCIDRs: []string{"192.168.1.0/24", "not-a-cidr"},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() expected error for invalid CIDR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCIDRs(t *testing.T) {
|
||||||
|
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
|
||||||
|
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,15 +12,18 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/web/backend/api"
|
"github.com/sipeed/picoclaw/web/backend/api"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -58,12 +61,47 @@ func main() {
|
||||||
log.Fatalf("Failed to resolve config path: %v", err)
|
log.Fatalf("Failed to resolve config path: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var explicitPort bool
|
||||||
|
var explicitPublic bool
|
||||||
|
flag.Visit(func(f *flag.Flag) {
|
||||||
|
switch f.Name {
|
||||||
|
case "port":
|
||||||
|
explicitPort = true
|
||||||
|
case "public":
|
||||||
|
explicitPublic = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
launcherPath := launcherconfig.PathForAppConfig(absPath)
|
||||||
|
launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to load %s: %v", launcherPath, err)
|
||||||
|
launcherCfg = launcherconfig.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
effectivePort := *port
|
||||||
|
effectivePublic := *public
|
||||||
|
if !explicitPort {
|
||||||
|
effectivePort = strconv.Itoa(launcherCfg.Port)
|
||||||
|
}
|
||||||
|
if !explicitPublic {
|
||||||
|
effectivePublic = launcherCfg.Public
|
||||||
|
}
|
||||||
|
|
||||||
|
portNum, err := strconv.Atoi(effectivePort)
|
||||||
|
if err != nil || portNum < 1 || portNum > 65535 {
|
||||||
|
if err == nil {
|
||||||
|
err = errors.New("must be in range 1-65535")
|
||||||
|
}
|
||||||
|
log.Fatalf("Invalid port %q: %v", effectivePort, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Determine listen address
|
// Determine listen address
|
||||||
var addr string
|
var addr string
|
||||||
if *public {
|
if effectivePublic {
|
||||||
addr = "0.0.0.0:" + *port
|
addr = "0.0.0.0:" + effectivePort
|
||||||
} else {
|
} else {
|
||||||
addr = "127.0.0.1:" + *port
|
addr = "127.0.0.1:" + effectivePort
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Server components
|
// Initialize Server components
|
||||||
|
|
@ -71,15 +109,21 @@ func main() {
|
||||||
|
|
||||||
// API Routes (e.g. /api/status)
|
// API Routes (e.g. /api/status)
|
||||||
apiHandler := api.NewHandler(absPath)
|
apiHandler := api.NewHandler(absPath)
|
||||||
|
apiHandler.SetServerOptions(portNum, effectivePublic, launcherCfg.AllowedCIDRs)
|
||||||
apiHandler.RegisterRoutes(mux)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Frontend Embedded Assets
|
// Frontend Embedded Assets
|
||||||
registerEmbedRoutes(mux)
|
registerEmbedRoutes(mux)
|
||||||
|
|
||||||
|
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply middleware stack
|
// Apply middleware stack
|
||||||
handler := middleware.Recoverer(
|
handler := middleware.Recoverer(
|
||||||
middleware.Logger(
|
middleware.Logger(
|
||||||
middleware.JSONContentType(mux),
|
middleware.JSONContentType(accessControlledMux),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -88,10 +132,10 @@ func main() {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(" Open the following URL in your browser:")
|
fmt.Println(" Open the following URL in your browser:")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf(" >> http://localhost:%s <<\n", *port)
|
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
|
||||||
if *public {
|
if effectivePublic {
|
||||||
if ip := getLocalIP(); ip != "" {
|
if ip := getLocalIP(); ip != "" {
|
||||||
fmt.Printf(" >> http://%s:%s <<\n", ip, *port)
|
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
@ -100,7 +144,7 @@ func main() {
|
||||||
if !*noBrowser {
|
if !*noBrowser {
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
url := "http://localhost:" + *port
|
url := "http://localhost:" + effectivePort
|
||||||
if err := openBrowser(url); err != nil {
|
if err := openBrowser(url); err != nil {
|
||||||
log.Printf("Warning: Failed to auto-open browser: %v", err)
|
log.Printf("Warning: Failed to auto-open browser: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
web/backend/middleware/access_control.go
Normal file
64
web/backend/middleware/access_control.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IPAllowlist restricts access to requests from configured CIDR ranges.
|
||||||
|
// Loopback addresses are always allowed for local administration.
|
||||||
|
// Empty CIDR list means no restriction.
|
||||||
|
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {
|
||||||
|
if len(allowedCIDRs) == 0 {
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
|
||||||
|
for _, cidr := range allowedCIDRs {
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
||||||
|
}
|
||||||
|
nets = append(nets, ipNet)
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := clientIPFromRemoteAddr(r.RemoteAddr)
|
||||||
|
if ip == nil {
|
||||||
|
rejectByPolicy(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ip.IsLoopback() {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ipNet := range nets {
|
||||||
|
if ipNet.Contains(ip) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectByPolicy(w, r)
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
|
||||||
|
host := remoteAddr
|
||||||
|
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
return net.ParseIP(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"access denied by network policy"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
}
|
||||||
86
web/backend/middleware/access_control_test.go
Normal file
86
web/backend/middleware/access_control_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) {
|
||||||
|
h, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IPAllowlist() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = "203.0.113.5:1234"
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) {
|
||||||
|
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IPAllowlist() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.8:1234"
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) {
|
||||||
|
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IPAllowlist() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.88:1234"
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) {
|
||||||
|
h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IPAllowlist() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = "127.0.0.1:1234"
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPAllowlist_InvalidCIDR(t *testing.T) {
|
||||||
|
_, err := IPAllowlist([]string{"bad-cidr"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("IPAllowlist() expected error for invalid CIDR")
|
||||||
|
}
|
||||||
|
}
|
||||||
62
web/frontend/src/api/system.ts
Normal file
62
web/frontend/src/api/system.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
export interface AutoStartStatus {
|
||||||
|
enabled: boolean
|
||||||
|
supported: boolean
|
||||||
|
platform: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LauncherConfig {
|
||||||
|
port: number
|
||||||
|
public: boolean
|
||||||
|
allowed_cidrs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, options)
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `API error: ${res.status} ${res.statusText}`
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
error?: string
|
||||||
|
errors?: string[]
|
||||||
|
}
|
||||||
|
if (Array.isArray(body.errors) && body.errors.length > 0) {
|
||||||
|
message = body.errors.join("; ")
|
||||||
|
} else if (typeof body.error === "string" && body.error.trim() !== "") {
|
||||||
|
message = body.error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep fallback error message when response body is not JSON.
|
||||||
|
}
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAutoStartStatus(): Promise<AutoStartStatus> {
|
||||||
|
return request<AutoStartStatus>("/api/system/autostart")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAutoStartEnabled(
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<AutoStartStatus> {
|
||||||
|
return request<AutoStartStatus>("/api/system/autostart", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLauncherConfig(): Promise<LauncherConfig> {
|
||||||
|
return request<LauncherConfig>("/api/system/launcher-config")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLauncherConfig(
|
||||||
|
payload: LauncherConfig,
|
||||||
|
): Promise<LauncherConfig> {
|
||||||
|
return request<LauncherConfig>("/api/system/launcher-config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -154,7 +154,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
<SidebarGroupContent className="pt-1">
|
<SidebarGroupContent className="pt-1">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const isActive = currentPath === item.url
|
const isActive =
|
||||||
|
currentPath === item.url ||
|
||||||
|
(item.url !== "/" &&
|
||||||
|
currentPath.startsWith(`${item.url}/`))
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
|
|
|
||||||
374
web/frontend/src/components/config/config-page.tsx
Normal file
374
web/frontend/src/components/config/config-page.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
import { IconCode, IconDeviceFloppy } from "@tabler/icons-react"
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { patchAppConfig } from "@/api/channels"
|
||||||
|
import {
|
||||||
|
getAutoStartStatus,
|
||||||
|
getLauncherConfig,
|
||||||
|
setAutoStartEnabled as updateAutoStartEnabled,
|
||||||
|
setLauncherConfig as updateLauncherConfig,
|
||||||
|
} from "@/api/system"
|
||||||
|
import {
|
||||||
|
AdvancedSection,
|
||||||
|
AgentDefaultsSection,
|
||||||
|
DevicesSection,
|
||||||
|
LauncherSection,
|
||||||
|
RuntimeSection,
|
||||||
|
} from "@/components/config/config-sections"
|
||||||
|
import {
|
||||||
|
type CoreConfigForm,
|
||||||
|
EMPTY_FORM,
|
||||||
|
EMPTY_LAUNCHER_FORM,
|
||||||
|
type LauncherForm,
|
||||||
|
buildFormFromConfig,
|
||||||
|
parseCIDRText,
|
||||||
|
parseIntField,
|
||||||
|
} from "@/components/config/form-model"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
export function ConfigPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form, setForm] = useState<CoreConfigForm>(EMPTY_FORM)
|
||||||
|
const [baseline, setBaseline] = useState<CoreConfigForm>(EMPTY_FORM)
|
||||||
|
const [launcherForm, setLauncherForm] =
|
||||||
|
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
|
||||||
|
const [launcherBaseline, setLauncherBaseline] =
|
||||||
|
useState<LauncherForm>(EMPTY_LAUNCHER_FORM)
|
||||||
|
const [autoStartEnabled, setAutoStartEnabled] = useState(false)
|
||||||
|
const [autoStartBaseline, setAutoStartBaseline] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/config")
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to load config")
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: launcherConfig,
|
||||||
|
isLoading: isLauncherLoading,
|
||||||
|
error: launcherError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["system", "launcher-config"],
|
||||||
|
queryFn: getLauncherConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: autoStartStatus,
|
||||||
|
isLoading: isAutoStartLoading,
|
||||||
|
error: autoStartError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["system", "autostart"],
|
||||||
|
queryFn: getAutoStartStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return
|
||||||
|
const parsed = buildFormFromConfig(data)
|
||||||
|
setForm(parsed)
|
||||||
|
setBaseline(parsed)
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!launcherConfig) return
|
||||||
|
const parsed: LauncherForm = {
|
||||||
|
port: String(launcherConfig.port),
|
||||||
|
publicAccess: launcherConfig.public,
|
||||||
|
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
|
||||||
|
}
|
||||||
|
setLauncherForm(parsed)
|
||||||
|
setLauncherBaseline(parsed)
|
||||||
|
}, [launcherConfig])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoStartStatus) return
|
||||||
|
setAutoStartEnabled(autoStartStatus.enabled)
|
||||||
|
setAutoStartBaseline(autoStartStatus.enabled)
|
||||||
|
}, [autoStartStatus])
|
||||||
|
|
||||||
|
const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)
|
||||||
|
const launcherDirty =
|
||||||
|
JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline)
|
||||||
|
const autoStartDirty = autoStartEnabled !== autoStartBaseline
|
||||||
|
const isDirty = configDirty || launcherDirty || autoStartDirty
|
||||||
|
|
||||||
|
const autoStartSupported = autoStartStatus?.supported !== false
|
||||||
|
const autoStartHint = autoStartError
|
||||||
|
? t(
|
||||||
|
"pages.config.autostart_load_error",
|
||||||
|
"Failed to load launch-at-login status.",
|
||||||
|
)
|
||||||
|
: !autoStartSupported
|
||||||
|
? t(
|
||||||
|
"pages.config.autostart_unsupported",
|
||||||
|
"Launch at login is not supported on this platform.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"pages.config.autostart_hint",
|
||||||
|
"Start PicoClaw Web automatically when you log in.",
|
||||||
|
)
|
||||||
|
|
||||||
|
const launcherHint = launcherError
|
||||||
|
? t(
|
||||||
|
"pages.config.launcher_load_error",
|
||||||
|
"Failed to load service parameters.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"pages.config.launcher_restart_hint",
|
||||||
|
"Service parameter changes apply after restarting PicoClaw Web.",
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateField = <K extends keyof CoreConfigForm>(
|
||||||
|
key: K,
|
||||||
|
value: CoreConfigForm[K],
|
||||||
|
) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLauncherField = <K extends keyof LauncherForm>(
|
||||||
|
key: K,
|
||||||
|
value: LauncherForm[K],
|
||||||
|
) => {
|
||||||
|
setLauncherForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setForm(baseline)
|
||||||
|
setLauncherForm(launcherBaseline)
|
||||||
|
setAutoStartEnabled(autoStartBaseline)
|
||||||
|
toast.info(
|
||||||
|
t(
|
||||||
|
"pages.config.reset_success",
|
||||||
|
"Changes have been reset to the last saved state.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
|
||||||
|
if (configDirty) {
|
||||||
|
const workspace = form.workspace.trim()
|
||||||
|
const dmScope = form.dmScope.trim()
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error("Workspace path is required.")
|
||||||
|
}
|
||||||
|
if (!dmScope) {
|
||||||
|
throw new Error("Session scope is required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTokens = parseIntField(form.maxTokens, "Max tokens", {
|
||||||
|
min: 1,
|
||||||
|
})
|
||||||
|
const maxToolIterations = parseIntField(
|
||||||
|
form.maxToolIterations,
|
||||||
|
"Max tool iterations",
|
||||||
|
{ min: 1 },
|
||||||
|
)
|
||||||
|
const summarizeMessageThreshold = parseIntField(
|
||||||
|
form.summarizeMessageThreshold,
|
||||||
|
"Summarize message threshold",
|
||||||
|
{ min: 1 },
|
||||||
|
)
|
||||||
|
const summarizeTokenPercent = parseIntField(
|
||||||
|
form.summarizeTokenPercent,
|
||||||
|
"Summarize token percent",
|
||||||
|
{ min: 1, max: 100 },
|
||||||
|
)
|
||||||
|
const heartbeatInterval = parseIntField(
|
||||||
|
form.heartbeatInterval,
|
||||||
|
"Heartbeat interval",
|
||||||
|
{ min: 1 },
|
||||||
|
)
|
||||||
|
|
||||||
|
await patchAppConfig({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
workspace,
|
||||||
|
restrict_to_workspace: form.restrictToWorkspace,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
max_tool_iterations: maxToolIterations,
|
||||||
|
summarize_message_threshold: summarizeMessageThreshold,
|
||||||
|
summarize_token_percent: summarizeTokenPercent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
dm_scope: dmScope,
|
||||||
|
},
|
||||||
|
heartbeat: {
|
||||||
|
enabled: form.heartbeatEnabled,
|
||||||
|
interval: heartbeatInterval,
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
enabled: form.devicesEnabled,
|
||||||
|
monitor_usb: form.monitorUSB,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setBaseline(form)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launcherDirty) {
|
||||||
|
const port = parseIntField(launcherForm.port, "Service port", {
|
||||||
|
min: 1,
|
||||||
|
max: 65535,
|
||||||
|
})
|
||||||
|
const allowedCIDRs = parseCIDRText(launcherForm.allowedCIDRsText)
|
||||||
|
const savedLauncherConfig = await updateLauncherConfig({
|
||||||
|
port,
|
||||||
|
public: launcherForm.publicAccess,
|
||||||
|
allowed_cidrs: allowedCIDRs,
|
||||||
|
})
|
||||||
|
const parsedLauncher: LauncherForm = {
|
||||||
|
port: String(savedLauncherConfig.port),
|
||||||
|
publicAccess: savedLauncherConfig.public,
|
||||||
|
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
setLauncherForm(parsedLauncher)
|
||||||
|
setLauncherBaseline(parsedLauncher)
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["system", "launcher-config"],
|
||||||
|
savedLauncherConfig,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoStartDirty) {
|
||||||
|
if (!autoStartSupported) {
|
||||||
|
throw new Error(
|
||||||
|
t(
|
||||||
|
"pages.config.autostart_unsupported",
|
||||||
|
"Launch at login is not supported on this platform.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const status = await updateAutoStartEnabled(autoStartEnabled)
|
||||||
|
setAutoStartEnabled(status.enabled)
|
||||||
|
setAutoStartBaseline(status.enabled)
|
||||||
|
queryClient.setQueryData(["system", "autostart"], status)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
t("pages.config.save_success", "Configuration saved successfully."),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t("pages.config.save_error", "Failed to save configuration."),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
title={t("navigation.config", "Config")}
|
||||||
|
children={
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/config/raw">
|
||||||
|
<IconCode className="size-4" />
|
||||||
|
{t("pages.config.open_raw", "Raw Config")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-auto p-3 lg:p-6">
|
||||||
|
<div className="mx-auto w-full max-w-[1000px] space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-6 text-sm">
|
||||||
|
{t("labels.loading", "Loading...")}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-destructive py-6 text-sm">
|
||||||
|
{t(
|
||||||
|
"pages.config.load_error",
|
||||||
|
"Failed to load configuration. Please refresh and try again.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{isDirty && (
|
||||||
|
<div className="bg-yellow-50 px-3 py-2 text-sm text-yellow-700">
|
||||||
|
{t(
|
||||||
|
"pages.config.unsaved_changes",
|
||||||
|
"You have unsaved changes.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<LauncherSection
|
||||||
|
launcherForm={launcherForm}
|
||||||
|
onFieldChange={updateLauncherField}
|
||||||
|
launcherHint={launcherHint}
|
||||||
|
disabled={saving || isLauncherLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DevicesSection
|
||||||
|
form={form}
|
||||||
|
onFieldChange={updateField}
|
||||||
|
autoStartEnabled={autoStartEnabled}
|
||||||
|
autoStartHint={autoStartHint}
|
||||||
|
autoStartDisabled={
|
||||||
|
isAutoStartLoading ||
|
||||||
|
Boolean(autoStartError) ||
|
||||||
|
!autoStartSupported ||
|
||||||
|
saving
|
||||||
|
}
|
||||||
|
onAutoStartChange={setAutoStartEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<AdvancedSection />
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!isDirty || saving}
|
||||||
|
>
|
||||||
|
{t("common.reset", "Reset")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!isDirty || saving}>
|
||||||
|
<IconDeviceFloppy className="size-4" />
|
||||||
|
{saving
|
||||||
|
? t("common.saving", "Saving...")
|
||||||
|
: t("common.save", "Save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
385
web/frontend/src/components/config/config-sections.tsx
Normal file
385
web/frontend/src/components/config/config-sections.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
import { IconCode } from "@tabler/icons-react"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CoreConfigForm,
|
||||||
|
DM_SCOPE_OPTIONS,
|
||||||
|
type LauncherForm,
|
||||||
|
} from "@/components/config/form-model"
|
||||||
|
import { Field, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
type UpdateCoreField = <K extends keyof CoreConfigForm>(
|
||||||
|
key: K,
|
||||||
|
value: CoreConfigForm[K],
|
||||||
|
) => void
|
||||||
|
|
||||||
|
type UpdateLauncherField = <K extends keyof LauncherForm>(
|
||||||
|
key: K,
|
||||||
|
value: LauncherForm[K],
|
||||||
|
) => void
|
||||||
|
|
||||||
|
interface AgentDefaultsSectionProps {
|
||||||
|
form: CoreConfigForm
|
||||||
|
onFieldChange: UpdateCoreField
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentDefaultsSection({
|
||||||
|
form,
|
||||||
|
onFieldChange,
|
||||||
|
}: AgentDefaultsSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.workspace", "Workspace Directory")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.workspace_hint",
|
||||||
|
"Base directory for agent file operations.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={form.workspace}
|
||||||
|
onChange={(e) => onFieldChange("workspace", e.target.value)}
|
||||||
|
placeholder="~/.picoclaw/workspace"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.restrict_workspace", "Restrict to Workspace")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.restrict_workspace_hint",
|
||||||
|
"Only allow file operations inside workspace.",
|
||||||
|
)}
|
||||||
|
checked={form.restrictToWorkspace}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("restrictToWorkspace", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.max_tokens", "Max Tokens")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.max_tokens_hint",
|
||||||
|
"Upper token limit per model response.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.maxTokens}
|
||||||
|
onChange={(e) => onFieldChange("maxTokens", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.max_tool_iterations", "Max Tool Iterations")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.max_tool_iterations_hint",
|
||||||
|
"Maximum tool-call loops in a single task.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.maxToolIterations}
|
||||||
|
onChange={(e) => onFieldChange("maxToolIterations", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t(
|
||||||
|
"pages.config.summarize_threshold",
|
||||||
|
"Summarize Message Threshold",
|
||||||
|
)}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.summarize_threshold_hint",
|
||||||
|
"Start summarization after this many messages.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.summarizeMessageThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("summarizeMessageThreshold", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t(
|
||||||
|
"pages.config.summarize_token_percent",
|
||||||
|
"Summarize Token Percent",
|
||||||
|
)}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.summarize_token_percent_hint",
|
||||||
|
"Used when conversation summary is triggered.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={form.summarizeTokenPercent}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("summarizeTokenPercent", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeSectionProps {
|
||||||
|
form: CoreConfigForm
|
||||||
|
onFieldChange: UpdateCoreField
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const selectedDmScopeOption = DM_SCOPE_OPTIONS.find(
|
||||||
|
(scope) => scope.value === form.dmScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.session_scope", "Session Scope")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.session_scope_hint",
|
||||||
|
"How chat context is isolated across peers/channels.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={form.dmScope}
|
||||||
|
onValueChange={(value) => onFieldChange("dmScope", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>
|
||||||
|
{selectedDmScopeOption
|
||||||
|
? t(
|
||||||
|
selectedDmScopeOption.labelKey,
|
||||||
|
selectedDmScopeOption.labelDefault,
|
||||||
|
)
|
||||||
|
: form.dmScope}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DM_SCOPE_OPTIONS.map((scope) => (
|
||||||
|
<SelectItem key={scope.value} value={scope.value}>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t(scope.labelKey, scope.labelDefault)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{t(scope.descKey, scope.descDefault)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.heartbeat_enabled", "Heartbeat")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.heartbeat_enabled_hint",
|
||||||
|
"Send periodic heartbeat messages.",
|
||||||
|
)}
|
||||||
|
checked={form.heartbeatEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("heartbeatEnabled", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.heartbeatEnabled && (
|
||||||
|
<Field
|
||||||
|
label={t(
|
||||||
|
"pages.config.heartbeat_interval",
|
||||||
|
"Heartbeat Interval (minutes)",
|
||||||
|
)}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.heartbeat_interval_hint",
|
||||||
|
"Interval in minutes between heartbeat signals.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.heartbeatInterval}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("heartbeatInterval", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LauncherSectionProps {
|
||||||
|
launcherForm: LauncherForm
|
||||||
|
onFieldChange: UpdateLauncherField
|
||||||
|
launcherHint: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LauncherSection({
|
||||||
|
launcherForm,
|
||||||
|
onFieldChange,
|
||||||
|
launcherHint,
|
||||||
|
disabled,
|
||||||
|
}: LauncherSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.server_port", "Service Port")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.server_port_hint",
|
||||||
|
"HTTP port used by PicoClaw Web.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={launcherForm.port}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onFieldChange("port", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.lan_access", "Enable LAN Access")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.lan_access_hint",
|
||||||
|
"Allow access from other devices on your local network.",
|
||||||
|
)}
|
||||||
|
checked={launcherForm.publicAccess}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={(checked) => onFieldChange("publicAccess", checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.allowed_cidrs", "Allowed Network CIDRs")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.allowed_cidrs_hint",
|
||||||
|
"Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={launcherForm.allowedCIDRsText}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.config.allowed_cidrs_placeholder",
|
||||||
|
"192.168.1.0/24\n10.0.0.0/8",
|
||||||
|
)}
|
||||||
|
className="min-h-[88px]"
|
||||||
|
onChange={(e) => onFieldChange("allowedCIDRsText", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-xs">{launcherHint}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DevicesSectionProps {
|
||||||
|
form: CoreConfigForm
|
||||||
|
onFieldChange: UpdateCoreField
|
||||||
|
autoStartEnabled: boolean
|
||||||
|
autoStartHint: string
|
||||||
|
autoStartDisabled: boolean
|
||||||
|
onAutoStartChange: (checked: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevicesSection({
|
||||||
|
form,
|
||||||
|
onFieldChange,
|
||||||
|
autoStartEnabled,
|
||||||
|
autoStartHint,
|
||||||
|
autoStartDisabled,
|
||||||
|
onAutoStartChange,
|
||||||
|
}: DevicesSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.devices_enabled", "Enable Devices")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.devices_enabled_hint",
|
||||||
|
"Enable hardware-device integrations.",
|
||||||
|
)}
|
||||||
|
checked={form.devicesEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("devicesEnabled", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.monitor_usb", "Monitor USB")}
|
||||||
|
hint={t(
|
||||||
|
"pages.config.monitor_usb_hint",
|
||||||
|
"Watch USB plug/unplug events when devices are enabled.",
|
||||||
|
)}
|
||||||
|
checked={form.monitorUSB}
|
||||||
|
onCheckedChange={(checked) => onFieldChange("monitorUSB", checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.autostart_label", "Launch at Login")}
|
||||||
|
hint={autoStartHint}
|
||||||
|
checked={autoStartEnabled}
|
||||||
|
disabled={autoStartDisabled}
|
||||||
|
onCheckedChange={onAutoStartChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvancedSection() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"pages.config.advanced_desc",
|
||||||
|
"Open the raw JSON page to edit every field directly.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/config/raw">
|
||||||
|
<IconCode className="size-4" />
|
||||||
|
{t("pages.config.open_raw", "Raw Config")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
web/frontend/src/components/config/form-model.ts
Normal file
172
web/frontend/src/components/config/form-model.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
export type JsonRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
export interface CoreConfigForm {
|
||||||
|
workspace: string
|
||||||
|
restrictToWorkspace: boolean
|
||||||
|
maxTokens: string
|
||||||
|
maxToolIterations: string
|
||||||
|
summarizeMessageThreshold: string
|
||||||
|
summarizeTokenPercent: string
|
||||||
|
dmScope: string
|
||||||
|
heartbeatEnabled: boolean
|
||||||
|
heartbeatInterval: string
|
||||||
|
devicesEnabled: boolean
|
||||||
|
monitorUSB: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LauncherForm {
|
||||||
|
port: string
|
||||||
|
publicAccess: boolean
|
||||||
|
allowedCIDRsText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DM_SCOPE_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: "per-channel-peer",
|
||||||
|
labelKey: "pages.config.session_scope_per_channel_peer",
|
||||||
|
labelDefault: "Per Channel + Peer",
|
||||||
|
descKey: "pages.config.session_scope_per_channel_peer_desc",
|
||||||
|
descDefault: "Separate context for each user in each channel.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "per-channel",
|
||||||
|
labelKey: "pages.config.session_scope_per_channel",
|
||||||
|
labelDefault: "Per Channel",
|
||||||
|
descKey: "pages.config.session_scope_per_channel_desc",
|
||||||
|
descDefault: "One shared context per channel.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "per-peer",
|
||||||
|
labelKey: "pages.config.session_scope_per_peer",
|
||||||
|
labelDefault: "Per Peer",
|
||||||
|
descKey: "pages.config.session_scope_per_peer_desc",
|
||||||
|
descDefault: "One context per user across channels.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "global",
|
||||||
|
labelKey: "pages.config.session_scope_global",
|
||||||
|
labelDefault: "Global",
|
||||||
|
descKey: "pages.config.session_scope_global_desc",
|
||||||
|
descDefault: "All messages share one global context.",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const EMPTY_FORM: CoreConfigForm = {
|
||||||
|
workspace: "",
|
||||||
|
restrictToWorkspace: true,
|
||||||
|
maxTokens: "32768",
|
||||||
|
maxToolIterations: "50",
|
||||||
|
summarizeMessageThreshold: "20",
|
||||||
|
summarizeTokenPercent: "75",
|
||||||
|
dmScope: "per-channel-peer",
|
||||||
|
heartbeatEnabled: true,
|
||||||
|
heartbeatInterval: "30",
|
||||||
|
devicesEnabled: false,
|
||||||
|
monitorUSB: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||||
|
port: "18800",
|
||||||
|
publicAccess: false,
|
||||||
|
allowedCIDRsText: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): JsonRecord {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as JsonRecord
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBool(value: unknown): boolean {
|
||||||
|
return value === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumberString(value: unknown, fallback: string): string {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
|
const root = asRecord(config)
|
||||||
|
const agents = asRecord(root.agents)
|
||||||
|
const defaults = asRecord(agents.defaults)
|
||||||
|
const session = asRecord(root.session)
|
||||||
|
const heartbeat = asRecord(root.heartbeat)
|
||||||
|
const devices = asRecord(root.devices)
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
|
||||||
|
restrictToWorkspace:
|
||||||
|
defaults.restrict_to_workspace === undefined
|
||||||
|
? EMPTY_FORM.restrictToWorkspace
|
||||||
|
: asBool(defaults.restrict_to_workspace),
|
||||||
|
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
|
||||||
|
maxToolIterations: asNumberString(
|
||||||
|
defaults.max_tool_iterations,
|
||||||
|
EMPTY_FORM.maxToolIterations,
|
||||||
|
),
|
||||||
|
summarizeMessageThreshold: asNumberString(
|
||||||
|
defaults.summarize_message_threshold,
|
||||||
|
EMPTY_FORM.summarizeMessageThreshold,
|
||||||
|
),
|
||||||
|
summarizeTokenPercent: asNumberString(
|
||||||
|
defaults.summarize_token_percent,
|
||||||
|
EMPTY_FORM.summarizeTokenPercent,
|
||||||
|
),
|
||||||
|
dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope,
|
||||||
|
heartbeatEnabled:
|
||||||
|
heartbeat.enabled === undefined
|
||||||
|
? EMPTY_FORM.heartbeatEnabled
|
||||||
|
: asBool(heartbeat.enabled),
|
||||||
|
heartbeatInterval: asNumberString(
|
||||||
|
heartbeat.interval,
|
||||||
|
EMPTY_FORM.heartbeatInterval,
|
||||||
|
),
|
||||||
|
devicesEnabled:
|
||||||
|
devices.enabled === undefined
|
||||||
|
? EMPTY_FORM.devicesEnabled
|
||||||
|
: asBool(devices.enabled),
|
||||||
|
monitorUSB:
|
||||||
|
devices.monitor_usb === undefined
|
||||||
|
? EMPTY_FORM.monitorUSB
|
||||||
|
: asBool(devices.monitor_usb),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIntField(
|
||||||
|
rawValue: string,
|
||||||
|
label: string,
|
||||||
|
options: { min?: number; max?: number } = {},
|
||||||
|
): number {
|
||||||
|
const value = Number(rawValue)
|
||||||
|
if (!Number.isInteger(value)) {
|
||||||
|
throw new Error(`${label} must be an integer.`)
|
||||||
|
}
|
||||||
|
if (options.min !== undefined && value < options.min) {
|
||||||
|
throw new Error(`${label} must be >= ${options.min}.`)
|
||||||
|
}
|
||||||
|
if (options.max !== undefined && value > options.max) {
|
||||||
|
throw new Error(`${label} must be <= ${options.max}.`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCIDRText(raw: string): string[] {
|
||||||
|
if (!raw.trim()) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v.length > 0)
|
||||||
|
}
|
||||||
230
web/frontend/src/components/config/raw-json-panel.tsx
Normal file
230
web/frontend/src/components/config/raw-json-panel.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
export function RawJsonPanel() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: config, isLoading } = useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/config")
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch config")
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (newConfig: string) => {
|
||||||
|
const res = await fetch("/api/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: newConfig,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to save config")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_, submittedConfig) => {
|
||||||
|
toast.success(
|
||||||
|
t("pages.config.save_success", "Configuration saved successfully."),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const savedConfig = JSON.parse(submittedConfig)
|
||||||
|
setLastSavedConfig(savedConfig)
|
||||||
|
setIsDirty(false)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||||
|
} catch {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("pages.config.save_error", "Failed to save configuration."))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [editorValue, setEditorValue] = useState("")
|
||||||
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
|
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null>(null)
|
||||||
|
|
||||||
|
const effectiveEditorValue =
|
||||||
|
editorValue || (config ? JSON.stringify(config, null, 2) : "")
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
try {
|
||||||
|
JSON.parse(effectiveEditorValue)
|
||||||
|
mutation.mutate(effectiveEditorValue)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t(
|
||||||
|
"pages.config.invalid_json",
|
||||||
|
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFormat = () => {
|
||||||
|
try {
|
||||||
|
const formatted = JSON.stringify(
|
||||||
|
JSON.parse(effectiveEditorValue),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
setEditorValue(formatted)
|
||||||
|
toast.success(
|
||||||
|
t("pages.config.format_success", "JSON formatted successfully."),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t(
|
||||||
|
"pages.config.format_error",
|
||||||
|
error instanceof Error ? error.message : "Invalid JSON format.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false)
|
||||||
|
|
||||||
|
const confirmReset = () => {
|
||||||
|
if (lastSavedConfig) {
|
||||||
|
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
|
||||||
|
} else if (config) {
|
||||||
|
setEditorValue(JSON.stringify(config, null, 2))
|
||||||
|
}
|
||||||
|
setIsDirty(false)
|
||||||
|
toast.info(
|
||||||
|
t(
|
||||||
|
"pages.config.reset_success",
|
||||||
|
"Changes have been reset to the last saved state.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setShowResetDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("pages.config.raw_json_title", "Raw JSON Configuration")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"pages.config.raw_json_desc",
|
||||||
|
"Advanced users can directly edit the raw JSON configuration below.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<p>{t("labels.loading", "Loading...")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isDirty && (
|
||||||
|
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
||||||
|
{t("pages.config.unsaved_changes", "You have unsaved changes.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-muted/30 relative rounded-lg border">
|
||||||
|
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
|
||||||
|
<Textarea
|
||||||
|
value={effectiveEditorValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditorValue(e.target.value)
|
||||||
|
setIsDirty(true)
|
||||||
|
}}
|
||||||
|
className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0"
|
||||||
|
placeholder={t(
|
||||||
|
"pages.config.json_placeholder",
|
||||||
|
"Enter valid JSON configuration...",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleFormat}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{t("pages.config.format", "Format")}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog
|
||||||
|
open={showResetDialog}
|
||||||
|
onOpenChange={setShowResetDialog}
|
||||||
|
>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!isDirty}
|
||||||
|
onClick={() => setShowResetDialog(true)}
|
||||||
|
>
|
||||||
|
{t("common.reset", "Reset")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("pages.config.reset_confirm_title", "Reset Changes")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
"pages.config.reset_confirm_desc",
|
||||||
|
"Are you sure you want to reset your unsaved changes back to the last saved state?",
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmReset}>
|
||||||
|
{t("common.confirm", "Confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending
|
||||||
|
? t("common.saving", "Saving...")
|
||||||
|
: t("common.save", "Save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,7 @@ interface SwitchCardFieldProps {
|
||||||
checked: boolean
|
checked: boolean
|
||||||
onCheckedChange: (checked: boolean) => void
|
onCheckedChange: (checked: boolean) => void
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
|
disabled?: boolean
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +94,7 @@ export function SwitchCardField({
|
||||||
checked,
|
checked,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
disabled,
|
||||||
children,
|
children,
|
||||||
}: SwitchCardFieldProps) {
|
}: SwitchCardFieldProps) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -109,6 +111,7 @@ export function SwitchCardField({
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
|
disabled={disabled}
|
||||||
aria-label={ariaLabel ?? label}
|
aria-label={ariaLabel ?? label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,30 @@ export interface ChatMessage {
|
||||||
type ConnectionState = "disconnected" | "connecting" | "connected" | "error"
|
type ConnectionState = "disconnected" | "connecting" | "connected" | "error"
|
||||||
|
|
||||||
function generateSessionId(): string {
|
function generateSessionId(): string {
|
||||||
return crypto.randomUUID()
|
const webCrypto = globalThis.crypto
|
||||||
|
if (webCrypto && typeof webCrypto.randomUUID === "function") {
|
||||||
|
return webCrypto.randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
|
||||||
|
const bytes = new Uint8Array(16)
|
||||||
|
webCrypto.getRandomValues(bytes)
|
||||||
|
|
||||||
|
// RFC4122 v4: set version and variant bits.
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||||
|
|
||||||
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"))
|
||||||
|
return (
|
||||||
|
`${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
|
||||||
|
`${hex[4]}${hex[5]}-` +
|
||||||
|
`${hex[6]}${hex[7]}-` +
|
||||||
|
`${hex[8]}${hex[9]}-` +
|
||||||
|
`${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `session-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNIX_MS_THRESHOLD = 1e12
|
const UNIX_MS_THRESHOLD = 1e12
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,62 @@
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"description": "System configuration and preferences.",
|
"description": "System configuration and preferences.",
|
||||||
|
"visual_title": "Core Configuration",
|
||||||
|
"visual_desc": "Edit key runtime options here. Use Raw Config for full JSON editing.",
|
||||||
|
"load_error": "Failed to load configuration. Please refresh and try again.",
|
||||||
|
"section_agents": "Agent Defaults",
|
||||||
|
"section_runtime": "Runtime",
|
||||||
|
"section_devices": "Devices",
|
||||||
|
"workspace": "Workspace Directory",
|
||||||
|
"workspace_hint": "Base directory for agent file operations.",
|
||||||
|
"restrict_workspace": "Restrict to Workspace",
|
||||||
|
"restrict_workspace_hint": "Only allow file operations inside workspace.",
|
||||||
|
"max_tokens": "Max Tokens",
|
||||||
|
"max_tokens_hint": "Upper token limit per model response.",
|
||||||
|
"max_tool_iterations": "Max Tool Iterations",
|
||||||
|
"max_tool_iterations_hint": "Maximum tool-call loops in a single task.",
|
||||||
|
"summarize_threshold": "Summarize Message Threshold",
|
||||||
|
"summarize_threshold_hint": "Start summarization after this many messages.",
|
||||||
|
"summarize_token_percent": "Summarize Token Percent",
|
||||||
|
"summarize_token_percent_hint": "Used when conversation summary is triggered.",
|
||||||
|
"session_scope": "Session Scope",
|
||||||
|
"session_scope_hint": "How chat context is isolated across peers/channels.",
|
||||||
|
"session_scope_per_channel_peer": "Per Channel + Peer",
|
||||||
|
"session_scope_per_channel_peer_desc": "Separate context for each user in each channel.",
|
||||||
|
"session_scope_per_channel": "Per Channel",
|
||||||
|
"session_scope_per_channel_desc": "One shared context per channel.",
|
||||||
|
"session_scope_per_peer": "Per Peer",
|
||||||
|
"session_scope_per_peer_desc": "One context per user across channels.",
|
||||||
|
"session_scope_global": "Global",
|
||||||
|
"session_scope_global_desc": "All messages share one global context.",
|
||||||
|
"heartbeat_enabled": "Heartbeat",
|
||||||
|
"heartbeat_enabled_hint": "Send periodic heartbeat messages.",
|
||||||
|
"heartbeat_interval": "Heartbeat Interval (minutes)",
|
||||||
|
"heartbeat_interval_hint": "Interval in minutes between heartbeat signals.",
|
||||||
|
"devices_enabled": "Enable Devices",
|
||||||
|
"devices_enabled_hint": "Enable hardware-device integrations.",
|
||||||
|
"monitor_usb": "Monitor USB",
|
||||||
|
"monitor_usb_hint": "Watch USB plug/unplug events when devices are enabled.",
|
||||||
|
"autostart_label": "Launch at Login",
|
||||||
|
"autostart_hint": "Start PicoClaw Web automatically when you log in.",
|
||||||
|
"autostart_unsupported": "Launch at login is not supported on this platform.",
|
||||||
|
"autostart_load_error": "Failed to load launch-at-login status.",
|
||||||
|
"server_port": "Service Port",
|
||||||
|
"server_port_hint": "HTTP port used by PicoClaw Web.",
|
||||||
|
"lan_access": "Enable LAN Access",
|
||||||
|
"lan_access_hint": "Allow access from other devices on your local network.",
|
||||||
|
"allowed_cidrs": "Allowed Network CIDRs",
|
||||||
|
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
|
||||||
|
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||||
|
"launcher_load_error": "Failed to load service parameters.",
|
||||||
|
"launcher_restart_hint": "Service parameter changes apply after restarting PicoClaw Web.",
|
||||||
|
"autostart_enable_success": "Launch at login has been enabled.",
|
||||||
|
"autostart_disable_success": "Launch at login has been disabled.",
|
||||||
|
"autostart_update_error": "Failed to update launch-at-login setting.",
|
||||||
|
"advanced_title": "Need full configuration access?",
|
||||||
|
"advanced_desc": "Open the raw JSON page to edit every field directly.",
|
||||||
|
"open_raw": "Raw Config",
|
||||||
|
"back_to_visual": "Visual Config",
|
||||||
"raw_json_title": "Raw JSON Configuration",
|
"raw_json_title": "Raw JSON Configuration",
|
||||||
"raw_json_desc": "Advanced users can directly edit the raw JSON configuration below.",
|
"raw_json_desc": "Advanced users can directly edit the raw JSON configuration below.",
|
||||||
"json_placeholder": "Enter valid JSON configuration...",
|
"json_placeholder": "Enter valid JSON configuration...",
|
||||||
|
|
@ -385,7 +441,8 @@
|
||||||
"format_success": "JSON formatted successfully.",
|
"format_success": "JSON formatted successfully.",
|
||||||
"format_error": "Invalid JSON format.",
|
"format_error": "Invalid JSON format.",
|
||||||
"format": "Format",
|
"format": "Format",
|
||||||
"lose_unsaved_changes": "You have unsaved changes. Are you sure you want to reset and lose these changes?"
|
"lose_unsaved_changes": "You have unsaved changes. Are you sure you want to reset and lose these changes?",
|
||||||
|
"unsaved_changes": "You have unsaved changes."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"description": "System logs and monitoring."
|
"description": "System logs and monitoring."
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,62 @@
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"description": "系统配置和偏好设置。",
|
"description": "系统配置和偏好设置。",
|
||||||
|
"visual_title": "核心配置",
|
||||||
|
"visual_desc": "这里可编辑关键运行配置;若需完整字段请使用原始配置页。",
|
||||||
|
"load_error": "加载配置失败,请刷新后重试。",
|
||||||
|
"section_agents": "智能体默认设置",
|
||||||
|
"section_runtime": "运行时",
|
||||||
|
"section_devices": "设备功能",
|
||||||
|
"workspace": "工作目录",
|
||||||
|
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
|
||||||
|
"restrict_workspace": "限制工作目录访问",
|
||||||
|
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
|
||||||
|
"max_tokens": "最大 Token 数",
|
||||||
|
"max_tokens_hint": "单次模型响应允许的最大 Token 数。",
|
||||||
|
"max_tool_iterations": "最大工具迭代次数",
|
||||||
|
"max_tool_iterations_hint": "单个任务中允许的工具调用循环上限。",
|
||||||
|
"summarize_threshold": "触发摘要的消息阈值",
|
||||||
|
"summarize_threshold_hint": "消息数量达到该值后开始触发摘要。",
|
||||||
|
"summarize_token_percent": "摘要目标 Token 百分比",
|
||||||
|
"summarize_token_percent_hint": "在触发会话摘要时使用。",
|
||||||
|
"session_scope": "会话隔离范围",
|
||||||
|
"session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文。",
|
||||||
|
"session_scope_per_channel_peer": "按频道+用户隔离",
|
||||||
|
"session_scope_per_channel_peer_desc": "同一频道内不同用户使用独立上下文。",
|
||||||
|
"session_scope_per_channel": "按频道隔离",
|
||||||
|
"session_scope_per_channel_desc": "同一频道内共享一个上下文。",
|
||||||
|
"session_scope_per_peer": "按用户隔离",
|
||||||
|
"session_scope_per_peer_desc": "同一用户跨频道共享一个上下文。",
|
||||||
|
"session_scope_global": "全局共享",
|
||||||
|
"session_scope_global_desc": "所有消息共用一个全局上下文。",
|
||||||
|
"heartbeat_enabled": "心跳开关",
|
||||||
|
"heartbeat_enabled_hint": "按间隔发送系统心跳。",
|
||||||
|
"heartbeat_interval": "心跳间隔(分钟)",
|
||||||
|
"heartbeat_interval_hint": "两次心跳发送之间的分钟间隔。",
|
||||||
|
"devices_enabled": "启用设备功能",
|
||||||
|
"devices_enabled_hint": "启用与本机硬件设备相关的能力。",
|
||||||
|
"monitor_usb": "监听 USB",
|
||||||
|
"monitor_usb_hint": "在启用设备功能时,监听 USB 插拔事件。",
|
||||||
|
"autostart_label": "开机自启",
|
||||||
|
"autostart_hint": "登录系统后自动启动 PicoClaw Web。",
|
||||||
|
"autostart_unsupported": "当前平台不支持开机自启。",
|
||||||
|
"autostart_load_error": "加载开机自启状态失败。",
|
||||||
|
"server_port": "服务端口",
|
||||||
|
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口。",
|
||||||
|
"lan_access": "启用局域网访问",
|
||||||
|
"lan_access_hint": "允许局域网中的其他设备访问当前服务。",
|
||||||
|
"allowed_cidrs": "允许访问网段(CIDR)",
|
||||||
|
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。",
|
||||||
|
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||||
|
"launcher_load_error": "加载服务参数失败。",
|
||||||
|
"launcher_restart_hint": "服务参数变更需重启 PicoClaw Web 后生效。",
|
||||||
|
"autostart_enable_success": "已开启开机自启。",
|
||||||
|
"autostart_disable_success": "已关闭开机自启。",
|
||||||
|
"autostart_update_error": "更新开机自启设置失败。",
|
||||||
|
"advanced_title": "需要完整配置能力?",
|
||||||
|
"advanced_desc": "可打开原始 JSON 页面直接编辑全部字段。",
|
||||||
|
"open_raw": "原始配置",
|
||||||
|
"back_to_visual": "可视化配置",
|
||||||
"raw_json_title": "原始 JSON 配置",
|
"raw_json_title": "原始 JSON 配置",
|
||||||
"raw_json_desc": "高级用户可以直接编辑下方的原始 JSON 配置。",
|
"raw_json_desc": "高级用户可以直接编辑下方的原始 JSON 配置。",
|
||||||
"json_placeholder": "请输入有效的 JSON 配置...",
|
"json_placeholder": "请输入有效的 JSON 配置...",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||||
import { Route as ConfigRouteImport } from './routes/config'
|
import { Route as ConfigRouteImport } from './routes/config'
|
||||||
import { Route as ChannelsRouteRouteImport } from './routes/channels/route'
|
import { Route as ChannelsRouteRouteImport } from './routes/channels/route'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as ConfigRawRouteImport } from './routes/config.raw'
|
||||||
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
||||||
|
|
||||||
const ProvidersRoute = ProvidersRouteImport.update({
|
const ProvidersRoute = ProvidersRouteImport.update({
|
||||||
|
|
@ -53,6 +54,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ConfigRawRoute = ConfigRawRouteImport.update({
|
||||||
|
id: '/raw',
|
||||||
|
path: '/raw',
|
||||||
|
getParentRoute: () => ConfigRoute,
|
||||||
|
} as any)
|
||||||
const ChannelsNameRoute = ChannelsNameRouteImport.update({
|
const ChannelsNameRoute = ChannelsNameRouteImport.update({
|
||||||
id: '/$name',
|
id: '/$name',
|
||||||
path: '/$name',
|
path: '/$name',
|
||||||
|
|
@ -62,33 +68,36 @@ const ChannelsNameRoute = ChannelsNameRouteImport.update({
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||||
'/config': typeof ConfigRoute
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/providers': typeof ProvidersRoute
|
'/providers': typeof ProvidersRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
|
'/config/raw': typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||||
'/config': typeof ConfigRoute
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/providers': typeof ProvidersRoute
|
'/providers': typeof ProvidersRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
|
'/config/raw': typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||||
'/config': typeof ConfigRoute
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/providers': typeof ProvidersRoute
|
'/providers': typeof ProvidersRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
|
'/config/raw': typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
|
@ -101,6 +110,7 @@ export interface FileRouteTypes {
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/providers'
|
| '/providers'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
|
| '/config/raw'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -111,6 +121,7 @@ export interface FileRouteTypes {
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/providers'
|
| '/providers'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
|
| '/config/raw'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -121,12 +132,13 @@ export interface FileRouteTypes {
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/providers'
|
| '/providers'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
|
| '/config/raw'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
|
ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
|
||||||
ConfigRoute: typeof ConfigRoute
|
ConfigRoute: typeof ConfigRouteWithChildren
|
||||||
CredentialsRoute: typeof CredentialsRoute
|
CredentialsRoute: typeof CredentialsRoute
|
||||||
LogsRoute: typeof LogsRoute
|
LogsRoute: typeof LogsRoute
|
||||||
ModelsRoute: typeof ModelsRoute
|
ModelsRoute: typeof ModelsRoute
|
||||||
|
|
@ -184,6 +196,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/config/raw': {
|
||||||
|
id: '/config/raw'
|
||||||
|
path: '/raw'
|
||||||
|
fullPath: '/config/raw'
|
||||||
|
preLoaderRoute: typeof ConfigRawRouteImport
|
||||||
|
parentRoute: typeof ConfigRoute
|
||||||
|
}
|
||||||
'/channels/$name': {
|
'/channels/$name': {
|
||||||
id: '/channels/$name'
|
id: '/channels/$name'
|
||||||
path: '/$name'
|
path: '/$name'
|
||||||
|
|
@ -206,10 +225,21 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
|
||||||
ChannelsRouteRouteChildren,
|
ChannelsRouteRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface ConfigRouteChildren {
|
||||||
|
ConfigRawRoute: typeof ConfigRawRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfigRouteChildren: ConfigRouteChildren = {
|
||||||
|
ConfigRawRoute: ConfigRawRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfigRouteWithChildren =
|
||||||
|
ConfigRoute._addFileChildren(ConfigRouteChildren)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
||||||
ConfigRoute: ConfigRoute,
|
ConfigRoute: ConfigRouteWithChildren,
|
||||||
CredentialsRoute: CredentialsRoute,
|
CredentialsRoute: CredentialsRoute,
|
||||||
LogsRoute: LogsRoute,
|
LogsRoute: LogsRoute,
|
||||||
ModelsRoute: ModelsRoute,
|
ModelsRoute: ModelsRoute,
|
||||||
|
|
|
||||||
36
web/frontend/src/routes/config.raw.tsx
Normal file
36
web/frontend/src/routes/config.raw.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { IconAdjustments } from "@tabler/icons-react"
|
||||||
|
import { Link, createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { RawJsonPanel } from "@/components/config/raw-json-panel"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/config/raw")({
|
||||||
|
component: RawConfigPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RawConfigPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
title={t("pages.config.raw_json_title", "Raw JSON Configuration")}
|
||||||
|
>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/config">
|
||||||
|
<IconAdjustments className="size-4" />
|
||||||
|
{t("pages.config.back_to_visual", "Visual Config")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-3 lg:p-6">
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<RawJsonPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,258 +1,19 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useTranslation } from "react-i18next"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { ConfigPage } from "@/components/config/config-page"
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/config")({
|
export const Route = createFileRoute("/config")({
|
||||||
component: ConfigPage,
|
component: ConfigRouteLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
function ConfigPage() {
|
function ConfigRouteLayout() {
|
||||||
const { t } = useTranslation()
|
const pathname = useRouterState({
|
||||||
return (
|
select: (state) => state.location.pathname,
|
||||||
<div className="flex h-full flex-col">
|
})
|
||||||
<PageHeader title={t("navigation.config", "Config")} />
|
|
||||||
<div className="flex-1 overflow-auto p-3 lg:p-6">
|
if (pathname === "/config") {
|
||||||
<div className="mx-auto max-w-4xl">
|
return <ConfigPage />
|
||||||
<RawJsonPanel />
|
}
|
||||||
</div>
|
|
||||||
</div>
|
return <Outlet />
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RawJsonPanel() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const { data: config, isLoading } = useQuery({
|
|
||||||
queryKey: ["config"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await fetch("/api/config")
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to fetch config")
|
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: async (newConfig: string) => {
|
|
||||||
const res = await fetch("/api/config", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: newConfig,
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to save config")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: (_, submittedConfig) => {
|
|
||||||
toast.success(
|
|
||||||
t("pages.config.save_success", "Configuration saved successfully."),
|
|
||||||
)
|
|
||||||
// Update last saved config and reset dirty state
|
|
||||||
try {
|
|
||||||
const savedConfig = JSON.parse(submittedConfig)
|
|
||||||
setLastSavedConfig(savedConfig)
|
|
||||||
setIsDirty(false)
|
|
||||||
// Important: Invalidate the query to refresh the cached data
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
|
||||||
} catch {
|
|
||||||
// If JSON parsing fails, invalidate to get fresh data
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error(t("pages.config.save_error", "Failed to save configuration."))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const [editorValue, setEditorValue] = useState("")
|
|
||||||
const [isDirty, setIsDirty] = useState(false)
|
|
||||||
|
|
||||||
// Store the last saved config to detect changes
|
|
||||||
const [lastSavedConfig, setLastSavedConfig] = useState<Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
> | null>(null)
|
|
||||||
|
|
||||||
const effectiveEditorValue =
|
|
||||||
editorValue || (config ? JSON.stringify(config, null, 2) : "")
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
try {
|
|
||||||
// Validate JSON before saving
|
|
||||||
JSON.parse(effectiveEditorValue)
|
|
||||||
mutation.mutate(effectiveEditorValue)
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t(
|
|
||||||
"pages.config.invalid_json",
|
|
||||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFormat = () => {
|
|
||||||
try {
|
|
||||||
const formatted = JSON.stringify(
|
|
||||||
JSON.parse(effectiveEditorValue),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
setEditorValue(formatted)
|
|
||||||
toast.success(
|
|
||||||
t("pages.config.format_success", "JSON formatted successfully."),
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t(
|
|
||||||
"pages.config.format_error",
|
|
||||||
error instanceof Error ? error.message : "Invalid JSON format.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [showResetDialog, setShowResetDialog] = useState(false)
|
|
||||||
|
|
||||||
const confirmReset = () => {
|
|
||||||
// Reset editor content to the last saved configuration
|
|
||||||
if (lastSavedConfig) {
|
|
||||||
setEditorValue(JSON.stringify(lastSavedConfig, null, 2))
|
|
||||||
} else if (config) {
|
|
||||||
// Fallback to current config if no last saved config
|
|
||||||
setEditorValue(JSON.stringify(config, null, 2))
|
|
||||||
}
|
|
||||||
setIsDirty(false)
|
|
||||||
toast.info(
|
|
||||||
t(
|
|
||||||
"pages.config.reset_success",
|
|
||||||
"Changes have been reset to the last saved state.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
setShowResetDialog(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
{t("pages.config.raw_json_title", "Raw JSON Configuration")}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t(
|
|
||||||
"pages.config.raw_json_desc",
|
|
||||||
"Advanced users can directly edit the raw JSON configuration below.",
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-64 items-center justify-center">
|
|
||||||
<p>{t("labels.loading", "Loading...")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{isDirty && (
|
|
||||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
|
||||||
{t("pages.config.unsaved_changes", "You have unsaved changes.")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="bg-muted/30 relative rounded-lg border">
|
|
||||||
<ScrollArea className="h-[calc(100vh-20rem)] min-h-[200px]">
|
|
||||||
<Textarea
|
|
||||||
value={effectiveEditorValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditorValue(e.target.value)
|
|
||||||
setIsDirty(true)
|
|
||||||
}}
|
|
||||||
className="min-h-[200px] resize-none border-0 bg-transparent px-4 py-3 font-mono text-sm shadow-none focus-visible:ring-0"
|
|
||||||
placeholder={t(
|
|
||||||
"pages.config.json_placeholder",
|
|
||||||
"Enter valid JSON configuration...",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleFormat}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
>
|
|
||||||
{t("pages.config.format", "Format")}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog
|
|
||||||
open={showResetDialog}
|
|
||||||
onOpenChange={setShowResetDialog}
|
|
||||||
>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!isDirty}
|
|
||||||
onClick={() => setShowResetDialog(true)}
|
|
||||||
>
|
|
||||||
{t("common.reset", "Reset")}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{t("pages.config.reset_confirm_title", "Reset Changes")}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t(
|
|
||||||
"pages.config.reset_confirm_desc",
|
|
||||||
"Are you sure you want to reset your unsaved changes back to the last saved state?",
|
|
||||||
)}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
{t("common.cancel", "Cancel")}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={confirmReset}>
|
|
||||||
{t("common.confirm", "Confirm")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
|
||||||
{mutation.isPending
|
|
||||||
? t("common.saving", "Saving...")
|
|
||||||
: t("common.save", "Save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue