fix: resolve test failures and lint issues after merge
- Remove obsolete AGENT.md file from embed directory - Refactor duplicate code in requestlog/logger.go by extracting calculateStats function - Fix variable shadowing issues in gateway/helpers.go, logger_test.go, and main.go - Remove unused fileExists function - Fix formatting issues (gci, gofumpt, golines)
This commit is contained in:
parent
43d4259d0a
commit
6b54d169c0
11 changed files with 490 additions and 142 deletions
|
|
@ -68,8 +68,8 @@ func gatewayCmd(debug bool) error {
|
||||||
// Initialize request logger
|
// Initialize request logger
|
||||||
requestLogger := requestlog.NewLogger(requestlog.DefaultConfig(), msgBus, cfg.WorkspacePath())
|
requestLogger := requestlog.NewLogger(requestlog.DefaultConfig(), msgBus, cfg.WorkspacePath())
|
||||||
fmt.Printf(" • Request log dir: %s\n", requestLogger.LogDir())
|
fmt.Printf(" • Request log dir: %s\n", requestLogger.LogDir())
|
||||||
if err := requestLogger.Start(); err != nil {
|
if startErr := requestLogger.Start(); startErr != nil {
|
||||||
fmt.Printf(" ⚠️ Failed to start request logger: %v\n", err)
|
fmt.Printf(" ⚠️ Failed to start request logger: %v\n", startErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,11 @@ type MessageBus struct {
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
|
|
||||||
inboundMu sync.RWMutex
|
inboundMu sync.RWMutex
|
||||||
inboundSubs []chan InboundMessage
|
inboundSubs []chan InboundMessage
|
||||||
outboundMu sync.RWMutex
|
outboundMu sync.RWMutex
|
||||||
outboundSubs []chan OutboundMessage
|
outboundSubs []chan OutboundMessage
|
||||||
outboundMediaMu sync.RWMutex
|
outboundMediaMu sync.RWMutex
|
||||||
outboundMediaSubs []chan OutboundMediaMessage
|
outboundMediaSubs []chan OutboundMediaMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,18 +75,18 @@ func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Agents AgentsConfig `json:"agents"`
|
Agents AgentsConfig `json:"agents"`
|
||||||
Bindings []AgentBinding `json:"bindings,omitempty"`
|
Bindings []AgentBinding `json:"bindings,omitempty"`
|
||||||
Session SessionConfig `json:"session,omitempty"`
|
Session SessionConfig `json:"session,omitempty"`
|
||||||
Channels ChannelsConfig `json:"channels"`
|
Channels ChannelsConfig `json:"channels"`
|
||||||
Providers ProvidersConfig `json:"providers,omitempty"`
|
Providers ProvidersConfig `json:"providers,omitempty"`
|
||||||
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
|
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
|
||||||
Gateway GatewayConfig `json:"gateway"`
|
Gateway GatewayConfig `json:"gateway"`
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
Voice VoiceConfig `json:"voice"`
|
Voice VoiceConfig `json:"voice"`
|
||||||
RequestLog RequestLogConfig `json:"request_log,omitempty"`
|
RequestLog RequestLogConfig `json:"request_log,omitempty"`
|
||||||
// BuildInfo contains build-time version information
|
// BuildInfo contains build-time version information
|
||||||
BuildInfo BuildInfo `json:"build_info,omitempty"`
|
BuildInfo BuildInfo `json:"build_info,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ func TestArchiver_Archive(t *testing.T) {
|
||||||
|
|
||||||
oldFile := filepath.Join(tmpDir, "requests-2024-01-01.jsonl")
|
oldFile := filepath.Join(tmpDir, "requests-2024-01-01.jsonl")
|
||||||
content := []byte(`{"timestamp":"2024-01-01T00:00:00Z","request_id":"1","channel":"test"}` + "\n")
|
content := []byte(`{"timestamp":"2024-01-01T00:00:00Z","request_id":"1","channel":"test"}` + "\n")
|
||||||
if err := os.WriteFile(oldFile, content, 0644); err != nil {
|
if err := os.WriteFile(oldFile, content, 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile failed: %v", err)
|
t.Fatalf("WriteFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ func TestArchiver_ArchiveNoCompress(t *testing.T) {
|
||||||
|
|
||||||
oldFile := filepath.Join(tmpDir, "requests-2024-01-01.jsonl")
|
oldFile := filepath.Join(tmpDir, "requests-2024-01-01.jsonl")
|
||||||
content := []byte(`{"timestamp":"2024-01-01T00:00:00Z","request_id":"1","channel":"test"}` + "\n")
|
content := []byte(`{"timestamp":"2024-01-01T00:00:00Z","request_id":"1","channel":"test"}` + "\n")
|
||||||
if err := os.WriteFile(oldFile, content, 0644); err != nil {
|
if err := os.WriteFile(oldFile, content, 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile failed: %v", err)
|
t.Fatalf("WriteFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +120,7 @@ func TestArchiver_CleanupOldFiles(t *testing.T) {
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
filename := filepath.Join(tmpDir, "requests-2024-01-"+padInt(i)+".jsonl")
|
filename := filepath.Join(tmpDir, "requests-2024-01-"+padInt(i)+".jsonl")
|
||||||
content := make([]byte, 1024*1024)
|
content := make([]byte, 1024*1024)
|
||||||
if err := os.WriteFile(filename, content, 0644); err != nil {
|
if err := os.WriteFile(filename, content, 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile failed: %v", err)
|
t.Fatalf("WriteFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +158,7 @@ func TestArchiver_CompressFile(t *testing.T) {
|
||||||
|
|
||||||
srcFile := filepath.Join(tmpDir, "test.jsonl")
|
srcFile := filepath.Join(tmpDir, "test.jsonl")
|
||||||
content := []byte(`{"test":"data"}` + "\n")
|
content := []byte(`{"test":"data"}` + "\n")
|
||||||
if err := os.WriteFile(srcFile, content, 0644); err != nil {
|
if err := os.WriteFile(srcFile, content, 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile failed: %v", err)
|
t.Fatalf("WriteFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,7 +198,7 @@ func TestArchiver_ArchiveRecentFiles(t *testing.T) {
|
||||||
|
|
||||||
recentFile := filepath.Join(tmpDir, "requests-"+time.Now().Format("2006-01-02")+".jsonl")
|
recentFile := filepath.Join(tmpDir, "requests-"+time.Now().Format("2006-01-02")+".jsonl")
|
||||||
content := []byte(`{"timestamp":"2024-01-01T00:00:00Z","request_id":"1","channel":"test"}` + "\n")
|
content := []byte(`{"timestamp":"2024-01-01T00:00:00Z","request_id":"1","channel":"test"}` + "\n")
|
||||||
if err := os.WriteFile(recentFile, content, 0644); err != nil {
|
if err := os.WriteFile(recentFile, content, 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile failed: %v", err)
|
t.Fatalf("WriteFile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
@ -124,54 +125,7 @@ func (r *Reader) GetStats(startTime, endTime time.Time) (map[string]any, error)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
byChannel := make(map[string]int)
|
return calculateStats(records), nil
|
||||||
byDay := make(map[string]int)
|
|
||||||
topSenders := make(map[string]int)
|
|
||||||
|
|
||||||
for _, rec := range records {
|
|
||||||
byChannel[rec.Channel]++
|
|
||||||
|
|
||||||
day := rec.Timestamp.Format("2006-01-02")
|
|
||||||
byDay[day]++
|
|
||||||
|
|
||||||
senderKey := rec.SenderID + ":" + rec.Channel
|
|
||||||
topSenders[senderKey]++
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]any{
|
|
||||||
"total": len(records),
|
|
||||||
"by_channel": byChannel,
|
|
||||||
"by_day": byDay,
|
|
||||||
}
|
|
||||||
|
|
||||||
type senderStat struct {
|
|
||||||
Sender string `json:"sender"`
|
|
||||||
Channel string `json:"channel"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var topList []senderStat
|
|
||||||
for k, v := range topSenders {
|
|
||||||
parts := strings.SplitN(k, ":", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
topList = append(topList, senderStat{
|
|
||||||
Sender: parts[0],
|
|
||||||
Channel: parts[1],
|
|
||||||
Count: v,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(topList, func(i, j int) bool {
|
|
||||||
return topList[i].Count > topList[j].Count
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(topList) > 10 {
|
|
||||||
topList = topList[:10]
|
|
||||||
}
|
|
||||||
result["top_senders"] = topList
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) GetConfig() Config {
|
func (l *Logger) GetConfig() Config {
|
||||||
|
|
@ -306,7 +260,7 @@ func NewStorage(logDir string, maxFileSizeMB int) *Storage {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Init() error {
|
func (s *Storage) Init() error {
|
||||||
if err := os.MkdirAll(s.logDir, 0755); err != nil {
|
if err := os.MkdirAll(s.logDir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.rotateFile()
|
return s.rotateFile()
|
||||||
|
|
@ -346,7 +300,7 @@ func (s *Storage) rotateFile() error {
|
||||||
dateStr := time.Now().Format("2006-01-02")
|
dateStr := time.Now().Format("2006-01-02")
|
||||||
filename := filepath.Join(s.logDir, "requests-"+dateStr+".jsonl")
|
filename := filepath.Join(s.logDir, "requests-"+dateStr+".jsonl")
|
||||||
|
|
||||||
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -371,15 +325,55 @@ func (s *Storage) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileExists(path string) (bool, error) {
|
func calculateStats(records []RequestRecord) map[string]any {
|
||||||
_, err := os.Stat(path)
|
byChannel := make(map[string]int)
|
||||||
if err == nil {
|
byDay := make(map[string]int)
|
||||||
return true, nil
|
topSenders := make(map[string]int)
|
||||||
|
|
||||||
|
for _, rec := range records {
|
||||||
|
byChannel[rec.Channel]++
|
||||||
|
|
||||||
|
day := rec.Timestamp.Format("2006-01-02")
|
||||||
|
byDay[day]++
|
||||||
|
|
||||||
|
senderKey := rec.SenderID + ":" + rec.Channel
|
||||||
|
topSenders[senderKey]++
|
||||||
}
|
}
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return false, nil
|
result := map[string]any{
|
||||||
|
"total": len(records),
|
||||||
|
"by_channel": byChannel,
|
||||||
|
"by_day": byDay,
|
||||||
}
|
}
|
||||||
return false, err
|
|
||||||
|
type senderStat struct {
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var topList []senderStat
|
||||||
|
for k, v := range topSenders {
|
||||||
|
parts := strings.SplitN(k, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
topList = append(topList, senderStat{
|
||||||
|
Sender: parts[0],
|
||||||
|
Channel: parts[1],
|
||||||
|
Count: v,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(topList, func(i, j int) bool {
|
||||||
|
return topList[i].Count > topList[j].Count
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(topList) > 10 {
|
||||||
|
topList = topList[:10]
|
||||||
|
}
|
||||||
|
result["top_senders"] = topList
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueryOptions struct {
|
type QueryOptions struct {
|
||||||
|
|
@ -550,52 +544,5 @@ func (l *Logger) GetStats(startTime, endTime time.Time) (map[string]any, error)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
byChannel := make(map[string]int)
|
return calculateStats(records), nil
|
||||||
byDay := make(map[string]int)
|
|
||||||
topSenders := make(map[string]int)
|
|
||||||
|
|
||||||
for _, r := range records {
|
|
||||||
byChannel[r.Channel]++
|
|
||||||
|
|
||||||
day := r.Timestamp.Format("2006-01-02")
|
|
||||||
byDay[day]++
|
|
||||||
|
|
||||||
senderKey := r.SenderID + ":" + r.Channel
|
|
||||||
topSenders[senderKey]++
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]any{
|
|
||||||
"total": len(records),
|
|
||||||
"by_channel": byChannel,
|
|
||||||
"by_day": byDay,
|
|
||||||
}
|
|
||||||
|
|
||||||
type senderStat struct {
|
|
||||||
Sender string `json:"sender"`
|
|
||||||
Channel string `json:"channel"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var topList []senderStat
|
|
||||||
for k, v := range topSenders {
|
|
||||||
parts := strings.SplitN(k, ":", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
topList = append(topList, senderStat{
|
|
||||||
Sender: parts[0],
|
|
||||||
Channel: parts[1],
|
|
||||||
Count: v,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(topList, func(i, j int) bool {
|
|
||||||
return topList[i].Count > topList[j].Count
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(topList) > 10 {
|
|
||||||
topList = topList[:10]
|
|
||||||
}
|
|
||||||
result["top_senders"] = topList
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ func TestStorage_WriteAndQuery(t *testing.T) {
|
||||||
t.Fatalf("json.Marshal failed: %v", err)
|
t.Fatalf("json.Marshal failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := storage.Write(data); err != nil {
|
if writeErr := storage.Write(data); writeErr != nil {
|
||||||
t.Fatalf("Write failed: %v", err)
|
t.Fatalf("Write failed: %v", writeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
records, err := storage.Query(QueryOptions{Limit: 10})
|
records, err := storage.Query(QueryOptions{Limit: 10})
|
||||||
|
|
@ -108,8 +108,12 @@ func TestStorage_QueryWithFilter(t *testing.T) {
|
||||||
expected: 1,
|
expected: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "filter by time range",
|
name: "filter by time range",
|
||||||
opts: QueryOptions{StartTime: now.Add(-90 * time.Minute), EndTime: now.Add(10 * time.Minute), Limit: 10},
|
opts: QueryOptions{
|
||||||
|
StartTime: now.Add(-90 * time.Minute),
|
||||||
|
EndTime: now.Add(10 * time.Minute),
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
expected: 2,
|
expected: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,19 @@ func (h *Handler) exportCSV(w http.ResponseWriter, records []requestlog.RequestR
|
||||||
writer := csv.NewWriter(w)
|
writer := csv.NewWriter(w)
|
||||||
defer writer.Flush()
|
defer writer.Flush()
|
||||||
|
|
||||||
header := []string{"timestamp", "request_id", "channel", "sender_id", "chat_id", "content", "content_length", "message_id", "media_count", "session_key", "processing_time_ms"}
|
header := []string{
|
||||||
|
"timestamp",
|
||||||
|
"request_id",
|
||||||
|
"channel",
|
||||||
|
"sender_id",
|
||||||
|
"chat_id",
|
||||||
|
"content",
|
||||||
|
"content_length",
|
||||||
|
"message_id",
|
||||||
|
"media_count",
|
||||||
|
"session_key",
|
||||||
|
"processing_time_ms",
|
||||||
|
}
|
||||||
writer.Write(header)
|
writer.Write(header)
|
||||||
|
|
||||||
for _, r := range records {
|
for _, r := range records {
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ func TestHandlePutRequestLogConfig(t *testing.T) {
|
||||||
func TestHandleArchiveNow(t *testing.T) {
|
func TestHandleArchiveNow(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
logDir := filepath.Join(tmpDir, "logs", "requests")
|
logDir := filepath.Join(tmpDir, "logs", "requests")
|
||||||
os.MkdirAll(logDir, 0755)
|
os.MkdirAll(logDir, 0o755)
|
||||||
|
|
||||||
logger := requestlog.NewLogger(requestlog.DefaultConfig(), nil, tmpDir)
|
logger := requestlog.NewLogger(requestlog.DefaultConfig(), nil, tmpDir)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,12 @@ func main() {
|
||||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||||
|
|
||||||
// Initialize requestlog Reader for stats API
|
// Initialize requestlog Reader for stats API
|
||||||
if cfg, err := config.LoadConfig(absPath); err == nil {
|
if loadedCfg, loadErr := config.LoadConfig(absPath); loadErr == nil {
|
||||||
workspacePath := cfg.WorkspacePath()
|
workspacePath := loadedCfg.WorkspacePath()
|
||||||
logDir := filepath.Join(workspacePath, "logs", "requests")
|
logDir := filepath.Join(workspacePath, "logs", "requests")
|
||||||
// Ensure log directory exists
|
// Ensure log directory exists
|
||||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
if mkdirErr := os.MkdirAll(logDir, 0o755); mkdirErr != nil {
|
||||||
log.Printf("Warning: failed to create request log directory: %v", err)
|
log.Printf("Warning: failed to create request log directory: %v", mkdirErr)
|
||||||
}
|
}
|
||||||
reader := requestlog.NewReader(logDir, 100)
|
reader := requestlog.NewReader(logDir, 100)
|
||||||
apiHandler.SetRequestLogReader(reader)
|
apiHandler.SetRequestLogReader(reader)
|
||||||
|
|
@ -134,10 +134,10 @@ func main() {
|
||||||
|
|
||||||
// Enable file logging
|
// Enable file logging
|
||||||
appLogDir := filepath.Join(workspacePath, "logs")
|
appLogDir := filepath.Join(workspacePath, "logs")
|
||||||
if err := os.MkdirAll(appLogDir, 0755); err == nil {
|
if mkdirErr := os.MkdirAll(appLogDir, 0o755); mkdirErr == nil {
|
||||||
logFile := filepath.Join(appLogDir, "launcher.log")
|
logFile := filepath.Join(appLogDir, "launcher.log")
|
||||||
if err := logger.EnableFileLogging(logFile); err != nil {
|
if fileErr := logger.EnableFileLogging(logFile); fileErr != nil {
|
||||||
log.Printf("Warning: failed to enable file logging: %v", err)
|
log.Printf("Warning: failed to enable file logging: %v", fileErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
191
web/frontend/src/components/logs/log-settings-panel.tsx
Normal file
191
web/frontend/src/components/logs/log-settings-panel.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import {
|
||||||
|
archiveNow,
|
||||||
|
getRequestLogConfig,
|
||||||
|
updateRequestLogConfig,
|
||||||
|
type RequestLogConfig,
|
||||||
|
} from "@/api/stats"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Field } from "@/components/shared-form"
|
||||||
|
|
||||||
|
export function LogSettingsPanel() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [config, setConfig] = useState<RequestLogConfig | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const cfg = await getRequestLogConfig()
|
||||||
|
setConfig(cfg)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load config")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!config) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
await updateRequestLogConfig(config)
|
||||||
|
setSuccess(t("pages.config.save_success"))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save config")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArchiveNow() {
|
||||||
|
try {
|
||||||
|
await archiveNow()
|
||||||
|
setSuccess(t("pages.logs.archive_success"))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Archive failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField<K extends keyof RequestLogConfig>(
|
||||||
|
key: K,
|
||||||
|
value: RequestLogConfig[K]
|
||||||
|
) {
|
||||||
|
if (!config) return
|
||||||
|
setConfig({ ...config, [key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<PageHeader title={t("pages.logs.settings_title")} />
|
||||||
|
<div className="mt-6 text-muted-foreground">{t("labels.loading")}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<PageHeader title={t("pages.logs.settings_title")} />
|
||||||
|
<div className="mt-6 text-red-500">{error || t("pages.logs.config_unavailable")}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title={t("pages.logs.settings_title")} />
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-4 sm:p-8">
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-500/10 p-4 text-red-500">{error}</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-md bg-green-500/10 p-4 text-green-600">{success}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
label={t("pages.logs.enabled")}
|
||||||
|
hint={t("pages.logs.enabled_hint")}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={config.enabled}
|
||||||
|
onCheckedChange={(checked) => updateField("enabled", checked)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.logs.max_file_size")}
|
||||||
|
hint={t("pages.logs.max_file_size_hint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.max_file_size_mb}
|
||||||
|
onChange={(e) => updateField("max_file_size_mb", parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.logs.max_files")}
|
||||||
|
hint={t("pages.logs.max_files_hint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.max_files}
|
||||||
|
onChange={(e) => updateField("max_files", parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.logs.retention_days")}
|
||||||
|
hint={t("pages.logs.retention_days_hint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.retention_days}
|
||||||
|
onChange={(e) => updateField("retention_days", parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.logs.archive_interval")}
|
||||||
|
hint={t("pages.logs.archive_interval_hint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={config.archive_interval}
|
||||||
|
onChange={(e) => updateField("archive_interval", e.target.value)}
|
||||||
|
placeholder="24h"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.logs.compress_archive")}
|
||||||
|
hint={t("pages.logs.compress_archive_hint")}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={config.compress_archive}
|
||||||
|
onCheckedChange={(checked) => updateField("compress_archive", checked)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.logs.content_max_length")}
|
||||||
|
hint={t("pages.logs.content_max_length_hint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.log_content_max_length}
|
||||||
|
onChange={(e) => updateField("log_content_max_length", parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-4">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleArchiveNow}>
|
||||||
|
{t("pages.logs.archive_now")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
web/frontend/src/components/logs/request-log-viewer.tsx
Normal file
194
web/frontend/src/components/logs/request-log-viewer.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { IconDownload, IconFilter, IconRefresh } from "@tabler/icons-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getRequestLogs,
|
||||||
|
getExportLogsUrl,
|
||||||
|
type RequestRecord,
|
||||||
|
} from "@/api/stats"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
|
const CHANNELS = ["", "telegram", "discord", "slack", "feishu", "dingtalk", "irc"]
|
||||||
|
|
||||||
|
export function RequestLogViewer() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [records, setRecords] = useState<RequestRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [channel, setChannel] = useState<string>("")
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [limit] = useState(50)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs()
|
||||||
|
}, [channel, offset])
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await getRequestLogs({
|
||||||
|
channel: channel || undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
})
|
||||||
|
setRecords(response.records)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load logs")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExport(format: "json" | "csv") {
|
||||||
|
const url = getExportLogsUrl({
|
||||||
|
channel: channel || undefined,
|
||||||
|
format,
|
||||||
|
})
|
||||||
|
window.open(url, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: string) {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateContent(content: string, maxLength = 100) {
|
||||||
|
if (content.length <= maxLength) return content
|
||||||
|
return content.slice(0, maxLength) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title={t("pages.logs.requests_title")} />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden p-4 sm:p-8">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconFilter className="size-4" />
|
||||||
|
<Select value={channel} onValueChange={setChannel}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder={t("pages.logs.all_channels")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">{t("pages.logs.all_channels")}</SelectItem>
|
||||||
|
{CHANNELS.slice(1).map((ch) => (
|
||||||
|
<SelectItem key={ch} value={ch}>
|
||||||
|
{ch}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||||
|
<IconRefresh className="size-4" />
|
||||||
|
{t("common.refresh", "Refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleExport("json")}>
|
||||||
|
<IconDownload className="size-4" />
|
||||||
|
JSON
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleExport("csv")}>
|
||||||
|
<IconDownload className="size-4" />
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-md bg-red-500/10 p-4 text-red-500">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg border">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
|
{t("labels.loading")}
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
|
{t("pages.logs.no_logs")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
<div className="grid grid-cols-[180px_100px_150px_1fr_100px] gap-4 bg-muted/50 p-3 text-sm font-medium">
|
||||||
|
<div>{t("pages.logs.timestamp")}</div>
|
||||||
|
<div>{t("pages.logs.channel")}</div>
|
||||||
|
<div>{t("pages.logs.sender")}</div>
|
||||||
|
<div>{t("pages.logs.content")}</div>
|
||||||
|
<div className="text-right">{t("pages.logs.proc_time")}</div>
|
||||||
|
</div>
|
||||||
|
{records.map((record) => (
|
||||||
|
<div
|
||||||
|
key={record.request_id}
|
||||||
|
className="grid grid-cols-[180px_100px_150px_1fr_100px] gap-4 p-3 text-sm hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatTimestamp(record.timestamp)}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">{record.channel}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{record.sender_info.username || record.sender_id}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-muted-foreground">
|
||||||
|
{truncateContent(record.content)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-muted-foreground">
|
||||||
|
{record.processing_time_ms}ms
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{records.length > 0 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("pages.logs.showing")} {offset + 1}-{offset + records.length}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||||
|
>
|
||||||
|
{t("pages.logs.previous")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={records.length < limit}
|
||||||
|
onClick={() => setOffset(offset + limit)}
|
||||||
|
>
|
||||||
|
{t("pages.logs.next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue