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 (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
// Handler serves HTTP API requests.
|
||||
type Handler struct {
|
||||
configPath string
|
||||
serverPort int
|
||||
serverPublic bool
|
||||
serverCIDRs []string
|
||||
oauthMu sync.Mutex
|
||||
oauthFlows map[string]*oauthFlow
|
||||
oauthState map[string]string
|
||||
|
|
@ -17,11 +22,19 @@ type Handler struct {
|
|||
func NewHandler(configPath string) *Handler {
|
||||
return &Handler{
|
||||
configPath: configPath,
|
||||
serverPort: launcherconfig.DefaultPort,
|
||||
oauthFlows: make(map[string]*oauthFlow),
|
||||
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.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Config CRUD
|
||||
|
|
@ -44,4 +57,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
|
||||
// Channel catalog (for frontend navigation/config pages)
|
||||
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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/api"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
)
|
||||
|
||||
|
|
@ -58,12 +61,47 @@ func main() {
|
|||
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
|
||||
var addr string
|
||||
if *public {
|
||||
addr = "0.0.0.0:" + *port
|
||||
if effectivePublic {
|
||||
addr = "0.0.0.0:" + effectivePort
|
||||
} else {
|
||||
addr = "127.0.0.1:" + *port
|
||||
addr = "127.0.0.1:" + effectivePort
|
||||
}
|
||||
|
||||
// Initialize Server components
|
||||
|
|
@ -71,15 +109,21 @@ func main() {
|
|||
|
||||
// API Routes (e.g. /api/status)
|
||||
apiHandler := api.NewHandler(absPath)
|
||||
apiHandler.SetServerOptions(portNum, effectivePublic, launcherCfg.AllowedCIDRs)
|
||||
apiHandler.RegisterRoutes(mux)
|
||||
|
||||
// Frontend Embedded Assets
|
||||
registerEmbedRoutes(mux)
|
||||
|
||||
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
||||
}
|
||||
|
||||
// Apply middleware stack
|
||||
handler := middleware.Recoverer(
|
||||
middleware.Logger(
|
||||
middleware.JSONContentType(mux),
|
||||
middleware.JSONContentType(accessControlledMux),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -88,10 +132,10 @@ func main() {
|
|||
fmt.Println()
|
||||
fmt.Println(" Open the following URL in your browser:")
|
||||
fmt.Println()
|
||||
fmt.Printf(" >> http://localhost:%s <<\n", *port)
|
||||
if *public {
|
||||
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
|
||||
if effectivePublic {
|
||||
if ip := getLocalIP(); ip != "" {
|
||||
fmt.Printf(" >> http://%s:%s <<\n", ip, *port)
|
||||
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
|
@ -100,7 +144,7 @@ func main() {
|
|||
if !*noBrowser {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
url := "http://localhost:" + *port
|
||||
url := "http://localhost:" + effectivePort
|
||||
if err := openBrowser(url); err != nil {
|
||||
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">
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const isActive = currentPath === item.url
|
||||
const isActive =
|
||||
currentPath === item.url ||
|
||||
(item.url !== "/" &&
|
||||
currentPath.startsWith(`${item.url}/`))
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<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
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +94,7 @@ export function SwitchCardField({
|
|||
checked,
|
||||
onCheckedChange,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
children,
|
||||
}: SwitchCardFieldProps) {
|
||||
return (
|
||||
|
|
@ -109,6 +111,7 @@ export function SwitchCardField({
|
|||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? label}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,30 @@ export interface ChatMessage {
|
|||
type ConnectionState = "disconnected" | "connecting" | "connected" | "error"
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -373,6 +373,62 @@
|
|||
},
|
||||
"config": {
|
||||
"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_desc": "Advanced users can directly edit the raw JSON configuration below.",
|
||||
"json_placeholder": "Enter valid JSON configuration...",
|
||||
|
|
@ -385,7 +441,8 @@
|
|||
"format_success": "JSON formatted successfully.",
|
||||
"format_error": "Invalid JSON 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": {
|
||||
"description": "System logs and monitoring."
|
||||
|
|
|
|||
|
|
@ -373,6 +373,62 @@
|
|||
},
|
||||
"config": {
|
||||
"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_desc": "高级用户可以直接编辑下方的原始 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 ChannelsRouteRouteImport } from './routes/channels/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ConfigRawRouteImport } from './routes/config.raw'
|
||||
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
||||
|
||||
const ProvidersRoute = ProvidersRouteImport.update({
|
||||
|
|
@ -53,6 +54,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ConfigRawRoute = ConfigRawRouteImport.update({
|
||||
id: '/raw',
|
||||
path: '/raw',
|
||||
getParentRoute: () => ConfigRoute,
|
||||
} as any)
|
||||
const ChannelsNameRoute = ChannelsNameRouteImport.update({
|
||||
id: '/$name',
|
||||
path: '/$name',
|
||||
|
|
@ -62,33 +68,36 @@ const ChannelsNameRoute = ChannelsNameRouteImport.update({
|
|||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||
'/config': typeof ConfigRoute
|
||||
'/config': typeof ConfigRouteWithChildren
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/providers': typeof ProvidersRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
'/config/raw': typeof ConfigRawRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||
'/config': typeof ConfigRoute
|
||||
'/config': typeof ConfigRouteWithChildren
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/providers': typeof ProvidersRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
'/config/raw': typeof ConfigRawRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||
'/config': typeof ConfigRoute
|
||||
'/config': typeof ConfigRouteWithChildren
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/providers': typeof ProvidersRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
'/config/raw': typeof ConfigRawRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
|
|
@ -101,6 +110,7 @@ export interface FileRouteTypes {
|
|||
| '/models'
|
||||
| '/providers'
|
||||
| '/channels/$name'
|
||||
| '/config/raw'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
|
|
@ -111,6 +121,7 @@ export interface FileRouteTypes {
|
|||
| '/models'
|
||||
| '/providers'
|
||||
| '/channels/$name'
|
||||
| '/config/raw'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
|
|
@ -121,12 +132,13 @@ export interface FileRouteTypes {
|
|||
| '/models'
|
||||
| '/providers'
|
||||
| '/channels/$name'
|
||||
| '/config/raw'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
|
||||
ConfigRoute: typeof ConfigRoute
|
||||
ConfigRoute: typeof ConfigRouteWithChildren
|
||||
CredentialsRoute: typeof CredentialsRoute
|
||||
LogsRoute: typeof LogsRoute
|
||||
ModelsRoute: typeof ModelsRoute
|
||||
|
|
@ -184,6 +196,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/config/raw': {
|
||||
id: '/config/raw'
|
||||
path: '/raw'
|
||||
fullPath: '/config/raw'
|
||||
preLoaderRoute: typeof ConfigRawRouteImport
|
||||
parentRoute: typeof ConfigRoute
|
||||
}
|
||||
'/channels/$name': {
|
||||
id: '/channels/$name'
|
||||
path: '/$name'
|
||||
|
|
@ -206,10 +225,21 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
|
|||
ChannelsRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface ConfigRouteChildren {
|
||||
ConfigRawRoute: typeof ConfigRawRoute
|
||||
}
|
||||
|
||||
const ConfigRouteChildren: ConfigRouteChildren = {
|
||||
ConfigRawRoute: ConfigRawRoute,
|
||||
}
|
||||
|
||||
const ConfigRouteWithChildren =
|
||||
ConfigRoute._addFileChildren(ConfigRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
||||
ConfigRoute: ConfigRoute,
|
||||
ConfigRoute: ConfigRouteWithChildren,
|
||||
CredentialsRoute: CredentialsRoute,
|
||||
LogsRoute: LogsRoute,
|
||||
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 { createFileRoute } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router"
|
||||
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
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"
|
||||
import { ConfigPage } from "@/components/config/config-page"
|
||||
|
||||
export const Route = createFileRoute("/config")({
|
||||
component: ConfigPage,
|
||||
component: ConfigRouteLayout,
|
||||
})
|
||||
|
||||
function ConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title={t("navigation.config", "Config")} />
|
||||
<div className="flex-1 overflow-auto p-3 lg:p-6">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<RawJsonPanel />
|
||||
</div>
|
||||
</div>
|
||||
</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()
|
||||
},
|
||||
function ConfigRouteLayout() {
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
})
|
||||
|
||||
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.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (pathname === "/config") {
|
||||
return <ConfigPage />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <Outlet />
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue