fix: add go.sum and fixes

This commit is contained in:
2026-02-06 14:26:15 -03:00
parent cf2b4f7b91
commit d6b08cb586
36 changed files with 3613 additions and 423 deletions

View File

@@ -27,7 +27,7 @@ OPHION é uma plataforma de observabilidade que combina **métricas, logs e trac
## 🚀 Instalação Rápida
```bash
curl -fsSL https://get.ophion.io | bash
curl -fsSL https://get.ophion.com.br | bash
```
O instalador interativo irá:

View File

@@ -7,118 +7,326 @@ import (
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem"
"github.com/shirou/gopsutil/v3/net"
psnet "github.com/shirou/gopsutil/v3/net"
)
type Metrics struct {
Hostname string `json:"hostname"`
Timestamp time.Time `json:"timestamp"`
CPU CPUMetric `json:"cpu"`
Memory MemMetric `json:"memory"`
Disk []DiskMetric `json:"disk"`
Network NetMetric `json:"network"`
// ═══════════════════════════════════════════════════════════
// 🐍 OPHION Agent - Observability Collector
// ═══════════════════════════════════════════════════════════
type Config struct {
ServerURL string
APIKey string
Hostname string
CollectInterval time.Duration
DockerEnabled bool
}
type CPUMetric struct {
UsagePercent float64 `json:"usage_percent"`
Cores int `json:"cores"`
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 MemMetric struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
UsedPercent float64 `json:"used_percent"`
}
type DiskMetric struct {
Path string `json:"path"`
Total uint64 `json:"total"`
Used uint64 `json:"used"`
UsedPercent float64 `json:"used_percent"`
}
type NetMetric struct {
BytesSent uint64 `json:"bytes_sent"`
BytesRecv uint64 `json:"bytes_recv"`
type ContainerStats struct {
ID string `json:"id"`
Name string `json:"name"`
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage uint64 `json:"memory_usage"`
MemoryLimit uint64 `json:"memory_limit"`
MemoryPercent float64 `json:"memory_percent"`
NetRx uint64 `json:"net_rx"`
NetTx uint64 `json:"net_tx"`
State string `json:"state"`
}
func main() {
serverURL := os.Getenv("OPHION_SERVER")
if serverURL == "" {
serverURL = "http://localhost:8080"
}
config := loadConfig()
apiKey := os.Getenv("OPHION_API_KEY")
if apiKey == "" {
log.Fatal("OPHION_API_KEY is required")
}
log.Printf("🐍 OPHION Agent starting")
log.Printf(" Server: %s", config.ServerURL)
log.Printf(" Host: %s", config.Hostname)
log.Printf(" Interval: %s", config.CollectInterval)
log.Printf(" Docker: %v", config.DockerEnabled)
interval := 30 * time.Second
log.Printf("🐍 OPHION Agent starting - reporting to %s every %s", serverURL, interval)
// Handle shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
ticker := time.NewTicker(interval)
for range ticker.C {
metrics := collectMetrics()
sendMetrics(serverURL, apiKey, metrics)
ticker := time.NewTicker(config.CollectInterval)
defer ticker.Stop()
// Collect immediately
collect(config)
for {
select {
case <-sigCh:
log.Println("🛑 Shutting down agent...")
return
case <-ticker.C:
collect(config)
}
}
}
func collectMetrics() Metrics {
func loadConfig() *Config {
hostname, _ := os.Hostname()
cpuPercent, _ := cpu.Percent(time.Second, false)
cpuUsage := 0.0
if len(cpuPercent) > 0 {
cpuUsage = cpuPercent[0]
interval := 30 * time.Second
if v := os.Getenv("OPHION_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
interval = d
}
}
memInfo, _ := mem.VirtualMemory()
diskInfo, _ := disk.Usage("/")
disks := []DiskMetric{{
Path: "/",
Total: diskInfo.Total,
Used: diskInfo.Used,
UsedPercent: diskInfo.UsedPercent,
}}
netIO, _ := net.IOCounters(false)
netMetric := NetMetric{}
if len(netIO) > 0 {
netMetric.BytesSent = netIO[0].BytesSent
netMetric.BytesRecv = netIO[0].BytesRecv
dockerEnabled := true
if v := os.Getenv("OPHION_DOCKER"); v == "false" || v == "0" {
dockerEnabled = false
}
return Metrics{
Hostname: hostname,
Timestamp: time.Now(),
CPU: CPUMetric{
UsagePercent: cpuUsage,
Cores: runtime.NumCPU(),
},
Memory: MemMetric{
Total: memInfo.Total,
Used: memInfo.Used,
UsedPercent: memInfo.UsedPercent,
},
Disk: disks,
Network: netMetric,
return &Config{
ServerURL: getEnv("OPHION_SERVER", "http://localhost:8080"),
APIKey: getEnv("OPHION_API_KEY", ""),
Hostname: getEnv("OPHION_HOSTNAME", hostname),
CollectInterval: interval,
DockerEnabled: dockerEnabled,
}
}
func sendMetrics(serverURL, apiKey string, metrics Metrics) {
data, _ := json.Marshal(metrics)
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func collect(config *Config) {
var metrics []Metric
now := time.Now()
host := config.Hostname
// System metrics
metrics = append(metrics, collectSystemMetrics(now, host)...)
// Docker metrics
if config.DockerEnabled {
metrics = append(metrics, collectDockerMetrics(now, host)...)
}
// Send to server
if len(metrics) > 0 {
sendMetrics(config, metrics)
}
}
func collectSystemMetrics(now time.Time, hostname string) []Metric {
var metrics []Metric
svc := "system"
// CPU
cpuPercent, err := cpu.Percent(time.Second, false)
if err == nil && len(cpuPercent) > 0 {
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "cpu.usage_percent", Value: cpuPercent[0], MetricType: "gauge",
})
}
// Load average
loadAvg, err := load.Avg()
if err == nil {
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "cpu.load_avg_1", Value: loadAvg.Load1, MetricType: "gauge",
})
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "cpu.load_avg_5", Value: loadAvg.Load5, MetricType: "gauge",
})
}
// Memory
memInfo, err := mem.VirtualMemory()
if err == nil {
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "memory.used_percent", Value: memInfo.UsedPercent, MetricType: "gauge",
})
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "memory.used_bytes", Value: float64(memInfo.Used), MetricType: "gauge",
})
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "memory.available_bytes", Value: float64(memInfo.Available), MetricType: "gauge",
})
}
// Disk
partitions, _ := disk.Partitions(false)
for _, p := range partitions {
if strings.HasPrefix(p.Mountpoint, "/snap") ||
strings.HasPrefix(p.Mountpoint, "/sys") ||
strings.HasPrefix(p.Mountpoint, "/proc") ||
strings.HasPrefix(p.Mountpoint, "/dev") ||
strings.HasPrefix(p.Mountpoint, "/run") {
continue
}
usage, err := disk.Usage(p.Mountpoint)
if err != nil {
continue
}
tags := map[string]string{"path": p.Mountpoint, "device": p.Device}
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "disk.used_percent", Value: usage.UsedPercent, MetricType: "gauge", Tags: tags,
})
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "disk.used_bytes", Value: float64(usage.Used), MetricType: "gauge", Tags: tags,
})
}
// Network
netIO, err := psnet.IOCounters(false)
if err == nil && len(netIO) > 0 {
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "network.bytes_sent", Value: float64(netIO[0].BytesSent), MetricType: "counter",
})
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "network.bytes_recv", Value: float64(netIO[0].BytesRecv), MetricType: "counter",
})
}
// Uptime
hostInfo, err := host.Info()
if err == nil {
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "host.uptime_seconds", Value: float64(hostInfo.Uptime), MetricType: "gauge",
})
}
// Cores
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "host.cpu_cores", Value: float64(runtime.NumCPU()), MetricType: "gauge",
})
return metrics
}
func collectDockerMetrics(now time.Time, hostname string) []Metric {
var metrics []Metric
svc := "docker"
// Check if docker is available
if _, err := exec.LookPath("docker"); err != nil {
return metrics
}
// Get container stats using docker CLI (simpler, no SDK needed)
out, err := exec.Command("docker", "stats", "--no-stream", "--format",
"{{.ID}}\t{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}").Output()
if err != nil {
return metrics
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
runningCount := 0
for _, line := range lines {
if line == "" {
continue
}
runningCount++
parts := strings.Split(line, "\t")
if len(parts) < 6 {
continue
}
id := parts[0]
name := parts[1]
cpuStr := strings.TrimSuffix(parts[2], "%")
memStr := strings.TrimSuffix(parts[4], "%")
var cpuPercent, memPercent float64
fmt.Sscanf(cpuStr, "%f", &cpuPercent)
fmt.Sscanf(memStr, "%f", &memPercent)
tags := map[string]string{"container": name, "container_id": id}
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "container.cpu_percent", Value: cpuPercent, MetricType: "gauge", Tags: tags,
})
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "container.memory_percent", Value: memPercent, MetricType: "gauge", Tags: tags,
})
}
// Total containers
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "containers.running", Value: float64(runningCount), MetricType: "gauge",
})
// Get all containers count
out, err = exec.Command("docker", "ps", "-a", "-q").Output()
if err == nil {
total := len(strings.Split(strings.TrimSpace(string(out)), "\n"))
if strings.TrimSpace(string(out)) == "" {
total = 0
}
metrics = append(metrics, Metric{
Timestamp: now, Service: svc, Host: hostname,
Name: "containers.total", Value: float64(total), MetricType: "gauge",
})
}
return metrics
}
func sendMetrics(config *Config, metrics []Metric) {
data, err := json.Marshal(map[string]any{"metrics": metrics})
if err != nil {
log.Printf("Error marshaling metrics: %v", err)
return
}
req, err := http.NewRequest("POST", config.ServerURL+"/api/v1/metrics", bytes.NewBuffer(data))
if err != nil {
log.Printf("Error creating request: %v", err)
return
}
req, _ := http.NewRequest("POST", serverURL+"/api/v1/metrics", bytes.NewBuffer(data))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
if config.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+config.APIKey)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
@@ -128,8 +336,10 @@ func sendMetrics(serverURL, apiKey string, metrics Metrics) {
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
log.Printf("✓ Metrics sent: CPU=%.1f%% MEM=%.1f%%",
metrics.CPU.UsagePercent, metrics.Memory.UsedPercent)
if resp.StatusCode >= 400 {
log.Printf("Server returned error: %d", resp.StatusCode)
return
}
log.Printf("📤 Sent %d metrics", len(metrics))
}

View File

@@ -1,65 +1,737 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"log"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"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
}
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() {
app := fiber.New(fiber.Config{
AppName: "OPHION Observability Platform",
})
// 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()
// Middleware
app.Use(logger.New())
app.Use(cors.New())
// Health check
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "healthy",
"service": "ophion",
"version": "0.1.0",
})
})
// API routes
api := app.Group("/api/v1")
api.Get("/metrics", getMetrics)
api.Post("/metrics", ingestMetrics)
api.Get("/logs", getLogs)
api.Post("/logs", ingestLogs)
api.Get("/alerts", getAlerts)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
// Test connection
if err := db.Ping(); err != nil {
log.Printf("⚠ Database not available: %v", err)
} else {
log.Println("✓ Connected to PostgreSQL")
initSchema(db)
}
log.Printf("🐍 OPHION starting on port %s", port)
log.Fatal(app.Listen(":" + port))
server := &Server{db: db}
// 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 getMetrics(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"metrics": []string{}})
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
);
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);
`
if _, err := db.Exec(schema); err != nil {
log.Printf("Error creating schema: %v", err)
} else {
log.Println("✓ Database schema initialized")
}
}
func ingestMetrics(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "received"})
func (s *Server) setupRoutes() {
// Health check
s.app.Get("/health", s.healthCheck)
// API v1
api := s.app.Group("/api/v1")
// Ingest endpoints (for agents)
api.Post("/metrics", s.ingestMetrics)
api.Post("/logs", s.ingestLogs)
api.Post("/traces", s.ingestTraces)
// Query endpoints (for dashboard)
api.Get("/metrics", s.queryMetrics)
api.Get("/metrics/names", s.getMetricNames)
api.Get("/logs", s.queryLogs)
api.Get("/traces", s.queryTraces)
api.Get("/traces/:traceId", s.getTrace)
api.Get("/services", s.getServices)
// Agents
api.Get("/agents", s.getAgents)
api.Post("/agents/register", s.registerAgent)
// Alerts
api.Get("/alerts", s.getAlerts)
api.Post("/alerts", s.createAlert)
api.Put("/alerts/:id/resolve", s.resolveAlert)
// Dashboard
api.Get("/dashboard/overview", s.getDashboardOverview)
}
func getLogs(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"logs": []string{}})
func (s *Server) healthCheck(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "healthy",
"service": "ophion",
"version": "0.2.0",
"timestamp": time.Now(),
})
}
func ingestLogs(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "received"})
// ─────────────────────────────────────────────────────────────
// 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 getAlerts(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"alerts": []string{}})
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
}

View File

@@ -0,0 +1,21 @@
<clickhouse>
<!-- Create default database on startup -->
<default_database>ophion</default_database>
<!-- Listen on all interfaces -->
<listen_host>::</listen_host>
<listen_host>0.0.0.0</listen_host>
<!-- Logging -->
<logger>
<level>information</level>
<console>1</console>
</logger>
<!-- Memory limits -->
<max_memory_usage>2000000000</max_memory_usage>
<!-- Query settings -->
<max_query_size>1000000</max_query_size>
<max_concurrent_queries>100</max_concurrent_queries>
</clickhouse>

43
dashboard/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# ═══════════════════════════════════════════════════════════
# 🐍 OPHION Dashboard - Dockerfile
# ═══════════════════════════════════════════════════════════
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
# Build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Runtime
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
# Copy built files
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,6 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
reactStrictMode: true,
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'}/api/:path*`,
},
];
},
};
module.exports = nextConfig
module.exports = nextConfig;

View File

@@ -1,33 +1,40 @@
{
"name": "ophion-dashboard",
"version": "1.0.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"dev": "next dev",
"build": "next build",
"start": "next start -p 3000",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-tabs": "^1.0.4",
"@tanstack/react-query": "^5.17.19",
"chart.js": "^4.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"d3": "^7.8.5",
"date-fns": "^3.3.0",
"lucide-react": "^0.311.0",
"next": "14.1.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"next-auth": "^4.24.0",
"@tanstack/react-query": "^5.17.0",
"recharts": "^2.10.0",
"lucide-react": "^0.312.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"date-fns": "^3.2.0",
"zustand": "^4.4.0"
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0"
"@types/d3": "^7.4.3",
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

View File

View File

@@ -3,24 +3,19 @@
@tailwind utilities;
:root {
--background: 2 6 23;
--foreground: 248 250 252;
--card: 15 23 42;
--card-foreground: 248 250 252;
--primary: 34 197 94;
--primary-foreground: 255 255 255;
--secondary: 139 92 246;
--muted: 51 65 85;
--muted-foreground: 148 163 184;
--accent: 34 197 94;
--destructive: 239 68 68;
--border: 51 65 85;
--ring: 34 197 94;
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 10, 10, 20;
--background-end-rgb: 10, 10, 20;
}
body {
background: rgb(var(--background));
color: rgb(var(--foreground));
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
/* Custom scrollbar */
@@ -30,34 +25,45 @@ body {
}
::-webkit-scrollbar-track {
background: rgb(15, 23, 42);
background: #1a1a2e;
}
::-webkit-scrollbar-thumb {
background: rgb(51, 65, 85);
background: #3b3b5c;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(71, 85, 105);
background: #4b4b7c;
}
/* Animations */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
/* Timeline span bars */
.span-bar {
@apply h-6 rounded relative;
min-width: 4px;
}
.pulse-glow {
animation: pulse-glow 2s infinite;
.span-bar-inner {
@apply absolute inset-0 rounded;
background: linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%);
}
/* Card hover effects */
.card-hover {
transition: all 0.2s ease;
.span-bar-error .span-bar-inner {
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%);
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
/* Log levels */
.log-level-ERROR { @apply text-red-400; }
.log-level-WARN { @apply text-yellow-400; }
.log-level-INFO { @apply text-blue-400; }
.log-level-DEBUG { @apply text-gray-400; }
.log-level-TRACE { @apply text-gray-500; }
/* Metric cards */
.metric-card {
@apply bg-gray-900/50 rounded-lg p-4 border border-gray-800;
}
.metric-card:hover {
@apply border-indigo-500/50;
}

View File

@@ -1,24 +1,33 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Sidebar } from '@/components/layout/Sidebar';
import { Providers } from '@/components/Providers';
const inter = Inter({ subsets: ['latin'] })
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'OPHION Dashboard',
description: 'Observability Platform with AI',
}
title: 'OPHION - Observability Platform',
description: 'Open Source Observability Platform',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
return (
<html lang="pt-BR" className="dark">
<body className={`${inter.className} bg-slate-950 text-white`}>
{children}
<html lang="en" className="dark">
<body className={inter.className}>
<Providers>
<div className="flex h-screen bg-gray-950">
<Sidebar />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</Providers>
</body>
</html>
)
);
}

View File

@@ -0,0 +1,212 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, RefreshCw, Filter, Download } from 'lucide-react';
import { api } from '@/lib/api';
import { formatTime } from '@/lib/utils';
interface LogEntry {
timestamp: string;
service: string;
host: string;
level: string;
message: string;
trace_id?: string;
span_id?: string;
source?: string;
container_id?: string;
}
const LOG_LEVELS = ['', 'TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'];
export default function LogsPage() {
const [service, setService] = useState('');
const [level, setLevel] = useState('');
const [query, setQuery] = useState('');
const [traceId, setTraceId] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false);
const { data, isLoading, refetch } = useQuery({
queryKey: ['logs', service, level, query, traceId],
queryFn: () => api.get('/api/v1/logs', {
service,
level,
q: query,
trace_id: traceId,
from: new Date(Date.now() - 3600000).toISOString(),
limit: '200',
}),
refetchInterval: autoRefresh ? 5000 : false,
});
const logs: LogEntry[] = data?.logs ?? [];
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Logs</h1>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm text-gray-400">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded border-gray-600 bg-gray-800 text-indigo-500"
/>
Auto-refresh
</label>
<button
onClick={() => refetch()}
className="p-2 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors"
>
<RefreshCw className={`h-5 w-5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-800">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search logs..."
className="w-full pl-10 pr-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div className="w-40">
<select
value={service}
onChange={(e) => setService(e.target.value)}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-indigo-500"
>
<option value="">All services</option>
{/* Services would be populated dynamically */}
</select>
</div>
<div className="w-32">
<select
value={level}
onChange={(e) => setLevel(e.target.value)}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-indigo-500"
>
{LOG_LEVELS.map((l) => (
<option key={l} value={l}>
{l || 'All levels'}
</option>
))}
</select>
</div>
<div className="w-64">
<input
type="text"
value={traceId}
onChange={(e) => setTraceId(e.target.value)}
placeholder="Trace ID"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
{/* Log Table */}
<div className="bg-gray-900/50 rounded-lg border border-gray-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Time</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Level</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Service</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Message</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{logs.map((log, i) => (
<LogRow key={i} log={log} />
))}
{!isLoading && logs.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
No logs found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between text-sm text-gray-500">
<span>Showing {logs.length} logs</span>
{data?.count && data.count > logs.length && (
<span>Limited to 200 results</span>
)}
</div>
</div>
);
}
function LogRow({ log }: { log: LogEntry }) {
const [expanded, setExpanded] = useState(false);
const levelColors: Record<string, string> = {
ERROR: 'text-red-400 bg-red-400/10',
WARN: 'text-yellow-400 bg-yellow-400/10',
INFO: 'text-blue-400 bg-blue-400/10',
DEBUG: 'text-gray-400 bg-gray-400/10',
TRACE: 'text-gray-500 bg-gray-500/10',
FATAL: 'text-red-500 bg-red-500/20',
};
return (
<>
<tr
onClick={() => setExpanded(!expanded)}
className="hover:bg-gray-800/50 cursor-pointer"
>
<td className="px-4 py-2 text-sm text-gray-400 whitespace-nowrap font-mono">
{formatTime(log.timestamp)}
</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 text-xs font-medium rounded ${levelColors[log.level] || ''}`}>
{log.level}
</span>
</td>
<td className="px-4 py-2 text-sm text-gray-300 whitespace-nowrap">
{log.service}
</td>
<td className="px-4 py-2 text-sm text-gray-200 max-w-xl truncate font-mono">
{log.message}
</td>
</tr>
{expanded && (
<tr className="bg-gray-800/30">
<td colSpan={4} className="px-4 py-3">
<pre className="text-sm text-gray-300 whitespace-pre-wrap font-mono">
{log.message}
</pre>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-gray-500">
<span>Host: {log.host}</span>
<span>Source: {log.source}</span>
{log.container_id && <span>Container: {log.container_id}</span>}
{log.trace_id && (
<a
href={`/traces?id=${log.trace_id}`}
className="text-indigo-400 hover:text-indigo-300"
>
Trace: {log.trace_id.slice(0, 16)}...
</a>
)}
</div>
</td>
</tr>
)}
</>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { RefreshCw, TrendingUp, Cpu, HardDrive, Activity } from 'lucide-react';
import { api } from '@/lib/api';
import { MetricsChart } from '@/components/metrics/MetricsChart';
const METRIC_PRESETS = [
{ name: 'CPU Usage', metric: 'cpu.usage_percent', service: 'system', icon: Cpu },
{ name: 'Memory Usage', metric: 'memory.used_percent', service: 'system', icon: Activity },
{ name: 'Load Average', metric: 'cpu.load_avg_1', service: 'system', icon: TrendingUp },
{ name: 'Disk Usage', metric: 'disk.used_percent', service: 'system', icon: HardDrive },
{ name: 'Network Sent', metric: 'network.bytes_sent', service: 'system', icon: TrendingUp },
{ name: 'Network Recv', metric: 'network.bytes_recv', service: 'system', icon: TrendingUp },
{ name: 'Containers Running', metric: 'containers.running', service: 'docker', icon: Activity },
{ name: 'Container CPU', metric: 'container.cpu_percent', service: 'docker', icon: Cpu },
];
export default function MetricsPage() {
const [timeRange, setTimeRange] = useState('1h');
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([
'cpu.usage_percent',
'memory.used_percent',
]);
const getTimeFrom = () => {
const ranges: Record<string, number> = {
'15m': 15 * 60 * 1000,
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
};
return new Date(Date.now() - (ranges[timeRange] || ranges['1h'])).toISOString();
};
const toggleMetric = (metric: string) => {
setSelectedMetrics((prev) =>
prev.includes(metric)
? prev.filter((m) => m !== metric)
: [...prev, metric]
);
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Metrics</h1>
<div className="flex items-center gap-4">
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-indigo-500"
>
<option value="15m">Last 15 minutes</option>
<option value="1h">Last hour</option>
<option value="6h">Last 6 hours</option>
<option value="24h">Last 24 hours</option>
</select>
</div>
</div>
{/* Metric Selector */}
<div className="flex flex-wrap gap-2">
{METRIC_PRESETS.map((preset) => {
const Icon = preset.icon;
const isSelected = selectedMetrics.includes(preset.metric);
return (
<button
key={preset.metric}
onClick={() => toggleMetric(preset.metric)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
isSelected
? 'bg-indigo-600 border-indigo-500 text-white'
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
<Icon className="h-4 w-4" />
{preset.name}
</button>
);
})}
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{selectedMetrics.map((metric) => {
const preset = METRIC_PRESETS.find((p) => p.metric === metric);
return (
<MetricsChart
key={metric}
title={preset?.name || metric}
service={preset?.service || 'system'}
metric={metric}
from={getTimeFrom()}
/>
);
})}
</div>
{selectedMetrics.length === 0 && (
<div className="flex items-center justify-center h-64 bg-gray-900/50 rounded-lg border border-gray-800 text-gray-500">
Select metrics to display
</div>
)}
</div>
);
}

View File

@@ -1,134 +1,90 @@
'use client'
'use client';
import { useState, useEffect } from 'react'
import Sidebar from '@/components/layout/Sidebar'
import Header from '@/components/layout/Header'
import MetricCard from '@/components/ui/MetricCard'
import HostsTable from '@/components/ui/HostsTable'
import AlertsList from '@/components/ui/AlertsList'
import CpuChart from '@/components/charts/CpuChart'
import MemoryChart from '@/components/charts/MemoryChart'
import AIInsights from '@/components/ui/AIInsights'
import Copilot from '@/components/ui/Copilot'
import { useQuery } from '@tanstack/react-query';
import { Activity, AlertTriangle, Box, Server } from 'lucide-react';
import { MetricCard } from '@/components/dashboard/MetricCard';
import { RecentAlerts } from '@/components/dashboard/RecentAlerts';
import { api } from '@/lib/api';
export default function Dashboard() {
const [showCopilot, setShowCopilot] = useState(false)
const [metrics, setMetrics] = useState({
totalHosts: 0,
healthyHosts: 0,
activeAlerts: 0,
cpuAvg: 0,
memoryAvg: 0,
diskAvg: 0,
})
// Simulated data - replace with real API calls
useEffect(() => {
setMetrics({
totalHosts: 12,
healthyHosts: 11,
activeAlerts: 3,
cpuAvg: 42.5,
memoryAvg: 68.3,
diskAvg: 54.2,
})
}, [])
export default function DashboardPage() {
const { data: overview, isLoading } = useQuery({
queryKey: ['dashboard', 'overview'],
queryFn: () => api.get('/api/v1/dashboard/overview'),
refetchInterval: 30000,
});
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<Sidebar />
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<Header onCopilotClick={() => setShowCopilot(true)} />
{/* Dashboard Content */}
<main className="flex-1 overflow-y-auto p-6 bg-slate-950">
{/* Top Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard
title="Total Hosts"
value={metrics.totalHosts}
subtitle={`${metrics.healthyHosts} healthy`}
icon="🖥️"
trend="stable"
/>
<MetricCard
title="CPU Average"
value={`${metrics.cpuAvg}%`}
subtitle="across all hosts"
icon="⚡"
trend={metrics.cpuAvg > 70 ? 'up' : 'stable'}
alert={metrics.cpuAvg > 80}
/>
<MetricCard
title="Memory Average"
value={`${metrics.memoryAvg}%`}
subtitle="across all hosts"
icon="💾"
trend={metrics.memoryAvg > 70 ? 'up' : 'stable'}
alert={metrics.memoryAvg > 85}
/>
<MetricCard
title="Active Alerts"
value={metrics.activeAlerts}
subtitle="2 critical, 1 warning"
icon="🚨"
trend={metrics.activeAlerts > 0 ? 'up' : 'down'}
alert={metrics.activeAlerts > 0}
/>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<h3 className="text-lg font-semibold mb-4">CPU Usage (24h)</h3>
<CpuChart />
</div>
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<h3 className="text-lg font-semibold mb-4">Memory Usage (24h)</h3>
<MemoryChart />
</div>
</div>
{/* AI Insights */}
<div className="mb-6">
<AIInsights />
</div>
{/* Bottom Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Hosts Table */}
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Hosts</h3>
<button className="text-sm text-green-400 hover:text-green-300">
View all
</button>
</div>
<HostsTable />
</div>
{/* Alerts */}
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Recent Alerts</h3>
<button className="text-sm text-green-400 hover:text-green-300">
View all
</button>
</div>
<AlertsList />
</div>
</div>
</main>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">
🐍 OPHION Dashboard
</h1>
<span className="text-sm text-gray-400">
{new Date().toLocaleString()}
</span>
</div>
{/* AI Copilot Sidebar */}
{showCopilot && (
<Copilot onClose={() => setShowCopilot(false)} />
)}
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="Agents"
value={overview?.agents?.active ?? '-'}
subtitle={`${overview?.agents?.total ?? 0} total`}
icon={<Server className="h-5 w-5" />}
color="blue"
/>
<MetricCard
title="Services"
value={overview?.services?.count ?? '-'}
subtitle="discovered"
icon={<Box className="h-5 w-5" />}
color="green"
/>
<MetricCard
title="Alerts"
value={overview?.alerts?.firing ?? 0}
subtitle="firing"
icon={<AlertTriangle className="h-5 w-5" />}
color={overview?.alerts?.firing > 0 ? 'red' : 'gray'}
/>
<MetricCard
title="Status"
value="Healthy"
subtitle="all systems operational"
icon={<Activity className="h-5 w-5" />}
color="green"
/>
</div>
{/* Alerts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<RecentAlerts />
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4">Quick Links</h3>
<div className="space-y-2">
<QuickLink href="/traces" label="View Traces" desc="Distributed tracing" />
<QuickLink href="/logs" label="Search Logs" desc="Container logs" />
<QuickLink href="/metrics" label="Metrics" desc="System metrics" />
<QuickLink href="/services" label="Service Map" desc="Dependencies" />
</div>
</div>
</div>
</div>
)
);
}
function QuickLink({ href, label, desc }: { href: string; label: string; desc: string }) {
return (
<a
href={href}
className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors"
>
<div>
<p className="text-white font-medium">{label}</p>
<p className="text-sm text-gray-400">{desc}</p>
</div>
<span className="text-gray-400"></span>
</a>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { RefreshCw, GitBranch, AlertCircle } from 'lucide-react';
import { api } from '@/lib/api';
import { ServiceMapGraph } from '@/components/services/ServiceMapGraph';
interface Service {
name: string;
type?: string;
span_count: number;
error_count: number;
avg_duration_ms: number;
first_seen: string;
last_seen: string;
}
interface Dependency {
source: string;
target: string;
call_count: number;
error_count: number;
avg_duration_ms: number;
}
interface ServiceMap {
services: Service[];
dependencies: Dependency[];
updated_at: string;
}
export default function ServicesPage() {
const { data: serviceMap, isLoading, refetch } = useQuery({
queryKey: ['services', 'map'],
queryFn: () => api.get('/api/v1/services/map'),
refetchInterval: 60000,
});
const services: Service[] = serviceMap?.services ?? [];
const dependencies: Dependency[] = serviceMap?.dependencies ?? [];
const totalCalls = dependencies.reduce((sum, d) => sum + d.call_count, 0);
const totalErrors = dependencies.reduce((sum, d) => sum + d.error_count, 0);
const errorRate = totalCalls > 0 ? (totalErrors / totalCalls) * 100 : 0;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Service Map</h1>
<p className="text-sm text-gray-400 mt-1">
Visualize dependencies between services
</p>
</div>
<button
onClick={() => refetch()}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Services"
value={services.length}
icon={<GitBranch className="h-5 w-5 text-indigo-400" />}
/>
<StatCard
label="Dependencies"
value={dependencies.length}
icon={<GitBranch className="h-5 w-5 text-blue-400" />}
/>
<StatCard
label="Total Calls (24h)"
value={totalCalls.toLocaleString()}
icon={<GitBranch className="h-5 w-5 text-green-400" />}
/>
<StatCard
label="Error Rate"
value={`${errorRate.toFixed(2)}%`}
icon={<AlertCircle className={`h-5 w-5 ${errorRate > 5 ? 'text-red-400' : 'text-gray-400'}`} />}
/>
</div>
{/* Service Map Visualization */}
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4" style={{ height: '500px' }}>
{services.length > 0 ? (
<ServiceMapGraph services={services} dependencies={dependencies} />
) : (
<div className="flex items-center justify-center h-full text-gray-500">
{isLoading ? 'Loading service map...' : 'No services discovered yet'}
</div>
)}
</div>
{/* Services Table */}
<div className="bg-gray-900/50 rounded-lg border border-gray-800 overflow-hidden">
<div className="p-4 border-b border-gray-800">
<h2 className="text-lg font-semibold text-white">Services</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Service</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Spans</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Errors</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Avg Duration</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Last Seen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{services.map((svc) => (
<tr key={svc.name} className="hover:bg-gray-800/50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span className="text-white font-medium">{svc.name}</span>
</div>
</td>
<td className="px-4 py-3 text-gray-300">
{svc.span_count.toLocaleString()}
</td>
<td className="px-4 py-3">
<span className={svc.error_count > 0 ? 'text-red-400' : 'text-gray-400'}>
{svc.error_count.toLocaleString()}
</span>
</td>
<td className="px-4 py-3 text-gray-300">
{svc.avg_duration_ms?.toFixed(2)} ms
</td>
<td className="px-4 py-3 text-gray-400 text-sm">
{new Date(svc.last_seen).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function StatCard({ label, value, icon }: { label: string; value: number | string; icon: React.ReactNode }) {
return (
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">{label}</span>
{icon}
</div>
<p className="text-2xl font-bold text-white mt-2">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, Clock, AlertCircle, ExternalLink } from 'lucide-react';
import { api } from '@/lib/api';
import { TraceTimeline } from '@/components/traces/TraceTimeline';
import { formatDuration, formatTime } from '@/lib/utils';
interface Trace {
trace_id: string;
services: string[];
start_time: string;
duration_ns: number;
span_count: number;
has_error: boolean;
root_span?: {
operation: string;
service: string;
};
}
export default function TracesPage() {
const [service, setService] = useState('');
const [operation, setOperation] = useState('');
const [minDuration, setMinDuration] = useState('');
const [onlyErrors, setOnlyErrors] = useState(false);
const [selectedTrace, setSelectedTrace] = useState<string | null>(null);
const { data: traces, isLoading, refetch } = useQuery({
queryKey: ['traces', service, operation, minDuration, onlyErrors],
queryFn: () => api.get('/api/v1/traces', {
service,
operation,
min_duration_ms: minDuration,
error: onlyErrors ? 'true' : '',
from: new Date(Date.now() - 3600000).toISOString(),
}),
});
const { data: traceDetail } = useQuery({
queryKey: ['trace', selectedTrace],
queryFn: () => api.get(`/api/v1/traces/${selectedTrace}`),
enabled: !!selectedTrace,
});
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Traces</h1>
<button
onClick={() => refetch()}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
>
Refresh
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-800">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm text-gray-400 mb-1">Service</label>
<input
type="text"
value={service}
onChange={(e) => setService(e.target.value)}
placeholder="All services"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm text-gray-400 mb-1">Operation</label>
<input
type="text"
value={operation}
onChange={(e) => setOperation(e.target.value)}
placeholder="All operations"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="w-32">
<label className="block text-sm text-gray-400 mb-1">Min Duration</label>
<input
type="text"
value={minDuration}
onChange={(e) => setMinDuration(e.target.value)}
placeholder="ms"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 px-3 py-2 cursor-pointer">
<input
type="checkbox"
checked={onlyErrors}
onChange={(e) => setOnlyErrors(e.target.checked)}
className="rounded border-gray-600 bg-gray-800 text-indigo-500 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-300">Errors only</span>
</label>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Trace List */}
<div className="space-y-2">
<h2 className="text-lg font-semibold text-white">
{isLoading ? 'Loading...' : `${traces?.traces?.length ?? 0} traces`}
</h2>
<div className="space-y-2 max-h-[600px] overflow-auto">
{traces?.traces?.map((trace: Trace) => (
<div
key={trace.trace_id}
onClick={() => setSelectedTrace(trace.trace_id)}
className={`p-4 bg-gray-900/50 rounded-lg border cursor-pointer transition-colors ${
selectedTrace === trace.trace_id
? 'border-indigo-500'
: 'border-gray-800 hover:border-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
{trace.has_error && (
<AlertCircle className="h-4 w-4 text-red-400" />
)}
<span className="font-medium text-white">
{trace.root_span?.operation || trace.trace_id.slice(0, 16)}
</span>
</div>
<div className="flex flex-wrap gap-1 mt-1">
{trace.services?.slice(0, 3).map((svc) => (
<span
key={svc}
className="px-2 py-0.5 text-xs bg-gray-800 text-gray-300 rounded"
>
{svc}
</span>
))}
{(trace.services?.length ?? 0) > 3 && (
<span className="text-xs text-gray-500">
+{trace.services.length - 3} more
</span>
)}
</div>
</div>
<div className="text-right text-sm">
<div className="flex items-center gap-1 text-gray-400">
<Clock className="h-3 w-3" />
{formatDuration(trace.duration_ns)}
</div>
<div className="text-gray-500">
{trace.span_count} spans
</div>
</div>
</div>
<div className="mt-2 text-xs text-gray-500">
{formatTime(trace.start_time)}
</div>
</div>
))}
{!isLoading && (!traces?.traces || traces.traces.length === 0) && (
<div className="text-center py-8 text-gray-500">
No traces found
</div>
)}
</div>
</div>
{/* Trace Detail */}
<div className="space-y-2">
<h2 className="text-lg font-semibold text-white">Trace Timeline</h2>
{selectedTrace && traceDetail ? (
<TraceTimeline trace={traceDetail} />
) : (
<div className="flex items-center justify-center h-64 bg-gray-900/50 rounded-lg border border-gray-800 text-gray-500">
Select a trace to view timeline
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30 seconds
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { ReactNode } from 'react';
interface MetricCardProps {
title: string;
value: string | number;
subtitle?: string;
icon?: ReactNode;
color?: 'blue' | 'green' | 'red' | 'yellow' | 'gray';
trend?: { value: number; isUp: boolean };
}
const colorClasses = {
blue: 'text-blue-400 bg-blue-400/10',
green: 'text-green-400 bg-green-400/10',
red: 'text-red-400 bg-red-400/10',
yellow: 'text-yellow-400 bg-yellow-400/10',
gray: 'text-gray-400 bg-gray-400/10',
};
export function MetricCard({
title,
value,
subtitle,
icon,
color = 'blue',
trend,
}: MetricCardProps) {
return (
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4 hover:border-gray-700 transition-colors">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">{title}</span>
{icon && (
<div className={`p-2 rounded-lg ${colorClasses[color]}`}>
{icon}
</div>
)}
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-white">{value}</span>
{trend && (
<span
className={`text-sm ${
trend.isUp ? 'text-green-400' : 'text-red-400'
}`}
>
{trend.isUp ? '↑' : '↓'} {Math.abs(trend.value)}%
</span>
)}
</div>
{subtitle && (
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle, Clock } from 'lucide-react';
import { api } from '@/lib/api';
import { formatTime } from '@/lib/utils';
interface Alert {
id: string;
name: string;
severity: string;
status: string;
service?: string;
message: string;
fired_at: string;
}
export function RecentAlerts() {
const { data, isLoading } = useQuery({
queryKey: ['alerts', 'recent'],
queryFn: () => api.get('/api/v1/alerts', { limit: 5 }),
refetchInterval: 30000,
});
const alerts: Alert[] = data?.alerts ?? [];
const severityColors: Record<string, string> = {
critical: 'text-red-400 bg-red-400/10 border-red-400/20',
warning: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20',
info: 'text-blue-400 bg-blue-400/10 border-blue-400/20',
};
return (
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Recent Alerts</h3>
<a
href="/alerts"
className="text-sm text-indigo-400 hover:text-indigo-300"
>
View all
</a>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-500">
Loading...
</div>
) : alerts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<CheckCircle className="h-8 w-8 mb-2 text-green-400" />
<p>No active alerts</p>
</div>
) : (
<div className="space-y-2">
{alerts.map((alert) => (
<div
key={alert.id}
className={`p-3 rounded-lg border ${
severityColors[alert.severity] || severityColors.info
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
<span className="font-medium">{alert.name}</span>
</div>
<span className="text-xs opacity-75">
{alert.status === 'firing' ? '🔴' : '✅'} {alert.status}
</span>
</div>
<p className="text-sm mt-1 opacity-75 line-clamp-2">
{alert.message}
</p>
<div className="flex items-center gap-2 mt-2 text-xs opacity-50">
<Clock className="h-3 w-3" />
{formatTime(alert.fired_at)}
{alert.service && (
<>
<span></span>
<span>{alert.service}</span>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,96 +1,74 @@
'use client'
'use client';
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Home,
Activity,
FileText,
GitBranch,
BarChart2,
AlertTriangle,
Server,
Settings,
} from 'lucide-react';
const menuItems = [
{ name: 'Overview', icon: '📊', href: '/' },
{ name: 'Hosts', icon: '🖥️', href: '/hosts' },
{ name: 'Containers', icon: '🐳', href: '/containers' },
{ name: 'Metrics', icon: '📈', href: '/metrics' },
{ name: 'Logs', icon: '📝', href: '/logs' },
{ name: 'Traces', icon: '🔍', href: '/traces' },
{ name: 'Alerts', icon: '🚨', href: '/alerts' },
{ name: 'Dashboards', icon: '📋', href: '/dashboards' },
]
const navigation = [
{ name: 'Dashboard', href: '/', icon: Home },
{ name: 'Traces', href: '/traces', icon: Activity },
{ name: 'Logs', href: '/logs', icon: FileText },
{ name: 'Metrics', href: '/metrics', icon: BarChart2 },
{ name: 'Services', href: '/services', icon: GitBranch },
{ name: 'Alerts', href: '/alerts', icon: AlertTriangle },
{ name: 'Agents', href: '/agents', icon: Server },
];
const bottomItems = [
{ name: 'AI Insights', icon: '🤖', href: '/ai' },
{ name: 'Settings', icon: '⚙️', href: '/settings' },
]
export default function Sidebar() {
const pathname = usePathname()
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
<div className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
{/* Logo */}
<div className="p-6 border-b border-slate-800">
<Link href="/" className="flex items-center space-x-3">
<span className="text-3xl">🐍</span>
<div>
<span className="text-xl font-bold bg-gradient-to-r from-green-400 to-emerald-500 bg-clip-text text-transparent">
OPHION
</span>
<span className="ml-2 text-xs bg-purple-600 px-2 py-0.5 rounded-full">AI</span>
</div>
<div className="h-16 flex items-center px-6 border-b border-gray-800">
<Link href="/" className="flex items-center gap-2">
<span className="text-2xl">🐍</span>
<span className="text-xl font-bold text-white">OPHION</span>
</Link>
</div>
{/* Main Navigation */}
<nav className="flex-1 p-4 space-y-1">
{menuItems.map((item) => {
const isActive = pathname === item.href
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-all ${
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'bg-green-600/20 text-green-400 border-l-2 border-green-400'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
? 'bg-indigo-600 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<span className="text-xl">{item.icon}</span>
<span className="font-medium">{item.name}</span>
<Icon className="h-5 w-5" />
{item.name}
</Link>
)
);
})}
</nav>
{/* Bottom Navigation */}
<div className="p-4 border-t border-slate-800 space-y-1">
{bottomItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-all ${
isActive
? 'bg-purple-600/20 text-purple-400'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`}
>
<span className="text-xl">{item.icon}</span>
<span className="font-medium">{item.name}</span>
</Link>
)
})}
{/* Footer */}
<div className="p-4 border-t border-gray-800">
<Link
href="/settings"
className="flex items-center gap-3 px-3 py-2 text-gray-400 hover:bg-gray-800 hover:text-white rounded-lg transition-colors"
>
<Settings className="h-5 w-5" />
Settings
</Link>
<p className="text-xs text-gray-600 mt-4 px-3">v0.2.0</p>
</div>
{/* User */}
<div className="p-4 border-t border-slate-800">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-emerald-600 flex items-center justify-center font-bold">
A
</div>
<div>
<p className="font-medium">Admin</p>
<p className="text-xs text-slate-500">admin@empresa.com</p>
</div>
</div>
</div>
</aside>
)
</div>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import { api } from '@/lib/api';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
interface MetricsChartProps {
title: string;
service: string;
metric: string;
from: string;
}
interface MetricPoint {
timestamp: string;
value: number;
}
export function MetricsChart({ title, service, metric, from }: MetricsChartProps) {
const { data, isLoading, error } = useQuery({
queryKey: ['metrics', service, metric, from],
queryFn: () => api.get('/api/v1/metrics', { service, name: metric, from }),
refetchInterval: 30000,
});
const metrics: MetricPoint[] = data?.metrics ?? [];
const chartData = {
labels: metrics.map((m) => {
const date = new Date(m.timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}),
datasets: [
{
label: title,
data: metrics.map((m) => m.value),
borderColor: 'rgb(99, 102, 241)',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 4,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const,
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(17, 17, 27, 0.9)',
titleColor: '#fff',
bodyColor: '#a1a1aa',
borderColor: '#3f3f46',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: (context: any) => {
let value = context.parsed.y;
if (metric.includes('percent')) {
return `${value.toFixed(1)}%`;
}
if (metric.includes('bytes')) {
if (value > 1e9) return `${(value / 1e9).toFixed(2)} GB`;
if (value > 1e6) return `${(value / 1e6).toFixed(2)} MB`;
if (value > 1e3) return `${(value / 1e3).toFixed(2)} KB`;
return `${value} B`;
}
return value.toFixed(2);
},
},
},
},
scales: {
x: {
grid: {
color: 'rgba(63, 63, 70, 0.3)',
},
ticks: {
color: '#71717a',
maxTicksLimit: 6,
},
},
y: {
grid: {
color: 'rgba(63, 63, 70, 0.3)',
},
ticks: {
color: '#71717a',
callback: (value: number) => {
if (metric.includes('percent')) {
return `${value}%`;
}
if (metric.includes('bytes')) {
if (value > 1e9) return `${(value / 1e9).toFixed(0)}G`;
if (value > 1e6) return `${(value / 1e6).toFixed(0)}M`;
if (value > 1e3) return `${(value / 1e3).toFixed(0)}K`;
return value;
}
return value;
},
},
beginAtZero: true,
suggestedMax: metric.includes('percent') ? 100 : undefined,
},
},
};
// Calculate current/avg/max
const values = metrics.map((m) => m.value);
const current = values.length > 0 ? values[values.length - 1] : 0;
const avg = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
const max = values.length > 0 ? Math.max(...values) : 0;
const formatValue = (v: number) => {
if (metric.includes('percent')) return `${v.toFixed(1)}%`;
if (metric.includes('bytes')) {
if (v > 1e9) return `${(v / 1e9).toFixed(2)} GB`;
if (v > 1e6) return `${(v / 1e6).toFixed(2)} MB`;
if (v > 1e3) return `${(v / 1e3).toFixed(2)} KB`;
return `${v.toFixed(0)} B`;
}
return v.toFixed(2);
};
return (
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-400">
Current: <span className="text-white">{formatValue(current)}</span>
</span>
<span className="text-gray-400">
Avg: <span className="text-white">{formatValue(avg)}</span>
</span>
<span className="text-gray-400">
Max: <span className="text-white">{formatValue(max)}</span>
</span>
</div>
</div>
<div className="h-48">
{isLoading ? (
<div className="flex items-center justify-center h-full text-gray-500">
Loading...
</div>
) : error ? (
<div className="flex items-center justify-center h-full text-red-400">
Error loading data
</div>
) : metrics.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500">
No data available
</div>
) : (
<Line data={chartData} options={options as any} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
'use client';
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
interface Service {
name: string;
span_count: number;
error_count: number;
}
interface Dependency {
source: string;
target: string;
call_count: number;
error_count: number;
}
interface ServiceMapGraphProps {
services: Service[];
dependencies: Dependency[];
}
export function ServiceMapGraph({ services, dependencies }: ServiceMapGraphProps) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!svgRef.current || services.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const width = svgRef.current.clientWidth;
const height = svgRef.current.clientHeight;
// Create nodes
const nodes = services.map((s) => ({
id: s.name,
...s,
}));
// Create links
const links = dependencies.map((d) => ({
...d,
}));
// Force simulation
const simulation = d3
.forceSimulation(nodes as any)
.force(
'link',
d3
.forceLink(links)
.id((d: any) => d.id)
.distance(150)
)
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(60));
// Arrow markers
svg
.append('defs')
.append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '-0 -5 10 10')
.attr('refX', 25)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 8)
.attr('markerHeight', 8)
.append('path')
.attr('d', 'M 0,-5 L 10,0 L 0,5')
.attr('fill', '#4b5563');
// Links
const link = svg
.append('g')
.selectAll('line')
.data(links)
.enter()
.append('line')
.attr('stroke', (d: any) => (d.error_count > 0 ? '#ef4444' : '#4b5563'))
.attr('stroke-width', (d: any) => Math.min(Math.log(d.call_count + 1) + 1, 4))
.attr('stroke-opacity', 0.6)
.attr('marker-end', 'url(#arrowhead)');
// Link labels
const linkLabels = svg
.append('g')
.selectAll('text')
.data(links)
.enter()
.append('text')
.attr('font-size', '10px')
.attr('fill', '#9ca3af')
.text((d: any) => d.call_count.toLocaleString());
// Nodes
const node = svg
.append('g')
.selectAll('g')
.data(nodes)
.enter()
.append('g')
.call(
d3.drag<SVGGElement, any>()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
})
);
// Node circles
node
.append('circle')
.attr('r', (d: any) => Math.min(Math.log(d.span_count + 1) * 5 + 20, 40))
.attr('fill', (d: any) =>
d.error_count > 0 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(99, 102, 241, 0.2)'
)
.attr('stroke', (d: any) =>
d.error_count > 0 ? '#ef4444' : '#6366f1'
)
.attr('stroke-width', 2);
// Node labels
node
.append('text')
.attr('text-anchor', 'middle')
.attr('dy', 4)
.attr('font-size', '12px')
.attr('font-weight', '500')
.attr('fill', '#e5e7eb')
.text((d: any) => d.id.length > 15 ? d.id.slice(0, 15) + '...' : d.id);
// Span count labels
node
.append('text')
.attr('text-anchor', 'middle')
.attr('dy', 18)
.attr('font-size', '10px')
.attr('fill', '#9ca3af')
.text((d: any) => `${d.span_count.toLocaleString()} spans`);
simulation.on('tick', () => {
link
.attr('x1', (d: any) => d.source.x)
.attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x)
.attr('y2', (d: any) => d.target.y);
linkLabels
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
.attr('y', (d: any) => (d.source.y + d.target.y) / 2);
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`);
});
return () => {
simulation.stop();
};
}, [services, dependencies]);
return (
<svg
ref={svgRef}
className="w-full h-full"
style={{ minHeight: '400px' }}
/>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import { useMemo } from 'react';
import { formatDuration } from '@/lib/utils';
interface Span {
trace_id: string;
span_id: string;
parent_span_id?: string;
service: string;
operation: string;
start_time: string;
end_time: string;
duration_ns: number;
status: { code: string; message?: string };
kind: string;
attributes?: Record<string, any>;
}
interface Trace {
trace_id: string;
spans: Span[];
duration_ns: number;
start_time: string;
}
interface TraceTimelineProps {
trace: Trace;
}
export function TraceTimeline({ trace }: TraceTimelineProps) {
const { spans, minTime, maxTime, duration } = useMemo(() => {
if (!trace.spans || trace.spans.length === 0) {
return { spans: [], minTime: 0, maxTime: 0, duration: 0 };
}
const times = trace.spans.map((s) => ({
start: new Date(s.start_time).getTime(),
end: new Date(s.end_time).getTime(),
}));
const minTime = Math.min(...times.map((t) => t.start));
const maxTime = Math.max(...times.map((t) => t.end));
const duration = maxTime - minTime;
// Sort spans by start time
const sortedSpans = [...trace.spans].sort(
(a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
);
return { spans: sortedSpans, minTime, maxTime, duration };
}, [trace]);
const serviceColors: Record<string, string> = {};
const colors = [
'bg-indigo-500',
'bg-blue-500',
'bg-green-500',
'bg-yellow-500',
'bg-purple-500',
'bg-pink-500',
'bg-cyan-500',
];
spans.forEach((span, i) => {
if (!serviceColors[span.service]) {
serviceColors[span.service] = colors[Object.keys(serviceColors).length % colors.length];
}
});
if (spans.length === 0) {
return (
<div className="flex items-center justify-center h-64 bg-gray-900/50 rounded-lg border border-gray-800 text-gray-500">
No spans in this trace
</div>
);
}
return (
<div className="bg-gray-900/50 rounded-lg border border-gray-800 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-800">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">
Trace: {trace.trace_id.slice(0, 16)}...
</h3>
<p className="text-sm text-gray-400">
{spans.length} spans {formatDuration(trace.duration_ns)}
</p>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(serviceColors).map(([service, color]) => (
<span
key={service}
className="flex items-center gap-1 text-xs text-gray-400"
>
<span className={`w-2 h-2 rounded ${color}`} />
{service}
</span>
))}
</div>
</div>
</div>
{/* Timeline */}
<div className="p-4 space-y-2 max-h-[400px] overflow-auto">
{spans.map((span, i) => {
const startOffset = new Date(span.start_time).getTime() - minTime;
const spanDuration = span.duration_ns / 1000000; // ns to ms
const left = duration > 0 ? (startOffset / duration) * 100 : 0;
const width = duration > 0 ? Math.max((spanDuration / (duration / 1000000)) * 100, 1) : 100;
const hasError = span.status?.code === 'ERROR';
const color = hasError ? 'bg-red-500' : serviceColors[span.service];
return (
<div key={span.span_id} className="group">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-500 w-20 text-right font-mono">
{formatDuration(span.duration_ns)}
</span>
<span className={`w-2 h-2 rounded ${color}`} />
<span className="text-sm text-gray-300 truncate flex-1">
{span.service}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-20" />
<div className="flex-1 h-6 bg-gray-800 rounded relative">
<div
className={`absolute h-full rounded ${color} opacity-75 hover:opacity-100 transition-opacity cursor-pointer`}
style={{
left: `${left}%`,
width: `${Math.max(width, 0.5)}%`,
minWidth: '4px',
}}
title={`${span.operation}\n${formatDuration(span.duration_ns)}`}
/>
</div>
</div>
<div className="flex items-center gap-2 mt-1">
<div className="w-20" />
<span className="text-xs text-gray-500 truncate">
{span.operation}
{hasError && (
<span className="ml-2 text-red-400">
{span.status.message || 'Error'}
</span>
)}
</span>
</div>
</div>
);
})}
</div>
</div>
);
}

87
dashboard/src/lib/api.ts Normal file
View File

@@ -0,0 +1,87 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || '';
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = '') {
this.baseUrl = baseUrl;
}
async get(path: string, params?: Record<string, any>): Promise<any> {
const url = new URL(path, this.baseUrl || window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.append(key, String(value));
}
});
}
const response = await fetch(url.toString(), {
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
async post(path: string, data: any): Promise<any> {
const url = new URL(path, this.baseUrl || window.location.origin);
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
async put(path: string, data?: any): Promise<any> {
const url = new URL(path, this.baseUrl || window.location.origin);
const response = await fetch(url.toString(), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
async delete(path: string): Promise<any> {
const url = new URL(path, this.baseUrl || window.location.origin);
const response = await fetch(url.toString(), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
}
export const api = new ApiClient(API_URL);

View File

@@ -0,0 +1,78 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDuration(nanoseconds: number): string {
const ms = nanoseconds / 1_000_000;
if (ms < 1) {
return `${(nanoseconds / 1000).toFixed(0)}μs`;
}
if (ms < 1000) {
return `${ms.toFixed(1)}ms`;
}
if (ms < 60000) {
return `${(ms / 1000).toFixed(2)}s`;
}
return `${(ms / 60000).toFixed(1)}m`;
}
export function formatTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
// Within last hour, show relative time
if (diff < 3600000) {
const minutes = Math.floor(diff / 60000);
if (minutes < 1) {
return 'just now';
}
return `${minutes}m ago`;
}
// Today, show time only
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
// Show date and time
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export function formatNumber(num: number): string {
if (num >= 1_000_000) {
return `${(num / 1_000_000).toFixed(1)}M`;
}
if (num >= 1_000) {
return `${(num / 1_000).toFixed(1)}K`;
}
return num.toString();
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length) + '...';
}
export function generateId(): string {
return Math.random().toString(36).substr(2, 9);
}

View File

@@ -25,10 +25,6 @@ COPY internal/ ./internal/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o ophion-server ./cmd/server
# Build agent
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o ophion-agent ./cmd/agent
# ─────────────────────────────────────────────────────────────
# Stage 2: Build Dashboard (Next.js)
# ─────────────────────────────────────────────────────────────
@@ -38,7 +34,7 @@ WORKDIR /build
# Dependências
COPY dashboard/package*.json ./
RUN npm ci --only=production
RUN npm install
# Código fonte
COPY dashboard/ ./
@@ -76,7 +72,6 @@ WORKDIR /app
# Copiar binários Go
COPY --from=go-builder /build/ophion-server /app/bin/
COPY --from=go-builder /build/ophion-agent /app/bin/
# Copiar Dashboard
COPY --from=web-builder /build/.next /app/web/.next

View File

@@ -0,0 +1,57 @@
# ═══════════════════════════════════════════════════════════
# 🐍 OPHION Agent - Dockerfile
# ═══════════════════════════════════════════════════════════
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /build
# Install dependencies
RUN apk add --no-cache git ca-certificates
# Copy go modules first (cache layer)
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY cmd/ ./cmd/
COPY internal/ ./internal/
# Build the agent binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o ophion-agent ./cmd/agent
# Runtime stage
FROM alpine:3.19
LABEL org.opencontainers.image.title="OPHION Agent"
LABEL org.opencontainers.image.description="Observability Agent - Metrics, Logs, Traces Collector"
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata
# Create non-root user
RUN addgroup -g 1000 ophion && \
adduser -u 1000 -G ophion -s /bin/sh -D ophion
WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/ophion-agent /app/
USER ophion
# Environment defaults
ENV TZ=America/Sao_Paulo \
OPHION_SERVER=http://localhost:8080 \
OPHION_INTERVAL=30s \
OPHION_DOCKER=true \
OPHION_LOGS=true \
OPHION_OTLP=true \
OPHION_OTLP_PORT=4318
# OTLP receiver port
EXPOSE 4318
ENTRYPOINT ["/app/ophion-agent"]

View File

@@ -1,13 +1,59 @@
# ═══════════════════════════════════════════════════════════
# 🐍 OPHION Server - Dockerfile
# ═══════════════════════════════════════════════════════════
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
WORKDIR /build
# Install dependencies
RUN apk add --no-cache git ca-certificates
# Copy go modules first (cache layer)
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o ophion-server ./cmd/server
# Copy source code
COPY cmd/ ./cmd/
COPY internal/ ./internal/
# Build the server binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o ophion-server ./cmd/server
# Runtime stage
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
LABEL org.opencontainers.image.title="OPHION Server"
LABEL org.opencontainers.image.description="Observability Platform API Server"
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata wget
# Create non-root user
RUN addgroup -g 1000 ophion && \
adduser -u 1000 -G ophion -s /bin/sh -D ophion
WORKDIR /app
COPY --from=builder /app/ophion-server .
# Copy binary from builder
COPY --from=builder /build/ophion-server /app/
# Create data directories
RUN mkdir -p /app/data /app/logs && \
chown -R ophion:ophion /app
USER ophion
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:8080/health || exit 1
# Environment defaults
ENV TZ=America/Sao_Paulo \
PORT=8080
EXPOSE 8080
CMD ["./ophion-server"]
ENTRYPOINT ["/app/ophion-server"]

View File

@@ -1,35 +1,24 @@
version: '3.8'
services:
ophion-server:
ophion:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.server
dockerfile: deploy/docker/Dockerfile
ports:
- "8080:8080"
- "8090:8080"
- "3001:3000"
environment:
- DATABASE_URL=postgres://ophion:ophion@postgres:5432/ophion
- CLICKHOUSE_URL=clickhouse://clickhouse:9000/ophion
- REDIS_URL=redis://redis:6379
- DATABASE_URL=${DATABASE_URL:-postgres://ophion:ophion@postgres:5432/ophion}
- CLICKHOUSE_URL=${CLICKHOUSE_URL:-clickhouse://clickhouse:9000/ophion}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@ophion.com.br}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-ophion123}
depends_on:
- postgres
- clickhouse
- redis
restart: unless-stopped
ophion-web:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.web
ports:
- "3000:3000"
environment:
- API_URL=http://ophion-server:8080
depends_on:
- ophion-server
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:

View File

@@ -24,8 +24,11 @@ case "$MODE" in
exec npm start
;;
all)
echo "Starting all services with supervisor..."
exec supervisord -c /etc/supervisord.conf
echo "Starting all services..."
# Start server in background
/app/bin/ophion-server &
# Start web
cd /app/web && npm start
;;
*)
echo "Unknown mode: $MODE"

123
docker-compose.yml Normal file
View File

@@ -0,0 +1,123 @@
version: '3.8'
# ═══════════════════════════════════════════════════════════
# 🐍 OPHION - Docker Compose
# Observability Platform with ClickHouse, PostgreSQL, Redis
# ═══════════════════════════════════════════════════════════
services:
# ─────────────────────────────────────────────────────────
# OPHION Server (Go API)
# ─────────────────────────────────────────────────────────
server:
build:
context: .
dockerfile: deploy/docker/Dockerfile.server
ports:
- "8080:8080"
environment:
- PORT=8080
- DATABASE_URL=postgres://ophion:ophion@postgres:5432/ophion?sslmode=disable
- CLICKHOUSE_URL=clickhouse://default:@clickhouse:9000/ophion
- REDIS_URL=redis://redis:6379
depends_on:
postgres:
condition: service_healthy
clickhouse:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
networks:
- ophion
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 3
# ─────────────────────────────────────────────────────────
# OPHION Dashboard (Next.js)
# ─────────────────────────────────────────────────────────
dashboard:
build:
context: ./dashboard
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://server:8080
- NODE_ENV=production
depends_on:
- server
restart: unless-stopped
networks:
- ophion
# ─────────────────────────────────────────────────────────
# PostgreSQL (Metadata, Users, Alerts)
# ─────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ophion
POSTGRES_PASSWORD: ophion
POSTGRES_DB: ophion
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- ophion
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ophion"]
interval: 5s
timeout: 5s
retries: 5
# ─────────────────────────────────────────────────────────
# ClickHouse (Metrics, Traces, Logs)
# ─────────────────────────────────────────────────────────
clickhouse:
image: clickhouse/clickhouse-server:24.1
ports:
- "9000:9000" # Native protocol
- "8123:8123" # HTTP interface
volumes:
- clickhouse_data:/var/lib/clickhouse
- ./configs/clickhouse:/etc/clickhouse-server/config.d
environment:
- CLICKHOUSE_DB=ophion
restart: unless-stopped
networks:
- ophion
healthcheck:
test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
interval: 5s
timeout: 5s
retries: 5
# ─────────────────────────────────────────────────────────
# Redis (Cache, Pub/Sub)
# ─────────────────────────────────────────────────────────
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
restart: unless-stopped
networks:
- ophion
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
ophion:
driver: bridge
volumes:
postgres_data:
clickhouse_data:
redis_data:

View File

@@ -0,0 +1,424 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 5 0 R /F3 9 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 200 /Length 1086 /SMask 4 0 R
/Subtype /Image /Type /XObject /Width 200
>>
stream
Gb"0S6-e1<&:dVQdXBHg1\C=Q%Z;s$I\B#cJN05=^F,<nS]G/JLL)S5zzzzzz!&3='hqu[kp-e!8=HpDl7_:'kXlI4ImuL(EL##f'VuY\68m3M]gV@H!8ZS!d!U+9<hZU185j*?@rVgm88?8,%qrp]<h5I,LbfAI7q^WaVoe`dZpWYt\+)gkeoiY7TBoTZA7escs]h>adrbbfcb2n_Zg/lbRC6":RED9^jjm]5B/uu$A4=t;?=*nWjC7_+gm!pamhk/K`nJL5<<I;3UC\.:D?,_$AP'rEmq_M\P%WU>#=_Z7?V5m%T^LX&gDp"7-W]XQA_Q<DC<FKs89N5$:>r#]NoQ7?5?8>@mF$9%PX-]$+Wf0bI-E7uYXdYu!#e4i2atCPNrT.""LZ)NYNpD`-N^Ted)bm9o-iN$H?t)l">HY+q%7W_SW,@3Ja%#u^eP5Q'fZ;Lk79Z[$M<a*>0TVU9Kf6l1T?+d$mR]8#Z4cPTpMs!?RRG3hNEjr8VFFW9#$spMW,@cY?!b+o^^XQQPjpX#;<m+59"b?+8G]-a.dqBU;>0h/fS_6D\eMZ.)Ce0u;r]L9/(rJ>&WW+GG)C-gQ)%^o;$Bp,7<B87A+.LH,(&qE\r5q/3G"07;W<h.>u+OY(+&kUQ'CM_qu"FsM@n!IKm>&LC@g;HnU2nOfX8CD.el5UYCU,Yh@4O#]:,O$jdL?Y\mg>g>@EgX=-i9n0N%j#9D;noX"8;YL;Cpj?C%`lm*tr(d8bP:-D2&W@Eb]a$f4n-WH_!9N"C@eN&D4F2K)];f$S8@C@.a-V<'S=J:P8jhGa'j)>IO:Xs2**+[S>tV@UInJoi<=H?@+3ds"/V<Ah%^a1&gU^IqY]9mgJs;UmZOXm,"07M?L]oL*i[E^)]fM5m@G6:2RfS9g%Mr^CYe-=mEecj9sPP7M\-f,<*t9/!;&4[&9S'nE5`G/(s#Ah\OX-&Vju&/@,/T!`mITW<3W*jjWm*#0idoGk4rJ+ETNU8"?rO:OO]DiQi(N^fc&".iI#+t<d/'n^n+PGm.*5tdeKo[q?!Dp)&nSZW(oD]4)G#ljr*zzzzz!!%PEr!-a14G3~>endstream
endobj
4 0 obj
<<
/BitsPerComponent 8 /ColorSpace /DeviceGray /Decode [ 0 1 ] /Filter [ /ASCII85Decode /FlateDecode ] /Height 200 /Length 865
/Subtype /Image /Type /XObject /Width 200
>>
stream
Gb"/jgN%p#$q1'o5HR[("J"D.,SR@DHQHnVfi(O!V)0Jb#U+j463n`f&4-XGKFgHU+bUCnaD>BsFp]nnAp+dtOtf$mMdfSH5U)@`;*Qq27EScJ2/l!!;$2+!7?uTY0L\/M?F/L(5`4>b4"nPB\;tf<M;hA@[U1S"^kn:SPna9^C8'X4<509cj-=9iO)h$=*mI)-4?<O4n;qs[n"5Hpd1p=,%=7r0hE3ML3W_T_FDhDoa"'nd+Ckr3@np,879*VXNiHOYGVYU$FX0Kc<Kgcj4;Z(nY/t$d!(AFbU>3Lj?n6B4rNLWVM&7TNRYZ*<Y2Y!Z@Qr/C7[F#^ba&abA&NBl^=NFC\8ILL4FFVb.sYNY3:FRY6la,0>X(!*0o#Se,7G/s<@*a5d+@@ApDB19&RgL9A<@HB#TGP?@n7<[g+aXtr1aH^_jpkF6i9AmXInZ!1.#Kd+m;<`LgX5J3=!L,>1(7Z)pn5N/*@:`[WsZY=R@ig+Jp50^p&+gnPp>o<F(fUdQfYb8!J]iCer1s1gD75BKKHD$d-@a=\p%1<]=(>@i.@:cG0Nl@=_EY12[@uPAdPdTml%ca^A<54:?LWTae&^m([pi.iqH_6sWs9%R(n,Mp_^3a`isg!pcQ7&6t3jlLZ2?XaJ2.MTV+BDd`>]eV);]Oh*t\"*t00BOhdW(fGc,2?CAD/s]tT:$3bg;Zsk82O^c)4.GCW/WU_ea%IJmW)5DNP?*"&<HM=We&KqZnm8L(n46]?89R#?G,5$Q3GrT$P7/)'1HSnrf1$%IdFaN5iA<*[;%]3:9Zt+;30!QA!r3\?MgSj)4s&s]G0_"a,pWa>KFgHU+bUCn#U+j463n`f&4-X/`#gs::,)~>endstream
endobj
5 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
/FormXob.c0e18debeba036958ca41c3f85fba57a 3 0 R
>>
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
10 0 obj
<<
/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/Contents 34 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
14 0 obj
<<
/Contents 35 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
15 0 obj
<<
/Contents 36 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
16 0 obj
<<
/Contents 37 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
17 0 obj
<<
/Contents 38 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
18 0 obj
<<
/Contents 39 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
19 0 obj
<<
/Contents 40 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
20 0 obj
<<
/Contents 41 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
21 0 obj
<<
/Contents 42 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
22 0 obj
<<
/Contents 43 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
23 0 obj
<<
/Contents 44 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
24 0 obj
<<
/Contents 45 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
25 0 obj
<<
/PageMode /UseNone /Pages 27 0 R /Type /Catalog
>>
endobj
26 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260206102714-03'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260206102714-03'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
27 0 obj
<<
/Count 18 /Kids [ 6 0 R 7 0 R 8 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R
17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R ] /Type /Pages
>>
endobj
28 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 806
>>
stream
GasbX?#S1G(kqGM/)EoaRci;[^+<b-&_l#rJ<1<Y8XbZ)Rqudt1l3=#Rsq/.f2OS)eB#\PF,l<@_!/`/4^S3%:Z2-X!<j`)5fFE_f5R-GNtH8p7PNkV8$LDqA$)DLd9Rk+MDMH#-`(?!Pc]Ij;)7CCgShso)Etp9S&Mit@lZ3h9oH*o;D*s\/l*5EEp!!F9=7LQ6VSZ742tb9f/frBGrgNMU(]XY$t];<7M[i.1236j(7P,V6GP4?1Vk5;:auOr3Y`gl9Mc,:^%\<nVXS1[q&#02ha6lAP':lr+/Sl>R'kHfP%k:[0HmOG#`LTtQJrG^G<guMA_I-0bIhh0$hPH/n_'`;;`)H-IdFmsWY8bE)$TkOEVBOr42GFFJT2j@TGZ4A"?3tm.i%QVgS)1Y+,UChf2#+I^c5O6c#]>=o5aL*PBja.;*FUoH8A?Ai9_^:4m51Wh?hn&TJajn6c1K%`.UNmj_Hq+G57'C%oQkgn,1t"DSM.5iF70j\\:bWHha1K(g^>]YdO:EBUIZ+p5j$]0b6(;O3+Q#EOB&Jo/'6Yp^7m\2(S-fRdK-K'%8ub[=>*4Z7;3Aoh^`rpoY:gka:@CL/[94'=?'=ld-+0gk`b*iQO9dF7ZeQQ@t9W'D#5o9MI10)?^jg&)E^1$n\"\R/Zhn3)8]Im9@&pX<2nWFiEot_6PE?L8!f6QSQcFoRgREWB('2CSeRc/b#XSSdN`ei/SMb*D+@-UZIM`'E^e:.eBCPmOM%?$P?4+4<0CUQ;22Eh:_:s>gCf5q7D<iRQaS'b@2>W)/?WS&H)^?RXnr~>endstream
endobj
29 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 664
>>
stream
Gat=i_2d5'&;KX9`>iS<>h^M63MnBFOgYm>U>?>6i$u"JEVfU(7-mO(gob3ki\H_fkF$gK(f3>2r]TrT*uk"EK`U5\S:G/rKB<KKc/Q0>pIrD[.!`q-a:#jW;;ud:-G7P>gL(B1Bt9oT\FTd&Z'Pj&A<4\iE&n`sHn*C?+>h86N&N=>GG`n\oa[GJd(G.alR66?^_9%;(PM@eQr(X+4F]r4"gHVf1Vp=6Nm>\9@EAfQdY"pr<B8aWA93CcZmDe]B=tS<Ga.n>q`gTeSVY)g<:2YZYPdTp!99:1YLY,^eI]P_=X(nTi.D7f48Jg4f,-C9?/GhtRkbQu,PY5ZdV!A`(np4*HKmj#78Y=.j9.ZiiGu0N[:)cXGETu`#*GoZJWe[Si9.Od!XN#Z?EH+L64G,7<Fmloj/P3,@98.P0arBtOi,7<27^OmN8C_O.G8EV%PENX.V_<S:DIJ5T^JZJ:j'q:g]#QZ.3N+)_GYNu."T'1BVJg;'8"$`+$J6nQOl",1sn1]1X8-CMb\Q`hS)0,;e=(t/Sd4(KK4e"'%M#Z\:9\l89B-Ug0P^4s1_h(]/1aM7[,<BOX`s]g#^[8r-okaCEmAOkK.I,Rr"T8&"OUkf)"lVm[_=M5%ZP?/6S,!QM#L1mVYISbqNK#a%=)+034_V3W~>endstream
endobj
30 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 596
>>
stream
GatUp9i'Ou&;KZN/*=?@9,a:Ip9pe>+@4]/ZmRI>B/[7lia=ipkcaG#:]qJhBNLKD41==?GPp.r6>g9/WYXN&!Umc/&/'jPLa\(P\:DoEibfn\<[KRmEg[;+j"q<mP4Ll/%V-UeOn"SW%eD61^t&mR-6<0o'J0QV+HeIhP[ipR#iM%mB](CDClY1`%E5rApmp/T<-e%f)G\a78Kt`9Z&WeOEK@]bS=O#8"+m48kSs0(23tihJLMVm*L=:a(%V-;+AepeFbe>,[p#J06"=ZMgV$$EDO^sfH"qFqS^R9dpEa"H\!m=Rd%f\&VUOC(CoR"1H>CmnSI+0(RcNE`X[+@'R`#cYs!5XG`BBV'B9V=j;M0,3I@.iC_`-j16]eM&ob];'8d%)re'62R>d#e^n4>]Q\5*jq*6h"s:A.KA@QYOIojnZu5?Jd^!uT/YJLlg-Q\21C$MK,?l5<!Xm8#i%]l&dao/6$q(-RcFBlk5jJhu>4S+hZ@#B,XKLfn!maFfdAXNV)fG^uoF[!\-Hh2q:cc83`U0[Rg;V3TZ0F5qV?Yide_T7Rs9oFN+<].FKGMX<JbXt.,.8,B&e=Sr?\mp)d~>endstream
endobj
31 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 490
>>
stream
Gat=g?VeNm'ZJu*'^'K2:tfgQ>2Bhj->65Apqk4)18eMi2iJ8rGh[s$&Rq'&adVq[H[b-i]aWCB(;@sDE1UQY`P=J(2-<0Xj:be\^%WT&B*qCeieemP1W#AQY0M_-<s`BbV3$ok8JBa^kLA,p5TnrFE=O>.39OU/iWLM'^8i1.ltk'>rdt'l'-H!j7?,aul2cE6E)0k-2fP_Yd'.>CBVt0P[K-td%,klUdI\4+AoLa%p<imLE7WRTP?o6X08fUO@1'W`^&Dih9ARGdc+7'qQ27UC2DI*/h3\u6$PdkS:JKZ0<mMg6a8A$T'WY\S=u7lfe^_K\;_;?pbc]ME*S>+0e:WT,gUIfcTAcPO<V;2U:b85]$ZO6Zr&]$ncqGe]]=ZaCo?n=EWecm364sU*Ys&Q,DG4QZ=dfLmLCLaCQPi7ML@jVUV'"6eYt\(++fV9)7rE/*I=*38e0!N,%`^D_\_E@`rITh]oqs82^&]rTrsJ~>endstream
endobj
32 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 530
>>
stream
Gat=gbAQ&g'L_]pMHL+#S5:IW;P\M2?RV!9D5r$b/^q.3]qS#Z76eL3Q.iKATLM13K#i<-bT'Ffe1DH-Gm2u6Ld!RJNbef`O9F5g5%M!T)1/BJS4Lm^6Rl!N[+)8)*>>Rh`fqtsJnd^fFPE4Q`qBHH=lkBZ0^4@6mRPT5K$SR(R*t&mH%YGgZ:ckGeZF=X`o<C7%EGA5HkLHKBLjiah:m^ik_l/!Qf&7aqss[maetU3mbcl,iI-piZlOBXki*!ePTRFYPd+0p&H9P*fVaa^))[g:D6Z"Bs!pYe8:5rh,u%Bke$g!mI9MLrBVL_l:dh"`fNa0Ac=3k8'Bom0Y00fLO"lm.kW?JuI-aI1Du+C]fF:4&0;E2r#L]ukU8^`@<+OP<2ccg5SWQ;/B/s9am*=+U.F00W;PIMcN(\BL&'0bK1$*n=l7\YjXCJK%Di4XAg!o`2X>jW(XbuL"5"8>;mQ?0nWTO!pdl&hqk55:+$Y&F/`'0Wi/T(8B1p!U%lR')8D_\]1?Crpgljr_3knX~>endstream
endobj
33 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 504
>>
stream
Gas1[D,5d<&H9CNEV]u9XB^=Z9uMik%D"K2'4]OR3n^#19W#h%mfCn^Q^B*Oo^8;tmcfA^d_^;PAH;T3[Z:u]K,FdfbQlStD-G7;>\"2eK]3?4*OH\6Tr=!gN%5uI;@3e:-!_V88W1sbX+:87&NC"0'fb@k[,Wr%..ZnMnljb0/`>>kC&W:hYlWtfb\#E&N7MA7?#R-c&oLqMnFIn@3FGkP#Du*P,kuJN_K`DA?(jel?;4<Q=`N1[nj$5TBdQVq%,1>R:;H*)O-<o"e`"*bAn=$4lS[FSXqMh!j'JIl;;;KF>rKt%dVQom7CGlM\M2.94rA#JesFZWEtkr;T?j.&[.t_Yh5B`"nKtQR/FF$j08bs,&1=CP17URH0ufMfUHA<s(j2&@^.D#u<;48UpqL8FM(<$ZaVRne$;A#f3cAQ1<>5fUaWa@m=[;OSoqtR/V-b[#H,+2XJ=pY,2&<]*]7KE\6#g+(-haAbV\WmQZk,`C5DF^>#>K]C5l~>endstream
endobj
34 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 385
>>
stream
Gat>Qh+iSV'F4,ST&O!M03D#$Z?!G!cXb%oI=]O%^^N<ZDSEggVCn'#6CRPDVs!e=9bE1gf^="oYRiM%5sPcd!uQeJL_X0Qi$6$I-)2XDPTutq)BrhLht+l3jY>c=Q\5\(1D5<h!oFH:!!]1kbpacNRT%qISg5Ia8R&oDC"=(ChEY=(hNM;pAsAgQUcgncB]o.68396&b]@?tcCf8sB=$iEXm>8cdTJsD);nCTEDJQ?oK&h>Y#W63_;t-Zbb7q,/b0irP/3'?DJu4F7W&H!+R.MaGM5+4j#=g*g*lj5c?M0HIo+EiH&mGjjJL)m&3],bop%.Ukf)E@Ee)>rZ0m"?9])U0X2;q$,:B79gf!U@X`f3]ALSYTcd-P9^&[bQk0<~>endstream
endobj
35 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 464
>>
stream
Gatn"d7V;1'Sc)R'^'KH9U`=_P9IdIRg=Vpo`[\,g1]*h//,Ii2sE`>+^!M*UnQu4s8JOY&.%)7h41YK-5k_Z!C@r6!f9b_f`OpSi&r2iR>`=:W$%0K.3a?M\'Ka?'epleV4](J4H.)jJQ!fN!!_-!M,PP@Mc8o;#7SFnILjcR7<Sjf.qD#.HZJ.*AAh@;K+ok1GmpTX#HiXQX`5NrkCNe+*/oL2YgYb>jjIDT)=n`k&"Z5*]fl*S&sOZ@>F>Nsj,GW\i9/,DS[>u-7kdfg-j)E!o7:c8<4uYqp,EFg#A4@Tn8ZQ`#]sF[cDSF=IEeqRDX-Yh<3if@%oPsE,2r@X_Vo%+nHZl\AW>!eIq'AZ(W9?Kd>+.0(M(Z\);Bqh_Z*PqM&PPOX/j8"]Tfi03^0<s:=4)l)rFW+0!MEg[G@f'%\iR+ld.n4e;MWNgKNWB[PHHXHf>$W:DrC/?i~>endstream
endobj
36 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 345
>>
stream
GatUlh+GRM&;BTE'Sg"oi0r?]VsB>l;R%@Q_S/:>gDJ%Ee(phppeG!).>ig7YV=;Fq9E,0bXMF'eH5I`2\-%Scl5e*''L^eFgR.lCGV.u;c44u1`)rC`HOE[;C+^E?4gnQ-?0)o7]]):J.1/;\--Aq\<MX<74tfL4YE&HR(9Z*?$?VR%Xg3"?atOeO4,3/GX"BHaTIKc`;:ebfJJ72FJe:2(/qSo02OMF?^T49^?^1j0`Ni8X^/G"_2"h9I)s']>[t-]*o#Pe+W.tpf%dQj!]A_RGG>i@EIJ+@k*jY$LUD[jf(l=r9\rP'Anl]/fCHZq[,.pi$fK*;eWe*Z7%Z^SRqM~>endstream
endobj
37 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 306
>>
stream
Gat=d5tf-M&;BTO(%8*LNLamTD7m!DGgM)+57k-*7bX`:h$(V:n$FShM[,Aq&EZ^DpEkQFMjDh7^pV#?/V,1k1T2!6bWTi;p6O+XUln&YW<o&/,=j?0moU?'QUE]lLoSgE;'Ik8]?rQ&_/sKG6#&<EEje04"h+:WY3rr>6f4EO]Y[pta@EDjYj:QZ[TMThPWY[bO(-f&Lt]H(a"':Jcj(A'_W]Gl\2`X?UU.'s3SMghX>oNPI#\mglU'sKe&H"t6TDXUB=N`,M]?W$hH+FKiraHA;EWTT^@DMOFpdX_!4#CXfPM#~>endstream
endobj
38 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 307
>>
stream
Gat=c_+qm%&4H!_ME+QKRB2mRF`.Eu$&K%i--f/&MH_G44)j-DEDhHd`;rshm:CJ(bn,ah2!q.BXu3A"#pc3K:ao$#HYdftQI5G</Oce;+tTaJ+B>K7QXh7>8[M/=U+4Zu-kUW*Eha^A%G`=O$*$L:.7`\3-*Zbr$s5S)L,ahB6XPkpLGXKhkLD5%1!-=AGsnJM`RNq(\m)Ab=uQRRb:`XI=!-g;5CC]&p^iDE`ql9JfOrYD[^?R>iV1D!Cd7r^:!V)/G/HJt\:QWaliMAk[OKEDa/3A4E]feg([hk^&WhDA'O>OP~>endstream
endobj
39 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 270
>>
stream
Gat>N_.phP&-h'>T&MuC"kT;"fsoYfp/E3Pq>b)jelC1qM&uNe:83P:Tb7Rl1,JsN&@</Eh:KHK_t]3V%/1'#<=8J\=-rh1FP8RQN+GZ'ZWrVM<pjM"fVV&8=](Tq;k9ZY8gKDAJ:XaIS3a"[BC;K981PBK<<g2Gn[j4&>JiUHj?i,bG*6.8\fY9XXMID"*msp27r*dEd/"$_W^=?c@N$lDX#`/RfN')`4!^@-Q\s^2Mcm=_h_B:0gmRQaSNqWCpZ2j<j8^=qIa.~>endstream
endobj
40 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 427
>>
stream
Gat=&>>)jf(k(;F30/-bZsVOa8`.0S&-h,pHl[@<CUKM^G-ZZ`hPA]HNI`:)j<<\J[tJ/T7GY[/J?'74/V,mu"QV<Si=qJpn2?pmUu4opXpDc)1K$R(3IT/^Ef$TH0[EeJG#>FIRZ7"B!b%eE3Ck!-c40H.LuE@hQgFsn(PF"Xp7U"t5t<]n+K0t\9,FM(Q7YC7eFaN>;FsD+;9)3s*NsYM-r-A;+ibYsPX]gK*Cs\g9H'8@9q>da2"N0E[Ts-(24r)6mr%Q+J[D7jAS'=[Gs76^'%WlR5dPa=n.[bE++fXM&^9[G:YXpCYKiqhR_HK$W_B]5XLMP>eN1sTdcrFNU*:+s<u04ql9CPSTteU@e)M'4RX\7`\$p;I\9SLP\:0M)hPTg'e1Co<$U-hMFgcOSG6.QMMsY\7&efmnCji7;~>endstream
endobj
41 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 301
>>
stream
GarW392!2W'SZ;['kc4i&US#WC#$+jpfaU0E&4!3M'ZWc0iOA5pYH#b.rld>S<NF%&i(McBA3Nr_9"!S,g8%m(HK\[/kki%d!&D[TsW=&A[)rFQ0*Q9$`S3AOt39JFID7[?,AGp&La$<i=_pf>fqRl8IDP8p[4NED&+fY0F'o=_"3UdC&L[cj.DW'5S4rZaFYasj#a'W\9Thh7!4!=>J&d$SZ]+F=fD!;H@qSXFmJeVM8OMl9oPR>O3M5*l?YD"Tpl4.Faf&UB0WL__RR"fR7;YYNJi"el.tiA59sU$MHW+~>endstream
endobj
42 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 275
>>
stream
GarW3b>,r/&4Q?hMS"NoL5bLZ1WrgN'cSFu5Y)t"aFgkUnaVR5MM_b#A8C9'3+eC5]UWgLC_@e\)!m@Xi'7>-)3:cl.eo`sNhSRGe5]5E;9`b"j>8QK8Z4+=b(0:a=;q2T7&8\V*B<2#kAp28Hbt*`G=-R:btZrOpIOaDX'GL1o3\a=o%.?1AM!ql\()#IC^lYmL^Je<o+(ZJP&$k"*ETX?rj&!9PRFUKAD$7m[,^f0N<<LVp>\9o)jgLcD4B#B\po`qj39ebquE&`;k<~>endstream
endobj
43 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 284
>>
stream
GarVH9hWDY&A@g>]V@(r4)(//>SREN01,Y'>e>oslddsW#l]']MR^1;5i.U8Y7PFh>0b@n\4O3E-]gjPJq=A$CYYcOUS8iBoIc";6J$C@,`:mZfm*llX;':;_KEZ(X43,X"!Z/ZbrmJD\bB]aTc(@G5?oDG,-<E];<P^(L0\kXP?<#KE/kk5%Pha3`A%aQ*FnH@>qNeEY7XQIVj+q*<Mdp9@fs/sVR2@^a^`.'VK*Xj`r:(Bb<5%[G2*rtjir64auhfLdd*'OQ`GQ7H1jC#hdJcO.K~>endstream
endobj
44 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 324
>>
stream
GarW3_+qm%&4H!_ME-D4*3U;CG&DiY',QPF]qfOD8E/X)s,S0D5S[I:f90cZh@b&a>'i=4%i3-E5Q^W^.Tm)TKZ7Vr)1=$#@*/aBW+<lg-kl@72\W&6dS[k,2k$'=>o)8[-U>M.d9M3$1)D-;k9Lp]nCP.cc+WkpH0Xg*hdJGmp,U]RdD@U5)f6:mFG\`i!@CWH/YulZ)\&g76?"o$<oZDl;.nO=W0pCk`GQ'S5iY:;,=kl*'ftE:ITd6Eab@:FdC12MT_7?acOPTrk*8-hkP(O:Q5]-J%-X&>;A[;FZ[Zic[H<no]fQWqIrd0H0>NZUec~>endstream
endobj
45 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 382
>>
stream
Gas2D;,>%_'SYH=/+2FOZ1MA66?uHBN'nm?NDI4&R'Xq9fLk.U5MiOFPX*GeR^Dh]4#mOsqn[6K"i-+PmO\JQkVkcC"R$(-l"Cah:+;#k71,`\oGK-Xr'.r.U]f\m93B']J.&=HaOBWd+@-eb:4TX$'/etj-%kH*D(Crq5_"<uB^D>hS?kW]@sLJ?JB%X!$$]ss_c/Y7;rb/,f_)k],ti*k(00j>%-*[jD242la,U;YKur0EX4oi;b"'G+XG2hjd`n>r3edPP670QNe-dGF$NqIQS&cDgWL&\JG1nCQSaPOHnWAV:UelNDQOqL&FOgKP^7iZth0AJrhf(`5f;s!>c2?\[e>mjtIi9/jbAHVrPL]OIQcH=9.YrNR[F'f+~>endstream
endobj
xref
0 46
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000001509 00000 n
0000002581 00000 n
0000002693 00000 n
0000002961 00000 n
0000003166 00000 n
0000003371 00000 n
0000003476 00000 n
0000003682 00000 n
0000003888 00000 n
0000004094 00000 n
0000004300 00000 n
0000004506 00000 n
0000004712 00000 n
0000004918 00000 n
0000005124 00000 n
0000005330 00000 n
0000005536 00000 n
0000005742 00000 n
0000005948 00000 n
0000006154 00000 n
0000006360 00000 n
0000006566 00000 n
0000006636 00000 n
0000006917 00000 n
0000007098 00000 n
0000007995 00000 n
0000008750 00000 n
0000009437 00000 n
0000010018 00000 n
0000010639 00000 n
0000011234 00000 n
0000011710 00000 n
0000012265 00000 n
0000012701 00000 n
0000013098 00000 n
0000013496 00000 n
0000013857 00000 n
0000014375 00000 n
0000014767 00000 n
0000015133 00000 n
0000015508 00000 n
0000015923 00000 n
trailer
<<
/ID
[<e304fee0e103b9acf5e15719aff8b081><e304fee0e103b9acf5e15719aff8b081>]
% ReportLab generated PDF document -- digest (opensource)
/Info 26 0 R
/Root 25 0 R
/Size 46
>>
startxref
16396
%%EOF

BIN
docs/ophion_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
docs/ophion_logo_small.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
docs/ophion_snake_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

30
go.mod
View File

@@ -4,8 +4,32 @@ go 1.22
require (
github.com/gofiber/fiber/v2 v2.52.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/redis/go-redis/v9 v9.4.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.5.0
github.com/lib/pq v1.10.9
github.com/redis/go-redis/v9 v9.17.3
github.com/shirou/gopsutil/v3 v3.24.1
golang.org/x/crypto v0.18.0
golang.org/x/crypto v0.14.0
)
require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/sys v0.16.0 // indirect
)

86
go.sum Normal file
View File

@@ -0,0 +1,86 @@
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE=
github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/shirou/gopsutil/v3 v3.24.1 h1:R3t6ondCEvmARp3wxODhXMTLC/klMa87h2PHUw5m7QI=
github.com/shirou/gopsutil/v3 v3.24.1/go.mod h1:UU7a2MSBQa+kW1uuDq8DeEBS8kmrnQwsv2b5O513rwU=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=