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
-7
View File
@@ -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"`
+32
View File
@@ -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")
+1 -1
View File
@@ -347,7 +347,7 @@ func DefaultConfig() *Config {
Host: "127.0.0.1",
Port: 18790,
HotReload: false,
LogLevel: "warn",
LogLevel: DefaultGatewayLogLevel,
},
Tools: ToolsConfig{
FilterSensitiveData: true,
+72
View 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)
}
+12 -5
View File
@@ -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
+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>") {
+48 -14
View File
@@ -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:")
+31
View 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,
)
}
})
}
}
@@ -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>
)
}
+14 -9
View File
@@ -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>
</>
}
/>
+1
View File
@@ -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..."
}
+1
View File
@@ -547,6 +547,7 @@
"unsaved_changes": "您有未保存的更改。"
},
"logs": {
"log_level_error": "更新日志等级失败。",
"clear": "清空日志",
"empty": "等待日志中..."
}