1075 lines
29 KiB
Go
1075 lines
29 KiB
Go
|
|
package service
|
||
|
|
|
||
|
|
import (
|
||
|
|
"database/sql"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"log"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
_ "github.com/lib/pq"
|
||
|
|
|
||
|
|
"github.com/seifghazi/claude-code-monitor/internal/config"
|
||
|
|
"github.com/seifghazi/claude-code-monitor/internal/model"
|
||
|
|
)
|
||
|
|
|
||
|
|
type postgresStorageService struct {
|
||
|
|
db *sql.DB
|
||
|
|
config *config.StorageConfig
|
||
|
|
logger *log.Logger
|
||
|
|
|
||
|
|
// Prepared statements for frequently used queries
|
||
|
|
stmtInsertRequest *sql.Stmt
|
||
|
|
stmtUpdateResponse *sql.Stmt
|
||
|
|
stmtUpdateGrading *sql.Stmt
|
||
|
|
stmtGetRequestByID *sql.Stmt
|
||
|
|
stmtGetRequestsPage *sql.Stmt
|
||
|
|
stmtGetRequestsCount *sql.Stmt
|
||
|
|
stmtDeleteOldRequests *sql.Stmt
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewPostgresStorageService(cfg *config.StorageConfig) (StorageService, error) {
|
||
|
|
return NewPostgresStorageServiceWithLogger(cfg, log.Default())
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewPostgresStorageServiceWithLogger(cfg *config.StorageConfig, logger *log.Logger) (StorageService, error) {
|
||
|
|
db, err := sql.Open("postgres", cfg.DatabaseURL)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to open postgres database: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Configure connection pool — PostgreSQL handles concurrency well
|
||
|
|
db.SetMaxOpenConns(25)
|
||
|
|
db.SetMaxIdleConns(5)
|
||
|
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||
|
|
|
||
|
|
// Verify connection
|
||
|
|
if err := db.Ping(); err != nil {
|
||
|
|
db.Close()
|
||
|
|
return nil, fmt.Errorf("failed to ping postgres database: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
service := &postgresStorageService{
|
||
|
|
db: db,
|
||
|
|
config: cfg,
|
||
|
|
logger: logger,
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := service.createTables(); err != nil {
|
||
|
|
db.Close()
|
||
|
|
return nil, fmt.Errorf("failed to create tables: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := service.prepareStatements(); err != nil {
|
||
|
|
db.Close()
|
||
|
|
return nil, fmt.Errorf("failed to prepare statements: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := service.cleanupExpiredRequests(); err != nil {
|
||
|
|
logger.Printf("Warning: failed to apply retention policy during startup: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return service, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) createTables() error {
|
||
|
|
schema := `
|
||
|
|
CREATE TABLE IF NOT EXISTS requests (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
timestamp TIMESTAMPTZ NOT NULL,
|
||
|
|
method TEXT NOT NULL,
|
||
|
|
endpoint TEXT NOT NULL,
|
||
|
|
headers TEXT NOT NULL,
|
||
|
|
body TEXT NOT NULL,
|
||
|
|
user_agent TEXT,
|
||
|
|
content_type TEXT,
|
||
|
|
prompt_grade TEXT,
|
||
|
|
response TEXT,
|
||
|
|
model TEXT,
|
||
|
|
original_model TEXT,
|
||
|
|
routed_model TEXT
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp DESC);
|
||
|
|
CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model);
|
||
|
|
CREATE INDEX IF NOT EXISTS idx_requests_endpoint ON requests(endpoint);
|
||
|
|
`
|
||
|
|
|
||
|
|
_, err := s.db.Exec(schema)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
return runMigrations(s.db, []string{
|
||
|
|
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS conversation_hash TEXT",
|
||
|
|
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS message_count INTEGER DEFAULT 0",
|
||
|
|
"CREATE INDEX IF NOT EXISTS idx_requests_conversation_hash ON requests(conversation_hash)",
|
||
|
|
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS organization_id TEXT",
|
||
|
|
"CREATE INDEX IF NOT EXISTS idx_requests_organization_id ON requests(organization_id)",
|
||
|
|
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
|
||
|
|
}, nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) prepareStatements() error {
|
||
|
|
var err error
|
||
|
|
|
||
|
|
s.stmtInsertRequest, err = s.db.Prepare(`
|
||
|
|
INSERT INTO requests (id, timestamp, method, endpoint, headers, body, user_agent, content_type, model, original_model, routed_model, conversation_hash, message_count)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||
|
|
`)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to prepare insert statement: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.stmtUpdateResponse, err = s.db.Prepare(`
|
||
|
|
UPDATE requests SET response = $1, organization_id = COALESCE(NULLIF($3, ''), organization_id) WHERE id = $2
|
||
|
|
`)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to prepare update response statement: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.stmtUpdateGrading, err = s.db.Prepare(`
|
||
|
|
UPDATE requests SET prompt_grade = $1 WHERE id = $2
|
||
|
|
`)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to prepare update grading statement: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.stmtGetRequestByID, err = s.db.Prepare(`
|
||
|
|
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model
|
||
|
|
FROM requests
|
||
|
|
WHERE id = $1
|
||
|
|
`)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to prepare get by ID statement: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.stmtGetRequestsPage, err = s.db.Prepare(`
|
||
|
|
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model
|
||
|
|
FROM requests
|
||
|
|
ORDER BY timestamp DESC
|
||
|
|
LIMIT $1 OFFSET $2
|
||
|
|
`)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to prepare get requests page statement: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.stmtGetRequestsCount, err = s.db.Prepare(`
|
||
|
|
SELECT COUNT(*) FROM requests
|
||
|
|
`)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to prepare count statement: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.stmtDeleteOldRequests, err = s.db.Prepare(`
|
||
|
|
DELETE FROM requests WHERE timestamp < $1
|
||
|
|
`)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to prepare delete old requests statement: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) SaveRequest(request *model.RequestLog) (string, error) {
|
||
|
|
headersJSON, err := json.Marshal(request.Headers)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("failed to marshal headers: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
bodyForStorage, err := prepareRequestBodyForStorage(s.config, request.Body)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("failed to prepare body for storage: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
bodyJSON, err := json.Marshal(bodyForStorage)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("failed to marshal body: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err = s.stmtInsertRequest.Exec(
|
||
|
|
request.RequestID,
|
||
|
|
request.Timestamp,
|
||
|
|
request.Method,
|
||
|
|
request.Endpoint,
|
||
|
|
string(headersJSON),
|
||
|
|
string(bodyJSON),
|
||
|
|
request.UserAgent,
|
||
|
|
request.ContentType,
|
||
|
|
request.Model,
|
||
|
|
request.OriginalModel,
|
||
|
|
request.RoutedModel,
|
||
|
|
request.ConversationHash,
|
||
|
|
request.MessageCount,
|
||
|
|
)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("failed to insert request: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.cleanupExpiredRequests(); err != nil {
|
||
|
|
s.logger.Printf("Warning: failed to apply retention policy: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return request.RequestID, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) GetRequests(page, limit int, modelFilter string) ([]model.RequestLog, int, error) {
|
||
|
|
whereClause := ""
|
||
|
|
countArgs := []interface{}{}
|
||
|
|
queryArgs := []interface{}{}
|
||
|
|
argIdx := 1
|
||
|
|
|
||
|
|
if filterValue, ok := modelFilterPattern(modelFilter, escapePostgresLikePattern); ok {
|
||
|
|
whereClause = fmt.Sprintf(" WHERE LOWER(model) LIKE $%d", argIdx)
|
||
|
|
countArgs = append(countArgs, filterValue)
|
||
|
|
queryArgs = append(queryArgs, filterValue)
|
||
|
|
argIdx++
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get total count
|
||
|
|
var total int
|
||
|
|
countQuery := "SELECT COUNT(*) FROM requests" + whereClause
|
||
|
|
err := s.db.QueryRow(countQuery, countArgs...).Scan(&total)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get paginated results
|
||
|
|
offset := (page - 1) * limit
|
||
|
|
query := fmt.Sprintf(`
|
||
|
|
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model
|
||
|
|
FROM requests%s
|
||
|
|
ORDER BY timestamp DESC
|
||
|
|
LIMIT $%d OFFSET $%d
|
||
|
|
`, whereClause, argIdx, argIdx+1)
|
||
|
|
queryArgs = append(queryArgs, limit, offset)
|
||
|
|
|
||
|
|
rows, err := s.db.Query(query, queryArgs...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("failed to query requests: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
requests, err := s.scanRequestRows(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
|
||
|
|
return requests, total, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) ClearRequests() (int, error) {
|
||
|
|
result, err := s.db.Exec("DELETE FROM requests")
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to clear requests: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
rowsAffected, err := result.RowsAffected()
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to get rows affected: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return int(rowsAffected), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) UpdateRequestWithGrading(requestID string, grade *model.PromptGrade) error {
|
||
|
|
gradeJSON, err := json.Marshal(grade)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to marshal grade: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
result, err := s.stmtUpdateGrading.Exec(string(gradeJSON), requestID)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to update request with grading: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
rowsAffected, _ := result.RowsAffected()
|
||
|
|
if rowsAffected == 0 {
|
||
|
|
return fmt.Errorf("request %s not found", requestID)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.cleanupExpiredRequests(); err != nil {
|
||
|
|
s.logger.Printf("Warning: failed to apply retention policy: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) UpdateRequestWithResponse(request *model.RequestLog) error {
|
||
|
|
responseForStorage, err := prepareResponseForStorage(s.config, s.logger, request.Response)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to prepare response for storage: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
responseJSON, err := json.Marshal(responseForStorage)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to marshal response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
orgID := request.OrganizationID
|
||
|
|
result, err := s.stmtUpdateResponse.Exec(string(responseJSON), request.RequestID, orgID)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to update request with response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
rowsAffected, _ := result.RowsAffected()
|
||
|
|
if rowsAffected == 0 {
|
||
|
|
return fmt.Errorf("request %s not found", request.RequestID)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) EnsureDirectoryExists() error {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) GetRequestByShortID(shortID string) (*model.RequestLog, string, error) {
|
||
|
|
escapedID := escapePostgresLikePattern(shortID)
|
||
|
|
|
||
|
|
query := `
|
||
|
|
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model
|
||
|
|
FROM requests
|
||
|
|
WHERE id LIKE $1
|
||
|
|
ORDER BY timestamp DESC
|
||
|
|
LIMIT 1
|
||
|
|
`
|
||
|
|
|
||
|
|
var req model.RequestLog
|
||
|
|
var headersJSON, bodyJSON string
|
||
|
|
var promptGradeJSON, responseJSON sql.NullString
|
||
|
|
var timestamp time.Time
|
||
|
|
|
||
|
|
err := s.db.QueryRow(query, "%"+escapedID).Scan(
|
||
|
|
&req.RequestID,
|
||
|
|
×tamp,
|
||
|
|
&req.Method,
|
||
|
|
&req.Endpoint,
|
||
|
|
&headersJSON,
|
||
|
|
&bodyJSON,
|
||
|
|
&req.Model,
|
||
|
|
&req.UserAgent,
|
||
|
|
&req.ContentType,
|
||
|
|
&promptGradeJSON,
|
||
|
|
&responseJSON,
|
||
|
|
&req.OriginalModel,
|
||
|
|
&req.RoutedModel,
|
||
|
|
)
|
||
|
|
|
||
|
|
if err == sql.ErrNoRows {
|
||
|
|
return nil, "", fmt.Errorf("request with ID %s not found", shortID)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, "", fmt.Errorf("failed to query request: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
req.Timestamp = timestamp.Format(time.RFC3339)
|
||
|
|
|
||
|
|
if err := unmarshalStoredRequestFields(s.logger, &req, headersJSON, bodyJSON, promptGradeJSON, responseJSON); err != nil {
|
||
|
|
return nil, "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
return &req, req.RequestID, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) GetConfig() *config.StorageConfig {
|
||
|
|
return s.config
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) GetAllRequests(modelFilter string) ([]*model.RequestLog, error) {
|
||
|
|
return s.getAllRequestsWithLimit(modelFilter, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) getAllRequestsWithLimit(modelFilter string, limit int) ([]*model.RequestLog, error) {
|
||
|
|
var query string
|
||
|
|
args := []interface{}{}
|
||
|
|
argIdx := 1
|
||
|
|
|
||
|
|
if filterValue, ok := modelFilterPattern(modelFilter, escapePostgresLikePattern); ok {
|
||
|
|
query = fmt.Sprintf(`
|
||
|
|
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model
|
||
|
|
FROM requests
|
||
|
|
WHERE LOWER(model) LIKE $%d
|
||
|
|
ORDER BY timestamp DESC
|
||
|
|
`, argIdx)
|
||
|
|
args = append(args, filterValue)
|
||
|
|
argIdx++
|
||
|
|
} else {
|
||
|
|
query = `
|
||
|
|
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response, original_model, routed_model
|
||
|
|
FROM requests
|
||
|
|
ORDER BY timestamp DESC
|
||
|
|
`
|
||
|
|
}
|
||
|
|
|
||
|
|
if limit > 0 {
|
||
|
|
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
||
|
|
args = append(args, limit)
|
||
|
|
}
|
||
|
|
|
||
|
|
rows, err := s.db.Query(query, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to query requests: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var requests []*model.RequestLog
|
||
|
|
for rows.Next() {
|
||
|
|
req, err := s.scanSingleRow(rows)
|
||
|
|
if err != nil {
|
||
|
|
s.logger.Printf("Warning: failed to scan request row: %v", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
requests = append(requests, req)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return requests, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) DeleteRequestsOlderThan(age time.Duration) (int, error) {
|
||
|
|
cutoff := time.Now().Add(-age)
|
||
|
|
|
||
|
|
result, err := s.stmtDeleteOldRequests.Exec(cutoff.Format(time.RFC3339))
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to delete old requests: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
rowsAffected, err := result.RowsAffected()
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to get rows affected: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return int(rowsAffected), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) GetDatabaseStats() (map[string]interface{}, error) {
|
||
|
|
stats := make(map[string]interface{})
|
||
|
|
|
||
|
|
// Get row count
|
||
|
|
var count int
|
||
|
|
err := s.stmtGetRequestsCount.QueryRow().Scan(&count)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to get count: %w", err)
|
||
|
|
}
|
||
|
|
stats["total_requests"] = count
|
||
|
|
|
||
|
|
// Get database size
|
||
|
|
var dbSize int64
|
||
|
|
err = s.db.QueryRow("SELECT pg_database_size(current_database())").Scan(&dbSize)
|
||
|
|
if err == nil {
|
||
|
|
stats["database_size_bytes"] = dbSize
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get oldest and newest timestamps
|
||
|
|
var oldest, newest sql.NullTime
|
||
|
|
err = s.db.QueryRow("SELECT MIN(timestamp), MAX(timestamp) FROM requests").Scan(&oldest, &newest)
|
||
|
|
if err == nil {
|
||
|
|
if oldest.Valid {
|
||
|
|
stats["oldest_request"] = oldest.Time.Format(time.RFC3339)
|
||
|
|
}
|
||
|
|
if newest.Valid {
|
||
|
|
stats["newest_request"] = newest.Time.Format(time.RFC3339)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return stats, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) Close() error {
|
||
|
|
if s.stmtInsertRequest != nil {
|
||
|
|
s.stmtInsertRequest.Close()
|
||
|
|
}
|
||
|
|
if s.stmtUpdateResponse != nil {
|
||
|
|
s.stmtUpdateResponse.Close()
|
||
|
|
}
|
||
|
|
if s.stmtUpdateGrading != nil {
|
||
|
|
s.stmtUpdateGrading.Close()
|
||
|
|
}
|
||
|
|
if s.stmtGetRequestByID != nil {
|
||
|
|
s.stmtGetRequestByID.Close()
|
||
|
|
}
|
||
|
|
if s.stmtGetRequestsPage != nil {
|
||
|
|
s.stmtGetRequestsPage.Close()
|
||
|
|
}
|
||
|
|
if s.stmtGetRequestsCount != nil {
|
||
|
|
s.stmtGetRequestsCount.Close()
|
||
|
|
}
|
||
|
|
if s.stmtDeleteOldRequests != nil {
|
||
|
|
s.stmtDeleteOldRequests.Close()
|
||
|
|
}
|
||
|
|
|
||
|
|
return s.db.Close()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper functions
|
||
|
|
|
||
|
|
// escapePostgresLikePattern escapes special characters in LIKE patterns for PostgreSQL
|
||
|
|
func escapePostgresLikePattern(s string) string {
|
||
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||
|
|
s = strings.ReplaceAll(s, `%`, `\%`)
|
||
|
|
s = strings.ReplaceAll(s, `_`, `\_`)
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) scanRequestRows(rows *sql.Rows) ([]model.RequestLog, error) {
|
||
|
|
var requests []model.RequestLog
|
||
|
|
|
||
|
|
for rows.Next() {
|
||
|
|
var req model.RequestLog
|
||
|
|
var headersJSON, bodyJSON string
|
||
|
|
var promptGradeJSON, responseJSON sql.NullString
|
||
|
|
var timestamp time.Time
|
||
|
|
|
||
|
|
err := rows.Scan(
|
||
|
|
&req.RequestID,
|
||
|
|
×tamp,
|
||
|
|
&req.Method,
|
||
|
|
&req.Endpoint,
|
||
|
|
&headersJSON,
|
||
|
|
&bodyJSON,
|
||
|
|
&req.Model,
|
||
|
|
&req.UserAgent,
|
||
|
|
&req.ContentType,
|
||
|
|
&promptGradeJSON,
|
||
|
|
&responseJSON,
|
||
|
|
&req.OriginalModel,
|
||
|
|
&req.RoutedModel,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
s.logger.Printf("Warning: failed to scan row: %v", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
req.Timestamp = timestamp.Format(time.RFC3339)
|
||
|
|
|
||
|
|
if err := unmarshalStoredRequestFields(s.logger, &req, headersJSON, bodyJSON, promptGradeJSON, responseJSON); err != nil {
|
||
|
|
s.logger.Printf("Warning: failed to unmarshal request fields: %v", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
requests = append(requests, req)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return requests, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) scanSingleRow(rows *sql.Rows) (*model.RequestLog, error) {
|
||
|
|
var req model.RequestLog
|
||
|
|
var headersJSON, bodyJSON string
|
||
|
|
var promptGradeJSON, responseJSON sql.NullString
|
||
|
|
var timestamp time.Time
|
||
|
|
|
||
|
|
err := rows.Scan(
|
||
|
|
&req.RequestID,
|
||
|
|
×tamp,
|
||
|
|
&req.Method,
|
||
|
|
&req.Endpoint,
|
||
|
|
&headersJSON,
|
||
|
|
&bodyJSON,
|
||
|
|
&req.Model,
|
||
|
|
&req.UserAgent,
|
||
|
|
&req.ContentType,
|
||
|
|
&promptGradeJSON,
|
||
|
|
&responseJSON,
|
||
|
|
&req.OriginalModel,
|
||
|
|
&req.RoutedModel,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to scan row: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
req.Timestamp = timestamp.Format(time.RFC3339)
|
||
|
|
|
||
|
|
if err := unmarshalStoredRequestFields(s.logger, &req, headersJSON, bodyJSON, promptGradeJSON, responseJSON); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
return &req, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) cleanupExpiredRequests() error {
|
||
|
|
if s.config == nil || s.config.RetentionDays <= 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err := s.DeleteRequestsOlderThan(time.Duration(s.config.RetentionDays) * 24 * time.Hour)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetUsageStats returns aggregated token usage statistics
|
||
|
|
func (s *postgresStorageService) GetUsageStats(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) {
|
||
|
|
stats := &model.UsageStats{
|
||
|
|
RequestsByModel: make(map[string]model.ModelStats),
|
||
|
|
}
|
||
|
|
|
||
|
|
whereClause := "WHERE response IS NOT NULL"
|
||
|
|
args := []interface{}{}
|
||
|
|
argIdx := 1
|
||
|
|
|
||
|
|
if startDate != "" {
|
||
|
|
whereClause += fmt.Sprintf(" AND timestamp >= $%d", argIdx)
|
||
|
|
args = append(args, startDate)
|
||
|
|
argIdx++
|
||
|
|
stats.StartDate = startDate
|
||
|
|
}
|
||
|
|
|
||
|
|
if endDate != "" {
|
||
|
|
whereClause += fmt.Sprintf(" AND timestamp <= $%d", argIdx)
|
||
|
|
args = append(args, endDate)
|
||
|
|
argIdx++
|
||
|
|
stats.EndDate = endDate
|
||
|
|
}
|
||
|
|
|
||
|
|
if filterValue, ok := modelFilterPattern(modelFilter, escapePostgresLikePattern); ok {
|
||
|
|
whereClause += fmt.Sprintf(" AND LOWER(model) LIKE $%d", argIdx)
|
||
|
|
args = append(args, filterValue)
|
||
|
|
argIdx++
|
||
|
|
}
|
||
|
|
|
||
|
|
if orgFilter != "" {
|
||
|
|
whereClause += fmt.Sprintf(" AND organization_id = $%d", argIdx)
|
||
|
|
args = append(args, orgFilter)
|
||
|
|
argIdx++
|
||
|
|
}
|
||
|
|
|
||
|
|
query := `
|
||
|
|
SELECT model, response
|
||
|
|
FROM requests
|
||
|
|
` + whereClause
|
||
|
|
|
||
|
|
rows, err := s.db.Query(query, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to query usage stats: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
for rows.Next() {
|
||
|
|
var modelName string
|
||
|
|
var responseJSON sql.NullString
|
||
|
|
|
||
|
|
if err := rows.Scan(&modelName, &responseJSON); err != nil {
|
||
|
|
s.logger.Printf("Warning: failed to scan usage row: %v", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, ok := decodeStoredResponse(responseJSON)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
bodySummary, ok := decodeResponseBodySummary(resp.Body)
|
||
|
|
if !ok || bodySummary.Usage == nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
addUsageStats(stats, modelName, bodySummary.Usage)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, fmt.Errorf("error iterating usage rows: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if stats.StartDate == "" || stats.EndDate == "" {
|
||
|
|
var oldest, newest sql.NullTime
|
||
|
|
err := s.db.QueryRow("SELECT MIN(timestamp), MAX(timestamp) FROM requests WHERE response IS NOT NULL").Scan(&oldest, &newest)
|
||
|
|
if err == nil {
|
||
|
|
if stats.StartDate == "" && oldest.Valid {
|
||
|
|
stats.StartDate = oldest.Time.Format(time.RFC3339)
|
||
|
|
}
|
||
|
|
if stats.EndDate == "" && newest.Valid {
|
||
|
|
stats.EndDate = newest.Time.Format(time.RFC3339)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return stats, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetRequestsSummary returns minimal data for list view
|
||
|
|
func (s *postgresStorageService) GetRequestsSummary(modelFilter string) ([]*model.RequestSummary, error) {
|
||
|
|
query := `
|
||
|
|
SELECT id, timestamp, method, endpoint, model, original_model, routed_model, response, COALESCE(conversation_hash, ''), COALESCE(message_count, 0)
|
||
|
|
FROM requests
|
||
|
|
`
|
||
|
|
args := []interface{}{}
|
||
|
|
|
||
|
|
if filterValue, ok := modelFilterPattern(modelFilter, escapePostgresLikePattern); ok {
|
||
|
|
query += " WHERE LOWER(model) LIKE $1"
|
||
|
|
args = append(args, filterValue)
|
||
|
|
}
|
||
|
|
|
||
|
|
query += " ORDER BY timestamp DESC"
|
||
|
|
|
||
|
|
rows, err := s.db.Query(query, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to query requests: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
return s.scanSummaryRows(rows)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetRequestsSummaryPaginated returns minimal data for list view with pagination
|
||
|
|
func (s *postgresStorageService) GetRequestsSummaryPaginated(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error) {
|
||
|
|
whereClauses := []string{}
|
||
|
|
args := []interface{}{}
|
||
|
|
argIdx := 1
|
||
|
|
|
||
|
|
if filterValue, ok := modelFilterPattern(modelFilter, escapePostgresLikePattern); ok {
|
||
|
|
whereClauses = append(whereClauses, fmt.Sprintf("LOWER(model) LIKE $%d", argIdx))
|
||
|
|
args = append(args, filterValue)
|
||
|
|
argIdx++
|
||
|
|
}
|
||
|
|
|
||
|
|
if startTime != "" && endTime != "" {
|
||
|
|
whereClauses = append(whereClauses, fmt.Sprintf("timestamp >= $%d AND timestamp <= $%d", argIdx, argIdx+1))
|
||
|
|
args = append(args, startTime, endTime)
|
||
|
|
argIdx += 2
|
||
|
|
}
|
||
|
|
|
||
|
|
whereClause := ""
|
||
|
|
if len(whereClauses) > 0 {
|
||
|
|
whereClause = " WHERE " + strings.Join(whereClauses, " AND ")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get total count
|
||
|
|
var total int
|
||
|
|
countQuery := "SELECT COUNT(*) FROM requests" + whereClause
|
||
|
|
countArgs := make([]interface{}, len(args))
|
||
|
|
copy(countArgs, args)
|
||
|
|
if err := s.db.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get the requested page
|
||
|
|
query := `
|
||
|
|
SELECT id, timestamp, method, endpoint, model, original_model, routed_model, response, COALESCE(conversation_hash, ''), COALESCE(message_count, 0)
|
||
|
|
FROM requests
|
||
|
|
` + whereClause + " ORDER BY timestamp DESC"
|
||
|
|
|
||
|
|
if limit > 0 {
|
||
|
|
query += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||
|
|
args = append(args, limit, offset)
|
||
|
|
} else if offset > 0 {
|
||
|
|
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
||
|
|
args = append(args, offset)
|
||
|
|
}
|
||
|
|
|
||
|
|
rows, err := s.db.Query(query, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("failed to query requests: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
summaries, err := s.scanSummaryRows(rows)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
|
||
|
|
s.logger.Printf("GetRequestsSummaryPaginated: returned %d requests (total: %d, limit: %d, offset: %d)", len(summaries), total, limit, offset)
|
||
|
|
return summaries, total, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) scanSummaryRows(rows *sql.Rows) ([]*model.RequestSummary, error) {
|
||
|
|
var summaries []*model.RequestSummary
|
||
|
|
for rows.Next() {
|
||
|
|
var summary model.RequestSummary
|
||
|
|
var responseJSON sql.NullString
|
||
|
|
var timestamp time.Time
|
||
|
|
|
||
|
|
err := rows.Scan(
|
||
|
|
&summary.RequestID,
|
||
|
|
×tamp,
|
||
|
|
&summary.Method,
|
||
|
|
&summary.Endpoint,
|
||
|
|
&summary.Model,
|
||
|
|
&summary.OriginalModel,
|
||
|
|
&summary.RoutedModel,
|
||
|
|
&responseJSON,
|
||
|
|
&summary.ConversationHash,
|
||
|
|
&summary.MessageCount,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
s.logger.Printf("Warning: failed to scan summary row: %v", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
summary.Timestamp = timestamp.Format(time.RFC3339)
|
||
|
|
|
||
|
|
applyStoredResponseToSummary(&summary, responseJSON)
|
||
|
|
|
||
|
|
summaries = append(summaries, &summary)
|
||
|
|
}
|
||
|
|
return summaries, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetStats returns aggregated statistics for the dashboard
|
||
|
|
func (s *postgresStorageService) GetStats(startDate, endDate, orgFilter string) (*model.DashboardStats, error) {
|
||
|
|
stats := &model.DashboardStats{
|
||
|
|
DailyStats: make([]model.DailyTokens, 0),
|
||
|
|
}
|
||
|
|
|
||
|
|
query := `
|
||
|
|
SELECT timestamp, COALESCE(model, 'unknown') as model, response
|
||
|
|
FROM requests
|
||
|
|
WHERE timestamp >= $1 AND timestamp <= $2
|
||
|
|
`
|
||
|
|
args := []interface{}{startDate, endDate}
|
||
|
|
if orgFilter != "" {
|
||
|
|
query += ` AND organization_id = $3`
|
||
|
|
args = append(args, orgFilter)
|
||
|
|
}
|
||
|
|
query += ` ORDER BY timestamp`
|
||
|
|
|
||
|
|
rows, err := s.db.Query(query, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to query stats: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
dailyMap := make(map[string]*model.DailyTokens)
|
||
|
|
|
||
|
|
for rows.Next() {
|
||
|
|
var timestamp time.Time
|
||
|
|
var modelName string
|
||
|
|
var responseJSON sql.NullString
|
||
|
|
|
||
|
|
if err := rows.Scan(×tamp, &modelName, &responseJSON); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
date := timestamp.Format("2006-01-02")
|
||
|
|
|
||
|
|
tokens := int64(0)
|
||
|
|
if resp, ok := decodeStoredResponse(responseJSON); ok {
|
||
|
|
if bodySummary, ok := decodeResponseBodySummary(resp.Body); ok {
|
||
|
|
tokens = totalTokensFromUsage(bodySummary.Usage)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
addDailyTokens(dailyMap, date, modelName, tokens)
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, v := range dailyMap {
|
||
|
|
stats.DailyStats = append(stats.DailyStats, *v)
|
||
|
|
}
|
||
|
|
|
||
|
|
return stats, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetHourlyStats returns time-bucketed breakdown for a specific time range.
|
||
|
|
// bucketMinutes controls the granularity (e.g. 5, 15, 30, 60).
|
||
|
|
func (s *postgresStorageService) GetHourlyStats(startTime, endTime string, bucketMinutes int, orgFilter string) (*model.HourlyStatsResponse, error) {
|
||
|
|
if bucketMinutes <= 0 {
|
||
|
|
bucketMinutes = 60
|
||
|
|
}
|
||
|
|
|
||
|
|
query := `
|
||
|
|
SELECT timestamp, COALESCE(model, 'unknown') as model, response
|
||
|
|
FROM requests
|
||
|
|
WHERE timestamp >= $1 AND timestamp <= $2
|
||
|
|
`
|
||
|
|
args := []interface{}{startTime, endTime}
|
||
|
|
if orgFilter != "" {
|
||
|
|
query += ` AND organization_id = $3`
|
||
|
|
args = append(args, orgFilter)
|
||
|
|
}
|
||
|
|
query += ` ORDER BY timestamp`
|
||
|
|
|
||
|
|
rows, err := s.db.Query(query, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to query hourly stats: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
bucketMap := make(map[string]*model.HourlyTokens)
|
||
|
|
var totalTokens int64
|
||
|
|
var totalRequests int
|
||
|
|
var totalResponseTime int64
|
||
|
|
var responseCount int
|
||
|
|
|
||
|
|
for rows.Next() {
|
||
|
|
var timestamp time.Time
|
||
|
|
var modelName string
|
||
|
|
var responseJSON sql.NullString
|
||
|
|
|
||
|
|
if err := rows.Scan(×tamp, &modelName, &responseJSON); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
// Always use absolute time buckets so multi-day ranges show per-slot data
|
||
|
|
var bucketKey, bucketLabel string
|
||
|
|
minuteOfDay := timestamp.Hour()*60 + timestamp.Minute()
|
||
|
|
bucketStart := (minuteOfDay / bucketMinutes) * bucketMinutes
|
||
|
|
bucketTime := time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(), bucketStart/60, bucketStart%60, 0, 0, timestamp.Location())
|
||
|
|
bucketKey = bucketTime.Format("2006-01-02T15:04")
|
||
|
|
bucketLabel = bucketTime.Format("Jan 2 15:04")
|
||
|
|
|
||
|
|
tokens := int64(0)
|
||
|
|
responseTime := int64(0)
|
||
|
|
if resp, ok := decodeStoredResponse(responseJSON); ok {
|
||
|
|
responseTime = resp.ResponseTime
|
||
|
|
if bodySummary, ok := decodeResponseBodySummary(resp.Body); ok {
|
||
|
|
tokens = totalTokensFromUsage(bodySummary.Usage)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
totalTokens += tokens
|
||
|
|
totalRequests++
|
||
|
|
|
||
|
|
if responseTime > 0 {
|
||
|
|
totalResponseTime += responseTime
|
||
|
|
responseCount++
|
||
|
|
}
|
||
|
|
|
||
|
|
addHourlyTokens(bucketMap, bucketKey, bucketLabel, modelName, tokens)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Convert map to sorted slice
|
||
|
|
keys := make([]string, 0, len(bucketMap))
|
||
|
|
for k := range bucketMap {
|
||
|
|
keys = append(keys, k)
|
||
|
|
}
|
||
|
|
sort.Strings(keys)
|
||
|
|
|
||
|
|
hourlyStats := make([]model.HourlyTokens, 0, len(keys))
|
||
|
|
for _, k := range keys {
|
||
|
|
hourlyStats = append(hourlyStats, *bucketMap[k])
|
||
|
|
}
|
||
|
|
|
||
|
|
avgResponseTime := int64(0)
|
||
|
|
if responseCount > 0 {
|
||
|
|
avgResponseTime = totalResponseTime / int64(responseCount)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &model.HourlyStatsResponse{
|
||
|
|
HourlyStats: hourlyStats,
|
||
|
|
TodayTokens: totalTokens,
|
||
|
|
TodayRequests: totalRequests,
|
||
|
|
AvgResponseTime: avgResponseTime,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetModelStats returns model breakdown for a specific time range
|
||
|
|
func (s *postgresStorageService) GetModelStats(startTime, endTime, orgFilter string) (*model.ModelStatsResponse, error) {
|
||
|
|
query := `
|
||
|
|
SELECT COALESCE(model, 'unknown') as model, response
|
||
|
|
FROM requests
|
||
|
|
WHERE timestamp >= $1 AND timestamp <= $2
|
||
|
|
`
|
||
|
|
args := []interface{}{startTime, endTime}
|
||
|
|
if orgFilter != "" {
|
||
|
|
query += ` AND organization_id = $3`
|
||
|
|
args = append(args, orgFilter)
|
||
|
|
}
|
||
|
|
|
||
|
|
rows, err := s.db.Query(query, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to query model stats: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
modelMap := make(map[string]*model.ModelTokens)
|
||
|
|
|
||
|
|
for rows.Next() {
|
||
|
|
var modelName string
|
||
|
|
var responseJSON sql.NullString
|
||
|
|
|
||
|
|
if err := rows.Scan(&modelName, &responseJSON); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
tokens := int64(0)
|
||
|
|
if resp, ok := decodeStoredResponse(responseJSON); ok {
|
||
|
|
if bodySummary, ok := decodeResponseBodySummary(resp.Body); ok {
|
||
|
|
tokens = totalTokensFromUsage(bodySummary.Usage)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
addModelTokens(modelMap, modelName, tokens)
|
||
|
|
}
|
||
|
|
|
||
|
|
modelStats := make([]model.ModelTokens, 0)
|
||
|
|
for _, v := range modelMap {
|
||
|
|
modelStats = append(modelStats, *v)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &model.ModelStatsResponse{
|
||
|
|
ModelStats: modelStats,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetLatestRequestDate returns the timestamp of the most recent request
|
||
|
|
func (s *postgresStorageService) GetLatestRequestDate() (*time.Time, error) {
|
||
|
|
var timestamp sql.NullTime
|
||
|
|
err := s.db.QueryRow("SELECT timestamp FROM requests ORDER BY timestamp DESC LIMIT 1").Scan(×tamp)
|
||
|
|
if err == sql.ErrNoRows || !timestamp.Valid {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to query latest request: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
t := timestamp.Time
|
||
|
|
return &t, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) GetSettings() (*model.ProxySettings, error) {
|
||
|
|
var value string
|
||
|
|
err := s.db.QueryRow("SELECT value FROM settings WHERE key = 'proxy_settings'").Scan(&value)
|
||
|
|
if err == sql.ErrNoRows {
|
||
|
|
return &model.ProxySettings{}, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to get settings: %w", err)
|
||
|
|
}
|
||
|
|
var settings model.ProxySettings
|
||
|
|
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to parse settings: %w", err)
|
||
|
|
}
|
||
|
|
return &settings, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) SaveSettings(settings *model.ProxySettings) error {
|
||
|
|
data, err := json.Marshal(settings)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to marshal settings: %w", err)
|
||
|
|
}
|
||
|
|
_, err = s.db.Exec(
|
||
|
|
"INSERT INTO settings (key, value) VALUES ('proxy_settings', $1) ON CONFLICT (key) DO UPDATE SET value = $1",
|
||
|
|
string(data),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to save settings: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *postgresStorageService) GetDistinctOrganizations() ([]string, error) {
|
||
|
|
rows, err := s.db.Query(`SELECT DISTINCT organization_id FROM requests WHERE organization_id IS NOT NULL AND organization_id != '' ORDER BY organization_id`)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to query organizations: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var orgs []string
|
||
|
|
for rows.Next() {
|
||
|
|
var org string
|
||
|
|
if err := rows.Scan(&org); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
orgs = append(orgs, org)
|
||
|
|
}
|
||
|
|
return orgs, nil
|
||
|
|
}
|