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
|
||||
requestLogger := requestlog.NewLogger(requestlog.DefaultConfig(), msgBus, cfg.WorkspacePath())
|
||||
fmt.Printf(" • Request log dir: %s\n", requestLogger.LogDir())
|
||||
if err := requestLogger.Start(); err != nil {
|
||||
fmt.Printf(" ⚠️ Failed to start request logger: %v\n", err)
|
||||
if startErr := requestLogger.Start(); startErr != nil {
|
||||
fmt.Printf(" ⚠️ Failed to start request logger: %v\n", startErr)
|
||||
}
|
||||
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ type MessageBus struct {
|
|||
done chan struct{}
|
||||
closed atomic.Bool
|
||||
|
||||
inboundMu sync.RWMutex
|
||||
inboundSubs []chan InboundMessage
|
||||
outboundMu sync.RWMutex
|
||||
outboundSubs []chan OutboundMessage
|
||||
outboundMediaMu sync.RWMutex
|
||||
inboundMu sync.RWMutex
|
||||
inboundSubs []chan InboundMessage
|
||||
outboundMu sync.RWMutex
|
||||
outboundSubs []chan OutboundMessage
|
||||
outboundMediaMu sync.RWMutex
|
||||
outboundMediaSubs []chan OutboundMediaMessage
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,18 +75,18 @@ func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
|
|||
}
|
||||
|
||||
type Config struct {
|
||||
Agents AgentsConfig `json:"agents"`
|
||||
Bindings []AgentBinding `json:"bindings,omitempty"`
|
||||
Session SessionConfig `json:"session,omitempty"`
|
||||
Channels ChannelsConfig `json:"channels"`
|
||||
Providers ProvidersConfig `json:"providers,omitempty"`
|
||||
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||
Devices DevicesConfig `json:"devices"`
|
||||
Voice VoiceConfig `json:"voice"`
|
||||
RequestLog RequestLogConfig `json:"request_log,omitempty"`
|
||||
Agents AgentsConfig `json:"agents"`
|
||||
Bindings []AgentBinding `json:"bindings,omitempty"`
|
||||
Session SessionConfig `json:"session,omitempty"`
|
||||
Channels ChannelsConfig `json:"channels"`
|
||||
Providers ProvidersConfig `json:"providers,omitempty"`
|
||||
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||
Devices DevicesConfig `json:"devices"`
|
||||
Voice VoiceConfig `json:"voice"`
|
||||
RequestLog RequestLogConfig `json:"request_log,omitempty"`
|
||||
// BuildInfo contains build-time version information
|
||||
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")
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ func TestArchiver_ArchiveNoCompress(t *testing.T) {
|
|||
|
||||
oldFile := filepath.Join(tmpDir, "requests-2024-01-01.jsonl")
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ func TestArchiver_CleanupOldFiles(t *testing.T) {
|
|||
for i := range 5 {
|
||||
filename := filepath.Join(tmpDir, "requests-2024-01-"+padInt(i)+".jsonl")
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ func TestArchiver_CompressFile(t *testing.T) {
|
|||
|
||||
srcFile := filepath.Join(tmpDir, "test.jsonl")
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ func TestArchiver_ArchiveRecentFiles(t *testing.T) {
|
|||
|
||||
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")
|
||||
if err := os.WriteFile(recentFile, content, 0644); err != nil {
|
||||
if err := os.WriteFile(recentFile, content, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"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
|
||||
}
|
||||
|
||||
byChannel := make(map[string]int)
|
||||
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
|
||||
return calculateStats(records), nil
|
||||
}
|
||||
|
||||
func (l *Logger) GetConfig() Config {
|
||||
|
|
@ -306,7 +260,7 @@ func NewStorage(logDir string, maxFileSizeMB int) *Storage {
|
|||
}
|
||||
|
||||
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 s.rotateFile()
|
||||
|
|
@ -346,7 +300,7 @@ func (s *Storage) rotateFile() error {
|
|||
dateStr := time.Now().Format("2006-01-02")
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
@ -371,15 +325,55 @@ func (s *Storage) Close() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
func calculateStats(records []RequestRecord) map[string]any {
|
||||
byChannel := make(map[string]int)
|
||||
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]++
|
||||
}
|
||||
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 {
|
||||
|
|
@ -550,52 +544,5 @@ func (l *Logger) GetStats(startTime, endTime time.Time) (map[string]any, error)
|
|||
return nil, err
|
||||
}
|
||||
|
||||
byChannel := make(map[string]int)
|
||||
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
|
||||
return calculateStats(records), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ func TestStorage_WriteAndQuery(t *testing.T) {
|
|||
t.Fatalf("json.Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
if err := storage.Write(data); err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
if writeErr := storage.Write(data); writeErr != nil {
|
||||
t.Fatalf("Write failed: %v", writeErr)
|
||||
}
|
||||
|
||||
records, err := storage.Query(QueryOptions{Limit: 10})
|
||||
|
|
@ -108,8 +108,12 @@ func TestStorage_QueryWithFilter(t *testing.T) {
|
|||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "filter by time range",
|
||||
opts: QueryOptions{StartTime: now.Add(-90 * time.Minute), EndTime: now.Add(10 * time.Minute), Limit: 10},
|
||||
name: "filter by time range",
|
||||
opts: QueryOptions{
|
||||
StartTime: now.Add(-90 * time.Minute),
|
||||
EndTime: now.Add(10 * time.Minute),
|
||||
Limit: 10,
|
||||
},
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -245,7 +245,19 @@ func (h *Handler) exportCSV(w http.ResponseWriter, records []requestlog.RequestR
|
|||
writer := csv.NewWriter(w)
|
||||
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)
|
||||
|
||||
for _, r := range records {
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ func TestHandlePutRequestLogConfig(t *testing.T) {
|
|||
func TestHandleArchiveNow(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logDir := filepath.Join(tmpDir, "logs", "requests")
|
||||
os.MkdirAll(logDir, 0755)
|
||||
os.MkdirAll(logDir, 0o755)
|
||||
|
||||
logger := requestlog.NewLogger(requestlog.DefaultConfig(), nil, tmpDir)
|
||||
|
||||
|
|
|
|||
|
|
@ -121,12 +121,12 @@ func main() {
|
|||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||
|
||||
// Initialize requestlog Reader for stats API
|
||||
if cfg, err := config.LoadConfig(absPath); err == nil {
|
||||
workspacePath := cfg.WorkspacePath()
|
||||
if loadedCfg, loadErr := config.LoadConfig(absPath); loadErr == nil {
|
||||
workspacePath := loadedCfg.WorkspacePath()
|
||||
logDir := filepath.Join(workspacePath, "logs", "requests")
|
||||
// Ensure log directory exists
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
log.Printf("Warning: failed to create request log directory: %v", err)
|
||||
if mkdirErr := os.MkdirAll(logDir, 0o755); mkdirErr != nil {
|
||||
log.Printf("Warning: failed to create request log directory: %v", mkdirErr)
|
||||
}
|
||||
reader := requestlog.NewReader(logDir, 100)
|
||||
apiHandler.SetRequestLogReader(reader)
|
||||
|
|
@ -134,10 +134,10 @@ func main() {
|
|||
|
||||
// Enable file logging
|
||||
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")
|
||||
if err := logger.EnableFileLogging(logFile); err != nil {
|
||||
log.Printf("Warning: failed to enable file logging: %v", err)
|
||||
if fileErr := logger.EnableFileLogging(logFile); fileErr != nil {
|
||||
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