From b53f3ea246e026963b9a85834c8062ccfe07a027 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 05:27:27 -1000 Subject: [PATCH 01/26] feat(payload-log): add model/payload_log.go --- model/payload_log.go | 83 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 model/payload_log.go diff --git a/model/payload_log.go b/model/payload_log.go new file mode 100644 index 000000000000..e84771f9f0c8 --- /dev/null +++ b/model/payload_log.go @@ -0,0 +1,83 @@ +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" +} + +// 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. Use GetPayloadLogById to fetch a single row with the full bodies. +func GetPayloadLogs(username, modelName, requestId string, startTimestamp, endTimestamp int64, startIdx, pageSize int) (logs []*PayloadLog, total int64, err error) { + tx := LOG_DB.Model(&PayloadLog{}) + 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 +} + +func GetPayloadLogById(id int) (*PayloadLog, error) { + if id == 0 { + return nil, gorm.ErrRecordNotFound + } + var log PayloadLog + if err := LOG_DB.Where("id = ?", id).First(&log).Error; err != nil { + return nil, err + } + return &log, nil +} From 7d9197884fe9a867ed82ef8b2564a2216f19c33c 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 05:27:38 -1000 Subject: [PATCH 02/26] feat(payload-log): add middleware/payload_log.go --- middleware/payload_log.go | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 middleware/payload_log.go 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) +} From 8efada52e275619d46dd2e3b0f8ecd17d4cdb818 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 05:27:50 -1000 Subject: [PATCH 03/26] feat(payload-log): add controller/payload_log.go --- controller/payload_log.go | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 controller/payload_log.go diff --git a/controller/payload_log.go b/controller/payload_log.go new file mode 100644 index 000000000000..8a901f3bdf26 --- /dev/null +++ b/controller/payload_log.go @@ -0,0 +1,41 @@ +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 captured payload logs. +// Admin-only (registered behind AdminAuth). +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(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) +} + +// GetPayloadLogDetail returns a single payload log including the full request +// and response bodies. Admin-only. +func GetPayloadLogDetail(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + log, err := model.GetPayloadLogById(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, log) +} From b42af153829f19c01f499bf54238fd5d55c5815a 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 05:28:01 -1000 Subject: [PATCH 04/26] feat(payload-log): add web/src/features/payload-logs/types.ts --- web/src/features/payload-logs/types.ts | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 web/src/features/payload-logs/types.ts diff --git a/web/src/features/payload-logs/types.ts b/web/src/features/payload-logs/types.ts new file mode 100644 index 000000000000..6bd7911bed82 --- /dev/null +++ b/web/src/features/payload-logs/types.ts @@ -0,0 +1,43 @@ +/* +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 +} From 06f6af119418a5b8ee1a386da4a69fe439981835 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 05:28:12 -1000 Subject: [PATCH 05/26] feat(payload-log): add web/src/features/payload-logs/api.ts --- web/src/features/payload-logs/api.ts | 61 ++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 web/src/features/payload-logs/api.ts diff --git a/web/src/features/payload-logs/api.ts b/web/src/features/payload-logs/api.ts new file mode 100644 index 000000000000..ce0e7ca19159 --- /dev/null +++ b/web/src/features/payload-logs/api.ts @@ -0,0 +1,61 @@ +/* +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 } from './types' + +export interface GetPayloadLogsParams { + page?: number + page_size?: number + username?: string + model_name?: string + request_id?: string +} + +export async function getPayloadLogs(params: GetPayloadLogsParams = {}) { + const res = await api.get<{ data: PayloadLogListData }>('/api/payload_log/', { + params, + }) + return res.data?.data +} + +export async function getPayloadLogDetail(id: number) { + const res = await api.get<{ data: PayloadLogDetail }>( + `/api/payload_log/${id}` + ) + return res.data?.data +} + +// The platform-wide switch is stored as the PayloadLogEnabled option +// (root-only, read/written through the generic option endpoints). +export async function getPayloadLogEnabled() { + const res = await api.get<{ data: { key: string; value: string }[] }>( + '/api/option/' + ) + const opt = res.data?.data?.find((o) => o.key === 'PayloadLogEnabled') + return opt?.value === 'true' +} + +export async function setPayloadLogEnabled(enabled: boolean) { + const res = await api.put('/api/option/', { + key: 'PayloadLogEnabled', + value: enabled, + }) + return res.data +} From 337aff2df837d657fa3a139691a71c56614034df 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 05:28:24 -1000 Subject: [PATCH 06/26] feat(payload-log): add web/src/features/payload-logs/index.tsx --- web/src/features/payload-logs/index.tsx | 253 ++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 web/src/features/payload-logs/index.tsx diff --git a/web/src/features/payload-logs/index.tsx b/web/src/features/payload-logs/index.tsx new file mode 100644 index 000000000000..315680da37aa --- /dev/null +++ b/web/src/features/payload-logs/index.tsx @@ -0,0 +1,253 @@ +/* +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 { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { SectionPageLayout } from '@/components/layout' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +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 { + getPayloadLogDetail, + getPayloadLogEnabled, + getPayloadLogs, + setPayloadLogEnabled, +} from './api' + +const PAGE_SIZE = 20 + +function formatTime(seconds: number) { + if (!seconds) return '-' + const d = new Date(seconds * 1000) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +export function PayloadLogs() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [page, setPage] = useState(1) + const [detailId, setDetailId] = useState(null) + + const { data: enabled } = useQuery({ + queryKey: ['payload-log-enabled'], + queryFn: getPayloadLogEnabled, + }) + + const { data, isLoading } = useQuery({ + queryKey: ['payload-logs', page], + queryFn: () => getPayloadLogs({ page, page_size: PAGE_SIZE }), + }) + + const { data: detail, isLoading: detailLoading } = useQuery({ + queryKey: ['payload-log', detailId], + queryFn: () => getPayloadLogDetail(detailId as number), + enabled: detailId != null, + }) + + const onToggle = async (next: boolean) => { + await setPayloadLogEnabled(next) + queryClient.invalidateQueries({ queryKey: ['payload-log-enabled'] }) + } + + const items = data?.items ?? [] + const total = data?.total ?? 0 + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + return ( + + {t('Payload Logs')} + +
+
+
+
+
+ {t('Business payload logging')} +
+

+ {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' + )} +

+
+ +
+
+ +
+ + + + {t('Time')} + {t('User')} + {t('Model')} + {t('Status')} + {t('Request ID')} + {t('Action')} + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + )) + ) : items.length === 0 ? ( + + + {t('No payload logs yet')} + + + ) : ( + items.map((item) => ( + + + {formatTime(item.created_at)} + + {item.username || item.user_id} + + {item.model_name || '-'} + + + = 200 && item.status_code < 300 + ? 'secondary' + : 'destructive' + } + > + {item.status_code || '-'} + + + + {item.request_id || '-'} + + + + + + )) + )} + +
+
+ +
+ {total} +
+ + + {page} / {totalPages} + + +
+
+ + !open && setDetailId(null)} + > + + + {t('Call detail')} + + {detailLoading || !detail ? ( +
+ + +
+ ) : ( +
+
+
+ {t('Request body')} +
+
+
+                        {detail.request_body || '-'}
+                      
+
+
+
+
+ {t('Response body')} +
+
+
+                        {detail.response_body || '-'}
+                      
+
+
+
+ )} +
+
+
+
+
+ ) +} From f68c0675d6ff06c0008ac62439f3718619338aba 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 05:28:35 -1000 Subject: [PATCH 07/26] feat(payload-log): add web/src/routes/_authenticated/payload-logs/index.tsx --- .../_authenticated/payload-logs/index.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 web/src/routes/_authenticated/payload-logs/index.tsx 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..c13dd5f138d8 --- /dev/null +++ b/web/src/routes/_authenticated/payload-logs/index.tsx @@ -0,0 +1,36 @@ +/* +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, redirect } from '@tanstack/react-router' + +import { PayloadLogs } from '@/features/payload-logs' +import { ROLE } from '@/lib/roles' +import { useAuthStore } from '@/stores/auth-store' + +export const Route = createFileRoute('/_authenticated/payload-logs/')({ + beforeLoad: () => { + const { auth } = useAuthStore.getState() + + if (auth.user?.role !== ROLE.SUPER_ADMIN) { + throw redirect({ + to: '/403', + }) + } + }, + component: PayloadLogs, +}) From 6c275ee5bdeb0c53e2ee1796fa7fe9d9fc7f862e 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 05:28:58 -1000 Subject: [PATCH 08/26] feat(payload-log): update common/constants.go --- common/constants.go | 4 ++++ 1 file changed, 4 insertions(+) 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} From f3308c52930669c443f6697e9ffc54a4f5cd101b 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 05:29:20 -1000 Subject: [PATCH 09/26] feat(payload-log): update model/option.go --- model/option.go | 3 +++ 1 file changed, 3 insertions(+) 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 From b66cb188dae3c5cbb128570bf18af057f2fe94e2 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 05:29:42 -1000 Subject: [PATCH 10/26] feat(payload-log): update model/main.go --- model/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/model/main.go b/model/main.go index 21445593e54e..8e644e9d3445 100644 --- a/model/main.go +++ b/model/main.go @@ -270,6 +270,7 @@ func migrateDB() error { &Redemption{}, &Ability{}, &Log{}, + &PayloadLog{}, &Midjourney{}, &TopUp{}, &QuotaData{}, @@ -400,7 +401,7 @@ func migrateLOGDB() error { if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { return migrateClickHouseLogDB() } - return LOG_DB.AutoMigrate(&Log{}) + return LOG_DB.AutoMigrate(&Log{}, &PayloadLog{}) } func migrateClickHouseLogDB() error { From 81d498b581604bd3ef5b104698b9ba30390b0d7e 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 05:30:04 -1000 Subject: [PATCH 11/26] feat(payload-log): update router/relay-router.go --- router/relay-router.go | 1 + 1 file changed, 1 insertion(+) 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) { From 6ed6d1e9083fe6a607bc52c5b61ba7ec17fbc65d 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 05:30:27 -1000 Subject: [PATCH 12/26] feat(payload-log): update router/api-router.go --- router/api-router.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/router/api-router.go b/router/api-router.go index 31c595e00db2..879cfe80820b 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -278,6 +278,10 @@ 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("/:id", middleware.AdminAuth(), controller.GetPayloadLogDetail) + systemTaskRoute := apiRouter.Group("/system-task") systemTaskRoute.Use(middleware.RootAuth()) { From 865148479b3a454522e71395d330e8c9fdf03ef6 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 05:30:49 -1000 Subject: [PATCH 13/26] feat(payload-log): update web/src/hooks/use-sidebar-data.ts --- web/src/hooks/use-sidebar-data.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/src/hooks/use-sidebar-data.ts b/web/src/hooks/use-sidebar-data.ts index 40a0615aa347..382698f07004 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, @@ -150,6 +151,12 @@ export function useSidebarData(): SidebarData { icon: ServerCog, requiredRole: ROLE.SUPER_ADMIN, }, + { + title: t('Payload Logs'), + url: '/payload-logs', + icon: ScrollText, + requiredRole: ROLE.SUPER_ADMIN, + }, { title: t('System Settings'), url: '/system-settings/site', From 0810408aff98e6e2a6c07539be7b7e412bb9c1d5 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 05:31:12 -1000 Subject: [PATCH 14/26] feat(payload-log): update web/src/i18n/locales/en.json --- web/src/i18n/locales/en.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 86095b29c1b3..d767765bfe5e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1,5 +1,14 @@ { "translation": { + "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", From 4f46ebc066a354a25b13e64718da1491951f29d1 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 05:31:35 -1000 Subject: [PATCH 15/26] feat(payload-log): update web/src/i18n/locales/zh.json --- web/src/i18n/locales/zh.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 457b1c53f2b0..612306e972dc 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1,5 +1,14 @@ { "translation": { + "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", From d4118f58c6b6e469baaf05f5395eca75b2b694c1 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 05:57:30 -1000 Subject: [PATCH 16/26] feat(payload-log): round2 update model/payload_log.go --- model/payload_log.go | 56 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/model/payload_log.go b/model/payload_log.go index e84771f9f0c8..e4c67c1e23ea 100644 --- a/model/payload_log.go +++ b/model/payload_log.go @@ -30,6 +30,21 @@ 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" @@ -46,9 +61,13 @@ func RecordPayloadLog(log *PayloadLog) { } // GetPayloadLogs returns a page of payload logs WITHOUT the request/response -// bodies. Use GetPayloadLogById to fetch a single row with the full bodies. -func GetPayloadLogs(username, modelName, requestId string, startTimestamp, endTimestamp int64, startIdx, pageSize int) (logs []*PayloadLog, total int64, err error) { +// 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) } @@ -71,13 +90,42 @@ func GetPayloadLogs(username, modelName, requestId string, startTimestamp, endTi return logs, total, err } -func GetPayloadLogById(id int) (*PayloadLog, error) { +// 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 := LOG_DB.Where("id = ?", id).First(&log).Error; err != nil { + 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 +} From 00808ac06ba7499f30a7ebecbca8ba028ba4b023 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 05:57:52 -1000 Subject: [PATCH 17/26] feat(payload-log): round2 update controller/payload_log.go --- controller/payload_log.go | 83 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/controller/payload_log.go b/controller/payload_log.go index 8a901f3bdf26..be0842043145 100644 --- a/controller/payload_log.go +++ b/controller/payload_log.go @@ -9,8 +9,8 @@ import ( "github.com/gin-gonic/gin" ) -// GetPayloadLogs returns a paginated, body-free list of captured payload logs. -// Admin-only (registered behind AdminAuth). +// 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") @@ -18,7 +18,7 @@ func GetPayloadLogs(c *gin.Context) { 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(username, modelName, requestId, startTimestamp, endTimestamp, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + logs, total, err := model.GetPayloadLogs(0, username, modelName, requestId, startTimestamp, endTimestamp, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return @@ -28,14 +28,85 @@ func GetPayloadLogs(c *gin.Context) { common.ApiSuccess(c, pageInfo) } -// GetPayloadLogDetail returns a single payload log including the full request -// and response bodies. Admin-only. +// 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) + 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) +} From 033878775105ba801336dbe68cf3af04652075d4 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 05:58:14 -1000 Subject: [PATCH 18/26] feat(payload-log): round2 update model/main.go --- model/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/model/main.go b/model/main.go index 8e644e9d3445..c9da8eeff79a 100644 --- a/model/main.go +++ b/model/main.go @@ -271,6 +271,7 @@ func migrateDB() error { &Ability{}, &Log{}, &PayloadLog{}, + &PayloadLogSwitchAudit{}, &Midjourney{}, &TopUp{}, &QuotaData{}, @@ -401,7 +402,7 @@ func migrateLOGDB() error { if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { return migrateClickHouseLogDB() } - return LOG_DB.AutoMigrate(&Log{}, &PayloadLog{}) + return LOG_DB.AutoMigrate(&Log{}, &PayloadLog{}, &PayloadLogSwitchAudit{}) } func migrateClickHouseLogDB() error { From 013a536d37a2ca3d50506938f1f78fb5da398367 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 05:58:37 -1000 Subject: [PATCH 19/26] feat(payload-log): round2 update router/api-router.go --- router/api-router.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/router/api-router.go b/router/api-router.go index 879cfe80820b..e85a63751394 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -280,7 +280,12 @@ func SetApiRouter(router *gin.Engine) { payloadLogRoute := apiRouter.Group("/payload_log") payloadLogRoute.GET("/", middleware.AdminAuth(), controller.GetPayloadLogs) - payloadLogRoute.GET("/:id", middleware.AdminAuth(), controller.GetPayloadLogDetail) + 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()) From 4027e3c0947af93cc5e04cc2cff978d2d35c60ca 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 05:58:58 -1000 Subject: [PATCH 20/26] feat(payload-log): round2 update web/src/features/payload-logs/types.ts --- web/src/features/payload-logs/types.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/web/src/features/payload-logs/types.ts b/web/src/features/payload-logs/types.ts index 6bd7911bed82..f7a60ed3ac63 100644 --- a/web/src/features/payload-logs/types.ts +++ b/web/src/features/payload-logs/types.ts @@ -41,3 +41,18 @@ export interface PayloadLogListData { 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 +} From 3440ef60636e194c05f40fb16be0f5266715142f 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 05:59:20 -1000 Subject: [PATCH 21/26] feat(payload-log): round2 update web/src/features/payload-logs/api.ts --- web/src/features/payload-logs/api.ts | 59 +++++++++++++++++----------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/web/src/features/payload-logs/api.ts b/web/src/features/payload-logs/api.ts index ce0e7ca19159..11e2ad589bac 100644 --- a/web/src/features/payload-logs/api.ts +++ b/web/src/features/payload-logs/api.ts @@ -18,44 +18,59 @@ For commercial licensing, please contact support@quantumnous.com */ import { api } from '@/lib/api' -import type { PayloadLogDetail, PayloadLogListData } from './types' +import type { + PayloadLogDetail, + PayloadLogListData, + SwitchAuditListData, +} from './types' export interface GetPayloadLogsParams { page?: number page_size?: number - username?: string model_name?: string request_id?: string } -export async function getPayloadLogs(params: GetPayloadLogsParams = {}) { - const res = await api.get<{ data: PayloadLogListData }>('/api/payload_log/', { - params, - }) +// 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) { - const res = await api.get<{ data: PayloadLogDetail }>( - `/api/payload_log/${id}` - ) +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 } -// The platform-wide switch is stored as the PayloadLogEnabled option -// (root-only, read/written through the generic option endpoints). -export async function getPayloadLogEnabled() { - const res = await api.get<{ data: { key: string; value: string }[] }>( - '/api/option/' +export async function getSwitchStatus() { + const res = await api.get<{ data: { enabled: boolean } }>( + '/api/payload_log/switch' ) - const opt = res.data?.data?.find((o) => o.key === 'PayloadLogEnabled') - return opt?.value === 'true' + return res.data?.data?.enabled ?? false } -export async function setPayloadLogEnabled(enabled: boolean) { - const res = await api.put('/api/option/', { - key: 'PayloadLogEnabled', - value: enabled, - }) +// 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 +} From 4f5483fc8e1aecde5fa954cd2ea44a94d86d36d7 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 05:59:42 -1000 Subject: [PATCH 22/26] feat(payload-log): round2 update web/src/features/payload-logs/index.tsx --- web/src/features/payload-logs/index.tsx | 319 +++++++++++++++--------- 1 file changed, 207 insertions(+), 112 deletions(-) diff --git a/web/src/features/payload-logs/index.tsx b/web/src/features/payload-logs/index.tsx index 315680da37aa..629cab27e33e 100644 --- a/web/src/features/payload-logs/index.tsx +++ b/web/src/features/payload-logs/index.tsx @@ -17,12 +17,18 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +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 { Button } from '@/components/ui/button' import { Dialog, DialogContent, @@ -39,64 +45,112 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { ROLE } from '@/lib/roles' +import { useAuthStore } from '@/stores/auth-store' import { getPayloadLogDetail, - getPayloadLogEnabled, getPayloadLogs, - setPayloadLogEnabled, + getSwitchAudits, + getSwitchStatus, + setSwitch, } from './api' +import type { PayloadLogItem } from './types' -const PAGE_SIZE = 20 +const PAGE_SIZE = 100 -function formatTime(seconds: number) { - if (!seconds) return '-' +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) - const pad = (n: number) => String(n).padStart(2, '0') - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + 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 [page, setPage] = useState(1) + 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-enabled'], - queryFn: getPayloadLogEnabled, + queryKey: ['payload-log-switch'], + queryFn: getSwitchStatus, }) - const { data, isLoading } = useQuery({ - queryKey: ['payload-logs', page], - queryFn: () => getPayloadLogs({ page, page_size: PAGE_SIZE }), + 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], - queryFn: () => getPayloadLogDetail(detailId as number), + queryKey: ['payload-log', detailId, isRoot], + queryFn: () => getPayloadLogDetail(detailId as number, isRoot), enabled: detailId != null, }) const onToggle = async (next: boolean) => { - await setPayloadLogEnabled(next) - queryClient.invalidateQueries({ queryKey: ['payload-log-enabled'] }) + await setSwitch(next) + queryClient.invalidateQueries({ queryKey: ['payload-log-switch'] }) + queryClient.invalidateQueries({ queryKey: ['payload-log-switch-audits'] }) } - const items = data?.items ?? [] - const total = data?.total ?? 0 - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + 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 @@ -107,109 +161,145 @@ export function PayloadLogs() { 'Off by default the platform stores only billing metadata never your prompts or responses' )}

+ {!isRoot && ( +

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

+ )}
- +
-
- - - - {t('Time')} - {t('User')} - {t('Model')} - {t('Status')} - {t('Request ID')} - {t('Action')} - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, i) => ( - - - - - - )) - ) : items.length === 0 ? ( + {/* Switch change history */} +
+
+ {t('Switch change history')} +
+
+
+ - - {t('No payload logs yet')} - + {t('Time')} + {t('User')} + {t('Action')} - ) : ( - items.map((item) => ( - - - {formatTime(item.created_at)} - - {item.username || item.user_id} - - {item.model_name || '-'} - - - = 200 && item.status_code < 300 - ? 'secondary' - : 'destructive' - } - > - {item.status_code || '-'} - - - - {item.request_id || '-'} - - - + + + {(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')} + + + + )) + )} + + +
-
- {total} -
- - - {page} / {totalPages} - - + {/* 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) => ( + + ))} +
+ )} +
+ ) + })} +
+ )}
!open && setDetailId(null)} + onOpenChange={(o) => !o && setDetailId(null)} > @@ -222,6 +312,11 @@ export function PayloadLogs() {
) : (
+
+ {fullTime(detail.created_at)} + {detail.model_name} + {detail.request_id} +
{t('Request body')} From dcc0726d1f245b643ba10d836619224ad610f9aa 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:00:04 -1000 Subject: [PATCH 23/26] feat(payload-log): round2 update web/src/routes/_authenticated/payload-logs/index.tsx --- .../routes/_authenticated/payload-logs/index.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/web/src/routes/_authenticated/payload-logs/index.tsx b/web/src/routes/_authenticated/payload-logs/index.tsx index c13dd5f138d8..1a4c407ebc0a 100644 --- a/web/src/routes/_authenticated/payload-logs/index.tsx +++ b/web/src/routes/_authenticated/payload-logs/index.tsx @@ -16,21 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { PayloadLogs } from '@/features/payload-logs' -import { ROLE } from '@/lib/roles' -import { useAuthStore } from '@/stores/auth-store' +// 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/')({ - beforeLoad: () => { - const { auth } = useAuthStore.getState() - - if (auth.user?.role !== ROLE.SUPER_ADMIN) { - throw redirect({ - to: '/403', - }) - } - }, component: PayloadLogs, }) From 382f9d2bd956e7f3fcc850d0da093021f49792a7 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:00:28 -1000 Subject: [PATCH 24/26] feat(payload-log): round2 update web/src/hooks/use-sidebar-data.ts --- web/src/hooks/use-sidebar-data.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/web/src/hooks/use-sidebar-data.ts b/web/src/hooks/use-sidebar-data.ts index 382698f07004..ad65f24208c4 100644 --- a/web/src/hooks/use-sidebar-data.ts +++ b/web/src/hooks/use-sidebar-data.ts @@ -98,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, + }, ], }, { @@ -151,12 +156,6 @@ export function useSidebarData(): SidebarData { icon: ServerCog, requiredRole: ROLE.SUPER_ADMIN, }, - { - title: t('Payload Logs'), - url: '/payload-logs', - icon: ScrollText, - requiredRole: ROLE.SUPER_ADMIN, - }, { title: t('System Settings'), url: '/system-settings/site', From 17e554b89f75be54692deece7d0c8eb792242774 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:00:51 -1000 Subject: [PATCH 25/26] feat(payload-log): round2 update web/src/i18n/locales/en.json --- web/src/i18n/locales/en.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d767765bfe5e..0873dc55dbc5 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1,5 +1,14 @@ { "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.", From c18bdf99b6efa358bddb1c8329ccba6e6d2ce4ce 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:14 -1000 Subject: [PATCH 26/26] feat(payload-log): round2 update web/src/i18n/locales/zh.json --- web/src/i18n/locales/zh.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 612306e972dc..c4b1b494c796 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1,5 +1,14 @@ { "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 或模型响应正文。",