mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): add service log level controls (#2227)
- centralize gateway log level resolution and normalization - propagate debug flags to spawned launcher and gateway processes - add a log level selector to the logs page - cover the new behavior with backend and config tests
This commit is contained in:
@@ -20,6 +20,14 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
|
||||
}
|
||||
|
||||
func (h *Handler) applyRuntimeLogLevel() {
|
||||
if h.debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
return
|
||||
}
|
||||
logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath))
|
||||
}
|
||||
|
||||
// handleGetConfig returns the complete system configuration.
|
||||
//
|
||||
// GET /api/config
|
||||
@@ -80,8 +88,6 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("configuration updated successfully")
|
||||
|
||||
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -89,6 +95,8 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Refresh cached pico token in case user changed it.
|
||||
refreshPicoToken(&cfg)
|
||||
h.applyRuntimeLogLevel()
|
||||
logger.Infof("configuration updated successfully")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
@@ -133,7 +141,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
|
||||
@@ -187,6 +194,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Refresh cached pico token in case user changed it.
|
||||
refreshPicoToken(&newCfg)
|
||||
h.applyRuntimeLogLevel()
|
||||
logger.Infof("configuration updated successfully")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
|
||||
@@ -9,8 +9,38 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) {
|
||||
t.Helper()
|
||||
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
initialLevel := logger.GetLevel()
|
||||
logger.SetLevel(logger.INFO)
|
||||
t.Cleanup(func() {
|
||||
logger.SetLevel(initialLevel)
|
||||
})
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(method, "/api/config", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := logger.GetLevel(); got != want {
|
||||
t.Fatalf("logger.GetLevel() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
@@ -251,6 +281,68 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateConfig_AppliesGatewayLogLevel(t *testing.T) {
|
||||
assertGatewayLogLevelApplied(t, http.MethodPut, `{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model_name": "custom-default"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"log_level": "error"
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "custom-default",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_keys": ["sk-default"]
|
||||
}
|
||||
]
|
||||
}`, logger.ERROR)
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_AppliesGatewayLogLevel(t *testing.T) {
|
||||
assertGatewayLogLevelApplied(t, http.MethodPatch, `{
|
||||
"gateway": {
|
||||
"log_level": "debug"
|
||||
}
|
||||
}`, logger.DEBUG)
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
initialLevel := logger.GetLevel()
|
||||
logger.SetLevel(logger.INFO)
|
||||
t.Cleanup(func() {
|
||||
logger.SetLevel(initialLevel)
|
||||
})
|
||||
|
||||
h := NewHandler(configPath)
|
||||
h.SetDebug(true)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||
"gateway": {
|
||||
"log_level": "error"
|
||||
}
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := logger.GetLevel(); got != logger.DEBUG {
|
||||
t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -69,6 +69,14 @@ func ensurePicoTokenCachedLocked(configPath string) {
|
||||
refreshPicoTokensLocked(configPath)
|
||||
}
|
||||
|
||||
func (h *Handler) gatewayCommandArgs() []string {
|
||||
args := []string{"gateway", "-E"}
|
||||
if h.debug {
|
||||
args = append(args, "-d")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
const (
|
||||
protocolKey = "Sec-Websocket-Protocol"
|
||||
tokenPrefix = "token."
|
||||
@@ -531,7 +539,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
|
||||
execPath := utils.FindPicoclawBinary()
|
||||
logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
|
||||
|
||||
cmd = exec.Command(execPath, "gateway", "-E")
|
||||
cmd = exec.Command(execPath, h.gatewayCommandArgs()...)
|
||||
cmd.Env = os.Environ()
|
||||
// Forward the launcher's config path via the environment variable that
|
||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||
|
||||
@@ -77,6 +77,8 @@ func resetGatewayTestState(t *testing.T) {
|
||||
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = nil
|
||||
gateway.pidData = nil
|
||||
gateway.owned = false
|
||||
gateway.bootDefaultModel = ""
|
||||
gateway.bootConfigSignature = ""
|
||||
setGatewayRuntimeStatusLocked("stopped")
|
||||
@@ -166,6 +168,17 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCommandArgsIncludesDebugFlagWhenEnabled(t *testing.T) {
|
||||
h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
|
||||
h.SetDebug(true)
|
||||
|
||||
args := h.gatewayCommandArgs()
|
||||
want := []string{"gateway", "-E", "-d"}
|
||||
if strings.Join(args, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("gatewayCommandArgs() = %v, want %v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -14,6 +14,7 @@ type Handler struct {
|
||||
serverPublic bool
|
||||
serverPublicExplicit bool
|
||||
serverCIDRs []string
|
||||
debug bool
|
||||
oauthMu sync.Mutex
|
||||
oauthFlows map[string]*oauthFlow
|
||||
oauthState map[string]string
|
||||
@@ -43,6 +44,10 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a
|
||||
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
||||
}
|
||||
|
||||
func (h *Handler) SetDebug(debug bool) {
|
||||
h.debug = debug
|
||||
}
|
||||
|
||||
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Config CRUD
|
||||
|
||||
@@ -90,6 +90,9 @@ func (h *Handler) resolveLaunchCommand() (string, []string, error) {
|
||||
}
|
||||
|
||||
args := []string{"-no-browser"}
|
||||
if h.debug {
|
||||
args = append(args, "-d")
|
||||
}
|
||||
if h.configPath != "" {
|
||||
args = append(args, h.configPath)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,29 @@ func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLaunchCommandIncludesDebugFlagWhenEnabled(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
h.SetDebug(true)
|
||||
|
||||
_, args, err := h.resolveLaunchCommand()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveLaunchCommand() error = %v", err)
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("args len = %d, want 3 (got %v)", len(args), args)
|
||||
}
|
||||
if args[0] != "-no-browser" {
|
||||
t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser")
|
||||
}
|
||||
if args[1] != "-d" {
|
||||
t.Fatalf("args[1] = %q, want %q", args[1], "-d")
|
||||
}
|
||||
if args[2] != configPath {
|
||||
t.Fatalf("args[2] = %q, want %q", args[2], configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
|
||||
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
|
||||
if !strings.Contains(plist, "<key>RunAtLoad</key>") {
|
||||
|
||||
Reference in New Issue
Block a user