From d6b08cb586017e6a5f674f0ad9e7d82781887028 Mon Sep 17 00:00:00 2001 From: bigtux Date: Fri, 6 Feb 2026 14:26:15 -0300 Subject: [PATCH] fix: add go.sum and fixes --- README.md | 2 +- cmd/agent/main.go | 380 +++++++-- cmd/server/main.go | 750 +++++++++++++++++- configs/clickhouse/config.xml | 21 + dashboard/Dockerfile | 43 + dashboard/next.config.js | 13 +- dashboard/package.json | 41 +- dashboard/public/.gitkeep | 0 dashboard/src/app/globals.css | 66 +- dashboard/src/app/layout.tsx | 33 +- dashboard/src/app/logs/page.tsx | 212 +++++ dashboard/src/app/metrics/page.tsx | 108 +++ dashboard/src/app/page.tsx | 208 ++--- dashboard/src/app/services/page.tsx | 158 ++++ dashboard/src/app/traces/page.tsx | 184 +++++ dashboard/src/components/Providers.tsx | 24 + .../src/components/dashboard/MetricCard.tsx | 57 ++ .../src/components/dashboard/RecentAlerts.tsx | 91 +++ dashboard/src/components/layout/Sidebar.tsx | 126 ++- .../src/components/metrics/MetricsChart.tsx | 192 +++++ .../components/services/ServiceMapGraph.tsx | 181 +++++ .../src/components/traces/TraceTimeline.tsx | 160 ++++ dashboard/src/lib/api.ts | 87 ++ dashboard/src/lib/utils.ts | 78 ++ deploy/docker/Dockerfile | 7 +- deploy/docker/Dockerfile.agent | 57 ++ deploy/docker/Dockerfile.server | 58 +- deploy/docker/docker-compose.yml | 29 +- deploy/docker/entrypoint.sh | 7 +- docker-compose.yml | 123 +++ docs/OPHION_Manual_Completo.pdf | 424 ++++++++++ docs/ophion_logo.png | Bin 0 -> 32687 bytes docs/ophion_logo_small.png | Bin 0 -> 22982 bytes docs/ophion_snake_logo.png | Bin 0 -> 1439 bytes go.mod | 30 +- go.sum | 86 ++ 36 files changed, 3613 insertions(+), 423 deletions(-) create mode 100644 configs/clickhouse/config.xml create mode 100644 dashboard/Dockerfile create mode 100644 dashboard/public/.gitkeep create mode 100644 dashboard/src/app/logs/page.tsx create mode 100644 dashboard/src/app/metrics/page.tsx create mode 100644 dashboard/src/app/services/page.tsx create mode 100644 dashboard/src/app/traces/page.tsx create mode 100644 dashboard/src/components/Providers.tsx create mode 100644 dashboard/src/components/dashboard/MetricCard.tsx create mode 100644 dashboard/src/components/dashboard/RecentAlerts.tsx create mode 100644 dashboard/src/components/metrics/MetricsChart.tsx create mode 100644 dashboard/src/components/services/ServiceMapGraph.tsx create mode 100644 dashboard/src/components/traces/TraceTimeline.tsx create mode 100644 dashboard/src/lib/api.ts create mode 100644 dashboard/src/lib/utils.ts create mode 100644 deploy/docker/Dockerfile.agent create mode 100644 docker-compose.yml create mode 100644 docs/OPHION_Manual_Completo.pdf create mode 100644 docs/ophion_logo.png create mode 100644 docs/ophion_logo_small.png create mode 100644 docs/ophion_snake_logo.png create mode 100644 go.sum diff --git a/README.md b/README.md index 80aa9e0..c0c8046 100644 --- a/README.md +++ b/README.md @@ -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Γ‘: diff --git a/cmd/agent/main.go b/cmd/agent/main.go index e6b5b9b..a916ce8 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -7,118 +7,326 @@ import ( "log" "net/http" "os" + "os/exec" + "os/signal" "runtime" + "strings" + "syscall" "time" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/load" "github.com/shirou/gopsutil/v3/mem" - "github.com/shirou/gopsutil/v3/net" + psnet "github.com/shirou/gopsutil/v3/net" ) -type Metrics struct { - Hostname string `json:"hostname"` - Timestamp time.Time `json:"timestamp"` - CPU CPUMetric `json:"cpu"` - Memory MemMetric `json:"memory"` - Disk []DiskMetric `json:"disk"` - Network NetMetric `json:"network"` +// ═══════════════════════════════════════════════════════════ +// 🐍 OPHION Agent - Observability Collector +// ═══════════════════════════════════════════════════════════ + +type Config struct { + ServerURL string + APIKey string + Hostname string + CollectInterval time.Duration + DockerEnabled bool } -type CPUMetric struct { - UsagePercent float64 `json:"usage_percent"` - Cores int `json:"cores"` +type Metric struct { + Timestamp time.Time `json:"timestamp"` + Service string `json:"service"` + Host string `json:"host"` + Name string `json:"name"` + Value float64 `json:"value"` + MetricType string `json:"metric_type"` + Tags map[string]string `json:"tags,omitempty"` } -type MemMetric struct { - Total uint64 `json:"total"` - Used uint64 `json:"used"` - UsedPercent float64 `json:"used_percent"` -} - -type DiskMetric struct { - Path string `json:"path"` - Total uint64 `json:"total"` - Used uint64 `json:"used"` - UsedPercent float64 `json:"used_percent"` -} - -type NetMetric struct { - BytesSent uint64 `json:"bytes_sent"` - BytesRecv uint64 `json:"bytes_recv"` +type ContainerStats struct { + ID string `json:"id"` + Name string `json:"name"` + CPUPercent float64 `json:"cpu_percent"` + MemoryUsage uint64 `json:"memory_usage"` + MemoryLimit uint64 `json:"memory_limit"` + MemoryPercent float64 `json:"memory_percent"` + NetRx uint64 `json:"net_rx"` + NetTx uint64 `json:"net_tx"` + State string `json:"state"` } func main() { - serverURL := os.Getenv("OPHION_SERVER") - if serverURL == "" { - serverURL = "http://localhost:8080" - } + config := loadConfig() - apiKey := os.Getenv("OPHION_API_KEY") - if apiKey == "" { - log.Fatal("OPHION_API_KEY is required") + log.Printf("🐍 OPHION Agent starting") + log.Printf(" Server: %s", config.ServerURL) + log.Printf(" Host: %s", config.Hostname) + log.Printf(" Interval: %s", config.CollectInterval) + log.Printf(" Docker: %v", config.DockerEnabled) + + // 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) + } } +} + +func loadConfig() *Config { + hostname, _ := os.Hostname() interval := 30 * time.Second - log.Printf("🐍 OPHION Agent starting - reporting to %s every %s", serverURL, interval) + if v := os.Getenv("OPHION_INTERVAL"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + interval = d + } + } - ticker := time.NewTicker(interval) - for range ticker.C { - metrics := collectMetrics() - sendMetrics(serverURL, apiKey, metrics) + dockerEnabled := true + if v := os.Getenv("OPHION_DOCKER"); v == "false" || v == "0" { + dockerEnabled = false + } + + return &Config{ + ServerURL: getEnv("OPHION_SERVER", "http://localhost:8080"), + APIKey: getEnv("OPHION_API_KEY", ""), + Hostname: getEnv("OPHION_HOSTNAME", hostname), + CollectInterval: interval, + DockerEnabled: dockerEnabled, } } -func collectMetrics() Metrics { - hostname, _ := os.Hostname() - - cpuPercent, _ := cpu.Percent(time.Second, false) - cpuUsage := 0.0 - if len(cpuPercent) > 0 { - cpuUsage = cpuPercent[0] +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)...) } - 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 - } - - 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, + // Send to server + if len(metrics) > 0 { + sendMetrics(config, metrics) } } -func sendMetrics(serverURL, apiKey string, metrics Metrics) { - data, _ := json.Marshal(metrics) - - req, _ := http.NewRequest("POST", serverURL+"/api/v1/metrics", bytes.NewBuffer(data)) +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.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)) } diff --git a/cmd/server/main.go b/cmd/server/main.go index f660472..c01c960 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,65 +1,737 @@ package main import ( + "context" + "database/sql" + "encoding/json" "log" "os" + "os/signal" + "strconv" + "syscall" + "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/google/uuid" + _ "github.com/lib/pq" ) +// ═══════════════════════════════════════════════════════════ +// 🐍 OPHION Server - Observability Platform API +// ═══════════════════════════════════════════════════════════ + +type Server struct { + app *fiber.App + db *sql.DB +} + +type Metric struct { + Timestamp time.Time `json:"timestamp"` + Service string `json:"service"` + Host string `json:"host"` + Name string `json:"name"` + Value float64 `json:"value"` + MetricType string `json:"metric_type"` + Tags map[string]string `json:"tags,omitempty"` +} + +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Service string `json:"service"` + Host string `json:"host"` + Level string `json:"level"` + Message string `json:"message"` + TraceID string `json:"trace_id,omitempty"` + SpanID string `json:"span_id,omitempty"` + Source string `json:"source"` + ContainerID string `json:"container_id,omitempty"` +} + +type Span struct { + TraceID string `json:"trace_id"` + SpanID string `json:"span_id"` + ParentSpanID string `json:"parent_span_id,omitempty"` + Service string `json:"service"` + Operation string `json:"operation"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + DurationNs int64 `json:"duration_ns"` + StatusCode string `json:"status_code"` + StatusMsg string `json:"status_message,omitempty"` + Kind string `json:"kind"` + Attributes map[string]any `json:"attributes,omitempty"` +} + +type Alert struct { + ID string `json:"id"` + Name string `json:"name"` + Severity string `json:"severity"` + Service string `json:"service"` + Host string `json:"host"` + Message string `json:"message"` + Status string `json:"status"` + FiredAt time.Time `json:"fired_at"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` +} + +type Agent struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + Version string `json:"version"` + Status string `json:"status"` + LastSeen time.Time `json:"last_seen"` + CreatedAt time.Time `json:"created_at"` +} + func main() { - app := fiber.New(fiber.Config{ - AppName: "OPHION Observability Platform", - }) + // Initialize database + pgDSN := getEnv("DATABASE_URL", "postgres://ophion:ophion@localhost:5432/ophion?sslmode=disable") + db, err := sql.Open("postgres", pgDSN) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close() - // Middleware - app.Use(logger.New()) - app.Use(cors.New()) - - // Health check - app.Get("/health", func(c *fiber.Ctx) error { - return c.JSON(fiber.Map{ - "status": "healthy", - "service": "ophion", - "version": "0.1.0", - }) - }) - - // API routes - api := app.Group("/api/v1") - api.Get("/metrics", getMetrics) - api.Post("/metrics", ingestMetrics) - api.Get("/logs", getLogs) - api.Post("/logs", ingestLogs) - api.Get("/alerts", getAlerts) - - port := os.Getenv("PORT") - if port == "" { - port = "8080" + // Test connection + if err := db.Ping(); err != nil { + log.Printf("⚠ Database not available: %v", err) + } else { + log.Println("βœ“ Connected to PostgreSQL") + initSchema(db) } - log.Printf("🐍 OPHION starting on port %s", port) - log.Fatal(app.Listen(":" + port)) + server := &Server{db: db} + + // Create Fiber app + app := fiber.New(fiber.Config{ + AppName: "OPHION Observability Platform", + BodyLimit: 50 * 1024 * 1024, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + }) + server.app = app + + // Middleware + app.Use(recover.New()) + app.Use(logger.New(logger.Config{ + Format: "${time} ${status} ${method} ${path} ${latency}\n", + })) + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", + AllowHeaders: "Origin, Content-Type, Accept, Authorization", + })) + + // Routes + server.setupRoutes() + + // Graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + log.Println("πŸ›‘ Shutting down server...") + cancel() + app.Shutdown() + }() + + // Background jobs + go server.runBackgroundJobs(ctx) + + port := getEnv("PORT", "8080") + log.Printf("🐍 OPHION server starting on port %s", port) + if err := app.Listen(":" + port); err != nil { + log.Fatal(err) + } } -func getMetrics(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"metrics": []string{}}) +func initSchema(db *sql.DB) { + schema := ` + CREATE TABLE IF NOT EXISTS metrics ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + service VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + value DOUBLE PRECISION NOT NULL, + metric_type VARCHAR(50), + tags JSONB + ); + + CREATE TABLE IF NOT EXISTS logs ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + service VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + level VARCHAR(20), + message TEXT, + trace_id VARCHAR(64), + span_id VARCHAR(32), + source VARCHAR(50), + container_id VARCHAR(64) + ); + + CREATE TABLE IF NOT EXISTS spans ( + id SERIAL PRIMARY KEY, + trace_id VARCHAR(64) NOT NULL, + span_id VARCHAR(32) NOT NULL, + parent_span_id VARCHAR(32), + service VARCHAR(255) NOT NULL, + operation VARCHAR(255) NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + duration_ns BIGINT, + status_code VARCHAR(20), + status_message TEXT, + kind VARCHAR(20), + attributes JSONB + ); + + CREATE TABLE IF NOT EXISTS agents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hostname VARCHAR(255) NOT NULL UNIQUE, + ip VARCHAR(45), + version VARCHAR(50), + status VARCHAR(20) DEFAULT 'active', + last_seen TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS alerts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + severity VARCHAR(20), + service VARCHAR(255), + host VARCHAR(255), + message TEXT, + status VARCHAR(20) DEFAULT 'firing', + fired_at TIMESTAMPTZ DEFAULT NOW(), + resolved_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_metrics_service_name ON metrics(service, name); + CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_logs_service ON logs(service); + CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON spans(trace_id); + CREATE INDEX IF NOT EXISTS idx_spans_service ON spans(service); + ` + + if _, err := db.Exec(schema); err != nil { + log.Printf("Error creating schema: %v", err) + } else { + log.Println("βœ“ Database schema initialized") + } } -func ingestMetrics(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"status": "received"}) +func (s *Server) setupRoutes() { + // Health check + s.app.Get("/health", s.healthCheck) + + // API v1 + api := s.app.Group("/api/v1") + + // Ingest endpoints (for agents) + api.Post("/metrics", s.ingestMetrics) + api.Post("/logs", s.ingestLogs) + api.Post("/traces", s.ingestTraces) + + // Query endpoints (for dashboard) + api.Get("/metrics", s.queryMetrics) + api.Get("/metrics/names", s.getMetricNames) + api.Get("/logs", s.queryLogs) + api.Get("/traces", s.queryTraces) + api.Get("/traces/:traceId", s.getTrace) + api.Get("/services", s.getServices) + + // Agents + api.Get("/agents", s.getAgents) + api.Post("/agents/register", s.registerAgent) + + // Alerts + api.Get("/alerts", s.getAlerts) + api.Post("/alerts", s.createAlert) + api.Put("/alerts/:id/resolve", s.resolveAlert) + + // Dashboard + api.Get("/dashboard/overview", s.getDashboardOverview) } -func getLogs(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"logs": []string{}}) +func (s *Server) healthCheck(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "ophion", + "version": "0.2.0", + "timestamp": time.Now(), + }) } -func ingestLogs(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"status": "received"}) +// ───────────────────────────────────────────────────────────── +// Ingest Endpoints +// ───────────────────────────────────────────────────────────── + +func (s *Server) ingestMetrics(c *fiber.Ctx) error { + var req struct { + Metrics []Metric `json:"metrics"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + + for _, m := range req.Metrics { + tags, _ := json.Marshal(m.Tags) + _, err := s.db.Exec(` + INSERT INTO metrics (timestamp, service, host, name, value, metric_type, tags) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + m.Timestamp, m.Service, m.Host, m.Name, m.Value, m.MetricType, tags) + if err != nil { + log.Printf("Error inserting metric: %v", err) + } + } + + // Update agent last_seen + if len(req.Metrics) > 0 { + host := req.Metrics[0].Host + s.db.Exec(`UPDATE agents SET last_seen = NOW(), status = 'active' WHERE hostname = $1`, host) + } + + return c.JSON(fiber.Map{"status": "received", "count": len(req.Metrics)}) } -func getAlerts(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"alerts": []string{}}) +func (s *Server) ingestLogs(c *fiber.Ctx) error { + var req struct { + Logs []LogEntry `json:"logs"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + + for _, l := range req.Logs { + _, err := s.db.Exec(` + INSERT INTO logs (timestamp, service, host, level, message, trace_id, span_id, source, container_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + l.Timestamp, l.Service, l.Host, l.Level, l.Message, l.TraceID, l.SpanID, l.Source, l.ContainerID) + if err != nil { + log.Printf("Error inserting log: %v", err) + } + } + + return c.JSON(fiber.Map{"status": "received", "count": len(req.Logs)}) +} + +func (s *Server) ingestTraces(c *fiber.Ctx) error { + var req struct { + Spans []Span `json:"spans"` + Host string `json:"host"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + + for _, sp := range req.Spans { + attrs, _ := json.Marshal(sp.Attributes) + _, err := s.db.Exec(` + INSERT INTO spans (trace_id, span_id, parent_span_id, service, operation, start_time, end_time, duration_ns, status_code, status_message, kind, attributes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + sp.TraceID, sp.SpanID, sp.ParentSpanID, sp.Service, sp.Operation, sp.StartTime, sp.EndTime, sp.DurationNs, sp.StatusCode, sp.StatusMsg, sp.Kind, attrs) + if err != nil { + log.Printf("Error inserting span: %v", err) + } + } + + return c.JSON(fiber.Map{"status": "received", "count": len(req.Spans)}) +} + +// ───────────────────────────────────────────────────────────── +// Query Endpoints +// ───────────────────────────────────────────────────────────── + +func (s *Server) queryMetrics(c *fiber.Ctx) error { + service := c.Query("service", "system") + name := c.Query("name", "cpu.usage_percent") + from := parseTime(c.Query("from"), time.Now().Add(-1*time.Hour)) + to := parseTime(c.Query("to"), time.Now()) + + rows, err := s.db.Query(` + SELECT timestamp, value FROM metrics + WHERE service = $1 AND name = $2 AND timestamp >= $3 AND timestamp <= $4 + ORDER BY timestamp ASC + LIMIT 1000`, service, name, from, to) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + defer rows.Close() + + var metrics []map[string]any + for rows.Next() { + var ts time.Time + var val float64 + if err := rows.Scan(&ts, &val); err == nil { + metrics = append(metrics, map[string]any{"timestamp": ts, "value": val}) + } + } + + return c.JSON(fiber.Map{"metrics": metrics, "count": len(metrics)}) +} + +func (s *Server) getMetricNames(c *fiber.Ctx) error { + rows, err := s.db.Query(`SELECT DISTINCT name FROM metrics ORDER BY name`) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if rows.Scan(&name) == nil { + names = append(names, name) + } + } + + return c.JSON(fiber.Map{"names": names}) +} + +func (s *Server) queryLogs(c *fiber.Ctx) error { + service := c.Query("service") + level := c.Query("level") + query := c.Query("q") + from := parseTime(c.Query("from"), time.Now().Add(-1*time.Hour)) + to := parseTime(c.Query("to"), time.Now()) + limit := parseInt(c.Query("limit"), 100) + + sql := `SELECT timestamp, service, host, level, message, source, container_id + FROM logs WHERE timestamp >= $1 AND timestamp <= $2` + args := []any{from, to} + argN := 3 + + if service != "" { + sql += ` AND service = $` + strconv.Itoa(argN) + args = append(args, service) + argN++ + } + if level != "" { + sql += ` AND level = $` + strconv.Itoa(argN) + args = append(args, level) + argN++ + } + if query != "" { + sql += ` AND message ILIKE $` + strconv.Itoa(argN) + args = append(args, "%"+query+"%") + argN++ + } + + sql += ` ORDER BY timestamp DESC LIMIT $` + strconv.Itoa(argN) + args = append(args, limit) + + rows, err := s.db.Query(sql, args...) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + defer rows.Close() + + var logs []LogEntry + for rows.Next() { + var l LogEntry + if err := rows.Scan(&l.Timestamp, &l.Service, &l.Host, &l.Level, &l.Message, &l.Source, &l.ContainerID); err == nil { + logs = append(logs, l) + } + } + + return c.JSON(fiber.Map{"logs": logs, "count": len(logs)}) +} + +func (s *Server) queryTraces(c *fiber.Ctx) error { + service := c.Query("service") + from := parseTime(c.Query("from"), time.Now().Add(-1*time.Hour)) + to := parseTime(c.Query("to"), time.Now()) + limit := parseInt(c.Query("limit"), 20) + + sql := `SELECT DISTINCT trace_id, service, operation, MIN(start_time), MAX(duration_ns) + FROM spans WHERE start_time >= $1 AND start_time <= $2` + args := []any{from, to} + argN := 3 + + if service != "" { + sql += ` AND service = $` + strconv.Itoa(argN) + args = append(args, service) + argN++ + } + + sql += ` GROUP BY trace_id, service, operation ORDER BY MIN(start_time) DESC LIMIT $` + strconv.Itoa(argN) + args = append(args, limit) + + rows, err := s.db.Query(sql, args...) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + defer rows.Close() + + var traces []map[string]any + for rows.Next() { + var traceID, service, operation string + var startTime time.Time + var durationNs int64 + if err := rows.Scan(&traceID, &service, &operation, &startTime, &durationNs); err == nil { + traces = append(traces, map[string]any{ + "trace_id": traceID, + "service": service, + "operation": operation, + "start_time": startTime, + "duration_ns": durationNs, + }) + } + } + + return c.JSON(fiber.Map{"traces": traces, "count": len(traces)}) +} + +func (s *Server) getTrace(c *fiber.Ctx) error { + traceID := c.Params("traceId") + + rows, err := s.db.Query(` + SELECT trace_id, span_id, parent_span_id, service, operation, start_time, end_time, duration_ns, status_code, status_message, kind + FROM spans WHERE trace_id = $1 ORDER BY start_time`, traceID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + defer rows.Close() + + var spans []Span + for rows.Next() { + var sp Span + var parentSpanID, statusMsg sql.NullString + if err := rows.Scan(&sp.TraceID, &sp.SpanID, &parentSpanID, &sp.Service, &sp.Operation, &sp.StartTime, &sp.EndTime, &sp.DurationNs, &sp.StatusCode, &statusMsg, &sp.Kind); err == nil { + sp.ParentSpanID = parentSpanID.String + sp.StatusMsg = statusMsg.String + spans = append(spans, sp) + } + } + + if len(spans) == 0 { + return c.Status(404).JSON(fiber.Map{"error": "trace not found"}) + } + + return c.JSON(fiber.Map{ + "trace_id": traceID, + "spans": spans, + "duration_ns": spans[len(spans)-1].EndTime.Sub(spans[0].StartTime).Nanoseconds(), + "start_time": spans[0].StartTime, + }) +} + +func (s *Server) getServices(c *fiber.Ctx) error { + rows, err := s.db.Query(`SELECT DISTINCT service FROM metrics UNION SELECT DISTINCT service FROM spans ORDER BY service`) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + defer rows.Close() + + var services []string + for rows.Next() { + var svc string + if rows.Scan(&svc) == nil { + services = append(services, svc) + } + } + + return c.JSON(fiber.Map{"services": services}) +} + +// ───────────────────────────────────────────────────────────── +// Agents +// ───────────────────────────────────────────────────────────── + +func (s *Server) getAgents(c *fiber.Ctx) error { + rows, err := s.db.Query(`SELECT id, hostname, ip, version, status, last_seen, created_at FROM agents ORDER BY last_seen DESC`) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + defer rows.Close() + + var agents []Agent + for rows.Next() { + var a Agent + if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Version, &a.Status, &a.LastSeen, &a.CreatedAt); err == nil { + agents = append(agents, a) + } + } + + return c.JSON(fiber.Map{"agents": agents}) +} + +func (s *Server) registerAgent(c *fiber.Ctx) error { + var agent Agent + if err := c.BodyParser(&agent); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + + agent.ID = uuid.New().String() + agent.Status = "active" + agent.LastSeen = time.Now() + agent.CreatedAt = time.Now() + + _, err := s.db.Exec(` + INSERT INTO agents (id, hostname, ip, version, status, last_seen, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (hostname) DO UPDATE SET ip = $3, version = $4, status = 'active', last_seen = NOW()`, + agent.ID, agent.Hostname, agent.IP, agent.Version, agent.Status, agent.LastSeen, agent.CreatedAt) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"status": "registered", "agent": agent}) +} + +// ───────────────────────────────────────────────────────────── +// Alerts +// ───────────────────────────────────────────────────────────── + +func (s *Server) getAlerts(c *fiber.Ctx) error { + status := c.Query("status") + limit := parseInt(c.Query("limit"), 50) + + sql := `SELECT id, name, severity, service, host, message, status, fired_at, resolved_at FROM alerts` + var args []any + if status != "" { + sql += ` WHERE status = $1` + args = append(args, status) + } + sql += ` ORDER BY fired_at DESC LIMIT ` + strconv.Itoa(limit) + + rows, err := s.db.Query(sql, args...) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + defer rows.Close() + + var alerts []Alert + for rows.Next() { + var a Alert + var resolvedAt *time.Time + if err := rows.Scan(&a.ID, &a.Name, &a.Severity, &a.Service, &a.Host, &a.Message, &a.Status, &a.FiredAt, &resolvedAt); err == nil { + a.ResolvedAt = resolvedAt + alerts = append(alerts, a) + } + } + + return c.JSON(fiber.Map{"alerts": alerts}) +} + +func (s *Server) createAlert(c *fiber.Ctx) error { + var alert Alert + if err := c.BodyParser(&alert); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + + alert.ID = uuid.New().String() + alert.Status = "firing" + alert.FiredAt = time.Now() + + _, err := s.db.Exec(` + INSERT INTO alerts (id, name, severity, service, host, message, status, fired_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + alert.ID, alert.Name, alert.Severity, alert.Service, alert.Host, alert.Message, alert.Status, alert.FiredAt) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"status": "created", "alert": alert}) +} + +func (s *Server) resolveAlert(c *fiber.Ctx) error { + alertID := c.Params("id") + _, err := s.db.Exec(`UPDATE alerts SET status = 'resolved', resolved_at = NOW() WHERE id = $1`, alertID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"status": "resolved"}) +} + +// ───────────────────────────────────────────────────────────── +// Dashboard +// ───────────────────────────────────────────────────────────── + +func (s *Server) getDashboardOverview(c *fiber.Ctx) error { + overview := fiber.Map{"timestamp": time.Now()} + + // Agents + var totalAgents, activeAgents int + s.db.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&totalAgents) + s.db.QueryRow(`SELECT COUNT(*) FROM agents WHERE status = 'active'`).Scan(&activeAgents) + overview["agents"] = fiber.Map{"total": totalAgents, "active": activeAgents} + + // Alerts + var firingAlerts int + s.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE status = 'firing'`).Scan(&firingAlerts) + overview["alerts"] = fiber.Map{"firing": firingAlerts} + + // Services + var serviceCount int + s.db.QueryRow(`SELECT COUNT(DISTINCT service) FROM metrics`).Scan(&serviceCount) + overview["services"] = fiber.Map{"count": serviceCount} + + return c.JSON(overview) +} + +// ───────────────────────────────────────────────────────────── +// Background Jobs +// ───────────────────────────────────────────────────────────── + +func (s *Server) runBackgroundJobs(ctx context.Context) { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // Mark stale agents + s.db.Exec(`UPDATE agents SET status = 'inactive' WHERE last_seen < NOW() - INTERVAL '5 minutes' AND status = 'active'`) + } + } +} + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +func getEnv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func parseTime(s string, def time.Time) time.Time { + if s == "" { + return def + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + if ts, err := strconv.ParseInt(s, 10, 64); err == nil { + return time.Unix(ts, 0) + } + if d, err := time.ParseDuration(s); err == nil { + return time.Now().Add(-d) + } + return def +} + +func parseInt(s string, def int) int { + if s == "" { + return def + } + if v, err := strconv.Atoi(s); err == nil { + return v + } + return def } diff --git a/configs/clickhouse/config.xml b/configs/clickhouse/config.xml new file mode 100644 index 0000000..2db7e25 --- /dev/null +++ b/configs/clickhouse/config.xml @@ -0,0 +1,21 @@ + + + ophion + + + :: + 0.0.0.0 + + + + information + 1 + + + + 2000000000 + + + 1000000 + 100 + diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..74c59af --- /dev/null +++ b/dashboard/Dockerfile @@ -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"] diff --git a/dashboard/next.config.js b/dashboard/next.config.js index 5cd8cc3..5c9c125 100644 --- a/dashboard/next.config.js +++ b/dashboard/next.config.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; diff --git a/dashboard/package.json b/dashboard/package.json index 7493da4..7d92479 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -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" } } diff --git a/dashboard/public/.gitkeep b/dashboard/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css index 03679e3..36b7b27 100644 --- a/dashboard/src/app/globals.css +++ b/dashboard/src/app/globals.css @@ -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; } diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx index 02a8fcd..bed1999 100644 --- a/dashboard/src/app/layout.tsx +++ b/dashboard/src/app/layout.tsx @@ -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 ( - - - {children} + + + +
+ +
+ {children} +
+
+
- ) + ); } diff --git a/dashboard/src/app/logs/page.tsx b/dashboard/src/app/logs/page.tsx new file mode 100644 index 0000000..199ff72 --- /dev/null +++ b/dashboard/src/app/logs/page.tsx @@ -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 ( +
+
+

Logs

+
+ + +
+
+ + {/* Filters */} +
+
+
+ + 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" + /> +
+
+
+ +
+
+ +
+
+ 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" + /> +
+
+ + {/* Log Table */} +
+
+ + + + + + + + + + + {logs.map((log, i) => ( + + ))} + {!isLoading && logs.length === 0 && ( + + + + )} + +
TimeLevelServiceMessage
+ No logs found +
+
+
+ + {/* Footer */} +
+ Showing {logs.length} logs + {data?.count && data.count > logs.length && ( + Limited to 200 results + )} +
+
+ ); +} + +function LogRow({ log }: { log: LogEntry }) { + const [expanded, setExpanded] = useState(false); + + const levelColors: Record = { + 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 ( + <> + setExpanded(!expanded)} + className="hover:bg-gray-800/50 cursor-pointer" + > + + {formatTime(log.timestamp)} + + + + {log.level} + + + + {log.service} + + + {log.message} + + + {expanded && ( + + +
+              {log.message}
+            
+
+ Host: {log.host} + Source: {log.source} + {log.container_id && Container: {log.container_id}} + {log.trace_id && ( + + Trace: {log.trace_id.slice(0, 16)}... + + )} +
+ + + )} + + ); +} diff --git a/dashboard/src/app/metrics/page.tsx b/dashboard/src/app/metrics/page.tsx new file mode 100644 index 0000000..27cd70e --- /dev/null +++ b/dashboard/src/app/metrics/page.tsx @@ -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([ + 'cpu.usage_percent', + 'memory.used_percent', + ]); + + const getTimeFrom = () => { + const ranges: Record = { + '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 ( +
+
+

Metrics

+
+ +
+
+ + {/* Metric Selector */} +
+ {METRIC_PRESETS.map((preset) => { + const Icon = preset.icon; + const isSelected = selectedMetrics.includes(preset.metric); + return ( + + ); + })} +
+ + {/* Charts Grid */} +
+ {selectedMetrics.map((metric) => { + const preset = METRIC_PRESETS.find((p) => p.metric === metric); + return ( + + ); + })} +
+ + {selectedMetrics.length === 0 && ( +
+ Select metrics to display +
+ )} +
+ ); +} diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index 5a4aaa0..bb9921b 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -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 ( -
- {/* Sidebar */} - - - {/* Main Content */} -
- {/* Header */} -
setShowCopilot(true)} /> - - {/* Dashboard Content */} -
- {/* Top Metrics */} -
- - 70 ? 'up' : 'stable'} - alert={metrics.cpuAvg > 80} - /> - 70 ? 'up' : 'stable'} - alert={metrics.memoryAvg > 85} - /> - 0 ? 'up' : 'down'} - alert={metrics.activeAlerts > 0} - /> -
- - {/* Charts Row */} -
-
-

CPU Usage (24h)

- -
-
-

Memory Usage (24h)

- -
-
- - {/* AI Insights */} -
- -
- - {/* Bottom Section */} -
- {/* Hosts Table */} -
-
-

Hosts

- -
- -
- - {/* Alerts */} -
-
-

Recent Alerts

- -
- -
-
-
+
+
+

+ 🐍 OPHION Dashboard +

+ + {new Date().toLocaleString()} +
- {/* AI Copilot Sidebar */} - {showCopilot && ( - setShowCopilot(false)} /> - )} + {/* Overview Cards */} +
+ } + color="blue" + /> + } + color="green" + /> + } + color={overview?.alerts?.firing > 0 ? 'red' : 'gray'} + /> + } + color="green" + /> +
+ + {/* Alerts */} +
+ + +
+

Quick Links

+
+ + + + +
+
+
- ) + ); +} + +function QuickLink({ href, label, desc }: { href: string; label: string; desc: string }) { + return ( + +
+

{label}

+

{desc}

+
+ β†’ +
+ ); } diff --git a/dashboard/src/app/services/page.tsx b/dashboard/src/app/services/page.tsx new file mode 100644 index 0000000..e7edde2 --- /dev/null +++ b/dashboard/src/app/services/page.tsx @@ -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 ( +
+
+
+

Service Map

+

+ Visualize dependencies between services +

+
+ +
+ + {/* Stats */} +
+ } + /> + } + /> + } + /> + 5 ? 'text-red-400' : 'text-gray-400'}`} />} + /> +
+ + {/* Service Map Visualization */} +
+ {services.length > 0 ? ( + + ) : ( +
+ {isLoading ? 'Loading service map...' : 'No services discovered yet'} +
+ )} +
+ + {/* Services Table */} +
+
+

Services

+
+
+ + + + + + + + + + + + {services.map((svc) => ( + + + + + + + + ))} + +
ServiceSpansErrorsAvg DurationLast Seen
+
+
+ {svc.name} +
+
+ {svc.span_count.toLocaleString()} + + 0 ? 'text-red-400' : 'text-gray-400'}> + {svc.error_count.toLocaleString()} + + + {svc.avg_duration_ms?.toFixed(2)} ms + + {new Date(svc.last_seen).toLocaleString()} +
+
+
+
+ ); +} + +function StatCard({ label, value, icon }: { label: string; value: number | string; icon: React.ReactNode }) { + return ( +
+
+ {label} + {icon} +
+

{value}

+
+ ); +} diff --git a/dashboard/src/app/traces/page.tsx b/dashboard/src/app/traces/page.tsx new file mode 100644 index 0000000..39aacdf --- /dev/null +++ b/dashboard/src/app/traces/page.tsx @@ -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(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 ( +
+
+

Traces

+ +
+ + {/* Filters */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+
+ +
+ {/* Trace List */} +
+

+ {isLoading ? 'Loading...' : `${traces?.traces?.length ?? 0} traces`} +

+
+ {traces?.traces?.map((trace: Trace) => ( +
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' + }`} + > +
+
+
+ {trace.has_error && ( + + )} + + {trace.root_span?.operation || trace.trace_id.slice(0, 16)} + +
+
+ {trace.services?.slice(0, 3).map((svc) => ( + + {svc} + + ))} + {(trace.services?.length ?? 0) > 3 && ( + + +{trace.services.length - 3} more + + )} +
+
+
+
+ + {formatDuration(trace.duration_ns)} +
+
+ {trace.span_count} spans +
+
+
+
+ {formatTime(trace.start_time)} +
+
+ ))} + {!isLoading && (!traces?.traces || traces.traces.length === 0) && ( +
+ No traces found +
+ )} +
+
+ + {/* Trace Detail */} +
+

Trace Timeline

+ {selectedTrace && traceDetail ? ( + + ) : ( +
+ Select a trace to view timeline +
+ )} +
+
+
+ ); +} diff --git a/dashboard/src/components/Providers.tsx b/dashboard/src/components/Providers.tsx new file mode 100644 index 0000000..3ef9ff1 --- /dev/null +++ b/dashboard/src/components/Providers.tsx @@ -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 ( + + {children} + + ); +} diff --git a/dashboard/src/components/dashboard/MetricCard.tsx b/dashboard/src/components/dashboard/MetricCard.tsx new file mode 100644 index 0000000..f89c940 --- /dev/null +++ b/dashboard/src/components/dashboard/MetricCard.tsx @@ -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 ( +
+
+ {title} + {icon && ( +
+ {icon} +
+ )} +
+
+ {value} + {trend && ( + + {trend.isUp ? '↑' : '↓'} {Math.abs(trend.value)}% + + )} +
+ {subtitle && ( +

{subtitle}

+ )} +
+ ); +} diff --git a/dashboard/src/components/dashboard/RecentAlerts.tsx b/dashboard/src/components/dashboard/RecentAlerts.tsx new file mode 100644 index 0000000..7315248 --- /dev/null +++ b/dashboard/src/components/dashboard/RecentAlerts.tsx @@ -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 = { + 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 ( +
+
+

Recent Alerts

+ + View all β†’ + +
+ + {isLoading ? ( +
+ Loading... +
+ ) : alerts.length === 0 ? ( +
+ +

No active alerts

+
+ ) : ( +
+ {alerts.map((alert) => ( +
+
+
+ + {alert.name} +
+ + {alert.status === 'firing' ? 'πŸ”΄' : 'βœ…'} {alert.status} + +
+

+ {alert.message} +

+
+ + {formatTime(alert.fired_at)} + {alert.service && ( + <> + β€’ + {alert.service} + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx index 3e9fd62..f9055f5 100644 --- a/dashboard/src/components/layout/Sidebar.tsx +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -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 ( - - ) +
+ ); } diff --git a/dashboard/src/components/metrics/MetricsChart.tsx b/dashboard/src/components/metrics/MetricsChart.tsx new file mode 100644 index 0000000..e1c4ade --- /dev/null +++ b/dashboard/src/components/metrics/MetricsChart.tsx @@ -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 ( +
+
+

{title}

+
+ + Current: {formatValue(current)} + + + Avg: {formatValue(avg)} + + + Max: {formatValue(max)} + +
+
+
+ {isLoading ? ( +
+ Loading... +
+ ) : error ? ( +
+ Error loading data +
+ ) : metrics.length === 0 ? ( +
+ No data available +
+ ) : ( + + )} +
+
+ ); +} diff --git a/dashboard/src/components/services/ServiceMapGraph.tsx b/dashboard/src/components/services/ServiceMapGraph.tsx new file mode 100644 index 0000000..8853bb1 --- /dev/null +++ b/dashboard/src/components/services/ServiceMapGraph.tsx @@ -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(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() + .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 ( + + ); +} diff --git a/dashboard/src/components/traces/TraceTimeline.tsx b/dashboard/src/components/traces/TraceTimeline.tsx new file mode 100644 index 0000000..f21820e --- /dev/null +++ b/dashboard/src/components/traces/TraceTimeline.tsx @@ -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; +} + +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 = {}; + 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 ( +
+ No spans in this trace +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

+ Trace: {trace.trace_id.slice(0, 16)}... +

+

+ {spans.length} spans β€’ {formatDuration(trace.duration_ns)} +

+
+
+ {Object.entries(serviceColors).map(([service, color]) => ( + + + {service} + + ))} +
+
+
+ + {/* Timeline */} +
+ {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 ( +
+
+ + {formatDuration(span.duration_ns)} + + + + {span.service} + +
+
+
+
+
+
+
+
+
+ + {span.operation} + {hasError && ( + + ⚠ {span.status.message || 'Error'} + + )} + +
+
+ ); + })} +
+
+ ); +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts new file mode 100644 index 0000000..e999345 --- /dev/null +++ b/dashboard/src/lib/api.ts @@ -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): Promise { + 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 { + 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 { + 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 { + 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); diff --git a/dashboard/src/lib/utils.ts b/dashboard/src/lib/utils.ts new file mode 100644 index 0000000..b6adab4 --- /dev/null +++ b/dashboard/src/lib/utils.ts @@ -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); +} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index e19049d..039bd87 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -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 diff --git a/deploy/docker/Dockerfile.agent b/deploy/docker/Dockerfile.agent new file mode 100644 index 0000000..27f431e --- /dev/null +++ b/deploy/docker/Dockerfile.agent @@ -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"] diff --git a/deploy/docker/Dockerfile.server b/deploy/docker/Dockerfile.server index e9c2c4a..c980af2 100644 --- a/deploy/docker/Dockerfile.server +++ b/deploy/docker/Dockerfile.server @@ -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"] diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 37a45d8..a8a1f68 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -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: diff --git a/deploy/docker/entrypoint.sh b/deploy/docker/entrypoint.sh index 8451668..b02eaf1 100644 --- a/deploy/docker/entrypoint.sh +++ b/deploy/docker/entrypoint.sh @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ea7d199 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/OPHION_Manual_Completo.pdf b/docs/OPHION_Manual_Completo.pdf new file mode 100644 index 0000000..f6ded35 --- /dev/null +++ b/docs/OPHION_Manual_Completo.pdf @@ -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,adrbbfcb2n_Zg/lbRC6":RED9^jjm]5B/uu$A4=t;?=*nWjC7_+gm!pamhk/K`nJL5<#=_Z7?V5m%T^LX&gDp"7-W]XQA_Qr#]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[$M0TVU9Kf6l1T?+d$mR]8#Z4cPTpMs!?RRG3hNEjr8VFFW9#$spMW,@cY?!b+o^^XQQPjpX#;0h/fS_6D\eMZ.)Ce0u;r]L9/(rJ>&WW+GG)C-gQ)%^o;$Bp,7u+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"/Vendstream +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\;tf3Lj?n6B4rNLWVM&7TNRYZ*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@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?*"&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;[^+R'kHfP%k:[0HmOG#`LTtQJrG^G=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>gCf5q7DW)/?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/rKBpIrD[.!`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"prq`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,7endstream +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,[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,?l54S+hZ@#B,XKLfn!maFfdAXNV)fG^uoF[!\-Hh2q:cc83`U0[Rg;V3TZ0F5qV?Yide_T7Rs9oFN+<].FKGMXendstream +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_-.39OU/iWLM'^8i1.ltk'>rdt'l'-H!j7?,aul2cE6E)0k-2fP_Yd'.>CBVt0P[K-td%,klUdI\4+AoLa%p+0e:WT,gUIfcTAcPOendstream +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`ojW(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?;4R:;H*)O-rKt%dVQom7CGlM\M2.94rA#JesFZWEtkr;T?j.&[.t_Yh5B`"nKtQR/FF$j08bs,&1=CP17URH0ufMfUHA5fUaWa@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%^^Nc=Q\5\(1D58cdTJsD);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;#7SFnILjcR7jjIDT)=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$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\[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&YW6f4EO]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$#HYdftQI5GK7QXh7>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&@JiUHj?i,bG*6.8\fY9XXMID"*msp27r*dEd/"$_W^=?c@N$lDX#`/RfN')`4!^@-Q\s^2Mcm=_h_B:0gmRQaSNqWCpZ2jendstream +endobj +40 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 427 +>> +stream +Gat=&>>)jf(k(;F30/-bZsVOa8`.0S&-h,pHl[@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*:+sendstream +endobj +41 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 301 +>> +stream +GarW392!2W'SZ;['kc4i&US#WC#$+jpfaU0E&4!3M'ZWc0iOA5pYH#b.rld>SfqRl8IDP8p[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\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,-qNeEY7XQIVj+q*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+o)8[-U>M.d9M3$1)D-;k9Lp]nCP.cc+WkpH0Xg*hdJGmp,U]RdD@U5)f6:mFG\`i!@CWH/YulZ)\&g76?"o$;A[;FZ[Zic[HNZUec~>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_"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 +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 26 0 R +/Root 25 0 R +/Size 46 +>> +startxref +16396 +%%EOF diff --git a/docs/ophion_logo.png b/docs/ophion_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..446a1ae70e9424550b8ecb552bcb5d7a532dba4d GIT binary patch literal 32687 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 literal 0 HcmV?d00001 diff --git a/docs/ophion_logo_small.png b/docs/ophion_logo_small.png new file mode 100644 index 0000000000000000000000000000000000000000..54b59dee3ad2beaa63bd6c05af4b717f014349ec GIT binary patch literal 22982 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 N002ovPDHLkV1lTx7nT43 literal 0 HcmV?d00001 diff --git a/docs/ophion_snake_logo.png b/docs/ophion_snake_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..24f2f7a6ce36f2787d5e3b4c06ae5605b4fad273 GIT binary patch literal 1439 zcmah}`!^E`9N*+Ik6dOak84`YjY3RU-gjnfLuI=pwBe9P6nPD6x5ac6wh<}O+_v?= z>~KkFi7OHE2$ej-Zg+~7W#+x>{sn!{`Fy@Vd_U*&IiFv?*Zfal_G%bu004l!I4qj5 zyL_R??dqz{6&^Uw)mvwuE`Gu=`#}|0Yq-DFYh?XWX+P_P@~a#mEo=_9T{WJ z7D}kV(46J!K!0UA!KYGpJJzx8-73C)Y#LU-Xg$H+FJWUewi6udvI>N$tSdx(QmD7A zMzbyYU}jCzfw@>aa11X;>k}i%s!)u1EPu&XY{}*AiVdpVK@g>lVA+#d4Eo0e$NM|f zPxZ_p57qip{uFN!Kc7O$&#=+r`H_o_h-fv#=g=FXM{}-!Rq~P&)^~`?+{n-iy>&!) z{`TUi16%R*_rmj_o~qZ%^RkPi_VG7F1aM)LP(-3GO>e9d$Y55-WMIGWDjMl*e@v1` zdY;i4*T4fd`$rvq{D`L0X( zJ)1{h>S!o&Fhko*G_R-AnP&h2;%ub<^pO>xZ^`eE47F1X0GbM2U zZZ$1sIBQsPrW7p52VrfUdxH4PP-J#2l@G;nb3K9TRcX3WL!ZfTn0n*0{@4Sr>?m(k zQSs1{BxqD)U*Rx=%p9M!FvqIl;onUmy*=gWtd_x*eFpa)PJ2KUigEYbgF6Uwj>u^Z#Hcn~g4CZZPPQfKizH#|u8UAZ*3#BwDPkY2(GpB`jiyLz`!nQpX z*z2%=*dsaKghk4Shq7*uyTWvWU(r@`6w~*dUsxcpro!9cLIuqb3@Q zI1<}%Tud{@$2U%gthHh;5LLM2cU%&vt$9fzeRmk;ZqFJB!)NtVPugx2&7s|y?vWyU11@cX@<8B#eg-D#+DU z%-6^0oRd=(qc`>9X2n4hzO^ydLCv|G#HotOeNeRH##=8nOWqZQl6ce7;IPdRoE z2O@o11R498hm9i_HlodeE f4_@HznFid_9IN;!jr6d0{9ldpI)Sc3g=hZ