mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Phase 0 cleanup verified end-to-end:
- Delete vendored APwhy runtime (cmd/apwhy, internal/apwhy) and the
unwired copy (internal/api/server.go + static bundle + cloned
internal/{storage,analytics,auth,rbac}) — ~7.5k lines of dead code.
- Keep Containr's native internal/gateway; move APwhy management
routes under /api/v1/gateway/* so they stop colliding with the
native /services paths. Fix handlers against the real schema:
UUID ids via RETURNING, integer enabled flags, plan validation,
service validate endpoint, unique-violation mapping.
- Port agents.go off GORM onto sqlc (sqlc/queries/agents.sql);
drop gorm dependencies; fix sqlc schema drift (services.environment_id).
- Replace the SELECT 1 goose baseline with the real consolidated
schema (52 tables) minus APwhy's parallel auth/RBAC objects;
idempotent via IF NOT EXISTS / OR REPLACE / guarded DO blocks so it
replays cleanly over legacy installs. Add a drop migration for the
dead objects and delete the legacy filename-tracked migrations/
loader, directory, and Dockerfile copy. Verified via cmd/migrate:
fresh install 53 tables, legacy upgrade clean to 20260917000000.
- Canvas: add invisible React Flow handles to ServiceNode so inferred
auto-connections stop emitting warning #008.
- Remove reactflow dep (superseded by @xyflow/react), test harness
cmds, and legacy 0001_init.sql backups.
Authored By: TDvorak <[email protected]>
740 lines
20 KiB
Go
740 lines
20 KiB
Go
package api
|
|
|
|
import (
|
|
"containr/internal/database"
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/lib/pq"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Default quota limits applied when the request omits them.
|
|
const (
|
|
DefaultRPMLimit = 60
|
|
DefaultMonthlyQuota = 1000
|
|
)
|
|
|
|
// Validator instance for request validation
|
|
var validate = validator.New()
|
|
|
|
// APIError represents a structured API error response
|
|
type APIError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Details interface{} `json:"details,omitempty"`
|
|
}
|
|
|
|
// APIResponse represents a standardized API response
|
|
type APIResponse struct {
|
|
Success bool `json:"success"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
Error *APIError `json:"error,omitempty"`
|
|
Meta *Meta `json:"meta,omitempty"`
|
|
}
|
|
|
|
// Meta contains pagination and metadata
|
|
type Meta struct {
|
|
Page int `json:"page,omitempty"`
|
|
PerPage int `json:"per_page,omitempty"`
|
|
Total int `json:"total,omitempty"`
|
|
TotalPages int `json:"total_pages,omitempty"`
|
|
}
|
|
|
|
// ServiceRequest represents the request payload for creating/updating services
|
|
type ServiceRequest struct {
|
|
Name string `json:"name" binding:"required,max=100" validate:"required,min=1,max=100"`
|
|
UpstreamURL string `json:"upstreamUrl" binding:"required,max=500" validate:"required,url,max=500"`
|
|
RoutePrefix string `json:"routePrefix" binding:"required,max=200" validate:"required,min=1,max=200"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
RPMLimit *int `json:"rpmLimit,omitempty" validate:"omitempty,min=1,max=10000"`
|
|
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
|
|
}
|
|
|
|
// APIKeyRequest represents the request payload for creating/updating API keys
|
|
type APIKeyRequest struct {
|
|
Name string `json:"name" binding:"required,max=100" validate:"required,min=1,max=100"`
|
|
Plan string `json:"plan" validate:"omitempty,oneof=free pro business enterprise"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
RPMLimit *int `json:"rpmLimit,omitempty" validate:"omitempty,min=1,max=10000"`
|
|
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
|
|
}
|
|
|
|
// generateAPIKey generates a cryptographically secure API key
|
|
func generateAPIKey() (string, error) {
|
|
bytes := make([]byte, 32)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
|
}
|
|
return "ap_" + hex.EncodeToString(bytes), nil
|
|
}
|
|
|
|
// hashAPIKey creates a bcrypt hash for the API key
|
|
func hashAPIKey(key string) (string, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to hash API key: %w", err)
|
|
}
|
|
return string(hash), nil
|
|
}
|
|
|
|
// validateAPIKeyPlan validates and returns default values for API key plans
|
|
func validateAPIKeyPlan(plan string) (string, int, int, error) {
|
|
if plan == "" {
|
|
plan = "free"
|
|
}
|
|
|
|
switch plan {
|
|
case "free":
|
|
return plan, 60, 1000, nil
|
|
case "pro":
|
|
return plan, 600, 50000, nil
|
|
case "business":
|
|
return plan, 3000, 300000, nil
|
|
case "enterprise":
|
|
return plan, 10000, 10000000, nil
|
|
default:
|
|
return "", 0, 0, fmt.Errorf("invalid plan: %s", plan)
|
|
}
|
|
}
|
|
|
|
// sendJSONResponse sends a standardized JSON response
|
|
func sendJSONResponse(c *gin.Context, statusCode int, response APIResponse) {
|
|
c.JSON(statusCode, response)
|
|
}
|
|
|
|
// sendErrorResponse sends a standardized error response
|
|
func sendErrorResponse(c *gin.Context, statusCode int, code, message string, details interface{}) {
|
|
response := APIResponse{
|
|
Success: false,
|
|
Error: &APIError{
|
|
Code: code,
|
|
Message: message,
|
|
Details: details,
|
|
},
|
|
}
|
|
sendJSONResponse(c, statusCode, response)
|
|
}
|
|
|
|
// sendSuccessResponse sends a standardized success response
|
|
func sendSuccessResponse(c *gin.Context, statusCode int, data interface{}) {
|
|
response := APIResponse{
|
|
Success: true,
|
|
Data: data,
|
|
}
|
|
sendJSONResponse(c, statusCode, response)
|
|
}
|
|
|
|
// validateRequest validates the request payload using the validator
|
|
func validateRequest(c *gin.Context, req interface{}) error {
|
|
if err := c.ShouldBindJSON(req); err != nil {
|
|
return fmt.Errorf("invalid request body: %w", err)
|
|
}
|
|
|
|
if err := validate.Struct(req); err != nil {
|
|
return fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isUniqueViolation reports whether err is a Postgres unique-constraint violation.
|
|
func isUniqueViolation(err error) bool {
|
|
var pqErr *pq.Error
|
|
return errors.As(err, &pqErr) && pqErr.Code == "23505"
|
|
}
|
|
|
|
// handleAPwhyServiceValidate probes the upstream health path and records the
|
|
// result on the service row. Ported from the standalone APwhy server.
|
|
func handleAPwhyServiceValidate(c *gin.Context) {
|
|
serviceID := c.Param("id")
|
|
db := c.MustGet("db").(*database.DB)
|
|
ctx := c.Request.Context()
|
|
|
|
var upstreamURL, healthPath string
|
|
var authHeader, authValue sql.NullString
|
|
var timeoutMs sql.NullInt64
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
|
|
FROM api_services WHERE id = $1
|
|
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
|
|
if err != nil {
|
|
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
|
|
return
|
|
}
|
|
|
|
timeout := 8000
|
|
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
|
|
timeout = int(timeoutMs.Int64)
|
|
}
|
|
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
|
|
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
|
if authHeader.Valid && authHeader.String != "" {
|
|
req.Header.Set(authHeader.String, authValue.String)
|
|
}
|
|
|
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond}
|
|
res, probeErr := client.Do(req)
|
|
|
|
status := http.StatusBadGateway
|
|
message := ""
|
|
ok := false
|
|
if probeErr != nil {
|
|
message = probeErr.Error()
|
|
} else {
|
|
defer res.Body.Close()
|
|
status = res.StatusCode
|
|
message = fmt.Sprintf("HTTP %d", res.StatusCode)
|
|
ok = res.StatusCode >= 200 && res.StatusCode < 300
|
|
}
|
|
|
|
validationStatus := "failed"
|
|
if ok {
|
|
validationStatus = "healthy"
|
|
}
|
|
|
|
_, _ = db.ExecContext(ctx, `
|
|
UPDATE api_services
|
|
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
|
|
WHERE id = $3
|
|
`, validationStatus, message, serviceID)
|
|
|
|
if !ok {
|
|
_, _ = db.ExecContext(ctx, `
|
|
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
|
|
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
|
|
`, serviceID, message, status)
|
|
}
|
|
|
|
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
|
|
"validation": map[string]interface{}{
|
|
"ok": ok,
|
|
"status": status,
|
|
"message": message,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleAPwhyServicesList returns a list of API services
|
|
func handleAPwhyServicesList(c *gin.Context) {
|
|
db := c.MustGet("db").(*database.DB)
|
|
ctx := context.Background()
|
|
|
|
// Query services from database
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
|
|
FROM api_services
|
|
ORDER BY created_at DESC
|
|
`)
|
|
if err != nil {
|
|
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
|
"Failed to query services", err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
services := make([]map[string]interface{}, 0)
|
|
for rows.Next() {
|
|
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
|
|
var enabled int
|
|
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
|
|
if err != nil {
|
|
continue // Skip malformed rows
|
|
}
|
|
|
|
services = append(services, map[string]interface{}{
|
|
"id": id,
|
|
"name": name,
|
|
"slug": slug,
|
|
"upstreamUrl": upstreamURL,
|
|
"routePrefix": routePrefix,
|
|
"enabled": enabled != 0,
|
|
"createdAt": createdAt,
|
|
"updatedAt": updatedAt,
|
|
})
|
|
}
|
|
|
|
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
|
|
"services": services,
|
|
"count": len(services),
|
|
})
|
|
}
|
|
|
|
// handleAPwhyServicesCreate creates a new API service
|
|
func handleAPwhyServicesCreate(c *gin.Context) {
|
|
var req ServiceRequest
|
|
|
|
if err := validateRequest(c, &req); err != nil {
|
|
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
|
|
"Invalid request parameters", err.Error())
|
|
return
|
|
}
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
ctx := context.Background()
|
|
|
|
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
|
|
|
|
var rpmLimit, monthlyQuota int
|
|
if req.RPMLimit != nil {
|
|
rpmLimit = *req.RPMLimit
|
|
} else {
|
|
rpmLimit = DefaultRPMLimit
|
|
}
|
|
|
|
if req.MonthlyQuota != nil {
|
|
monthlyQuota = *req.MonthlyQuota
|
|
} else {
|
|
monthlyQuota = DefaultMonthlyQuota
|
|
}
|
|
|
|
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
|
var id string
|
|
err := db.QueryRowContext(ctx, `
|
|
INSERT INTO api_services (
|
|
name, slug, upstream_url, route_prefix, health_path,
|
|
rpm_limit, monthly_quota, created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
|
|
RETURNING id
|
|
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
|
|
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
sendErrorResponse(c, http.StatusConflict, "CONFLICT",
|
|
"A service with this slug or route prefix already exists", err.Error())
|
|
return
|
|
}
|
|
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
|
"Failed to create service", err.Error())
|
|
return
|
|
}
|
|
|
|
serviceData := map[string]interface{}{
|
|
"id": id,
|
|
"name": req.Name,
|
|
"slug": slug,
|
|
"upstreamUrl": req.UpstreamURL,
|
|
"routePrefix": req.RoutePrefix,
|
|
"enabled": true,
|
|
"rpmLimit": rpmLimit,
|
|
"monthlyQuota": monthlyQuota,
|
|
"createdAt": time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
sendSuccessResponse(c, http.StatusCreated, map[string]interface{}{
|
|
"service": serviceData,
|
|
"message": "Service created successfully",
|
|
})
|
|
}
|
|
|
|
// handleAPwhyServicesPatch updates an existing API service
|
|
func handleAPwhyServicesPatch(c *gin.Context) {
|
|
serviceID := c.Param("id")
|
|
|
|
var input struct {
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"ok": false,
|
|
"error": "Invalid input: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
|
|
if input.Enabled != nil {
|
|
enabled := 0
|
|
if *input.Enabled {
|
|
enabled = 1
|
|
}
|
|
_, err := db.ExecContext(context.Background(),
|
|
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
|
enabled, serviceID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"ok": false,
|
|
"error": "Failed to update service: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"ok": true,
|
|
"data": gin.H{"id": serviceID, "updated": true},
|
|
})
|
|
}
|
|
|
|
// handleAPwhyKeysList returns a list of API keys
|
|
func handleAPwhyKeysList(c *gin.Context) {
|
|
db := c.MustGet("db").(*database.DB)
|
|
|
|
rows, err := db.QueryContext(context.Background(), `
|
|
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
|
|
FROM api_keys
|
|
ORDER BY created_at DESC
|
|
`)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"ok": true,
|
|
"data": []interface{}{},
|
|
})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
keys := make([]map[string]interface{}, 0)
|
|
for rows.Next() {
|
|
var id, name, keyPrefix, plan, createdAt, updatedAt string
|
|
var enabled int
|
|
var rpmLimit, monthlyQuota sql.NullInt64
|
|
|
|
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
key := map[string]interface{}{
|
|
"id": id,
|
|
"name": name,
|
|
"keyPrefix": keyPrefix,
|
|
"plan": plan,
|
|
"enabled": enabled != 0,
|
|
"createdAt": createdAt,
|
|
"updatedAt": updatedAt,
|
|
}
|
|
|
|
if rpmLimit.Valid {
|
|
key["rpmLimit"] = rpmLimit.Int64
|
|
}
|
|
if monthlyQuota.Valid {
|
|
key["monthlyQuota"] = monthlyQuota.Int64
|
|
}
|
|
|
|
keys = append(keys, key)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"ok": true,
|
|
"data": keys,
|
|
})
|
|
}
|
|
|
|
// handleAPwhyKeysCreate creates a new API key
|
|
func handleAPwhyKeysCreate(c *gin.Context) {
|
|
var input struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Plan string `json:"plan"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"ok": false,
|
|
"error": "Invalid input: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if input.Plan == "" {
|
|
input.Plan = "free"
|
|
}
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
|
|
// Generate API key and hash
|
|
apiKey, err := generateAPIKey()
|
|
if err != nil {
|
|
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
|
"Failed to generate API key", err.Error())
|
|
return
|
|
}
|
|
|
|
keyHash, err := hashAPIKey(apiKey)
|
|
if err != nil {
|
|
sendErrorResponse(c, http.StatusInternalServerError, "HASH_ERROR",
|
|
"Failed to hash API key", err.Error())
|
|
return
|
|
}
|
|
|
|
keyPrefix := apiKey[:8]
|
|
|
|
// Set default limits based on plan
|
|
plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan)
|
|
if err != nil {
|
|
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
|
|
"Invalid plan", err.Error())
|
|
return
|
|
}
|
|
|
|
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
|
var id string
|
|
err = db.QueryRowContext(context.Background(), `
|
|
INSERT INTO api_keys (
|
|
name, key_hash, key_prefix, plan, allowed_service_ids,
|
|
rpm_limit, monthly_quota, created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
|
|
RETURNING id
|
|
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"ok": false,
|
|
"error": "Failed to create key: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"ok": true,
|
|
"data": gin.H{
|
|
"id": id,
|
|
"name": input.Name,
|
|
"plan": plan,
|
|
"key": apiKey, // Only return the actual key once
|
|
"keyPrefix": keyPrefix,
|
|
"enabled": true,
|
|
"rpmLimit": rpmLimit,
|
|
"monthlyQuota": monthlyQuota,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleAPwhyKeysPatch updates an existing API key
|
|
func handleAPwhyKeysPatch(c *gin.Context) {
|
|
keyID := c.Param("id")
|
|
|
|
var input struct {
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"ok": false,
|
|
"error": "Invalid input: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
|
|
if input.Enabled != nil {
|
|
enabled := 0
|
|
if *input.Enabled {
|
|
enabled = 1
|
|
}
|
|
_, err := db.ExecContext(context.Background(),
|
|
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
|
enabled, keyID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"ok": false,
|
|
"error": "Failed to update key: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"ok": true,
|
|
"data": gin.H{"id": keyID, "updated": true},
|
|
})
|
|
}
|
|
|
|
// handleAPwhyAnalyticsOps returns operational analytics
|
|
func handleAPwhyAnalyticsOps(c *gin.Context) {
|
|
db := c.MustGet("db").(*database.DB)
|
|
|
|
// Get counts from database
|
|
var totalServices, totalKeys, totalUsers int
|
|
|
|
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"ok": false,
|
|
"error": "Failed to count services: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"ok": false,
|
|
"error": "Failed to count api keys: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"ok": false,
|
|
"error": "Failed to count users: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
var totalRequests int
|
|
if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"ok": false,
|
|
"error": "Failed to aggregate request counters: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
var requestsToday int
|
|
if err := db.QueryRowContext(context.Background(), `
|
|
SELECT COALESCE(SUM(value), 0)::int
|
|
FROM metrics_timeseries
|
|
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('day', NOW())
|
|
`).Scan(&requestsToday); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"ok": false,
|
|
"error": "Failed to aggregate today's requests: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
var requestsThisMonth int
|
|
if err := db.QueryRowContext(context.Background(), `
|
|
SELECT COALESCE(SUM(value), 0)::int
|
|
FROM metrics_timeseries
|
|
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('month', NOW())
|
|
`).Scan(&requestsThisMonth); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"ok": false,
|
|
"error": "Failed to aggregate monthly requests: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"ok": true,
|
|
"data": gin.H{
|
|
"total_requests": totalRequests,
|
|
"total_services": totalServices,
|
|
"total_keys": totalKeys,
|
|
"total_users": totalUsers,
|
|
"requests_today": requestsToday,
|
|
"requests_this_month": requestsThisMonth,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleAPwhyAnalyticsTraffic returns traffic analytics
|
|
func handleAPwhyAnalyticsTraffic(c *gin.Context) {
|
|
db := c.MustGet("db").(*database.DB)
|
|
|
|
topServices := make([]map[string]interface{}, 0)
|
|
serviceRows, err := db.QueryContext(context.Background(), `
|
|
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
|
|
FROM api_services s
|
|
LEFT JOIN usage_counters u ON u.service_id = s.id
|
|
GROUP BY s.id, s.name
|
|
ORDER BY total_requests DESC, s.name ASC
|
|
LIMIT 10
|
|
`)
|
|
if err == nil {
|
|
defer serviceRows.Close()
|
|
for serviceRows.Next() {
|
|
var serviceID, serviceName string
|
|
var totalRequests int
|
|
if scanErr := serviceRows.Scan(&serviceID, &serviceName, &totalRequests); scanErr == nil {
|
|
topServices = append(topServices, map[string]interface{}{
|
|
"service_id": serviceID,
|
|
"name": serviceName,
|
|
"requests": totalRequests,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
requestsByDay := make([]map[string]interface{}, 0)
|
|
trafficRows, err := db.QueryContext(context.Background(), `
|
|
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
|
|
FROM metrics_timeseries
|
|
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
|
|
GROUP BY DATE_TRUNC('day', occurred_at)
|
|
ORDER BY DATE_TRUNC('day', occurred_at) ASC
|
|
`)
|
|
if err == nil {
|
|
defer trafficRows.Close()
|
|
for trafficRows.Next() {
|
|
var day string
|
|
var count int
|
|
if scanErr := trafficRows.Scan(&day, &count); scanErr == nil {
|
|
requestsByDay = append(requestsByDay, map[string]interface{}{
|
|
"day": day,
|
|
"requests": count,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
statusCodes := make([]map[string]interface{}, 0)
|
|
statusRows, err := db.QueryContext(context.Background(), `
|
|
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
|
|
FROM incident_events
|
|
WHERE occurred_at >= NOW() - INTERVAL '7 days'
|
|
GROUP BY COALESCE(http_status, 0)
|
|
ORDER BY total DESC, status_code ASC
|
|
`)
|
|
if err == nil {
|
|
defer statusRows.Close()
|
|
for statusRows.Next() {
|
|
var code int
|
|
var total int
|
|
if scanErr := statusRows.Scan(&code, &total); scanErr == nil {
|
|
statusCodes = append(statusCodes, map[string]interface{}{
|
|
"status_code": code,
|
|
"count": total,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
clientEvents := make([]map[string]interface{}, 0)
|
|
eventRows, err := db.QueryContext(context.Background(), `
|
|
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
|
|
FROM metrics_timeseries
|
|
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
|
|
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
|
|
ORDER BY total DESC, path ASC
|
|
LIMIT 20
|
|
`)
|
|
if err == nil {
|
|
defer eventRows.Close()
|
|
for eventRows.Next() {
|
|
var path string
|
|
var total int
|
|
if scanErr := eventRows.Scan(&path, &total); scanErr == nil {
|
|
clientEvents = append(clientEvents, map[string]interface{}{
|
|
"path": path,
|
|
"count": total,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"ok": true,
|
|
"data": gin.H{
|
|
"top_services": topServices,
|
|
"requests_by_day": requestsByDay,
|
|
"status_codes": statusCodes,
|
|
"client_events": clientEvents,
|
|
},
|
|
})
|
|
}
|