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:
linhaolin1 2026-03-16 20:49:48 +08:00
parent 43d4259d0a
commit 6b54d169c0
11 changed files with 490 additions and 142 deletions

View file

@ -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)

View file

@ -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)
}

View file

@ -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
}

View file

@ -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})
@ -109,7 +109,11 @@ func TestStorage_QueryWithFilter(t *testing.T) {
},
{
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,
},
{

View file

@ -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 {

View file

@ -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)

View file

@ -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)
}
}
}

View 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>
)
}

View 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>
)
}