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:
parent
848f9dd2e9
commit
2bf842e460
18 changed files with 471 additions and 40 deletions
|
|
@ -636,13 +636,6 @@ func (c *ModelConfig) SetAPIKey(value string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type GatewayConfig struct {
|
|
||||||
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
|
|
||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
|
||||||
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
|
|
||||||
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolDiscoveryConfig struct {
|
type ToolDiscoveryConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
|
||||||
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
|
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
|
||||||
|
|
|
||||||
|
|
@ -1418,6 +1418,38 @@ func TestConfigLogLevelEmpty(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGatewayLogLevel(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
data := `{"version":1,"gateway":{"log_level":"debug"}}`
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ResolveGatewayLogLevel(cfgPath); got != "debug" {
|
||||||
|
t.Fatalf("ResolveGatewayLogLevel() = %q, want %q", got, "debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGatewayLogLevel_UsesEnvOverrideAndNormalizesInvalid(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
data := `{"version":1,"gateway":{"log_level":"debug"}}`
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_LOG_LEVEL", "warning")
|
||||||
|
if got := ResolveGatewayLogLevel(cfgPath); got != "warn" {
|
||||||
|
t.Fatalf("ResolveGatewayLogLevel() with env override = %q, want %q", got, "warn")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_LOG_LEVEL", "garbage")
|
||||||
|
if got := ResolveGatewayLogLevel(cfgPath); got != DefaultGatewayLogLevel {
|
||||||
|
t.Fatalf("ResolveGatewayLogLevel() with invalid env override = %q, want %q", got, DefaultGatewayLogLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
|
func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cfgPath := filepath.Join(dir, "config.json")
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ func DefaultConfig() *Config {
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: 18790,
|
Port: 18790,
|
||||||
HotReload: false,
|
HotReload: false,
|
||||||
LogLevel: "warn",
|
LogLevel: DefaultGatewayLogLevel,
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
FilterSensitiveData: true,
|
FilterSensitiveData: true,
|
||||||
|
|
|
||||||
72
pkg/config/gateway.go
Normal file
72
pkg/config/gateway.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultGatewayLogLevel = "warn"
|
||||||
|
|
||||||
|
type GatewayConfig struct {
|
||||||
|
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
|
||||||
|
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||||
|
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
|
||||||
|
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalGatewayLogLevel(level logger.LogLevel) string {
|
||||||
|
switch level {
|
||||||
|
case logger.DEBUG:
|
||||||
|
return "debug"
|
||||||
|
case logger.INFO:
|
||||||
|
return "info"
|
||||||
|
case logger.WARN:
|
||||||
|
return "warn"
|
||||||
|
case logger.ERROR:
|
||||||
|
return "error"
|
||||||
|
case logger.FATAL:
|
||||||
|
return "fatal"
|
||||||
|
default:
|
||||||
|
return DefaultGatewayLogLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGatewayLogLevel(logLevel string) string {
|
||||||
|
if level, ok := logger.ParseLevel(logLevel); ok {
|
||||||
|
return canonicalGatewayLogLevel(level)
|
||||||
|
}
|
||||||
|
return DefaultGatewayLogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveGatewayLogLevel returns the normalized runtime log level from a loaded config.
|
||||||
|
// Invalid or empty values fall back to the package default.
|
||||||
|
func EffectiveGatewayLogLevel(cfg *Config) string {
|
||||||
|
if cfg == nil {
|
||||||
|
return DefaultGatewayLogLevel
|
||||||
|
}
|
||||||
|
return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveGatewayLogLevel reads the configured gateway log level without triggering
|
||||||
|
// the full config loader, so startup code can apply logging before config load logs run.
|
||||||
|
// The PICOCLAW_LOG_LEVEL environment variable overrides the file value.
|
||||||
|
func ResolveGatewayLogLevel(path string) string {
|
||||||
|
cfg := struct {
|
||||||
|
Gateway GatewayConfig `json:"gateway"`
|
||||||
|
}{
|
||||||
|
Gateway: GatewayConfig{LogLevel: DefaultGatewayLogLevel},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
_ = json.Unmarshal(data, &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" {
|
||||||
|
cfg.Gateway.LogLevel = envLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,12 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
||||||
}
|
}
|
||||||
defer logger.DisableFileLogging()
|
defer logger.DisableFileLogging()
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
logger.SetLevel(logger.DEBUG)
|
||||||
|
} else {
|
||||||
|
logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("error loading config: %v", err)
|
logger.Fatalf("error loading config: %v", err)
|
||||||
|
|
@ -109,11 +115,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
||||||
|
|
||||||
// Debug mode permanently overrides the config log level to DEBUG.
|
// Debug mode permanently overrides the config log level to DEBUG.
|
||||||
if debug {
|
if debug {
|
||||||
logger.SetLevel(logger.DEBUG)
|
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
} else {
|
} else {
|
||||||
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
|
||||||
logger.Infof("Log level set to %q", cfg.Gateway.LogLevel)
|
logger.SetLevelFromString(effectiveLogLevel)
|
||||||
|
logger.Infof("Log level set to %q", effectiveLogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce singleton: write PID file with generated token.
|
// Enforce singleton: write PID file with generated token.
|
||||||
|
|
@ -476,8 +482,9 @@ func handleConfigReload(
|
||||||
// Debug mode permanently overrides the config log level to DEBUG.
|
// Debug mode permanently overrides the config log level to DEBUG.
|
||||||
if !debug {
|
if !debug {
|
||||||
// Update log level last so that reload-related info/warn logs above are not suppressed.
|
// Update log level last so that reload-related info/warn logs above are not suppressed.
|
||||||
logger.SetLevelFromString(newCfg.Gateway.LogLevel)
|
effectiveLogLevel := config.EffectiveGatewayLogLevel(newCfg)
|
||||||
logger.Infof("Log level changing from current to %q", newCfg.Gateway.LogLevel)
|
logger.SetLevelFromString(effectiveLogLevel)
|
||||||
|
logger.Infof("Log level changing from current to %q", effectiveLogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
|
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.
|
// handleGetConfig returns the complete system configuration.
|
||||||
//
|
//
|
||||||
// GET /api/config
|
// GET /api/config
|
||||||
|
|
@ -80,8 +88,6 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("configuration updated successfully")
|
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -89,6 +95,8 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Refresh cached pico token in case user changed it.
|
// Refresh cached pico token in case user changed it.
|
||||||
refreshPicoToken(&cfg)
|
refreshPicoToken(&cfg)
|
||||||
|
h.applyRuntimeLogLevel()
|
||||||
|
logger.Infof("configuration updated successfully")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
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)
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := json.Marshal(cfg)
|
existing, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
|
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.
|
// Refresh cached pico token in case user changed it.
|
||||||
refreshPicoToken(&newCfg)
|
refreshPicoToken(&newCfg)
|
||||||
|
h.applyRuntimeLogLevel()
|
||||||
|
logger.Infof("configuration updated successfully")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,38 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"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) {
|
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
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) {
|
func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,14 @@ func ensurePicoTokenCachedLocked(configPath string) {
|
||||||
refreshPicoTokensLocked(configPath)
|
refreshPicoTokensLocked(configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) gatewayCommandArgs() []string {
|
||||||
|
args := []string{"gateway", "-E"}
|
||||||
|
if h.debug {
|
||||||
|
args = append(args, "-d")
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
protocolKey = "Sec-Websocket-Protocol"
|
protocolKey = "Sec-Websocket-Protocol"
|
||||||
tokenPrefix = "token."
|
tokenPrefix = "token."
|
||||||
|
|
@ -531,7 +539,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
|
||||||
execPath := utils.FindPicoclawBinary()
|
execPath := utils.FindPicoclawBinary()
|
||||||
logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
|
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()
|
cmd.Env = os.Environ()
|
||||||
// Forward the launcher's config path via the environment variable that
|
// Forward the launcher's config path via the environment variable that
|
||||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ func resetGatewayTestState(t *testing.T) {
|
||||||
|
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
gateway.cmd = nil
|
gateway.cmd = nil
|
||||||
|
gateway.pidData = nil
|
||||||
|
gateway.owned = false
|
||||||
gateway.bootDefaultModel = ""
|
gateway.bootDefaultModel = ""
|
||||||
gateway.bootConfigSignature = ""
|
gateway.bootConfigSignature = ""
|
||||||
setGatewayRuntimeStatusLocked("stopped")
|
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) {
|
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ type Handler struct {
|
||||||
serverPublic bool
|
serverPublic bool
|
||||||
serverPublicExplicit bool
|
serverPublicExplicit bool
|
||||||
serverCIDRs []string
|
serverCIDRs []string
|
||||||
|
debug bool
|
||||||
oauthMu sync.Mutex
|
oauthMu sync.Mutex
|
||||||
oauthFlows map[string]*oauthFlow
|
oauthFlows map[string]*oauthFlow
|
||||||
oauthState map[string]string
|
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...)
|
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SetDebug(debug bool) {
|
||||||
|
h.debug = debug
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
|
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
|
||||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Config CRUD
|
// Config CRUD
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,9 @@ func (h *Handler) resolveLaunchCommand() (string, []string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{"-no-browser"}
|
args := []string{"-no-browser"}
|
||||||
|
if h.debug {
|
||||||
|
args = append(args, "-d")
|
||||||
|
}
|
||||||
if h.configPath != "" {
|
if h.configPath != "" {
|
||||||
args = append(args, 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) {
|
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
|
||||||
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
|
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
|
||||||
if !strings.Contains(plist, "<key>RunAtLoad</key>") {
|
if !strings.Contains(plist, "<key>RunAtLoad</key>") {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ var (
|
||||||
noBrowser *bool
|
noBrowser *bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
|
||||||
|
return !enableConsole || debug
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := flag.String("port", "18800", "Port to listen on")
|
port := flag.String("port", "18800", "Port to listen on")
|
||||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||||
|
|
@ -62,21 +66,30 @@ func main() {
|
||||||
lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
|
lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
|
||||||
console := flag.Bool("console", false, "Console mode, no GUI")
|
console := flag.Bool("console", false, "Console mode, no GUI")
|
||||||
|
|
||||||
|
var debug bool
|
||||||
|
flag.BoolVar(&debug, "d", false, "Enable debug logging")
|
||||||
|
flag.BoolVar(&debug, "debug", false, "Enable debug logging")
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr, "%s Launcher - A web-based configuration editor\n\n", appName)
|
fmt.Fprintf(os.Stderr, "%s Launcher - Web console and gateway manager\n\n", appName)
|
||||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||||
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||||
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||||
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
fmt.Fprintf(os.Stderr, " %s\n", os.Args[0])
|
||||||
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
fmt.Fprintf(os.Stderr, " Use default config path in GUI mode\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " %s ./config.json\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, " Specify a config file\n")
|
||||||
fmt.Fprintf(
|
fmt.Fprintf(
|
||||||
os.Stderr,
|
os.Stderr,
|
||||||
" %s -public ./config.json Allow access from other devices on the network\n",
|
" %s -public ./config.json\n",
|
||||||
os.Args[0],
|
os.Args[0],
|
||||||
)
|
)
|
||||||
|
fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n")
|
||||||
}
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
@ -90,12 +103,13 @@ func main() {
|
||||||
}
|
}
|
||||||
defer panicFunc()
|
defer panicFunc()
|
||||||
|
|
||||||
// By default, detect terminal to decide console log behavior
|
|
||||||
// If -console-logs flag is explicitly set, it overrides the detection
|
|
||||||
enableConsole := *console
|
enableConsole := *console
|
||||||
if !enableConsole {
|
fileLoggingEnabled := shouldEnableLauncherFileLogging(enableConsole, debug)
|
||||||
// Disable console logging by setting level to Fatal (no output)
|
if fileLoggingEnabled {
|
||||||
logger.SetConsoleLevel(logger.FATAL)
|
// GUI mode writes launcher logs to file. Debug mode keeps file logging enabled in console mode too.
|
||||||
|
if !debug {
|
||||||
|
logger.DisableConsole()
|
||||||
|
}
|
||||||
|
|
||||||
f := filepath.Join(picoHome, logPath, logFile)
|
f := filepath.Join(picoHome, logPath, logFile)
|
||||||
if err = logger.EnableFileLogging(f); err != nil {
|
if err = logger.EnableFileLogging(f); err != nil {
|
||||||
|
|
@ -103,9 +117,9 @@ func main() {
|
||||||
}
|
}
|
||||||
defer logger.DisableFileLogging()
|
defer logger.DisableFileLogging()
|
||||||
}
|
}
|
||||||
|
if debug {
|
||||||
logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
|
logger.SetLevel(logger.DEBUG)
|
||||||
logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
|
}
|
||||||
|
|
||||||
// Set language from command line or auto-detect
|
// Set language from command line or auto-detect
|
||||||
if *lang != "" {
|
if *lang != "" {
|
||||||
|
|
@ -126,6 +140,25 @@ func main() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err)
|
logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err)
|
||||||
}
|
}
|
||||||
|
if !debug {
|
||||||
|
logger.SetLevelFromString(config.ResolveGatewayLogLevel(absPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
|
||||||
|
logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
|
||||||
|
if debug {
|
||||||
|
logger.InfoC("web", "Debug mode enabled")
|
||||||
|
logger.DebugC(
|
||||||
|
"web",
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Launcher flags: console=%t public=%t no_browser=%t config=%s",
|
||||||
|
enableConsole,
|
||||||
|
*public,
|
||||||
|
*noBrowser,
|
||||||
|
absPath,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var explicitPort bool
|
var explicitPort bool
|
||||||
var explicitPublic bool
|
var explicitPublic bool
|
||||||
|
|
@ -181,7 +214,7 @@ func main() {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
tokenLogFileAbs := ""
|
tokenLogFileAbs := ""
|
||||||
if !enableConsole {
|
if fileLoggingEnabled {
|
||||||
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
|
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
|
||||||
}
|
}
|
||||||
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
|
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
|
||||||
|
|
@ -197,6 +230,7 @@ func main() {
|
||||||
|
|
||||||
// API Routes (e.g. /api/status)
|
// API Routes (e.g. /api/status)
|
||||||
apiHandler = api.NewHandler(absPath)
|
apiHandler = api.NewHandler(absPath)
|
||||||
|
apiHandler.SetDebug(debug)
|
||||||
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
||||||
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +260,7 @@ func main() {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Print startup banner and token (console mode only).
|
// Print startup banner and token (console mode only).
|
||||||
if enableConsole {
|
if enableConsole || debug {
|
||||||
fmt.Print(utils.Banner)
|
fmt.Print(utils.Banner)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(" Open the following URL in your browser:")
|
fmt.Println(" Open the following URL in your browser:")
|
||||||
|
|
|
||||||
31
web/backend/main_test.go
Normal file
31
web/backend/main_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShouldEnableLauncherFileLogging(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
enableConsole bool
|
||||||
|
debug bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "gui mode", enableConsole: false, debug: false, want: true},
|
||||||
|
{name: "console mode", enableConsole: true, debug: false, want: false},
|
||||||
|
{name: "debug gui mode", enableConsole: false, debug: true, want: true},
|
||||||
|
{name: "debug console mode", enableConsole: true, debug: true, want: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := shouldEnableLauncherFileLogging(tt.enableConsole, tt.debug); got != tt.want {
|
||||||
|
t.Fatalf(
|
||||||
|
"shouldEnableLauncherFileLogging(%t, %t) = %t, want %t",
|
||||||
|
tt.enableConsole,
|
||||||
|
tt.debug,
|
||||||
|
got,
|
||||||
|
tt.want,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
102
web/frontend/src/components/logs/log-level-select.tsx
Normal file
102
web/frontend/src/components/logs/log-level-select.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { type AppConfig, getAppConfig, patchAppConfig } from "@/api/channels"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
|
const LOG_LEVEL_OPTIONS = ["debug", "info", "warn", "error", "fatal"] as const
|
||||||
|
type GatewayLogLevel = (typeof LOG_LEVEL_OPTIONS)[number]
|
||||||
|
|
||||||
|
const LOG_LEVEL_LABELS: Record<GatewayLogLevel, string> = {
|
||||||
|
debug: "Debug",
|
||||||
|
info: "Info",
|
||||||
|
warn: "Warn",
|
||||||
|
error: "Error",
|
||||||
|
fatal: "Fatal",
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGatewayLogLevel(config: AppConfig | undefined): GatewayLogLevel {
|
||||||
|
const gateway = config?.gateway
|
||||||
|
if (typeof gateway === "object" && gateway !== null) {
|
||||||
|
const logLevel = (gateway as Record<string, unknown>).log_level
|
||||||
|
if (
|
||||||
|
typeof logLevel === "string" &&
|
||||||
|
LOG_LEVEL_OPTIONS.includes(logLevel as GatewayLogLevel)
|
||||||
|
) {
|
||||||
|
return logLevel as GatewayLogLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "warn"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogLevelSelect() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [logLevel, setLogLevel] = useState<GatewayLogLevel>("warn")
|
||||||
|
const [savingLogLevel, setSavingLogLevel] = useState(false)
|
||||||
|
|
||||||
|
const { data: configData } = useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: getAppConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogLevel(getGatewayLogLevel(configData))
|
||||||
|
}, [configData])
|
||||||
|
|
||||||
|
const handleLogLevelChange = async (nextValue: string) => {
|
||||||
|
const nextLevel = nextValue as GatewayLogLevel
|
||||||
|
const previousLevel = logLevel
|
||||||
|
setLogLevel(nextLevel)
|
||||||
|
setSavingLogLevel(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await patchAppConfig({
|
||||||
|
gateway: {
|
||||||
|
log_level: nextLevel,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||||
|
await refreshGatewayState({ force: true })
|
||||||
|
} catch (error) {
|
||||||
|
setLogLevel(previousLevel)
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t("pages.logs.log_level_error"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSavingLogLevel(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={logLevel}
|
||||||
|
onValueChange={handleLogLevelChange}
|
||||||
|
disabled={savingLogLevel}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="end">
|
||||||
|
{LOG_LEVEL_OPTIONS.map((level) => (
|
||||||
|
<SelectItem key={level} value={level}>
|
||||||
|
{LOG_LEVEL_LABELS[level]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { IconTrash } from "@tabler/icons-react"
|
import { IconTrash } from "@tabler/icons-react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { LogLevelSelect } from "@/components/logs/log-level-select"
|
||||||
import { LogsPanel } from "@/components/logs/logs-panel"
|
import { LogsPanel } from "@/components/logs/logs-panel"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -17,6 +18,9 @@ export function LogsPage() {
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t("navigation.logs")}
|
title={t("navigation.logs")}
|
||||||
children={
|
children={
|
||||||
|
<>
|
||||||
|
<LogLevelSelect />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -26,6 +30,7 @@ export function LogsPage() {
|
||||||
<IconTrash className="size-4" />
|
<IconTrash className="size-4" />
|
||||||
{t("pages.logs.clear")}
|
{t("pages.logs.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"unsaved_changes": "You have unsaved changes."
|
"unsaved_changes": "You have unsaved changes."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
|
"log_level_error": "Failed to update log level.",
|
||||||
"clear": "Clear logs",
|
"clear": "Clear logs",
|
||||||
"empty": "Waiting for logs..."
|
"empty": "Waiting for logs..."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"unsaved_changes": "您有未保存的更改。"
|
"unsaved_changes": "您有未保存的更改。"
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
|
"log_level_error": "更新日志等级失败。",
|
||||||
"clear": "清空日志",
|
"clear": "清空日志",
|
||||||
"empty": "等待日志中..."
|
"empty": "等待日志中..."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue