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:
@@ -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 {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
|
||||
@@ -347,7 +347,7 @@ func DefaultConfig() *Config {
|
||||
Host: "127.0.0.1",
|
||||
Port: 18790,
|
||||
HotReload: false,
|
||||
LogLevel: "warn",
|
||||
LogLevel: DefaultGatewayLogLevel,
|
||||
},
|
||||
Tools: ToolsConfig{
|
||||
FilterSensitiveData: true,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+12
-5
@@ -98,6 +98,12 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
||||
}
|
||||
defer logger.DisableFileLogging()
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
} else {
|
||||
logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
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.
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
} else {
|
||||
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
||||
logger.Infof("Log level set to %q", cfg.Gateway.LogLevel)
|
||||
effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
|
||||
logger.SetLevelFromString(effectiveLogLevel)
|
||||
logger.Infof("Log level set to %q", effectiveLogLevel)
|
||||
}
|
||||
|
||||
// Enforce singleton: write PID file with generated token.
|
||||
@@ -476,8 +482,9 @@ func handleConfigReload(
|
||||
// Debug mode permanently overrides the config log level to DEBUG.
|
||||
if !debug {
|
||||
// Update log level last so that reload-related info/warn logs above are not suppressed.
|
||||
logger.SetLevelFromString(newCfg.Gateway.LogLevel)
|
||||
logger.Infof("Log level changing from current to %q", newCfg.Gateway.LogLevel)
|
||||
effectiveLogLevel := config.EffectiveGatewayLogLevel(newCfg)
|
||||
logger.SetLevelFromString(effectiveLogLevel)
|
||||
logger.Infof("Log level changing from current to %q", effectiveLogLevel)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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>") {
|
||||
|
||||
+48
-14
@@ -55,6 +55,10 @@ var (
|
||||
noBrowser *bool
|
||||
)
|
||||
|
||||
func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
|
||||
return !enableConsole || debug
|
||||
}
|
||||
|
||||
func main() {
|
||||
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")
|
||||
@@ -62,21 +66,30 @@ func main() {
|
||||
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")
|
||||
|
||||
var debug bool
|
||||
flag.BoolVar(&debug, "d", false, "Enable debug logging")
|
||||
flag.BoolVar(&debug, "debug", false, "Enable debug logging")
|
||||
|
||||
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, "Arguments:\n")
|
||||
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " %s\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(
|
||||
os.Stderr,
|
||||
" %s -public ./config.json Allow access from other devices on the network\n",
|
||||
" %s -public ./config.json\n",
|
||||
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()
|
||||
|
||||
@@ -90,12 +103,13 @@ func main() {
|
||||
}
|
||||
defer panicFunc()
|
||||
|
||||
// By default, detect terminal to decide console log behavior
|
||||
// If -console-logs flag is explicitly set, it overrides the detection
|
||||
enableConsole := *console
|
||||
if !enableConsole {
|
||||
// Disable console logging by setting level to Fatal (no output)
|
||||
logger.SetConsoleLevel(logger.FATAL)
|
||||
fileLoggingEnabled := shouldEnableLauncherFileLogging(enableConsole, debug)
|
||||
if fileLoggingEnabled {
|
||||
// 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)
|
||||
if err = logger.EnableFileLogging(f); err != nil {
|
||||
@@ -103,9 +117,9 @@ func main() {
|
||||
}
|
||||
defer logger.DisableFileLogging()
|
||||
}
|
||||
|
||||
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.SetLevel(logger.DEBUG)
|
||||
}
|
||||
|
||||
// Set language from command line or auto-detect
|
||||
if *lang != "" {
|
||||
@@ -126,6 +140,25 @@ func main() {
|
||||
if err != nil {
|
||||
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 explicitPublic bool
|
||||
@@ -181,7 +214,7 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
tokenLogFileAbs := ""
|
||||
if !enableConsole {
|
||||
if fileLoggingEnabled {
|
||||
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
|
||||
}
|
||||
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
|
||||
@@ -197,6 +230,7 @@ func main() {
|
||||
|
||||
// API Routes (e.g. /api/status)
|
||||
apiHandler = api.NewHandler(absPath)
|
||||
apiHandler.SetDebug(debug)
|
||||
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
||||
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).
|
||||
if enableConsole {
|
||||
if enableConsole || debug {
|
||||
fmt.Print(utils.Banner)
|
||||
fmt.Println()
|
||||
fmt.Println(" Open the following URL in your browser:")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 { useTranslation } from "react-i18next"
|
||||
|
||||
import { LogLevelSelect } from "@/components/logs/log-level-select"
|
||||
import { LogsPanel } from "@/components/logs/logs-panel"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -17,15 +18,19 @@ export function LogsPage() {
|
||||
<PageHeader
|
||||
title={t("navigation.logs")}
|
||||
children={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearLogs}
|
||||
disabled={logs.length === 0 || clearing}
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
{t("pages.logs.clear")}
|
||||
</Button>
|
||||
<>
|
||||
<LogLevelSelect />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearLogs}
|
||||
disabled={logs.length === 0 || clearing}
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
{t("pages.logs.clear")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -547,6 +547,7 @@
|
||||
"unsaved_changes": "You have unsaved changes."
|
||||
},
|
||||
"logs": {
|
||||
"log_level_error": "Failed to update log level.",
|
||||
"clear": "Clear logs",
|
||||
"empty": "Waiting for logs..."
|
||||
}
|
||||
|
||||
@@ -547,6 +547,7 @@
|
||||
"unsaved_changes": "您有未保存的更改。"
|
||||
},
|
||||
"logs": {
|
||||
"log_level_error": "更新日志等级失败。",
|
||||
"clear": "清空日志",
|
||||
"empty": "等待日志中..."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user