From c27223efe5daa962749dc0d73a4d847503219308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=88=E4=BB=8E=E6=96=87?= Date: Tue, 18 Aug 2026 06:01:27 -1000 Subject: [PATCH] feat: business payload logging toggle + switch audit + per-user drill-down (#1) * feat(payload-log): add model/payload_log.go * feat(payload-log): add middleware/payload_log.go * feat(payload-log): add controller/payload_log.go * feat(payload-log): add web/src/features/payload-logs/types.ts * feat(payload-log): add web/src/features/payload-logs/api.ts * feat(payload-log): add web/src/features/payload-logs/index.tsx * feat(payload-log): add web/src/routes/_authenticated/payload-logs/index.tsx * feat(payload-log): update common/constants.go * feat(payload-log): update model/option.go * feat(payload-log): update model/main.go * feat(payload-log): update router/relay-router.go * feat(payload-log): update router/api-router.go * feat(payload-log): update web/src/hooks/use-sidebar-data.ts * feat(payload-log): update web/src/i18n/locales/en.json * feat(payload-log): update web/src/i18n/locales/zh.json * feat(payload-log): round2 update model/payload_log.go * feat(payload-log): round2 update controller/payload_log.go * feat(payload-log): round2 update model/main.go * feat(payload-log): round2 update router/api-router.go * feat(payload-log): round2 update web/src/features/payload-logs/types.ts * feat(payload-log): round2 update web/src/features/payload-logs/api.ts * feat(payload-log): round2 update web/src/features/payload-logs/index.tsx * feat(payload-log): round2 update web/src/routes/_authenticated/payload-logs/index.tsx * feat(payload-log): round2 update web/src/hooks/use-sidebar-data.ts * feat(payload-log): round2 update web/src/i18n/locales/en.json * feat(payload-log): round2 update web/src/i18n/locales/zh.json --- common/constants.go | 4 + controller/payload_log.go | 112 ++++++ middleware/payload_log.go | 80 ++++ model/main.go | 4 +- model/option.go | 3 + model/payload_log.go | 131 +++++++ router/api-router.go | 9 + router/relay-router.go | 1 + web/src/features/payload-logs/api.ts | 76 ++++ web/src/features/payload-logs/index.tsx | 348 ++++++++++++++++++ web/src/features/payload-logs/types.ts | 58 +++ web/src/hooks/use-sidebar-data.ts | 6 + web/src/i18n/locales/en.json | 18 + web/src/i18n/locales/zh.json | 18 + .../_authenticated/payload-logs/index.tsx | 28 ++ 15 files changed, 895 insertions(+), 1 deletion(-) create mode 100644 controller/payload_log.go create mode 100644 middleware/payload_log.go create mode 100644 model/payload_log.go create mode 100644 web/src/features/payload-logs/api.ts create mode 100644 web/src/features/payload-logs/index.tsx create mode 100644 web/src/features/payload-logs/types.ts create mode 100644 web/src/routes/_authenticated/payload-logs/index.tsx diff --git a/common/constants.go b/common/constants.go index d6b4fb52284c..ad77c672f33b 100644 --- a/common/constants.go +++ b/common/constants.go @@ -92,6 +92,10 @@ var MemoryCacheEnabled bool var LogConsumeEnabled = true +// PayloadLogEnabled is the platform-wide business-payload logging switch. +// OFF by default: relay request/response bodies are never captured or stored. +var PayloadLogEnabled = false + var TLSInsecureSkipVerify bool var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true} diff --git a/controller/payload_log.go b/controller/payload_log.go new file mode 100644 index 000000000000..be0842043145 --- /dev/null +++ b/controller/payload_log.go @@ -0,0 +1,112 @@ +package controller + +import ( + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// GetPayloadLogs returns a paginated, body-free list of ALL captured payload +// logs. Admin-only. +func GetPayloadLogs(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + username := c.Query("username") + modelName := c.Query("model_name") + requestId := c.Query("request_id") + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + logs, total, err := model.GetPayloadLogs(0, username, modelName, requestId, startTimestamp, endTimestamp, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) +} + +// GetSelfPayloadLogs returns the caller's OWN payload logs only. Any user. +func GetSelfPayloadLogs(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + userId := c.GetInt("id") + modelName := c.Query("model_name") + requestId := c.Query("request_id") + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + logs, total, err := model.GetPayloadLogs(userId, "", modelName, requestId, startTimestamp, endTimestamp, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) +} + +// GetPayloadLogDetail returns a single log with full bodies. Admin-only. +func GetPayloadLogDetail(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + log, err := model.GetPayloadLogById(id, 0) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, log) +} + +// GetSelfPayloadLogDetail returns a single log with full bodies, but only if it +// belongs to the caller. Any user. +func GetSelfPayloadLogDetail(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + log, err := model.GetPayloadLogById(id, c.GetInt("id")) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, log) +} + +// GetPayloadLogSwitchStatus reports the current platform-wide switch state. +// Readable by any authenticated user (transparency). +func GetPayloadLogSwitchStatus(c *gin.Context) { + common.ApiSuccess(c, gin.H{"enabled": common.PayloadLogEnabled}) +} + +// SetPayloadLogSwitch flips the platform-wide switch and records who did it. +// Root only. +func SetPayloadLogSwitch(c *gin.Context) { + var req struct { + Enabled bool `json:"enabled"` + } + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiErrorMsg(c, "invalid parameter") + return + } + value := "false" + if req.Enabled { + value = "true" + } + if err := model.UpdateOption("PayloadLogEnabled", value); err != nil { + common.ApiError(c, err) + return + } + model.RecordPayloadLogSwitchAudit(c.GetInt("id"), c.GetString("username"), req.Enabled) + common.ApiSuccess(c, gin.H{"enabled": req.Enabled}) +} + +// GetPayloadLogSwitchAudits returns the switch change history (who turned it on +// or off, and when). Readable by any authenticated user (transparency). +func GetPayloadLogSwitchAudits(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + audits, total, err := model.GetPayloadLogSwitchAudits(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(audits) + common.ApiSuccess(c, pageInfo) +} diff --git a/middleware/payload_log.go b/middleware/payload_log.go new file mode 100644 index 000000000000..67a2e315af90 --- /dev/null +++ b/middleware/payload_log.go @@ -0,0 +1,80 @@ +package middleware + +import ( + "bytes" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" +) + +// payloadLogMaxBodySize bounds how much of each request/response body is stored, +// so a huge upload or long stream cannot bloat the database. +const payloadLogMaxBodySize = 256 * 1024 // 256KB per body + +// PayloadLog captures the full request and response bodies of relay calls when +// the platform-wide "business payload logging" switch (common.PayloadLogEnabled) +// is ON. When it is OFF (the default) this middleware is a strict no-op with +// zero overhead: no body is read, no writer is wrapped, nothing is stored — so +// user prompts and model responses are never persisted. +func PayloadLog() gin.HandlerFunc { + return func(c *gin.Context) { + // Default-OFF fast path: zero overhead, no capture. + if !common.PayloadLogEnabled || c.Request.Method != "POST" { + c.Next() + return + } + + start := time.Now() + + // Read the request body before c.Next(); GetBodyStorage caches it, so + // the relay downstream still reads the same body. (BodyStorageCleanup + // releases the storage only after the request finishes.) + var requestBody string + if bs, err := common.GetBodyStorage(c); err == nil { + if b, err := bs.Bytes(); err == nil { + requestBody = truncatePayloadBody(b) + } + } + + // Tee a bounded copy of the response. auditResponseWriter (audit.go) + // already implements exactly this capped-buffer wrapper. + writer := &auditResponseWriter{ + ResponseWriter: c.Writer, + body: bytes.NewBuffer(nil), + maxSize: payloadLogMaxBodySize, + } + c.Writer = writer + + c.Next() + + entry := &model.PayloadLog{ + CreatedAt: common.GetTimestamp(), + UserId: c.GetInt("id"), + Username: c.GetString("username"), + TokenName: c.GetString("token_name"), + ModelName: c.GetString("original_model"), + ChannelId: c.GetInt("channel_id"), + RequestId: c.GetString(common.RequestIdKey), + Ip: c.ClientIP(), + StatusCode: writer.Status(), + DurationMs: time.Since(start).Milliseconds(), + RequestBody: requestBody, + ResponseBody: writer.body.String(), + } + // Persist off the request path so logging never adds latency. + gopool.Go(func() { + model.RecordPayloadLog(entry) + }) + } +} + +func truncatePayloadBody(b []byte) string { + if len(b) > payloadLogMaxBodySize { + return string(b[:payloadLogMaxBodySize]) + } + return string(b) +} diff --git a/model/main.go b/model/main.go index 21445593e54e..c9da8eeff79a 100644 --- a/model/main.go +++ b/model/main.go @@ -270,6 +270,8 @@ func migrateDB() error { &Redemption{}, &Ability{}, &Log{}, + &PayloadLog{}, + &PayloadLogSwitchAudit{}, &Midjourney{}, &TopUp{}, &QuotaData{}, @@ -400,7 +402,7 @@ func migrateLOGDB() error { if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { return migrateClickHouseLogDB() } - return LOG_DB.AutoMigrate(&Log{}) + return LOG_DB.AutoMigrate(&Log{}, &PayloadLog{}, &PayloadLogSwitchAudit{}) } func migrateClickHouseLogDB() error { diff --git a/model/option.go b/model/option.go index d78706537a80..f09d7f0816b6 100644 --- a/model/option.go +++ b/model/option.go @@ -48,6 +48,7 @@ func InitOptionMap() { common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled) common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled) + common.OptionMap["PayloadLogEnabled"] = strconv.FormatBool(common.PayloadLogEnabled) common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled) common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled) common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled) @@ -336,6 +337,8 @@ func updateOptionMap(key string, value string) (err error) { common.AutomaticEnableChannelEnabled = boolValue case "LogConsumeEnabled": common.LogConsumeEnabled = boolValue + case "PayloadLogEnabled": + common.PayloadLogEnabled = boolValue case "DisplayInCurrencyEnabled": // 兼容旧字段:同步到新配置 general_setting.quota_display_type(运行时生效) // true -> USD, false -> TOKENS diff --git a/model/payload_log.go b/model/payload_log.go new file mode 100644 index 000000000000..e4c67c1e23ea --- /dev/null +++ b/model/payload_log.go @@ -0,0 +1,131 @@ +package model + +import ( + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +// PayloadLog stores the full request and response bodies of a relay call. Rows +// are only ever written when the platform-wide switch common.PayloadLogEnabled +// is ON; with the switch OFF (default) no payload is captured or persisted, so +// the platform keeps only billing/ops metadata (the Log table). +type PayloadLog struct { + Id int `json:"id"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UserId int `json:"user_id" gorm:"index"` + Username string `json:"username" gorm:"index;default:''"` + TokenName string `json:"token_name" gorm:"default:''"` + ModelName string `json:"model_name" gorm:"index;default:''"` + ChannelId int `json:"channel_id" gorm:"index;default:0"` + RequestId string `json:"request_id" gorm:"type:varchar(64);index;default:''"` + Ip string `json:"ip" gorm:"default:''"` + StatusCode int `json:"status_code" gorm:"default:0"` + DurationMs int64 `json:"duration_ms" gorm:"default:0"` + RequestBody string `json:"request_body,omitempty" gorm:"type:text"` + ResponseBody string `json:"response_body,omitempty" gorm:"type:text"` +} + +func (PayloadLog) TableName() string { + return "payload_logs" +} + +// PayloadLogSwitchAudit records every change of the PayloadLogEnabled switch: +// who flipped it, to what state, and when. It is readable by any authenticated +// user so customers can independently verify the platform's logging behaviour. +type PayloadLogSwitchAudit struct { + Id int `json:"id"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UserId int `json:"user_id" gorm:"index"` + Username string `json:"username" gorm:"index;default:''"` + Enabled bool `json:"enabled"` +} + +func (PayloadLogSwitchAudit) TableName() string { + return "payload_log_switch_audits" +} + +// payloadLogListColumns excludes the two body columns so the list view stays +// light; full bodies are only loaded on demand via GetPayloadLogById. +const payloadLogListColumns = "id, created_at, user_id, username, token_name, model_name, channel_id, request_id, ip, status_code, duration_ms" + +// RecordPayloadLog persists a captured payload. Errors are swallowed with a log +// line: payload logging must never affect the live relay request. +func RecordPayloadLog(log *PayloadLog) { + if log == nil { + return + } + if err := LOG_DB.Create(log).Error; err != nil { + common.SysLog("failed to record payload log: " + err.Error()) + } +} + +// GetPayloadLogs returns a page of payload logs WITHOUT the request/response +// bodies. A non-zero userId scopes the result to that user (self view); pass 0 +// for the admin all-users view. +func GetPayloadLogs(userId int, username, modelName, requestId string, startTimestamp, endTimestamp int64, startIdx, pageSize int) (logs []*PayloadLog, total int64, err error) { + tx := LOG_DB.Model(&PayloadLog{}) + if userId != 0 { + tx = tx.Where("user_id = ?", userId) + } + if username != "" { + tx = tx.Where("username = ?", username) + } + if modelName != "" { + tx = tx.Where("model_name = ?", modelName) + } + if requestId != "" { + tx = tx.Where("request_id = ?", requestId) + } + if startTimestamp != 0 { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if err = tx.Count(&total).Error; err != nil { + return nil, 0, err + } + err = tx.Select(payloadLogListColumns).Order("id desc").Limit(pageSize).Offset(startIdx).Find(&logs).Error + return logs, total, err +} + +// GetPayloadLogById returns a single row with full bodies. A non-zero userId +// enforces ownership (self view); pass 0 to allow any row (admin view). +func GetPayloadLogById(id int, userId int) (*PayloadLog, error) { + if id == 0 { + return nil, gorm.ErrRecordNotFound + } + tx := LOG_DB.Where("id = ?", id) + if userId != 0 { + tx = tx.Where("user_id = ?", userId) + } + var log PayloadLog + if err := tx.First(&log).Error; err != nil { + return nil, err + } + return &log, nil +} + +// RecordPayloadLogSwitchAudit appends an entry to the switch change history. +func RecordPayloadLogSwitchAudit(userId int, username string, enabled bool) { + audit := &PayloadLogSwitchAudit{ + CreatedAt: common.GetTimestamp(), + UserId: userId, + Username: username, + Enabled: enabled, + } + if err := LOG_DB.Create(audit).Error; err != nil { + common.SysLog("failed to record payload log switch audit: " + err.Error()) + } +} + +// GetPayloadLogSwitchAudits returns the paginated switch change history. +func GetPayloadLogSwitchAudits(startIdx, pageSize int) (audits []*PayloadLogSwitchAudit, total int64, err error) { + tx := LOG_DB.Model(&PayloadLogSwitchAudit{}) + if err = tx.Count(&total).Error; err != nil { + return nil, 0, err + } + err = tx.Order("id desc").Limit(pageSize).Offset(startIdx).Find(&audits).Error + return audits, total, err +} diff --git a/router/api-router.go b/router/api-router.go index 31c595e00db2..e85a63751394 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -278,6 +278,15 @@ func SetApiRouter(router *gin.Engine) { logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) logRoute.GET("/self/search", middleware.UserAuth(), middleware.SearchRateLimit(), controller.SearchUserLogs) + payloadLogRoute := apiRouter.Group("/payload_log") + payloadLogRoute.GET("/", middleware.AdminAuth(), controller.GetPayloadLogs) + payloadLogRoute.GET("/detail/:id", middleware.AdminAuth(), controller.GetPayloadLogDetail) + payloadLogRoute.GET("/self", middleware.UserAuth(), controller.GetSelfPayloadLogs) + payloadLogRoute.GET("/self/detail/:id", middleware.UserAuth(), controller.GetSelfPayloadLogDetail) + payloadLogRoute.GET("/switch", middleware.UserAuth(), controller.GetPayloadLogSwitchStatus) + payloadLogRoute.POST("/switch", middleware.RootAuth(), controller.SetPayloadLogSwitch) + payloadLogRoute.GET("/switch/audits", middleware.UserAuth(), controller.GetPayloadLogSwitchAudits) + systemTaskRoute := apiRouter.Group("/system-task") systemTaskRoute.Use(middleware.RootAuth()) { diff --git a/router/relay-router.go b/router/relay-router.go index b230a5a8084c..12f4b8f3f923 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -83,6 +83,7 @@ func SetRelayRouter(router *gin.Engine) { //http router httpRouter := relayV1Router.Group("") httpRouter.Use(middleware.Distribute()) + httpRouter.Use(middleware.PayloadLog()) // claude related routes httpRouter.POST("/messages", func(c *gin.Context) { diff --git a/web/src/features/payload-logs/api.ts b/web/src/features/payload-logs/api.ts new file mode 100644 index 000000000000..11e2ad589bac --- /dev/null +++ b/web/src/features/payload-logs/api.ts @@ -0,0 +1,76 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { api } from '@/lib/api' + +import type { + PayloadLogDetail, + PayloadLogListData, + SwitchAuditListData, +} from './types' + +export interface GetPayloadLogsParams { + page?: number + page_size?: number + model_name?: string + request_id?: string +} + +// isAdmin selects between the all-users endpoint and the caller's own-logs +// endpoint, so a regular user can only ever see their own calls. +export async function getPayloadLogs( + params: GetPayloadLogsParams, + isAdmin: boolean +) { + const res = await api.get<{ data: PayloadLogListData }>( + isAdmin ? '/api/payload_log/' : '/api/payload_log/self', + { params } + ) + return res.data?.data +} + +export async function getPayloadLogDetail(id: number, isAdmin: boolean) { + const url = isAdmin + ? `/api/payload_log/detail/${id}` + : `/api/payload_log/self/detail/${id}` + const res = await api.get<{ data: PayloadLogDetail }>(url) + return res.data?.data +} + +export async function getSwitchStatus() { + const res = await api.get<{ data: { enabled: boolean } }>( + '/api/payload_log/switch' + ) + return res.data?.data?.enabled ?? false +} + +// Root only (enforced server-side). Records who flipped the switch. +export async function setSwitch(enabled: boolean) { + const res = await api.post('/api/payload_log/switch', { enabled }) + return res.data +} + +export async function getSwitchAudits( + params: { page?: number; page_size?: number } = {} +) { + const res = await api.get<{ data: SwitchAuditListData }>( + '/api/payload_log/switch/audits', + { params } + ) + return res.data?.data +} diff --git a/web/src/features/payload-logs/index.tsx b/web/src/features/payload-logs/index.tsx new file mode 100644 index 000000000000..629cab27e33e --- /dev/null +++ b/web/src/features/payload-logs/index.tsx @@ -0,0 +1,348 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + ChevronDown, + ChevronRight, + FileText, + Folder, + FolderOpen, +} from 'lucide-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { SectionPageLayout } from '@/components/layout' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { Switch } from '@/components/ui/switch' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { ROLE } from '@/lib/roles' +import { useAuthStore } from '@/stores/auth-store' + +import { + getPayloadLogDetail, + getPayloadLogs, + getSwitchAudits, + getSwitchStatus, + setSwitch, +} from './api' +import type { PayloadLogItem } from './types' + +const PAGE_SIZE = 100 + +function pad(n: number) { + return String(n).padStart(2, '0') +} +function dayOf(seconds: number) { + const d = new Date(seconds * 1000) + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} +function timeOf(seconds: number) { + const d = new Date(seconds * 1000) + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} +function fullTime(seconds: number) { + return `${dayOf(seconds)} ${timeOf(seconds)}` +} + +export function PayloadLogs() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const user = useAuthStore((s) => s.auth.user) + const isRoot = user?.role === ROLE.SUPER_ADMIN + + const [expanded, setExpanded] = useState>(new Set()) + const [detailId, setDetailId] = useState(null) + + const { data: enabled } = useQuery({ + queryKey: ['payload-log-switch'], + queryFn: getSwitchStatus, + }) + + const { data: audits } = useQuery({ + queryKey: ['payload-log-switch-audits'], + queryFn: () => getSwitchAudits({ page: 1, page_size: 20 }), + }) + + const { data: list, isLoading } = useQuery({ + queryKey: ['payload-logs', isRoot], + queryFn: () => getPayloadLogs({ page: 1, page_size: PAGE_SIZE }, isRoot), + }) + + const { data: detail, isLoading: detailLoading } = useQuery({ + queryKey: ['payload-log', detailId, isRoot], + queryFn: () => getPayloadLogDetail(detailId as number, isRoot), + enabled: detailId != null, + }) + + const onToggle = async (next: boolean) => { + await setSwitch(next) + queryClient.invalidateQueries({ queryKey: ['payload-log-switch'] }) + queryClient.invalidateQueries({ queryKey: ['payload-log-switch-audits'] }) + } + + const items = list?.items ?? [] + const groups = useMemo(() => { + const map = new Map() + for (const it of items) { + const day = dayOf(it.created_at) + const arr = map.get(day) + if (arr) arr.push(it) + else map.set(day, [it]) + } + return Array.from(map.entries()) + }, [items]) + + const toggleDay = (day: string) => + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(day)) next.delete(day) + else next.add(day) + return next + }) + + return ( + + {t('Payload Logs')} + +
+ {/* Status + switch */} +
+
+
+
+ {t('Business payload logging')} + + {enabled ? t('On') : t('Off')} + +
+

+ {enabled + ? t( + 'Enabling stores the full request and response of every call ensure this complies with your customer agreements' + ) + : t( + 'Off by default the platform stores only billing metadata never your prompts or responses' + )} +

+ {!isRoot && ( +

+ {t('Only root can change this switch')} +

+ )} +
+ +
+
+ + {/* Switch change history */} +
+
+ {t('Switch change history')} +
+
+ + + + {t('Time')} + {t('User')} + {t('Action')} + + + + {(audits?.items ?? []).length === 0 ? ( + + + {t('No changes yet')} + + + ) : ( + (audits?.items ?? []).map((a) => ( + + + {fullTime(a.created_at)} + + {a.username || a.user_id} + + + {a.enabled ? t('Turned on') : t('Turned off')} + + + + )) + )} + +
+
+
+ + {/* Drill-down: date folders -> call files -> detail */} +
+
+ {isRoot ? t('All calls') : t('My calls')} +
+ {isLoading ? ( +
+ + +
+ ) : groups.length === 0 ? ( +
+ {t('No payload logs yet')} +
+ ) : ( +
+ {groups.map(([day, calls]) => { + const open = expanded.has(day) + return ( +
+ + {open && ( +
+ {calls.map((call) => ( + + ))} +
+ )} +
+ ) + })} +
+ )} +
+ + !o && setDetailId(null)} + > + + + {t('Call detail')} + + {detailLoading || !detail ? ( +
+ + +
+ ) : ( +
+
+ {fullTime(detail.created_at)} + {detail.model_name} + {detail.request_id} +
+
+
+ {t('Request body')} +
+
+
+                        {detail.request_body || '-'}
+                      
+
+
+
+
+ {t('Response body')} +
+
+
+                        {detail.response_body || '-'}
+                      
+
+
+
+ )} +
+
+
+
+
+ ) +} diff --git a/web/src/features/payload-logs/types.ts b/web/src/features/payload-logs/types.ts new file mode 100644 index 000000000000..f7a60ed3ac63 --- /dev/null +++ b/web/src/features/payload-logs/types.ts @@ -0,0 +1,58 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +export interface PayloadLogItem { + id: number + created_at: number + user_id: number + username: string + token_name: string + model_name: string + channel_id: number + request_id: string + ip: string + status_code: number + duration_ms: number +} + +export interface PayloadLogDetail extends PayloadLogItem { + request_body: string + response_body: string +} + +export interface PayloadLogListData { + items: PayloadLogItem[] + total: number + page: number + page_size: number +} + +export interface SwitchAudit { + id: number + created_at: number + user_id: number + username: string + enabled: boolean +} + +export interface SwitchAuditListData { + items: SwitchAudit[] + total: number + page: number + page_size: number +} diff --git a/web/src/hooks/use-sidebar-data.ts b/web/src/hooks/use-sidebar-data.ts index 40a0615aa347..ad65f24208c4 100644 --- a/web/src/hooks/use-sidebar-data.ts +++ b/web/src/hooks/use-sidebar-data.ts @@ -27,6 +27,7 @@ import { ListTodo, MessageSquare, Radio, + ScrollText, ServerCog, Settings, Ticket, @@ -97,6 +98,11 @@ export function useSidebarData(): SidebarData { configUrls: ['/usage-logs/drawing', '/usage-logs/task'], icon: ListTodo, }, + { + title: t('Payload Logs'), + url: '/payload-logs', + icon: ScrollText, + }, ], }, { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 86095b29c1b3..0873dc55dbc5 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1,5 +1,23 @@ { "translation": { + "On": "On", + "Off": "Off", + "Turned on": "Turned on", + "Turned off": "Turned off", + "Switch change history": "Switch change history", + "Only root can change this switch": "Only administrators can change this switch", + "My calls": "My calls", + "All calls": "All calls", + "No changes yet": "No changes yet", + "Payload Logs": "Payload Logs", + "Business payload logging": "Business payload logging", + "Off by default the platform stores only billing metadata never your prompts or responses": "Off by default — the platform stores only billing metadata, never your prompts or model responses.", + "Enabling stores the full request and response of every call ensure this complies with your customer agreements": "When enabled, the platform stores the full request and response of every call. Make sure this complies with your customer agreements.", + "No payload logs yet": "No payload logs yet", + "View detail": "View detail", + "Call detail": "Call detail", + "Request body": "Request body", + "Response body": "Response body", "360": "360", "1000": "1000", "10000": "10000", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 457b1c53f2b0..c4b1b494c796 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1,5 +1,23 @@ { "translation": { + "On": "开启", + "Off": "关闭", + "Turned on": "已开启", + "Turned off": "已关闭", + "Switch change history": "开关变更记录", + "Only root can change this switch": "仅管理员可更改此开关", + "My calls": "我的调用", + "All calls": "全部调用", + "No changes yet": "暂无变更记录", + "Payload Logs": "业务报文日志", + "Business payload logging": "业务报文日志开关", + "Off by default the platform stores only billing metadata never your prompts or responses": "默认关闭——平台仅保留计费元数据,绝不存储你的 prompt 或模型响应正文。", + "Enabling stores the full request and response of every call ensure this complies with your customer agreements": "开启后,平台将存储每次调用的完整请求与响应报文。请确保符合与客户的约定与合规要求。", + "No payload logs yet": "暂无业务报文日志", + "View detail": "查看详情", + "Call detail": "调用详情", + "Request body": "请求报文", + "Response body": "响应报文", "360": "360", "1000": "1000", "10000": "10000", diff --git a/web/src/routes/_authenticated/payload-logs/index.tsx b/web/src/routes/_authenticated/payload-logs/index.tsx new file mode 100644 index 000000000000..1a4c407ebc0a --- /dev/null +++ b/web/src/routes/_authenticated/payload-logs/index.tsx @@ -0,0 +1,28 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { createFileRoute } from '@tanstack/react-router' + +import { PayloadLogs } from '@/features/payload-logs' + +// Visible to any authenticated user: regular users see only their own calls and +// a read-only switch; root can toggle it and see all calls. Row-level scoping is +// enforced server-side. +export const Route = createFileRoute('/_authenticated/payload-logs/')({ + component: PayloadLogs, +})