Files
ophion/cmd/server/main.go
bigtux 771cf6cf50 feat: Add OpenTelemetry OTLP HTTP receiver
- Add POST /v1/traces endpoint for OTLP JSON trace ingestion
- Convert OTLP spans to internal format and save to PostgreSQL
- Manual JSON parsing (no Go 1.24 dependencies)
- Add Node.js instrumentation example with Express
- Add Python instrumentation example with Flask
- Auto-instrumentation support for both languages
2026-02-06 14:59:29 -03:00

897 lines
28 KiB
Go

package main
import (
"context"
"database/sql"
"encoding/json"
"log"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/bigtux/ophion/internal/auth"
"github.com/bigtux/ophion/internal/otel"
"github.com/bigtux/ophion/internal/security"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
// ═══════════════════════════════════════════════════════════
// 🐍 OPHION Server - Observability Platform API
// ═══════════════════════════════════════════════════════════
type Server struct {
app *fiber.App
db *sql.DB
authHandler *auth.AuthHandler
}
type Metric struct {
Timestamp time.Time `json:"timestamp"`
Service string `json:"service"`
Host string `json:"host"`
Name string `json:"name"`
Value float64 `json:"value"`
MetricType string `json:"metric_type"`
Tags map[string]string `json:"tags,omitempty"`
}
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Service string `json:"service"`
Host string `json:"host"`
Level string `json:"level"`
Message string `json:"message"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
Source string `json:"source"`
ContainerID string `json:"container_id,omitempty"`
}
type Span struct {
TraceID string `json:"trace_id"`
SpanID string `json:"span_id"`
ParentSpanID string `json:"parent_span_id,omitempty"`
Service string `json:"service"`
Operation string `json:"operation"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DurationNs int64 `json:"duration_ns"`
StatusCode string `json:"status_code"`
StatusMsg string `json:"status_message,omitempty"`
Kind string `json:"kind"`
Attributes map[string]any `json:"attributes,omitempty"`
}
type Alert struct {
ID string `json:"id"`
Name string `json:"name"`
Severity string `json:"severity"`
Service string `json:"service"`
Host string `json:"host"`
Message string `json:"message"`
Status string `json:"status"`
FiredAt time.Time `json:"fired_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
type Agent struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
Version string `json:"version"`
Status string `json:"status"`
LastSeen time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
}
func main() {
// Initialize database
pgDSN := getEnv("DATABASE_URL", "postgres://ophion:ophion@localhost:5432/ophion?sslmode=disable")
db, err := sql.Open("postgres", pgDSN)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
// Test connection
if err := db.Ping(); err != nil {
log.Printf("⚠ Database not available: %v", err)
} else {
log.Println("✓ Connected to PostgreSQL")
initSchema(db)
// Create default admin user
if err := auth.CreateDefaultAdmin(db); err != nil {
log.Printf("⚠ Failed to create default admin: %v", err)
}
}
// Initialize auth handler
jwtSecret := getEnv("JWT_SECRET", "ophion-super-secret-key-change-in-production")
authHandler := auth.NewAuthHandler(db, jwtSecret)
server := &Server{db: db, authHandler: authHandler}
// Create Fiber app
app := fiber.New(fiber.Config{
AppName: "OPHION Observability Platform",
BodyLimit: 50 * 1024 * 1024,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
})
server.app = app
// Middleware
app.Use(recover.New())
app.Use(logger.New(logger.Config{
Format: "${time} ${status} ${method} ${path} ${latency}\n",
}))
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
}))
// Routes
server.setupRoutes()
// Graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("🛑 Shutting down server...")
cancel()
app.Shutdown()
}()
// Background jobs
go server.runBackgroundJobs(ctx)
port := getEnv("PORT", "8080")
log.Printf("🐍 OPHION server starting on port %s", port)
if err := app.Listen(":" + port); err != nil {
log.Fatal(err)
}
}
func initSchema(db *sql.DB) {
schema := `
CREATE TABLE IF NOT EXISTS metrics (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
service VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
value DOUBLE PRECISION NOT NULL,
metric_type VARCHAR(50),
tags JSONB
);
CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
service VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
level VARCHAR(20),
message TEXT,
trace_id VARCHAR(64),
span_id VARCHAR(32),
source VARCHAR(50),
container_id VARCHAR(64)
);
CREATE TABLE IF NOT EXISTS spans (
id SERIAL PRIMARY KEY,
trace_id VARCHAR(64) NOT NULL,
span_id VARCHAR(32) NOT NULL,
parent_span_id VARCHAR(32),
service VARCHAR(255) NOT NULL,
operation VARCHAR(255) NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
duration_ns BIGINT,
status_code VARCHAR(20),
status_message TEXT,
kind VARCHAR(20),
attributes JSONB
);
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hostname VARCHAR(255) NOT NULL UNIQUE,
ip VARCHAR(45),
version VARCHAR(50),
status VARCHAR(20) DEFAULT 'active',
last_seen TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
severity VARCHAR(20),
service VARCHAR(255),
host VARCHAR(255),
message TEXT,
status VARCHAR(20) DEFAULT 'firing',
fired_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
-- Users table for JWT auth
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- API Keys table for agent auth
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_hash VARCHAR(64) NOT NULL UNIQUE,
prefix VARCHAR(20) NOT NULL,
name VARCHAR(255) NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_metrics_service_name ON metrics(service, name);
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_logs_service ON logs(service);
CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON spans(trace_id);
CREATE INDEX IF NOT EXISTS idx_spans_service ON spans(service);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
`
if _, err := db.Exec(schema); err != nil {
log.Printf("Error creating schema: %v", err)
} else {
log.Println("✓ Database schema initialized")
}
}
func (s *Server) setupRoutes() {
// Health check (public)
s.app.Get("/health", s.healthCheck)
// Security headers
s.app.Use(security.SecurityHeaders())
// ═══════════════════════════════════════════════════════════
// 🔭 OTLP HTTP Receiver (OpenTelemetry Protocol)
// ═══════════════════════════════════════════════════════════
// Standard OTLP endpoint - can be public or protected based on config
otlpReceiver := otel.NewOTLPReceiver(s.db)
// OTLP routes (public by default for easy integration)
// For production, consider adding auth middleware
s.app.Post("/v1/traces", otlpReceiver.HandleTraces)
// Also support the full path that some SDKs use
s.app.Post("/v1/traces/", otlpReceiver.HandleTraces)
// API v1
api := s.app.Group("/api/v1")
// ═══════════════════════════════════════════════════════════
// 🔓 PUBLIC ROUTES (no auth required)
// ═══════════════════════════════════════════════════════════
// Auth endpoints
authGroup := api.Group("/auth")
authGroup.Post("/login", s.authHandler.Login)
authGroup.Post("/register", s.authHandler.Register)
// ═══════════════════════════════════════════════════════════
// 🔐 PROTECTED ROUTES (auth required)
// ═══════════════════════════════════════════════════════════
// Ingest endpoints (for agents - API key or JWT)
ingest := api.Group("/ingest", s.authMiddleware())
ingest.Post("/metrics", s.ingestMetrics)
ingest.Post("/logs", s.ingestLogs)
ingest.Post("/traces", s.ingestTraces)
// Legacy ingest routes (also protected, for backwards compat)
api.Post("/metrics", s.authMiddleware(), s.ingestMetrics)
api.Post("/logs", s.authMiddleware(), s.ingestLogs)
api.Post("/traces", s.authMiddleware(), s.ingestTraces)
// Protected routes
protected := api.Group("", s.authMiddleware())
// Query endpoints (for dashboard)
protected.Get("/metrics", s.queryMetrics)
protected.Get("/metrics/names", s.getMetricNames)
protected.Get("/logs", s.queryLogs)
protected.Get("/traces", s.queryTraces)
protected.Get("/traces/:traceId", s.getTrace)
protected.Get("/services", s.getServices)
// Agents
protected.Get("/agents", s.getAgents)
protected.Post("/agents/register", s.registerAgent)
// Alerts
protected.Get("/alerts", s.getAlerts)
protected.Post("/alerts", s.createAlert)
protected.Put("/alerts/:id/resolve", s.resolveAlert)
// Dashboard
protected.Get("/dashboard/overview", s.getDashboardOverview)
// User info
protected.Get("/me", s.authHandler.Me)
// API Keys management (JWT only)
protected.Post("/api-keys", s.authHandler.CreateAPIKey)
protected.Get("/api-keys", s.authHandler.ListAPIKeys)
protected.Delete("/api-keys/:id", s.authHandler.DeleteAPIKey)
}
// authMiddleware creates authentication middleware that accepts both JWT and API keys
func (s *Server) authMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(401).JSON(fiber.Map{
"error": "Unauthorized",
"message": "Missing Authorization header",
})
}
token := strings.TrimPrefix(authHeader, "Bearer ")
// Check if it's an API key (starts with ophion_)
if strings.HasPrefix(token, security.APIKeyPrefix) {
return s.authenticateAPIKey(c, token)
}
// Otherwise treat as JWT
return s.authenticateJWT(c, token)
}
}
// authenticateAPIKey validates API key authentication
func (s *Server) authenticateAPIKey(c *fiber.Ctx, apiKey string) error {
if !security.ValidateAPIKeyFormat(apiKey) {
return c.Status(401).JSON(fiber.Map{
"error": "Unauthorized",
"message": "Invalid API key format",
})
}
keyHash := security.HashAPIKey(apiKey)
var keyID, name, userID string
err := s.db.QueryRow(`
SELECT id, name, user_id FROM api_keys WHERE key_hash = $1
`, keyHash).Scan(&keyID, &name, &userID)
if err != nil {
return c.Status(401).JSON(fiber.Map{
"error": "Unauthorized",
"message": "Invalid API key",
})
}
// Update last_used (async)
go s.db.Exec(`UPDATE api_keys SET last_used = NOW() WHERE id = $1`, keyID)
// Set context
c.Locals("auth_type", "api_key")
c.Locals("api_key_id", keyID)
c.Locals("user_id", userID)
return c.Next()
}
// authenticateJWT validates JWT token authentication
func (s *Server) authenticateJWT(c *fiber.Ctx, token string) error {
authService := s.authHandler.GetAuthService()
claims, err := authService.ValidateAccessToken(token)
if err != nil {
return c.Status(401).JSON(fiber.Map{
"error": "Unauthorized",
"message": err.Error(),
})
}
// Set context
c.Locals("auth_type", "jwt")
c.Locals("user_id", claims.UserID)
c.Locals("email", claims.Email)
c.Locals("role", claims.Role)
c.Locals("token_id", claims.TokenID)
return c.Next()
}
func (s *Server) healthCheck(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "healthy",
"service": "ophion",
"version": "0.2.0",
"timestamp": time.Now(),
})
}
// ─────────────────────────────────────────────────────────────
// Ingest Endpoints
// ─────────────────────────────────────────────────────────────
func (s *Server) ingestMetrics(c *fiber.Ctx) error {
var req struct {
Metrics []Metric `json:"metrics"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
for _, m := range req.Metrics {
tags, _ := json.Marshal(m.Tags)
_, err := s.db.Exec(`
INSERT INTO metrics (timestamp, service, host, name, value, metric_type, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
m.Timestamp, m.Service, m.Host, m.Name, m.Value, m.MetricType, tags)
if err != nil {
log.Printf("Error inserting metric: %v", err)
}
}
// Update agent last_seen
if len(req.Metrics) > 0 {
host := req.Metrics[0].Host
s.db.Exec(`UPDATE agents SET last_seen = NOW(), status = 'active' WHERE hostname = $1`, host)
}
return c.JSON(fiber.Map{"status": "received", "count": len(req.Metrics)})
}
func (s *Server) ingestLogs(c *fiber.Ctx) error {
var req struct {
Logs []LogEntry `json:"logs"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
for _, l := range req.Logs {
_, err := s.db.Exec(`
INSERT INTO logs (timestamp, service, host, level, message, trace_id, span_id, source, container_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
l.Timestamp, l.Service, l.Host, l.Level, l.Message, l.TraceID, l.SpanID, l.Source, l.ContainerID)
if err != nil {
log.Printf("Error inserting log: %v", err)
}
}
return c.JSON(fiber.Map{"status": "received", "count": len(req.Logs)})
}
func (s *Server) ingestTraces(c *fiber.Ctx) error {
var req struct {
Spans []Span `json:"spans"`
Host string `json:"host"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
for _, sp := range req.Spans {
attrs, _ := json.Marshal(sp.Attributes)
_, err := s.db.Exec(`
INSERT INTO spans (trace_id, span_id, parent_span_id, service, operation, start_time, end_time, duration_ns, status_code, status_message, kind, attributes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
sp.TraceID, sp.SpanID, sp.ParentSpanID, sp.Service, sp.Operation, sp.StartTime, sp.EndTime, sp.DurationNs, sp.StatusCode, sp.StatusMsg, sp.Kind, attrs)
if err != nil {
log.Printf("Error inserting span: %v", err)
}
}
return c.JSON(fiber.Map{"status": "received", "count": len(req.Spans)})
}
// ─────────────────────────────────────────────────────────────
// Query Endpoints
// ─────────────────────────────────────────────────────────────
func (s *Server) queryMetrics(c *fiber.Ctx) error {
service := c.Query("service", "system")
name := c.Query("name", "cpu.usage_percent")
from := parseTime(c.Query("from"), time.Now().Add(-1*time.Hour))
to := parseTime(c.Query("to"), time.Now())
rows, err := s.db.Query(`
SELECT timestamp, value FROM metrics
WHERE service = $1 AND name = $2 AND timestamp >= $3 AND timestamp <= $4
ORDER BY timestamp ASC
LIMIT 1000`, service, name, from, to)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
defer rows.Close()
var metrics []map[string]any
for rows.Next() {
var ts time.Time
var val float64
if err := rows.Scan(&ts, &val); err == nil {
metrics = append(metrics, map[string]any{"timestamp": ts, "value": val})
}
}
return c.JSON(fiber.Map{"metrics": metrics, "count": len(metrics)})
}
func (s *Server) getMetricNames(c *fiber.Ctx) error {
rows, err := s.db.Query(`SELECT DISTINCT name FROM metrics ORDER BY name`)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
names = append(names, name)
}
}
return c.JSON(fiber.Map{"names": names})
}
func (s *Server) queryLogs(c *fiber.Ctx) error {
service := c.Query("service")
level := c.Query("level")
query := c.Query("q")
from := parseTime(c.Query("from"), time.Now().Add(-1*time.Hour))
to := parseTime(c.Query("to"), time.Now())
limit := parseInt(c.Query("limit"), 100)
sql := `SELECT timestamp, service, host, level, message, source, container_id
FROM logs WHERE timestamp >= $1 AND timestamp <= $2`
args := []any{from, to}
argN := 3
if service != "" {
sql += ` AND service = $` + strconv.Itoa(argN)
args = append(args, service)
argN++
}
if level != "" {
sql += ` AND level = $` + strconv.Itoa(argN)
args = append(args, level)
argN++
}
if query != "" {
sql += ` AND message ILIKE $` + strconv.Itoa(argN)
args = append(args, "%"+query+"%")
argN++
}
sql += ` ORDER BY timestamp DESC LIMIT $` + strconv.Itoa(argN)
args = append(args, limit)
rows, err := s.db.Query(sql, args...)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
defer rows.Close()
var logs []LogEntry
for rows.Next() {
var l LogEntry
if err := rows.Scan(&l.Timestamp, &l.Service, &l.Host, &l.Level, &l.Message, &l.Source, &l.ContainerID); err == nil {
logs = append(logs, l)
}
}
return c.JSON(fiber.Map{"logs": logs, "count": len(logs)})
}
func (s *Server) queryTraces(c *fiber.Ctx) error {
service := c.Query("service")
from := parseTime(c.Query("from"), time.Now().Add(-1*time.Hour))
to := parseTime(c.Query("to"), time.Now())
limit := parseInt(c.Query("limit"), 20)
sql := `SELECT DISTINCT trace_id, service, operation, MIN(start_time), MAX(duration_ns)
FROM spans WHERE start_time >= $1 AND start_time <= $2`
args := []any{from, to}
argN := 3
if service != "" {
sql += ` AND service = $` + strconv.Itoa(argN)
args = append(args, service)
argN++
}
sql += ` GROUP BY trace_id, service, operation ORDER BY MIN(start_time) DESC LIMIT $` + strconv.Itoa(argN)
args = append(args, limit)
rows, err := s.db.Query(sql, args...)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
defer rows.Close()
var traces []map[string]any
for rows.Next() {
var traceID, service, operation string
var startTime time.Time
var durationNs int64
if err := rows.Scan(&traceID, &service, &operation, &startTime, &durationNs); err == nil {
traces = append(traces, map[string]any{
"trace_id": traceID,
"service": service,
"operation": operation,
"start_time": startTime,
"duration_ns": durationNs,
})
}
}
return c.JSON(fiber.Map{"traces": traces, "count": len(traces)})
}
func (s *Server) getTrace(c *fiber.Ctx) error {
traceID := c.Params("traceId")
rows, err := s.db.Query(`
SELECT trace_id, span_id, parent_span_id, service, operation, start_time, end_time, duration_ns, status_code, status_message, kind
FROM spans WHERE trace_id = $1 ORDER BY start_time`, traceID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
defer rows.Close()
var spans []Span
for rows.Next() {
var sp Span
var parentSpanID, statusMsg sql.NullString
if err := rows.Scan(&sp.TraceID, &sp.SpanID, &parentSpanID, &sp.Service, &sp.Operation, &sp.StartTime, &sp.EndTime, &sp.DurationNs, &sp.StatusCode, &statusMsg, &sp.Kind); err == nil {
sp.ParentSpanID = parentSpanID.String
sp.StatusMsg = statusMsg.String
spans = append(spans, sp)
}
}
if len(spans) == 0 {
return c.Status(404).JSON(fiber.Map{"error": "trace not found"})
}
return c.JSON(fiber.Map{
"trace_id": traceID,
"spans": spans,
"duration_ns": spans[len(spans)-1].EndTime.Sub(spans[0].StartTime).Nanoseconds(),
"start_time": spans[0].StartTime,
})
}
func (s *Server) getServices(c *fiber.Ctx) error {
rows, err := s.db.Query(`SELECT DISTINCT service FROM metrics UNION SELECT DISTINCT service FROM spans ORDER BY service`)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
defer rows.Close()
var services []string
for rows.Next() {
var svc string
if rows.Scan(&svc) == nil {
services = append(services, svc)
}
}
return c.JSON(fiber.Map{"services": services})
}
// ─────────────────────────────────────────────────────────────
// Agents
// ─────────────────────────────────────────────────────────────
func (s *Server) getAgents(c *fiber.Ctx) error {
rows, err := s.db.Query(`SELECT id, hostname, ip, version, status, last_seen, created_at FROM agents ORDER BY last_seen DESC`)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
defer rows.Close()
var agents []Agent
for rows.Next() {
var a Agent
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Version, &a.Status, &a.LastSeen, &a.CreatedAt); err == nil {
agents = append(agents, a)
}
}
return c.JSON(fiber.Map{"agents": agents})
}
func (s *Server) registerAgent(c *fiber.Ctx) error {
var agent Agent
if err := c.BodyParser(&agent); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
agent.ID = uuid.New().String()
agent.Status = "active"
agent.LastSeen = time.Now()
agent.CreatedAt = time.Now()
_, err := s.db.Exec(`
INSERT INTO agents (id, hostname, ip, version, status, last_seen, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (hostname) DO UPDATE SET ip = $3, version = $4, status = 'active', last_seen = NOW()`,
agent.ID, agent.Hostname, agent.IP, agent.Version, agent.Status, agent.LastSeen, agent.CreatedAt)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"status": "registered", "agent": agent})
}
// ─────────────────────────────────────────────────────────────
// Alerts
// ─────────────────────────────────────────────────────────────
func (s *Server) getAlerts(c *fiber.Ctx) error {
status := c.Query("status")
limit := parseInt(c.Query("limit"), 50)
sql := `SELECT id, name, severity, service, host, message, status, fired_at, resolved_at FROM alerts`
var args []any
if status != "" {
sql += ` WHERE status = $1`
args = append(args, status)
}
sql += ` ORDER BY fired_at DESC LIMIT ` + strconv.Itoa(limit)
rows, err := s.db.Query(sql, args...)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
defer rows.Close()
var alerts []Alert
for rows.Next() {
var a Alert
var resolvedAt *time.Time
if err := rows.Scan(&a.ID, &a.Name, &a.Severity, &a.Service, &a.Host, &a.Message, &a.Status, &a.FiredAt, &resolvedAt); err == nil {
a.ResolvedAt = resolvedAt
alerts = append(alerts, a)
}
}
return c.JSON(fiber.Map{"alerts": alerts})
}
func (s *Server) createAlert(c *fiber.Ctx) error {
var alert Alert
if err := c.BodyParser(&alert); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
alert.ID = uuid.New().String()
alert.Status = "firing"
alert.FiredAt = time.Now()
_, err := s.db.Exec(`
INSERT INTO alerts (id, name, severity, service, host, message, status, fired_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
alert.ID, alert.Name, alert.Severity, alert.Service, alert.Host, alert.Message, alert.Status, alert.FiredAt)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"status": "created", "alert": alert})
}
func (s *Server) resolveAlert(c *fiber.Ctx) error {
alertID := c.Params("id")
_, err := s.db.Exec(`UPDATE alerts SET status = 'resolved', resolved_at = NOW() WHERE id = $1`, alertID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"status": "resolved"})
}
// ─────────────────────────────────────────────────────────────
// Dashboard
// ─────────────────────────────────────────────────────────────
func (s *Server) getDashboardOverview(c *fiber.Ctx) error {
overview := fiber.Map{"timestamp": time.Now()}
// Agents
var totalAgents, activeAgents int
s.db.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&totalAgents)
s.db.QueryRow(`SELECT COUNT(*) FROM agents WHERE status = 'active'`).Scan(&activeAgents)
overview["agents"] = fiber.Map{"total": totalAgents, "active": activeAgents}
// Alerts
var firingAlerts int
s.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE status = 'firing'`).Scan(&firingAlerts)
overview["alerts"] = fiber.Map{"firing": firingAlerts}
// Services
var serviceCount int
s.db.QueryRow(`SELECT COUNT(DISTINCT service) FROM metrics`).Scan(&serviceCount)
overview["services"] = fiber.Map{"count": serviceCount}
return c.JSON(overview)
}
// ─────────────────────────────────────────────────────────────
// Background Jobs
// ─────────────────────────────────────────────────────────────
func (s *Server) runBackgroundJobs(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Mark stale agents
s.db.Exec(`UPDATE agents SET status = 'inactive' WHERE last_seen < NOW() - INTERVAL '5 minutes' AND status = 'active'`)
}
}
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func parseTime(s string, def time.Time) time.Time {
if s == "" {
return def
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
if ts, err := strconv.ParseInt(s, 10, 64); err == nil {
return time.Unix(ts, 0)
}
if d, err := time.ParseDuration(s); err == nil {
return time.Now().Add(-d)
}
return def
}
func parseInt(s string, def int) int {
if s == "" {
return def
}
if v, err := strconv.Atoi(s); err == nil {
return v
}
return def
}