@@ -13,10 +13,12 @@ import (
1313 "net/http"
1414 "os"
1515 "strconv"
16+ "strings"
1617 "sync"
1718 "time"
1819
1920 "qoder2api/auth"
21+ "qoder2api/logs"
2022 "qoder2api/models"
2123 "qoder2api/stats"
2224 "qoder2api/store"
@@ -32,6 +34,7 @@ type Admin struct {
3234 store * store.Store
3335 modelFetcher ModelFetcher
3436 stats * stats.Recorder
37+ logRec * logs.Recorder
3538 sessions map [string ]time.Time
3639 sessionMu sync.Mutex
3740 limiter * loginLimiter
@@ -40,12 +43,13 @@ type Admin struct {
4043// ModelFetcher returns the current model catalog (dynamic or fallback).
4144type ModelFetcher func (ctx context.Context ) []string
4245
43- // New creates an Admin instance. rec may be nil.
44- func New (s * store.Store , mf ModelFetcher , rec * stats.Recorder ) * Admin {
46+ // New creates an Admin instance. rec and lr may be nil.
47+ func New (s * store.Store , mf ModelFetcher , rec * stats.Recorder , lr * logs. Recorder ) * Admin {
4548 return & Admin {
4649 store : s ,
4750 modelFetcher : mf ,
4851 stats : rec ,
52+ logRec : lr ,
4953 sessions : make (map [string ]time.Time ),
5054 limiter : newLoginLimiter (),
5155 }
@@ -67,6 +71,7 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
6771 mux .HandleFunc ("/admin/api/pat" , a .requireAuth (a .handlePAT ))
6872 mux .HandleFunc ("/admin/api/models" , a .requireAuth (a .handleModels ))
6973 mux .HandleFunc ("/admin/api/stats" , a .requireAuth (a .handleStats ))
74+ mux .HandleFunc ("/admin/api/logs" , a .requireAuth (a .handleLogs ))
7075 mux .HandleFunc ("/admin/api/config" , a .requireAuth (a .handleConfig ))
7176 mux .HandleFunc ("/admin/api/password" , a .requireAuth (a .handlePassword ))
7277}
@@ -429,24 +434,40 @@ func timeoutEnvManaged() bool {
429434 os .Getenv ("QODER_IDLE_TIMEOUT_SECONDS" ) != ""
430435}
431436
437+ // logLimitsEnvManaged reports whether the log limits are pinned by env vars,
438+ // in which case the panel must not change them (same policy as the timeouts).
439+ func logLimitsEnvManaged () bool {
440+ return os .Getenv ("QODER_LOG_RETENTION_DAYS" ) != "" ||
441+ os .Getenv ("QODER_LOG_MAX_ENTRIES" ) != ""
442+ }
443+
432444func (a * Admin ) handleConfig (w http.ResponseWriter , r * http.Request ) {
433445 switch r .Method {
434446 case http .MethodGet :
435- // Report the effective stream timeouts (env startup overrides are
436- // already folded into them), not just the persisted file values.
447+ // Report effective values, not the persisted file values: env startup
448+ // overrides are already folded into the stream timeouts and into the
449+ // log limits held by the recorder.
437450 timeouts := auth .CurrentStreamTimeouts ()
451+ retentionDays , maxEntries := a .store .GetLogRetentionDays (), a .store .GetLogMaxEntries ()
452+ if a .logRec != nil {
453+ retentionDays , maxEntries = a .logRec .Limits ()
454+ }
438455 writeJSON (w , 200 , map [string ]interface {}{
439456 "host" : a .store .GetHost (),
440457 "port" : a .store .GetPort (),
441458 "chat_timeout_seconds" : int (timeouts .Header .Seconds ()),
442459 "idle_timeout_seconds" : int (timeouts .Idle .Seconds ()),
460+ "log_retention_days" : retentionDays ,
461+ "log_max_entries" : maxEntries ,
443462 })
444463 case http .MethodPost :
445464 var body struct {
446465 Host string `json:"host"`
447466 Port int `json:"port"`
448467 ChatTimeoutSeconds int `json:"chat_timeout_seconds"`
449468 IdleTimeoutSeconds int `json:"idle_timeout_seconds"`
469+ LogRetentionDays int `json:"log_retention_days"`
470+ LogMaxEntries int `json:"log_max_entries"`
450471 }
451472 if err := readJSON (w , r , & body ); err != nil {
452473 writeJSONError (w , 400 , "请求格式错误" )
@@ -482,6 +503,25 @@ func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
482503 return
483504 }
484505 }
506+ // Log limits share the timeout convention: 0 = keep current, and a
507+ // value pinned by env vars is rejected.
508+ wantsLogLimits := body .LogRetentionDays != 0 || body .LogMaxEntries != 0
509+ if wantsLogLimits && logLimitsEnvManaged () {
510+ writeJSONError (w , 400 , "日志配置由环境变量 QODER_LOG_RETENTION_DAYS / QODER_LOG_MAX_ENTRIES 管理,无法在面板中修改" )
511+ return
512+ }
513+ if body .LogRetentionDays != 0 {
514+ if err := store .ValidateLogRetentionDays (body .LogRetentionDays ); err != nil {
515+ writeJSONError (w , 400 , "日志保留天数:" + err .Error ())
516+ return
517+ }
518+ }
519+ if body .LogMaxEntries != 0 {
520+ if err := store .ValidateLogMaxEntries (body .LogMaxEntries ); err != nil {
521+ writeJSONError (w , 400 , "日志条数上限:" + err .Error ())
522+ return
523+ }
524+ }
485525 if err := a .store .SetHostPort (body .Host , body .Port ); err != nil {
486526 writeJSONError (w , 500 , err .Error ())
487527 return
@@ -498,6 +538,23 @@ func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
498538 return
499539 }
500540 }
541+ if body .LogRetentionDays != 0 {
542+ if err := a .store .SetLogRetentionDays (body .LogRetentionDays ); err != nil {
543+ writeJSONError (w , 500 , err .Error ())
544+ return
545+ }
546+ }
547+ if body .LogMaxEntries != 0 {
548+ if err := a .store .SetLogMaxEntries (body .LogMaxEntries ); err != nil {
549+ writeJSONError (w , 500 , err .Error ())
550+ return
551+ }
552+ }
553+ // Hot-apply the log limits so a lowered cap prunes the live recorder at
554+ // once, not at the next request or flush.
555+ if wantsLogLimits && a .logRec != nil {
556+ a .logRec .SetLimits (a .store .GetLogRetentionDays (), a .store .GetLogMaxEntries ())
557+ }
501558 // Hot-apply only when the request actually carried timeout fields:
502559 // re-applying store values unconditionally would clobber env-pinned
503560 // timeouts on a legacy host/port-only save.
@@ -508,12 +565,18 @@ func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
508565 )
509566 }
510567 timeouts := auth .CurrentStreamTimeouts ()
568+ retentionDays , maxEntries := a .store .GetLogRetentionDays (), a .store .GetLogMaxEntries ()
569+ if a .logRec != nil {
570+ retentionDays , maxEntries = a .logRec .Limits ()
571+ }
511572 writeJSON (w , 200 , map [string ]interface {}{
512573 "host" : body .Host ,
513574 "port" : body .Port ,
514575 "chat_timeout_seconds" : int (timeouts .Header .Seconds ()),
515576 "idle_timeout_seconds" : int (timeouts .Idle .Seconds ()),
516- "restart" : "修改主机或端口后需要重启服务才能生效(超时配置已即时生效)" ,
577+ "log_retention_days" : retentionDays ,
578+ "log_max_entries" : maxEntries ,
579+ "restart" : "修改主机或端口后需要重启服务才能生效(超时与日志配置已即时生效)" ,
517580 })
518581 default :
519582 writeJSONError (w , 405 , "方法不允许" )
@@ -579,6 +642,52 @@ func (a *Admin) handleStats(w http.ResponseWriter, r *http.Request) {
579642 writeJSON (w , 200 , a .stats .Report ())
580643}
581644
645+ // --- Logs endpoint ---
646+
647+ // handleLogs serves GET /admin/api/logs: a paginated, filterable request log.
648+ // Query params: model (substring), key_id (exact), status ("error" or a code),
649+ // page, page_size.
650+ func (a * Admin ) handleLogs (w http.ResponseWriter , r * http.Request ) {
651+ if r .Method != http .MethodGet {
652+ writeJSONError (w , 405 , "方法不允许" )
653+ return
654+ }
655+ if a .logRec == nil {
656+ writeJSON (w , 200 , logs.Result {Logs : []logs.LogEntry {}})
657+ return
658+ }
659+ q := r .URL .Query ()
660+ f := logs.Filter {
661+ Model : strings .TrimSpace (q .Get ("model" )),
662+ KeyID : strings .TrimSpace (q .Get ("key_id" )),
663+ Page : atoiDefault (q .Get ("page" ), 1 ),
664+ PageSize : atoiDefault (q .Get ("page_size" ), 20 ),
665+ }
666+ switch v := q .Get ("status" ); v {
667+ case "error" :
668+ f .Status = - 1
669+ case "" :
670+ // no status filter
671+ default :
672+ if n , err := strconv .Atoi (v ); err == nil {
673+ f .Status = n
674+ }
675+ }
676+ writeJSON (w , 200 , a .logRec .Query (f ))
677+ }
678+
679+ // atoiDefault parses s as an int, falling back to def when empty or invalid.
680+ func atoiDefault (s string , def int ) int {
681+ if s == "" {
682+ return def
683+ }
684+ n , err := strconv .Atoi (s )
685+ if err != nil {
686+ return def
687+ }
688+ return n
689+ }
690+
582691// maskPAT returns a masked version of the PAT for display.
583692func maskPAT (pat string ) string {
584693 if len (pat ) <= 8 {
0 commit comments