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:
wenjie
2026-03-31 20:32:42 +08:00
committed by GitHub
parent 848f9dd2e9
commit 2bf842e460
18 changed files with 471 additions and 40 deletions
+12 -3
View File
@@ -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"})
+92
View File
@@ -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()
+9 -1
View File
@@ -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
+13
View File
@@ -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()
+5
View File
@@ -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
+3
View File
@@ -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)
}
+23
View File
@@ -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>") {