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