fix: add go.sum and fixes
This commit is contained in:
@@ -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á:
|
||||
|
||||
@@ -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"`
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🐍 OPHION Agent - Observability Collector
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
type Config struct {
|
||||
ServerURL string
|
||||
APIKey string
|
||||
Hostname string
|
||||
CollectInterval time.Duration
|
||||
DockerEnabled bool
|
||||
}
|
||||
|
||||
type Metric struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
CPU CPUMetric `json:"cpu"`
|
||||
Memory MemMetric `json:"memory"`
|
||||
Disk []DiskMetric `json:"disk"`
|
||||
Network NetMetric `json:"network"`
|
||||
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 CPUMetric struct {
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
Cores int `json:"cores"`
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
// Handle shutdown
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("OPHION_API_KEY")
|
||||
if apiKey == "" {
|
||||
log.Fatal("OPHION_API_KEY is required")
|
||||
}
|
||||
|
||||
interval := 30 * time.Second
|
||||
log.Printf("🐍 OPHION Agent starting - reporting to %s every %s", serverURL, interval)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
for range ticker.C {
|
||||
metrics := collectMetrics()
|
||||
sendMetrics(serverURL, apiKey, metrics)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
// Initialize database
|
||||
pgDSN := getEnv("DATABASE_URL", "postgres://ophion:ophion@localhost:5432/ophion?sslmode=disable")
|
||||
db, err := sql.Open("postgres", pgDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Test connection
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Printf("⚠ Database not available: %v", err)
|
||||
} else {
|
||||
log.Println("✓ Connected to PostgreSQL")
|
||||
initSchema(db)
|
||||
}
|
||||
|
||||
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(logger.New())
|
||||
app.Use(cors.New())
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} ${status} ${method} ${path} ${latency}\n",
|
||||
}))
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
}))
|
||||
|
||||
// Routes
|
||||
server.setupRoutes()
|
||||
|
||||
// Graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
log.Println("🛑 Shutting down server...")
|
||||
cancel()
|
||||
app.Shutdown()
|
||||
}()
|
||||
|
||||
// Background jobs
|
||||
go server.runBackgroundJobs(ctx)
|
||||
|
||||
port := getEnv("PORT", "8080")
|
||||
log.Printf("🐍 OPHION server starting on port %s", port)
|
||||
if err := app.Listen(":" + port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func initSchema(db *sql.DB) {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS metrics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
service VARCHAR(255) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
value DOUBLE PRECISION NOT NULL,
|
||||
metric_type VARCHAR(50),
|
||||
tags JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
service VARCHAR(255) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
level VARCHAR(20),
|
||||
message TEXT,
|
||||
trace_id VARCHAR(64),
|
||||
span_id VARCHAR(32),
|
||||
source VARCHAR(50),
|
||||
container_id VARCHAR(64)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
trace_id VARCHAR(64) NOT NULL,
|
||||
span_id VARCHAR(32) NOT NULL,
|
||||
parent_span_id VARCHAR(32),
|
||||
service VARCHAR(255) NOT NULL,
|
||||
operation VARCHAR(255) NOT NULL,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ NOT NULL,
|
||||
duration_ns BIGINT,
|
||||
status_code VARCHAR(20),
|
||||
status_message TEXT,
|
||||
kind VARCHAR(20),
|
||||
attributes JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
hostname VARCHAR(255) NOT NULL UNIQUE,
|
||||
ip VARCHAR(45),
|
||||
version VARCHAR(50),
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
last_seen TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
severity VARCHAR(20),
|
||||
service VARCHAR(255),
|
||||
host VARCHAR(255),
|
||||
message TEXT,
|
||||
status VARCHAR(20) DEFAULT 'firing',
|
||||
fired_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
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 (s *Server) setupRoutes() {
|
||||
// Health check
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
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 (s *Server) healthCheck(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"service": "ophion",
|
||||
"version": "0.1.0",
|
||||
})
|
||||
"version": "0.2.0",
|
||||
"timestamp": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Ingest Endpoints
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
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()})
|
||||
}
|
||||
|
||||
log.Printf("🐍 OPHION starting on port %s", port)
|
||||
log.Fatal(app.Listen(":" + port))
|
||||
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 getMetrics(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"metrics": []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 ingestMetrics(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "received"})
|
||||
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)})
|
||||
}
|
||||
|
||||
func getLogs(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"logs": []string{}})
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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 ingestLogs(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "received"})
|
||||
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 getAlerts(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"alerts": []string{}})
|
||||
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
|
||||
}
|
||||
|
||||
21
configs/clickhouse/config.xml
Normal file
21
configs/clickhouse/config.xml
Normal 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
43
dashboard/Dockerfile
Normal 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"]
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
0
dashboard/public/.gitkeep
Normal file
0
dashboard/public/.gitkeep
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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`}>
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
212
dashboard/src/app/logs/page.tsx
Normal file
212
dashboard/src/app/logs/page.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
108
dashboard/src/app/metrics/page.tsx
Normal file
108
dashboard/src/app/metrics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
title="Total Hosts"
|
||||
value={metrics.totalHosts}
|
||||
subtitle={`${metrics.healthyHosts} healthy`}
|
||||
icon="🖥️"
|
||||
trend="stable"
|
||||
title="Agents"
|
||||
value={overview?.agents?.active ?? '-'}
|
||||
subtitle={`${overview?.agents?.total ?? 0} total`}
|
||||
icon={<Server className="h-5 w-5" />}
|
||||
color="blue"
|
||||
/>
|
||||
<MetricCard
|
||||
title="CPU Average"
|
||||
value={`${metrics.cpuAvg}%`}
|
||||
subtitle="across all hosts"
|
||||
icon="⚡"
|
||||
trend={metrics.cpuAvg > 70 ? 'up' : 'stable'}
|
||||
alert={metrics.cpuAvg > 80}
|
||||
title="Services"
|
||||
value={overview?.services?.count ?? '-'}
|
||||
subtitle="discovered"
|
||||
icon={<Box className="h-5 w-5" />}
|
||||
color="green"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Memory Average"
|
||||
value={`${metrics.memoryAvg}%`}
|
||||
subtitle="across all hosts"
|
||||
icon="💾"
|
||||
trend={metrics.memoryAvg > 70 ? 'up' : 'stable'}
|
||||
alert={metrics.memoryAvg > 85}
|
||||
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="Active Alerts"
|
||||
value={metrics.activeAlerts}
|
||||
subtitle="2 critical, 1 warning"
|
||||
icon="🚨"
|
||||
trend={metrics.activeAlerts > 0 ? 'up' : 'down'}
|
||||
alert={metrics.activeAlerts > 0}
|
||||
title="Status"
|
||||
value="Healthy"
|
||||
subtitle="all systems operational"
|
||||
icon={<Activity className="h-5 w-5" />}
|
||||
color="green"
|
||||
/>
|
||||
</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>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<RecentAlerts />
|
||||
|
||||
{/* AI Copilot Sidebar */}
|
||||
{showCopilot && (
|
||||
<Copilot onClose={() => setShowCopilot(false)} />
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
158
dashboard/src/app/services/page.tsx
Normal file
158
dashboard/src/app/services/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
dashboard/src/app/traces/page.tsx
Normal file
184
dashboard/src/app/traces/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
dashboard/src/components/Providers.tsx
Normal file
24
dashboard/src/components/Providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
dashboard/src/components/dashboard/MetricCard.tsx
Normal file
57
dashboard/src/components/dashboard/MetricCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
dashboard/src/components/dashboard/RecentAlerts.tsx
Normal file
91
dashboard/src/components/dashboard/RecentAlerts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
<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'
|
||||
}`}
|
||||
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"
|
||||
>
|
||||
<span className="text-xl">{item.icon}</span>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<Settings className="h-5 w-5" />
|
||||
Settings
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
<p className="text-xs text-gray-600 mt-4 px-3">v0.2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
192
dashboard/src/components/metrics/MetricsChart.tsx
Normal file
192
dashboard/src/components/metrics/MetricsChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
dashboard/src/components/services/ServiceMapGraph.tsx
Normal file
181
dashboard/src/components/services/ServiceMapGraph.tsx
Normal 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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
160
dashboard/src/components/traces/TraceTimeline.tsx
Normal file
160
dashboard/src/components/traces/TraceTimeline.tsx
Normal 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
87
dashboard/src/lib/api.ts
Normal 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);
|
||||
78
dashboard/src/lib/utils.ts
Normal file
78
dashboard/src/lib/utils.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
57
deploy/docker/Dockerfile.agent
Normal file
57
deploy/docker/Dockerfile.agent
Normal 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"]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
123
docker-compose.yml
Normal 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:
|
||||
424
docs/OPHION_Manual_Completo.pdf
Normal file
424
docs/OPHION_Manual_Completo.pdf
Normal 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[qha6lAP':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
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
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
BIN
docs/ophion_snake_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
30
go.mod
30
go.mod
@@ -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
86
go.sum
Normal 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=
|
||||
Reference in New Issue
Block a user