Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions internal/handler/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,28 @@ func (h *AdminHandler) handleRoutingStrategies(w http.ResponseWriter, r *http.Re
}
}

// parseTimeQuery parses a time query parameter as either a 13-digit millisecond timestamp or RFC3339.
func parseTimeQuery(raw, name string) (*time.Time, error) {
if raw == "" {
return nil, nil
}
if millis, err := strconv.ParseInt(raw, 10, 64); err == nil {
digits := len(strings.TrimLeft(raw, "-"))
if digits != 13 {
return nil, errors.New("invalid " + name + ": expected 13-digit millisecond timestamp or RFC3339")
}
t := time.UnixMilli(millis).UTC()
return &t, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for _, format := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(format, raw); err == nil {
utc := t.UTC()
return &utc, nil
}
}
return nil, errors.New("invalid " + name + ": expected millisecond timestamp or RFC3339")
}

// ProxyRequest handlers
// Routes: /admin/requests, /admin/requests/count, /admin/requests/active, /admin/requests/{id}, /admin/requests/{id}/attempts, /admin/requests/{id}/recalculate-cost
func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Request, id uint64, parts []string) {
Expand Down Expand Up @@ -882,8 +904,10 @@ func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Reques
statusStr := r.URL.Query().Get("status")
apiTokenIDStr := r.URL.Query().Get("apiTokenId")
projectIDStr := r.URL.Query().Get("projectId")
startTimeStr := r.URL.Query().Get("startTime")
endTimeStr := r.URL.Query().Get("endTime")

if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" {
if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" || startTimeStr != "" || endTimeStr != "" {
filter = &repository.ProxyRequestFilter{}
if providerIDStr != "" {
providerID, err := strconv.ParseUint(providerIDStr, 10, 64)
Expand Down Expand Up @@ -912,6 +936,26 @@ func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Reques
}
filter.ProjectID = &projectID
}
if startTimeStr != "" {
startTime, err := parseTimeQuery(startTimeStr, "startTime")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
filter.StartTime = startTime
}
if endTimeStr != "" {
endTime, err := parseTimeQuery(endTimeStr, "endTime")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
filter.EndTime = endTime
}
if filter.StartTime != nil && filter.EndTime != nil && filter.EndTime.Before(*filter.StartTime) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endTime must be greater than or equal to startTime"})
return
}
}

result, err := h.svc.GetProxyRequestsCursor(tenantID, limit, before, after, filter)
Expand Down Expand Up @@ -941,8 +985,10 @@ func (h *AdminHandler) handleProxyRequestsCount(w http.ResponseWriter, r *http.R
statusStr := r.URL.Query().Get("status")
apiTokenIDStr := r.URL.Query().Get("apiTokenId")
projectIDStr := r.URL.Query().Get("projectId")
startTimeStr := r.URL.Query().Get("startTime")
endTimeStr := r.URL.Query().Get("endTime")

if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" {
if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" || startTimeStr != "" || endTimeStr != "" {
filter = &repository.ProxyRequestFilter{}
if providerIDStr != "" {
providerID, err := strconv.ParseUint(providerIDStr, 10, 64)
Expand Down Expand Up @@ -971,6 +1017,26 @@ func (h *AdminHandler) handleProxyRequestsCount(w http.ResponseWriter, r *http.R
}
filter.ProjectID = &projectID
}
if startTimeStr != "" {
startTime, err := parseTimeQuery(startTimeStr, "startTime")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
filter.StartTime = startTime
}
if endTimeStr != "" {
endTime, err := parseTimeQuery(endTimeStr, "endTime")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
filter.EndTime = endTime
}
if filter.StartTime != nil && filter.EndTime != nil && filter.EndTime.Before(*filter.StartTime) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endTime must be greater than or equal to startTime"})
return
}
}

count, err := h.svc.GetProxyRequestsCountWithFilter(tenantID, filter)
Expand Down
15 changes: 15 additions & 0 deletions internal/repository/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,21 @@ type ProxyRequestFilter struct {
Status *string // 状态,nil 表示不过滤
APITokenID *uint64 // API Token ID,nil 表示不过滤
ProjectID *uint64 // Project ID,nil 表示不过滤
StartTime *time.Time
EndTime *time.Time
}

func (f *ProxyRequestFilter) IsEmpty() bool {
if f == nil {
return true
}
return f.TenantID == nil &&
f.ProviderID == nil &&
f.Status == nil &&
f.APITokenID == nil &&
f.ProjectID == nil &&
f.StartTime == nil &&
f.EndTime == nil
}

type ProxyRequestRepository interface {
Expand Down
40 changes: 40 additions & 0 deletions internal/repository/sqlite/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,46 @@ var migrations = []Migration{
}
},
},
{
Version: 16,
Description: "Add proxy request created_at index for time-range search",
Up: func(db *gorm.DB) error {
var rowCount int64
if err := db.Raw("SELECT COUNT(*) FROM proxy_requests").Scan(&rowCount).Error; err != nil {
log.Printf("[Migration v16] could not count proxy_requests (%v); skipping index build. Apply manually if needed.", err)
return nil
}

if rowCount > detailCleanupIndexRowThreshold {
log.Printf("[Migration v16] SKIPPING idx_proxy_requests_created_at_id build: proxy_requests has %d rows (> %d threshold). "+
"Apply manually during a maintenance window: CREATE INDEX idx_proxy_requests_created_at_id ON proxy_requests(created_at, id);",
rowCount, detailCleanupIndexRowThreshold)
return nil
}

switch db.Dialector.Name() {
case "mysql":
err := db.Exec("CREATE INDEX idx_proxy_requests_created_at_id ON proxy_requests(created_at, id)").Error
if isMySQLDuplicateIndexError(err) {
return nil
}
return err
default:
return db.Exec("CREATE INDEX IF NOT EXISTS idx_proxy_requests_created_at_id ON proxy_requests(created_at, id)").Error
}
},
Down: func(db *gorm.DB) error {
switch db.Dialector.Name() {
case "mysql":
if err := db.Exec("DROP INDEX idx_proxy_requests_created_at_id ON proxy_requests").Error; err != nil && !isMySQLMissingIndexError(err) {
log.Printf("[Migration] Warning: rollback v16 failed: %v", err)
}
return nil
default:
return db.Exec("DROP INDEX IF EXISTS idx_proxy_requests_created_at_id").Error
}
},
},
}

// runDetailClearedColumnMigration 显式添加 detail_cleared 列到两张大表,带 threshold-skip。
Expand Down
14 changes: 13 additions & 1 deletion internal/repository/sqlite/proxy_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ func (r *ProxyRequestRepository) ListCursor(tenantID uint64, limit int, before,
if filter.ProjectID != nil {
baseQuery = baseQuery.Where("project_id = ?", *filter.ProjectID)
}
if filter.StartTime != nil {
baseQuery = baseQuery.Where("created_at >= ?", toTimestamp(*filter.StartTime))
}
if filter.EndTime != nil {
baseQuery = baseQuery.Where("created_at <= ?", toTimestamp(*filter.EndTime))
}
}

orderBy := "id DESC"
Expand Down Expand Up @@ -163,7 +169,7 @@ func (r *ProxyRequestRepository) Count(tenantID uint64) (int64, error) {
// CountWithFilter 带过滤条件的计数
func (r *ProxyRequestRepository) CountWithFilter(tenantID uint64, filter *repository.ProxyRequestFilter) (int64, error) {
// 如果没有过滤条件且没有 tenantID 过滤,使用缓存的总数
if tenantID == domain.TenantIDAll && (filter == nil || (filter.ProviderID == nil && filter.Status == nil && filter.APITokenID == nil && filter.ProjectID == nil)) {
if tenantID == domain.TenantIDAll && (filter == nil || filter.IsEmpty()) {
return atomic.LoadInt64(&r.count), nil
}

Expand All @@ -183,6 +189,12 @@ func (r *ProxyRequestRepository) CountWithFilter(tenantID uint64, filter *reposi
if filter.ProjectID != nil {
query = query.Where("project_id = ?", *filter.ProjectID)
}
if filter.StartTime != nil {
query = query.Where("created_at >= ?", toTimestamp(*filter.StartTime))
}
if filter.EndTime != nil {
query = query.Where("created_at <= ?", toTimestamp(*filter.EndTime))
}
}
if err := query.Count(&count).Error; err != nil {
return 0, err
Expand Down
52 changes: 52 additions & 0 deletions internal/repository/sqlite/proxy_request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/awsl-project/maxx/internal/domain"
"github.com/awsl-project/maxx/internal/repository"
)

func TestDetailCleanupBatchParams(t *testing.T) {
Expand Down Expand Up @@ -187,6 +188,57 @@ func TestProxyRequestListCursorBeforeCursorDoesNotRepeatOrSkipRecords(t *testing
}
}

func TestProxyRequestListCursorAndCountFilterByCreatedAtRange(t *testing.T) {
db, err := NewDBWithDSN("sqlite://:memory:")
if err != nil {
t.Fatalf("Failed to create DB: %v", err)
}
defer db.Close()

repo := NewProxyRequestRepository(db)
base := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC)
requests := []*domain.ProxyRequest{
buildTestProxyRequest("COMPLETED", 1),
buildTestProxyRequest("COMPLETED", 2),
buildTestProxyRequest("COMPLETED", 3),
buildTestProxyRequest("COMPLETED", 4),
}

for i, request := range requests {
if err := repo.Create(request); err != nil {
t.Fatalf("Failed to create request: %v", err)
}
createdAt := base.Add(time.Duration(i) * time.Hour).UnixMilli()
if err := db.gorm.Model(&ProxyRequest{}).Where("id = ?", request.ID).Update("created_at", createdAt).Error; err != nil {
t.Fatalf("Failed to update created_at: %v", err)
}
}

start := base.Add(1 * time.Hour)
end := base.Add(2 * time.Hour)
filter := &repository.ProxyRequestFilter{
StartTime: &start,
EndTime: &end,
}

items, err := repo.ListCursor(1, 10, 0, 0, filter)
if err != nil {
t.Fatalf("ListCursor failed: %v", err)
}
expected := []uint64{requests[2].ID, requests[1].ID}
if got := collectRequestIDs(items); fmt.Sprint(got) != fmt.Sprint(expected) {
t.Fatalf("expected filtered ids %v, got %v", expected, got)
}

count, err := repo.CountWithFilter(1, filter)
if err != nil {
t.Fatalf("CountWithFilter failed: %v", err)
}
if count != int64(len(expected)) {
t.Fatalf("expected filtered count %d, got %d", len(expected), count)
}
}

// TestProxyRequestUpdatePreservesRequestInfo 锁定 OOM 优化后的写入不变量:
// - Update 永不重写 request_info(Create 后不变),状态类 Update 不能把它清空;
// - Update 仅在 ResponseInfo 非 nil 时写 response_info;ResponseInfo 为 nil 时
Expand Down
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"lucide-react": "^0.562.0",
"postcss": "^8.5.10",
"react": "^19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.3",
"react-resizable-panels": "^2.1.7",
Expand Down
26 changes: 26 additions & 0 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading