fix: add go.sum and fixes
This commit is contained in:
@@ -27,7 +27,7 @@ OPHION é uma plataforma de observabilidade que combina **métricas, logs e trac
|
|||||||
## 🚀 Instalação Rápida
|
## 🚀 Instalação Rápida
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://get.ophion.io | bash
|
curl -fsSL https://get.ophion.com.br | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
O instalador interativo irá:
|
O instalador interativo irá:
|
||||||
|
|||||||
@@ -7,118 +7,326 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v3/cpu"
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
"github.com/shirou/gopsutil/v3/disk"
|
"github.com/shirou/gopsutil/v3/disk"
|
||||||
"github.com/shirou/gopsutil/v3/host"
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
|
"github.com/shirou/gopsutil/v3/load"
|
||||||
"github.com/shirou/gopsutil/v3/mem"
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
"github.com/shirou/gopsutil/v3/net"
|
psnet "github.com/shirou/gopsutil/v3/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Metrics struct {
|
// ═══════════════════════════════════════════════════════════
|
||||||
Hostname string `json:"hostname"`
|
// 🐍 OPHION Agent - Observability Collector
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string
|
||||||
|
APIKey string
|
||||||
|
Hostname string
|
||||||
|
CollectInterval time.Duration
|
||||||
|
DockerEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metric struct {
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
CPU CPUMetric `json:"cpu"`
|
Service string `json:"service"`
|
||||||
Memory MemMetric `json:"memory"`
|
Host string `json:"host"`
|
||||||
Disk []DiskMetric `json:"disk"`
|
Name string `json:"name"`
|
||||||
Network NetMetric `json:"network"`
|
Value float64 `json:"value"`
|
||||||
|
MetricType string `json:"metric_type"`
|
||||||
|
Tags map[string]string `json:"tags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CPUMetric struct {
|
type ContainerStats struct {
|
||||||
UsagePercent float64 `json:"usage_percent"`
|
ID string `json:"id"`
|
||||||
Cores int `json:"cores"`
|
Name string `json:"name"`
|
||||||
}
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
MemoryUsage uint64 `json:"memory_usage"`
|
||||||
type MemMetric struct {
|
MemoryLimit uint64 `json:"memory_limit"`
|
||||||
Total uint64 `json:"total"`
|
MemoryPercent float64 `json:"memory_percent"`
|
||||||
Used uint64 `json:"used"`
|
NetRx uint64 `json:"net_rx"`
|
||||||
UsedPercent float64 `json:"used_percent"`
|
NetTx uint64 `json:"net_tx"`
|
||||||
}
|
State string `json:"state"`
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
serverURL := os.Getenv("OPHION_SERVER")
|
config := loadConfig()
|
||||||
if serverURL == "" {
|
|
||||||
serverURL = "http://localhost:8080"
|
log.Printf("🐍 OPHION Agent starting")
|
||||||
|
log.Printf(" Server: %s", config.ServerURL)
|
||||||
|
log.Printf(" Host: %s", config.Hostname)
|
||||||
|
log.Printf(" Interval: %s", config.CollectInterval)
|
||||||
|
log.Printf(" Docker: %v", config.DockerEnabled)
|
||||||
|
|
||||||
|
// Handle shutdown
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(config.CollectInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Collect immediately
|
||||||
|
collect(config)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sigCh:
|
||||||
|
log.Println("🛑 Shutting down agent...")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
collect(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey := os.Getenv("OPHION_API_KEY")
|
|
||||||
if apiKey == "" {
|
|
||||||
log.Fatal("OPHION_API_KEY is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := 30 * time.Second
|
|
||||||
log.Printf("🐍 OPHION Agent starting - reporting to %s every %s", serverURL, interval)
|
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
for range ticker.C {
|
|
||||||
metrics := collectMetrics()
|
|
||||||
sendMetrics(serverURL, apiKey, metrics)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectMetrics() Metrics {
|
func loadConfig() *Config {
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
|
|
||||||
cpuPercent, _ := cpu.Percent(time.Second, false)
|
interval := 30 * time.Second
|
||||||
cpuUsage := 0.0
|
if v := os.Getenv("OPHION_INTERVAL"); v != "" {
|
||||||
if len(cpuPercent) > 0 {
|
if d, err := time.ParseDuration(v); err == nil {
|
||||||
cpuUsage = cpuPercent[0]
|
interval = d
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
memInfo, _ := mem.VirtualMemory()
|
dockerEnabled := true
|
||||||
|
if v := os.Getenv("OPHION_DOCKER"); v == "false" || v == "0" {
|
||||||
diskInfo, _ := disk.Usage("/")
|
dockerEnabled = false
|
||||||
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{
|
return &Config{
|
||||||
Hostname: hostname,
|
ServerURL: getEnv("OPHION_SERVER", "http://localhost:8080"),
|
||||||
Timestamp: time.Now(),
|
APIKey: getEnv("OPHION_API_KEY", ""),
|
||||||
CPU: CPUMetric{
|
Hostname: getEnv("OPHION_HOSTNAME", hostname),
|
||||||
UsagePercent: cpuUsage,
|
CollectInterval: interval,
|
||||||
Cores: runtime.NumCPU(),
|
DockerEnabled: dockerEnabled,
|
||||||
},
|
|
||||||
Memory: MemMetric{
|
|
||||||
Total: memInfo.Total,
|
|
||||||
Used: memInfo.Used,
|
|
||||||
UsedPercent: memInfo.UsedPercent,
|
|
||||||
},
|
|
||||||
Disk: disks,
|
|
||||||
Network: netMetric,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMetrics(serverURL, apiKey string, metrics Metrics) {
|
func getEnv(key, def string) string {
|
||||||
data, _ := json.Marshal(metrics)
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func collect(config *Config) {
|
||||||
|
var metrics []Metric
|
||||||
|
now := time.Now()
|
||||||
|
host := config.Hostname
|
||||||
|
|
||||||
|
// System metrics
|
||||||
|
metrics = append(metrics, collectSystemMetrics(now, host)...)
|
||||||
|
|
||||||
|
// Docker metrics
|
||||||
|
if config.DockerEnabled {
|
||||||
|
metrics = append(metrics, collectDockerMetrics(now, host)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to server
|
||||||
|
if len(metrics) > 0 {
|
||||||
|
sendMetrics(config, metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectSystemMetrics(now time.Time, hostname string) []Metric {
|
||||||
|
var metrics []Metric
|
||||||
|
svc := "system"
|
||||||
|
|
||||||
|
// CPU
|
||||||
|
cpuPercent, err := cpu.Percent(time.Second, false)
|
||||||
|
if err == nil && len(cpuPercent) > 0 {
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "cpu.usage_percent", Value: cpuPercent[0], MetricType: "gauge",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load average
|
||||||
|
loadAvg, err := load.Avg()
|
||||||
|
if err == nil {
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "cpu.load_avg_1", Value: loadAvg.Load1, MetricType: "gauge",
|
||||||
|
})
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "cpu.load_avg_5", Value: loadAvg.Load5, MetricType: "gauge",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
memInfo, err := mem.VirtualMemory()
|
||||||
|
if err == nil {
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "memory.used_percent", Value: memInfo.UsedPercent, MetricType: "gauge",
|
||||||
|
})
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "memory.used_bytes", Value: float64(memInfo.Used), MetricType: "gauge",
|
||||||
|
})
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "memory.available_bytes", Value: float64(memInfo.Available), MetricType: "gauge",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk
|
||||||
|
partitions, _ := disk.Partitions(false)
|
||||||
|
for _, p := range partitions {
|
||||||
|
if strings.HasPrefix(p.Mountpoint, "/snap") ||
|
||||||
|
strings.HasPrefix(p.Mountpoint, "/sys") ||
|
||||||
|
strings.HasPrefix(p.Mountpoint, "/proc") ||
|
||||||
|
strings.HasPrefix(p.Mountpoint, "/dev") ||
|
||||||
|
strings.HasPrefix(p.Mountpoint, "/run") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := disk.Usage(p.Mountpoint)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := map[string]string{"path": p.Mountpoint, "device": p.Device}
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "disk.used_percent", Value: usage.UsedPercent, MetricType: "gauge", Tags: tags,
|
||||||
|
})
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "disk.used_bytes", Value: float64(usage.Used), MetricType: "gauge", Tags: tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network
|
||||||
|
netIO, err := psnet.IOCounters(false)
|
||||||
|
if err == nil && len(netIO) > 0 {
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "network.bytes_sent", Value: float64(netIO[0].BytesSent), MetricType: "counter",
|
||||||
|
})
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "network.bytes_recv", Value: float64(netIO[0].BytesRecv), MetricType: "counter",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uptime
|
||||||
|
hostInfo, err := host.Info()
|
||||||
|
if err == nil {
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "host.uptime_seconds", Value: float64(hostInfo.Uptime), MetricType: "gauge",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cores
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "host.cpu_cores", Value: float64(runtime.NumCPU()), MetricType: "gauge",
|
||||||
|
})
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectDockerMetrics(now time.Time, hostname string) []Metric {
|
||||||
|
var metrics []Metric
|
||||||
|
svc := "docker"
|
||||||
|
|
||||||
|
// Check if docker is available
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get container stats using docker CLI (simpler, no SDK needed)
|
||||||
|
out, err := exec.Command("docker", "stats", "--no-stream", "--format",
|
||||||
|
"{{.ID}}\t{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}").Output()
|
||||||
|
if err != nil {
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
runningCount := 0
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runningCount++
|
||||||
|
|
||||||
|
parts := strings.Split(line, "\t")
|
||||||
|
if len(parts) < 6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
id := parts[0]
|
||||||
|
name := parts[1]
|
||||||
|
cpuStr := strings.TrimSuffix(parts[2], "%")
|
||||||
|
memStr := strings.TrimSuffix(parts[4], "%")
|
||||||
|
|
||||||
|
var cpuPercent, memPercent float64
|
||||||
|
fmt.Sscanf(cpuStr, "%f", &cpuPercent)
|
||||||
|
fmt.Sscanf(memStr, "%f", &memPercent)
|
||||||
|
|
||||||
|
tags := map[string]string{"container": name, "container_id": id}
|
||||||
|
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "container.cpu_percent", Value: cpuPercent, MetricType: "gauge", Tags: tags,
|
||||||
|
})
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "container.memory_percent", Value: memPercent, MetricType: "gauge", Tags: tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total containers
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "containers.running", Value: float64(runningCount), MetricType: "gauge",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all containers count
|
||||||
|
out, err = exec.Command("docker", "ps", "-a", "-q").Output()
|
||||||
|
if err == nil {
|
||||||
|
total := len(strings.Split(strings.TrimSpace(string(out)), "\n"))
|
||||||
|
if strings.TrimSpace(string(out)) == "" {
|
||||||
|
total = 0
|
||||||
|
}
|
||||||
|
metrics = append(metrics, Metric{
|
||||||
|
Timestamp: now, Service: svc, Host: hostname,
|
||||||
|
Name: "containers.total", Value: float64(total), MetricType: "gauge",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMetrics(config *Config, metrics []Metric) {
|
||||||
|
data, err := json.Marshal(map[string]any{"metrics": metrics})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error marshaling metrics: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", config.ServerURL+"/api/v1/metrics", bytes.NewBuffer(data))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating request: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
req, _ := http.NewRequest("POST", serverURL+"/api/v1/metrics", bytes.NewBuffer(data))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("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}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
@@ -128,8 +336,10 @@ func sendMetrics(serverURL, apiKey string, metrics Metrics) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode == 200 {
|
if resp.StatusCode >= 400 {
|
||||||
log.Printf("✓ Metrics sent: CPU=%.1f%% MEM=%.1f%%",
|
log.Printf("Server returned error: %d", resp.StatusCode)
|
||||||
metrics.CPU.UsagePercent, metrics.Memory.UsedPercent)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("📤 Sent %d metrics", len(metrics))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,737 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
"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() {
|
func main() {
|
||||||
|
// Initialize database
|
||||||
|
pgDSN := getEnv("DATABASE_URL", "postgres://ophion:ophion@localhost:5432/ophion?sslmode=disable")
|
||||||
|
db, err := sql.Open("postgres", pgDSN)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
log.Printf("⚠ Database not available: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("✓ Connected to PostgreSQL")
|
||||||
|
initSchema(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &Server{db: db}
|
||||||
|
|
||||||
|
// Create Fiber app
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
AppName: "OPHION Observability Platform",
|
AppName: "OPHION Observability Platform",
|
||||||
|
BodyLimit: 50 * 1024 * 1024,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
})
|
})
|
||||||
|
server.app = app
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.Use(logger.New())
|
app.Use(recover.New())
|
||||||
app.Use(cors.New())
|
app.Use(logger.New(logger.Config{
|
||||||
|
Format: "${time} ${status} ${method} ${path} ${latency}\n",
|
||||||
|
}))
|
||||||
|
app.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: "*",
|
||||||
|
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
server.setupRoutes()
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("🛑 Shutting down server...")
|
||||||
|
cancel()
|
||||||
|
app.Shutdown()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Background jobs
|
||||||
|
go server.runBackgroundJobs(ctx)
|
||||||
|
|
||||||
|
port := getEnv("PORT", "8080")
|
||||||
|
log.Printf("🐍 OPHION server starting on port %s", port)
|
||||||
|
if err := app.Listen(":" + port); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initSchema(db *sql.DB) {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS metrics (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
service VARCHAR(255) NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
value DOUBLE PRECISION NOT NULL,
|
||||||
|
metric_type VARCHAR(50),
|
||||||
|
tags JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
service VARCHAR(255) NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
level VARCHAR(20),
|
||||||
|
message TEXT,
|
||||||
|
trace_id VARCHAR(64),
|
||||||
|
span_id VARCHAR(32),
|
||||||
|
source VARCHAR(50),
|
||||||
|
container_id VARCHAR(64)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spans (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
trace_id VARCHAR(64) NOT NULL,
|
||||||
|
span_id VARCHAR(32) NOT NULL,
|
||||||
|
parent_span_id VARCHAR(32),
|
||||||
|
service VARCHAR(255) NOT NULL,
|
||||||
|
operation VARCHAR(255) NOT NULL,
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ NOT NULL,
|
||||||
|
duration_ns BIGINT,
|
||||||
|
status_code VARCHAR(20),
|
||||||
|
status_message TEXT,
|
||||||
|
kind VARCHAR(20),
|
||||||
|
attributes JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
hostname VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
ip VARCHAR(45),
|
||||||
|
version VARCHAR(50),
|
||||||
|
status VARCHAR(20) DEFAULT 'active',
|
||||||
|
last_seen TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
severity VARCHAR(20),
|
||||||
|
service VARCHAR(255),
|
||||||
|
host VARCHAR(255),
|
||||||
|
message TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'firing',
|
||||||
|
fired_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
resolved_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_metrics_service_name ON metrics(service, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_service ON logs(service);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON spans(trace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spans_service ON spans(service);
|
||||||
|
`
|
||||||
|
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
log.Printf("Error creating schema: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("✓ Database schema initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setupRoutes() {
|
||||||
// Health check
|
// Health check
|
||||||
app.Get("/health", func(c *fiber.Ctx) error {
|
s.app.Get("/health", s.healthCheck)
|
||||||
|
|
||||||
|
// API v1
|
||||||
|
api := s.app.Group("/api/v1")
|
||||||
|
|
||||||
|
// Ingest endpoints (for agents)
|
||||||
|
api.Post("/metrics", s.ingestMetrics)
|
||||||
|
api.Post("/logs", s.ingestLogs)
|
||||||
|
api.Post("/traces", s.ingestTraces)
|
||||||
|
|
||||||
|
// Query endpoints (for dashboard)
|
||||||
|
api.Get("/metrics", s.queryMetrics)
|
||||||
|
api.Get("/metrics/names", s.getMetricNames)
|
||||||
|
api.Get("/logs", s.queryLogs)
|
||||||
|
api.Get("/traces", s.queryTraces)
|
||||||
|
api.Get("/traces/:traceId", s.getTrace)
|
||||||
|
api.Get("/services", s.getServices)
|
||||||
|
|
||||||
|
// Agents
|
||||||
|
api.Get("/agents", s.getAgents)
|
||||||
|
api.Post("/agents/register", s.registerAgent)
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
api.Get("/alerts", s.getAlerts)
|
||||||
|
api.Post("/alerts", s.createAlert)
|
||||||
|
api.Put("/alerts/:id/resolve", s.resolveAlert)
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
api.Get("/dashboard/overview", s.getDashboardOverview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) healthCheck(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"service": "ophion",
|
"service": "ophion",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
})
|
"timestamp": time.Now(),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// API routes
|
// ─────────────────────────────────────────────────────────────
|
||||||
api := app.Group("/api/v1")
|
// Ingest Endpoints
|
||||||
api.Get("/metrics", getMetrics)
|
// ─────────────────────────────────────────────────────────────
|
||||||
api.Post("/metrics", ingestMetrics)
|
|
||||||
api.Get("/logs", getLogs)
|
|
||||||
api.Post("/logs", ingestLogs)
|
|
||||||
api.Get("/alerts", getAlerts)
|
|
||||||
|
|
||||||
port := os.Getenv("PORT")
|
func (s *Server) ingestMetrics(c *fiber.Ctx) error {
|
||||||
if port == "" {
|
var req struct {
|
||||||
port = "8080"
|
Metrics []Metric `json:"metrics"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("🐍 OPHION starting on port %s", port)
|
for _, m := range req.Metrics {
|
||||||
log.Fatal(app.Listen(":" + port))
|
tags, _ := json.Marshal(m.Tags)
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO metrics (timestamp, service, host, name, value, metric_type, tags)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
m.Timestamp, m.Service, m.Host, m.Name, m.Value, m.MetricType, tags)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error inserting metric: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update agent last_seen
|
||||||
|
if len(req.Metrics) > 0 {
|
||||||
|
host := req.Metrics[0].Host
|
||||||
|
s.db.Exec(`UPDATE agents SET last_seen = NOW(), status = 'active' WHERE hostname = $1`, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"status": "received", "count": len(req.Metrics)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMetrics(c *fiber.Ctx) error {
|
func (s *Server) ingestLogs(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{"metrics": []string{}})
|
var req struct {
|
||||||
|
Logs []LogEntry `json:"logs"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range req.Logs {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO logs (timestamp, service, host, level, message, trace_id, span_id, source, container_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
l.Timestamp, l.Service, l.Host, l.Level, l.Message, l.TraceID, l.SpanID, l.Source, l.ContainerID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error inserting log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"status": "received", "count": len(req.Logs)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ingestMetrics(c *fiber.Ctx) error {
|
func (s *Server) ingestTraces(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{"status": "received"})
|
var req struct {
|
||||||
|
Spans []Span `json:"spans"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sp := range req.Spans {
|
||||||
|
attrs, _ := json.Marshal(sp.Attributes)
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO spans (trace_id, span_id, parent_span_id, service, operation, start_time, end_time, duration_ns, status_code, status_message, kind, attributes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||||
|
sp.TraceID, sp.SpanID, sp.ParentSpanID, sp.Service, sp.Operation, sp.StartTime, sp.EndTime, sp.DurationNs, sp.StatusCode, sp.StatusMsg, sp.Kind, attrs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error inserting span: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"status": "received", "count": len(req.Spans)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLogs(c *fiber.Ctx) error {
|
// ─────────────────────────────────────────────────────────────
|
||||||
return c.JSON(fiber.Map{"logs": []string{}})
|
// Query Endpoints
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) queryMetrics(c *fiber.Ctx) error {
|
||||||
|
service := c.Query("service", "system")
|
||||||
|
name := c.Query("name", "cpu.usage_percent")
|
||||||
|
from := parseTime(c.Query("from"), time.Now().Add(-1*time.Hour))
|
||||||
|
to := parseTime(c.Query("to"), time.Now())
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT timestamp, value FROM metrics
|
||||||
|
WHERE service = $1 AND name = $2 AND timestamp >= $3 AND timestamp <= $4
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
LIMIT 1000`, service, name, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var metrics []map[string]any
|
||||||
|
for rows.Next() {
|
||||||
|
var ts time.Time
|
||||||
|
var val float64
|
||||||
|
if err := rows.Scan(&ts, &val); err == nil {
|
||||||
|
metrics = append(metrics, map[string]any{"timestamp": ts, "value": val})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"metrics": metrics, "count": len(metrics)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ingestLogs(c *fiber.Ctx) error {
|
func (s *Server) getMetricNames(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{"status": "received"})
|
rows, err := s.db.Query(`SELECT DISTINCT name FROM metrics ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if rows.Scan(&name) == nil {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"names": names})
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAlerts(c *fiber.Ctx) error {
|
func (s *Server) queryLogs(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{"alerts": []string{}})
|
service := c.Query("service")
|
||||||
|
level := c.Query("level")
|
||||||
|
query := c.Query("q")
|
||||||
|
from := parseTime(c.Query("from"), time.Now().Add(-1*time.Hour))
|
||||||
|
to := parseTime(c.Query("to"), time.Now())
|
||||||
|
limit := parseInt(c.Query("limit"), 100)
|
||||||
|
|
||||||
|
sql := `SELECT timestamp, service, host, level, message, source, container_id
|
||||||
|
FROM logs WHERE timestamp >= $1 AND timestamp <= $2`
|
||||||
|
args := []any{from, to}
|
||||||
|
argN := 3
|
||||||
|
|
||||||
|
if service != "" {
|
||||||
|
sql += ` AND service = $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, service)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if level != "" {
|
||||||
|
sql += ` AND level = $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, level)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if query != "" {
|
||||||
|
sql += ` AND message ILIKE $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, "%"+query+"%")
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY timestamp DESC LIMIT $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := s.db.Query(sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []LogEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var l LogEntry
|
||||||
|
if err := rows.Scan(&l.Timestamp, &l.Service, &l.Host, &l.Level, &l.Message, &l.Source, &l.ContainerID); err == nil {
|
||||||
|
logs = append(logs, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"logs": logs, "count": len(logs)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) queryTraces(c *fiber.Ctx) error {
|
||||||
|
service := c.Query("service")
|
||||||
|
from := parseTime(c.Query("from"), time.Now().Add(-1*time.Hour))
|
||||||
|
to := parseTime(c.Query("to"), time.Now())
|
||||||
|
limit := parseInt(c.Query("limit"), 20)
|
||||||
|
|
||||||
|
sql := `SELECT DISTINCT trace_id, service, operation, MIN(start_time), MAX(duration_ns)
|
||||||
|
FROM spans WHERE start_time >= $1 AND start_time <= $2`
|
||||||
|
args := []any{from, to}
|
||||||
|
argN := 3
|
||||||
|
|
||||||
|
if service != "" {
|
||||||
|
sql += ` AND service = $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, service)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` GROUP BY trace_id, service, operation ORDER BY MIN(start_time) DESC LIMIT $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := s.db.Query(sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var traces []map[string]any
|
||||||
|
for rows.Next() {
|
||||||
|
var traceID, service, operation string
|
||||||
|
var startTime time.Time
|
||||||
|
var durationNs int64
|
||||||
|
if err := rows.Scan(&traceID, &service, &operation, &startTime, &durationNs); err == nil {
|
||||||
|
traces = append(traces, map[string]any{
|
||||||
|
"trace_id": traceID,
|
||||||
|
"service": service,
|
||||||
|
"operation": operation,
|
||||||
|
"start_time": startTime,
|
||||||
|
"duration_ns": durationNs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"traces": traces, "count": len(traces)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getTrace(c *fiber.Ctx) error {
|
||||||
|
traceID := c.Params("traceId")
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT trace_id, span_id, parent_span_id, service, operation, start_time, end_time, duration_ns, status_code, status_message, kind
|
||||||
|
FROM spans WHERE trace_id = $1 ORDER BY start_time`, traceID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var spans []Span
|
||||||
|
for rows.Next() {
|
||||||
|
var sp Span
|
||||||
|
var parentSpanID, statusMsg sql.NullString
|
||||||
|
if err := rows.Scan(&sp.TraceID, &sp.SpanID, &parentSpanID, &sp.Service, &sp.Operation, &sp.StartTime, &sp.EndTime, &sp.DurationNs, &sp.StatusCode, &statusMsg, &sp.Kind); err == nil {
|
||||||
|
sp.ParentSpanID = parentSpanID.String
|
||||||
|
sp.StatusMsg = statusMsg.String
|
||||||
|
spans = append(spans, sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(spans) == 0 {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "trace not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"trace_id": traceID,
|
||||||
|
"spans": spans,
|
||||||
|
"duration_ns": spans[len(spans)-1].EndTime.Sub(spans[0].StartTime).Nanoseconds(),
|
||||||
|
"start_time": spans[0].StartTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getServices(c *fiber.Ctx) error {
|
||||||
|
rows, err := s.db.Query(`SELECT DISTINCT service FROM metrics UNION SELECT DISTINCT service FROM spans ORDER BY service`)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var services []string
|
||||||
|
for rows.Next() {
|
||||||
|
var svc string
|
||||||
|
if rows.Scan(&svc) == nil {
|
||||||
|
services = append(services, svc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"services": services})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Agents
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) getAgents(c *fiber.Ctx) error {
|
||||||
|
rows, err := s.db.Query(`SELECT id, hostname, ip, version, status, last_seen, created_at FROM agents ORDER BY last_seen DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var agents []Agent
|
||||||
|
for rows.Next() {
|
||||||
|
var a Agent
|
||||||
|
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Version, &a.Status, &a.LastSeen, &a.CreatedAt); err == nil {
|
||||||
|
agents = append(agents, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"agents": agents})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) registerAgent(c *fiber.Ctx) error {
|
||||||
|
var agent Agent
|
||||||
|
if err := c.BodyParser(&agent); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.ID = uuid.New().String()
|
||||||
|
agent.Status = "active"
|
||||||
|
agent.LastSeen = time.Now()
|
||||||
|
agent.CreatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO agents (id, hostname, ip, version, status, last_seen, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (hostname) DO UPDATE SET ip = $3, version = $4, status = 'active', last_seen = NOW()`,
|
||||||
|
agent.ID, agent.Hostname, agent.IP, agent.Version, agent.Status, agent.LastSeen, agent.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"status": "registered", "agent": agent})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Alerts
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) getAlerts(c *fiber.Ctx) error {
|
||||||
|
status := c.Query("status")
|
||||||
|
limit := parseInt(c.Query("limit"), 50)
|
||||||
|
|
||||||
|
sql := `SELECT id, name, severity, service, host, message, status, fired_at, resolved_at FROM alerts`
|
||||||
|
var args []any
|
||||||
|
if status != "" {
|
||||||
|
sql += ` WHERE status = $1`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
sql += ` ORDER BY fired_at DESC LIMIT ` + strconv.Itoa(limit)
|
||||||
|
|
||||||
|
rows, err := s.db.Query(sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var alerts []Alert
|
||||||
|
for rows.Next() {
|
||||||
|
var a Alert
|
||||||
|
var resolvedAt *time.Time
|
||||||
|
if err := rows.Scan(&a.ID, &a.Name, &a.Severity, &a.Service, &a.Host, &a.Message, &a.Status, &a.FiredAt, &resolvedAt); err == nil {
|
||||||
|
a.ResolvedAt = resolvedAt
|
||||||
|
alerts = append(alerts, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"alerts": alerts})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createAlert(c *fiber.Ctx) error {
|
||||||
|
var alert Alert
|
||||||
|
if err := c.BodyParser(&alert); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.ID = uuid.New().String()
|
||||||
|
alert.Status = "firing"
|
||||||
|
alert.FiredAt = time.Now()
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO alerts (id, name, severity, service, host, message, status, fired_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
alert.ID, alert.Name, alert.Severity, alert.Service, alert.Host, alert.Message, alert.Status, alert.FiredAt)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"status": "created", "alert": alert})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resolveAlert(c *fiber.Ctx) error {
|
||||||
|
alertID := c.Params("id")
|
||||||
|
_, err := s.db.Exec(`UPDATE alerts SET status = 'resolved', resolved_at = NOW() WHERE id = $1`, alertID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"status": "resolved"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Dashboard
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) getDashboardOverview(c *fiber.Ctx) error {
|
||||||
|
overview := fiber.Map{"timestamp": time.Now()}
|
||||||
|
|
||||||
|
// Agents
|
||||||
|
var totalAgents, activeAgents int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&totalAgents)
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM agents WHERE status = 'active'`).Scan(&activeAgents)
|
||||||
|
overview["agents"] = fiber.Map{"total": totalAgents, "active": activeAgents}
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
var firingAlerts int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE status = 'firing'`).Scan(&firingAlerts)
|
||||||
|
overview["alerts"] = fiber.Map{"firing": firingAlerts}
|
||||||
|
|
||||||
|
// Services
|
||||||
|
var serviceCount int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(DISTINCT service) FROM metrics`).Scan(&serviceCount)
|
||||||
|
overview["services"] = fiber.Map{"count": serviceCount}
|
||||||
|
|
||||||
|
return c.JSON(overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Background Jobs
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) runBackgroundJobs(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Mark stale agents
|
||||||
|
s.db.Exec(`UPDATE agents SET status = 'inactive' WHERE last_seen < NOW() - INTERVAL '5 minutes' AND status = 'active'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func getEnv(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(s string, def time.Time) time.Time {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if ts, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
|
return time.Unix(ts, 0)
|
||||||
|
}
|
||||||
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
|
return time.Now().Add(-d)
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s string, def int) int {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(s); err == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
}
|
}
|
||||||
|
|||||||
21
configs/clickhouse/config.xml
Normal file
21
configs/clickhouse/config.xml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<clickhouse>
|
||||||
|
<!-- Create default database on startup -->
|
||||||
|
<default_database>ophion</default_database>
|
||||||
|
|
||||||
|
<!-- Listen on all interfaces -->
|
||||||
|
<listen_host>::</listen_host>
|
||||||
|
<listen_host>0.0.0.0</listen_host>
|
||||||
|
|
||||||
|
<!-- Logging -->
|
||||||
|
<logger>
|
||||||
|
<level>information</level>
|
||||||
|
<console>1</console>
|
||||||
|
</logger>
|
||||||
|
|
||||||
|
<!-- Memory limits -->
|
||||||
|
<max_memory_usage>2000000000</max_memory_usage>
|
||||||
|
|
||||||
|
<!-- Query settings -->
|
||||||
|
<max_query_size>1000000</max_query_size>
|
||||||
|
<max_concurrent_queries>100</max_concurrent_queries>
|
||||||
|
</clickhouse>
|
||||||
43
dashboard/Dockerfile
Normal file
43
dashboard/Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 🐍 OPHION Dashboard - Dockerfile
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
}
|
reactStrictMode: true,
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'}/api/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig;
|
||||||
|
|||||||
@@ -1,33 +1,40 @@
|
|||||||
{
|
{
|
||||||
"name": "ophion-dashboard",
|
"name": "ophion-dashboard",
|
||||||
"version": "1.0.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "14.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"next-auth": "^4.24.0",
|
|
||||||
"@tanstack/react-query": "^5.17.0",
|
|
||||||
"recharts": "^2.10.0",
|
"recharts": "^2.10.0",
|
||||||
"lucide-react": "^0.312.0",
|
"tailwind-merge": "^2.2.0"
|
||||||
"clsx": "^2.1.0",
|
|
||||||
"tailwind-merge": "^2.2.0",
|
|
||||||
"date-fns": "^3.2.0",
|
|
||||||
"zustand": "^4.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.3.0",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.5",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.18",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.17",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.33",
|
||||||
"tailwindcss": "^3.4.0"
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
dashboard/public/.gitkeep
Normal file
0
dashboard/public/.gitkeep
Normal file
@@ -3,24 +3,19 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 2 6 23;
|
--foreground-rgb: 255, 255, 255;
|
||||||
--foreground: 248 250 252;
|
--background-start-rgb: 10, 10, 20;
|
||||||
--card: 15 23 42;
|
--background-end-rgb: 10, 10, 20;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: rgb(var(--background));
|
color: rgb(var(--foreground-rgb));
|
||||||
color: rgb(var(--foreground));
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgb(var(--background-end-rgb))
|
||||||
|
)
|
||||||
|
rgb(var(--background-start-rgb));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
@@ -30,34 +25,45 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: rgb(15, 23, 42);
|
background: #1a1a2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgb(51, 65, 85);
|
background: #3b3b5c;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgb(71, 85, 105);
|
background: #4b4b7c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Timeline span bars */
|
||||||
@keyframes pulse-glow {
|
.span-bar {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
@apply h-6 rounded relative;
|
||||||
50% { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
|
min-width: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-glow {
|
.span-bar-inner {
|
||||||
animation: pulse-glow 2s infinite;
|
@apply absolute inset-0 rounded;
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card hover effects */
|
.span-bar-error .span-bar-inner {
|
||||||
.card-hover {
|
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%);
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-hover:hover {
|
/* Log levels */
|
||||||
transform: translateY(-2px);
|
.log-level-ERROR { @apply text-red-400; }
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
.log-level-WARN { @apply text-yellow-400; }
|
||||||
|
.log-level-INFO { @apply text-blue-400; }
|
||||||
|
.log-level-DEBUG { @apply text-gray-400; }
|
||||||
|
.log-level-TRACE { @apply text-gray-500; }
|
||||||
|
|
||||||
|
/* Metric cards */
|
||||||
|
.metric-card {
|
||||||
|
@apply bg-gray-900/50 rounded-lg p-4 border border-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover {
|
||||||
|
@apply border-indigo-500/50;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter } from 'next/font/google';
|
||||||
import './globals.css'
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: 'OPHION Dashboard',
|
title: 'OPHION - Observability Platform',
|
||||||
description: 'Observability Platform with AI',
|
description: 'Open Source Observability Platform',
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="pt-BR" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body className={`${inter.className} bg-slate-950 text-white`}>
|
<body className={inter.className}>
|
||||||
|
<Providers>
|
||||||
|
<div className="flex h-screen bg-gray-950">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
{children}
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
212
dashboard/src/app/logs/page.tsx
Normal file
212
dashboard/src/app/logs/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Search, RefreshCw, Filter, Download } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { formatTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
service: string;
|
||||||
|
host: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
trace_id?: string;
|
||||||
|
span_id?: string;
|
||||||
|
source?: string;
|
||||||
|
container_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_LEVELS = ['', 'TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'];
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const [service, setService] = useState('');
|
||||||
|
const [level, setLevel] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [traceId, setTraceId] = useState('');
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['logs', service, level, query, traceId],
|
||||||
|
queryFn: () => api.get('/api/v1/logs', {
|
||||||
|
service,
|
||||||
|
level,
|
||||||
|
q: query,
|
||||||
|
trace_id: traceId,
|
||||||
|
from: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
limit: '200',
|
||||||
|
}),
|
||||||
|
refetchInterval: autoRefresh ? 5000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs: LogEntry[] = data?.logs ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Logs</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
|
className="rounded border-gray-600 bg-gray-800 text-indigo-500"
|
||||||
|
/>
|
||||||
|
Auto-refresh
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="p-2 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-5 w-5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-800">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search logs..."
|
||||||
|
className="w-full pl-10 pr-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<select
|
||||||
|
value={service}
|
||||||
|
onChange={(e) => setService(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="">All services</option>
|
||||||
|
{/* Services would be populated dynamically */}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<select
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
{LOG_LEVELS.map((l) => (
|
||||||
|
<option key={l} value={l}>
|
||||||
|
{l || 'All levels'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-64">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={traceId}
|
||||||
|
onChange={(e) => setTraceId(e.target.value)}
|
||||||
|
placeholder="Trace ID"
|
||||||
|
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Table */}
|
||||||
|
<div className="bg-gray-900/50 rounded-lg border border-gray-800 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800">
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Time</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Level</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Service</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800">
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<LogRow key={i} log={log} />
|
||||||
|
))}
|
||||||
|
{!isLoading && logs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
|
||||||
|
No logs found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>Showing {logs.length} logs</span>
|
||||||
|
{data?.count && data.count > logs.length && (
|
||||||
|
<span>Limited to 200 results</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogRow({ log }: { log: LogEntry }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const levelColors: Record<string, string> = {
|
||||||
|
ERROR: 'text-red-400 bg-red-400/10',
|
||||||
|
WARN: 'text-yellow-400 bg-yellow-400/10',
|
||||||
|
INFO: 'text-blue-400 bg-blue-400/10',
|
||||||
|
DEBUG: 'text-gray-400 bg-gray-400/10',
|
||||||
|
TRACE: 'text-gray-500 bg-gray-500/10',
|
||||||
|
FATAL: 'text-red-500 bg-red-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="hover:bg-gray-800/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-400 whitespace-nowrap font-mono">
|
||||||
|
{formatTime(log.timestamp)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-medium rounded ${levelColors[log.level] || ''}`}>
|
||||||
|
{log.level}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-300 whitespace-nowrap">
|
||||||
|
{log.service}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-200 max-w-xl truncate font-mono">
|
||||||
|
{log.message}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expanded && (
|
||||||
|
<tr className="bg-gray-800/30">
|
||||||
|
<td colSpan={4} className="px-4 py-3">
|
||||||
|
<pre className="text-sm text-gray-300 whitespace-pre-wrap font-mono">
|
||||||
|
{log.message}
|
||||||
|
</pre>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-4 text-xs text-gray-500">
|
||||||
|
<span>Host: {log.host}</span>
|
||||||
|
<span>Source: {log.source}</span>
|
||||||
|
{log.container_id && <span>Container: {log.container_id}</span>}
|
||||||
|
{log.trace_id && (
|
||||||
|
<a
|
||||||
|
href={`/traces?id=${log.trace_id}`}
|
||||||
|
className="text-indigo-400 hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
Trace: {log.trace_id.slice(0, 16)}...
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
dashboard/src/app/metrics/page.tsx
Normal file
108
dashboard/src/app/metrics/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { RefreshCw, TrendingUp, Cpu, HardDrive, Activity } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { MetricsChart } from '@/components/metrics/MetricsChart';
|
||||||
|
|
||||||
|
const METRIC_PRESETS = [
|
||||||
|
{ name: 'CPU Usage', metric: 'cpu.usage_percent', service: 'system', icon: Cpu },
|
||||||
|
{ name: 'Memory Usage', metric: 'memory.used_percent', service: 'system', icon: Activity },
|
||||||
|
{ name: 'Load Average', metric: 'cpu.load_avg_1', service: 'system', icon: TrendingUp },
|
||||||
|
{ name: 'Disk Usage', metric: 'disk.used_percent', service: 'system', icon: HardDrive },
|
||||||
|
{ name: 'Network Sent', metric: 'network.bytes_sent', service: 'system', icon: TrendingUp },
|
||||||
|
{ name: 'Network Recv', metric: 'network.bytes_recv', service: 'system', icon: TrendingUp },
|
||||||
|
{ name: 'Containers Running', metric: 'containers.running', service: 'docker', icon: Activity },
|
||||||
|
{ name: 'Container CPU', metric: 'container.cpu_percent', service: 'docker', icon: Cpu },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MetricsPage() {
|
||||||
|
const [timeRange, setTimeRange] = useState('1h');
|
||||||
|
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([
|
||||||
|
'cpu.usage_percent',
|
||||||
|
'memory.used_percent',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getTimeFrom = () => {
|
||||||
|
const ranges: Record<string, number> = {
|
||||||
|
'15m': 15 * 60 * 1000,
|
||||||
|
'1h': 60 * 60 * 1000,
|
||||||
|
'6h': 6 * 60 * 60 * 1000,
|
||||||
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
return new Date(Date.now() - (ranges[timeRange] || ranges['1h'])).toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMetric = (metric: string) => {
|
||||||
|
setSelectedMetrics((prev) =>
|
||||||
|
prev.includes(metric)
|
||||||
|
? prev.filter((m) => m !== metric)
|
||||||
|
: [...prev, metric]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Metrics</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={timeRange}
|
||||||
|
onChange={(e) => setTimeRange(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="15m">Last 15 minutes</option>
|
||||||
|
<option value="1h">Last hour</option>
|
||||||
|
<option value="6h">Last 6 hours</option>
|
||||||
|
<option value="24h">Last 24 hours</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metric Selector */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{METRIC_PRESETS.map((preset) => {
|
||||||
|
const Icon = preset.icon;
|
||||||
|
const isSelected = selectedMetrics.includes(preset.metric);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.metric}
|
||||||
|
onClick={() => toggleMetric(preset.metric)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-indigo-600 border-indigo-500 text-white'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{preset.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{selectedMetrics.map((metric) => {
|
||||||
|
const preset = METRIC_PRESETS.find((p) => p.metric === metric);
|
||||||
|
return (
|
||||||
|
<MetricsChart
|
||||||
|
key={metric}
|
||||||
|
title={preset?.name || metric}
|
||||||
|
service={preset?.service || 'system'}
|
||||||
|
metric={metric}
|
||||||
|
from={getTimeFrom()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedMetrics.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center h-64 bg-gray-900/50 rounded-lg border border-gray-800 text-gray-500">
|
||||||
|
Select metrics to display
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,134 +1,90 @@
|
|||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import Sidebar from '@/components/layout/Sidebar'
|
import { Activity, AlertTriangle, Box, Server } from 'lucide-react';
|
||||||
import Header from '@/components/layout/Header'
|
import { MetricCard } from '@/components/dashboard/MetricCard';
|
||||||
import MetricCard from '@/components/ui/MetricCard'
|
import { RecentAlerts } from '@/components/dashboard/RecentAlerts';
|
||||||
import HostsTable from '@/components/ui/HostsTable'
|
import { api } from '@/lib/api';
|
||||||
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'
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function DashboardPage() {
|
||||||
const [showCopilot, setShowCopilot] = useState(false)
|
const { data: overview, isLoading } = useQuery({
|
||||||
const [metrics, setMetrics] = useState({
|
queryKey: ['dashboard', 'overview'],
|
||||||
totalHosts: 0,
|
queryFn: () => api.get('/api/v1/dashboard/overview'),
|
||||||
healthyHosts: 0,
|
refetchInterval: 30000,
|
||||||
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,
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="p-6 space-y-6">
|
||||||
{/* Sidebar */}
|
<div className="flex items-center justify-between">
|
||||||
<Sidebar />
|
<h1 className="text-2xl font-bold text-white">
|
||||||
|
🐍 OPHION Dashboard
|
||||||
|
</h1>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{new Date().toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Overview Cards */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* Header */}
|
|
||||||
<Header onCopilotClick={() => setShowCopilot(true)} />
|
|
||||||
|
|
||||||
{/* Dashboard Content */}
|
|
||||||
<main className="flex-1 overflow-y-auto p-6 bg-slate-950">
|
|
||||||
{/* Top Metrics */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Total Hosts"
|
title="Agents"
|
||||||
value={metrics.totalHosts}
|
value={overview?.agents?.active ?? '-'}
|
||||||
subtitle={`${metrics.healthyHosts} healthy`}
|
subtitle={`${overview?.agents?.total ?? 0} total`}
|
||||||
icon="🖥️"
|
icon={<Server className="h-5 w-5" />}
|
||||||
trend="stable"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="CPU Average"
|
title="Services"
|
||||||
value={`${metrics.cpuAvg}%`}
|
value={overview?.services?.count ?? '-'}
|
||||||
subtitle="across all hosts"
|
subtitle="discovered"
|
||||||
icon="⚡"
|
icon={<Box className="h-5 w-5" />}
|
||||||
trend={metrics.cpuAvg > 70 ? 'up' : 'stable'}
|
color="green"
|
||||||
alert={metrics.cpuAvg > 80}
|
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Memory Average"
|
title="Alerts"
|
||||||
value={`${metrics.memoryAvg}%`}
|
value={overview?.alerts?.firing ?? 0}
|
||||||
subtitle="across all hosts"
|
subtitle="firing"
|
||||||
icon="💾"
|
icon={<AlertTriangle className="h-5 w-5" />}
|
||||||
trend={metrics.memoryAvg > 70 ? 'up' : 'stable'}
|
color={overview?.alerts?.firing > 0 ? 'red' : 'gray'}
|
||||||
alert={metrics.memoryAvg > 85}
|
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Active Alerts"
|
title="Status"
|
||||||
value={metrics.activeAlerts}
|
value="Healthy"
|
||||||
subtitle="2 critical, 1 warning"
|
subtitle="all systems operational"
|
||||||
icon="🚨"
|
icon={<Activity className="h-5 w-5" />}
|
||||||
trend={metrics.activeAlerts > 0 ? 'up' : 'down'}
|
color="green"
|
||||||
alert={metrics.activeAlerts > 0}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
||||||
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">CPU Usage (24h)</h3>
|
|
||||||
<CpuChart />
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Memory Usage (24h)</h3>
|
|
||||||
<MemoryChart />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Insights */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<AIInsights />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Section */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Hosts Table */}
|
|
||||||
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-semibold">Hosts</h3>
|
|
||||||
<button className="text-sm text-green-400 hover:text-green-300">
|
|
||||||
View all →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<HostsTable />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts */}
|
{/* Alerts */}
|
||||||
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<RecentAlerts />
|
||||||
<h3 className="text-lg font-semibold">Recent Alerts</h3>
|
|
||||||
<button className="text-sm text-green-400 hover:text-green-300">
|
|
||||||
View all →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<AlertsList />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Copilot Sidebar */}
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800">
|
||||||
{showCopilot && (
|
<h3 className="text-lg font-semibold text-white mb-4">Quick Links</h3>
|
||||||
<Copilot onClose={() => setShowCopilot(false)} />
|
<div className="space-y-2">
|
||||||
)}
|
<QuickLink href="/traces" label="View Traces" desc="Distributed tracing" />
|
||||||
|
<QuickLink href="/logs" label="Search Logs" desc="Container logs" />
|
||||||
|
<QuickLink href="/metrics" label="Metrics" desc="System metrics" />
|
||||||
|
<QuickLink href="/services" label="Service Map" desc="Dependencies" />
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickLink({ href, label, desc }: { href: string; label: string; desc: string }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">{label}</p>
|
||||||
|
<p className="text-sm text-gray-400">{desc}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400">→</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
158
dashboard/src/app/services/page.tsx
Normal file
158
dashboard/src/app/services/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { RefreshCw, GitBranch, AlertCircle } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { ServiceMapGraph } from '@/components/services/ServiceMapGraph';
|
||||||
|
|
||||||
|
interface Service {
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
span_count: number;
|
||||||
|
error_count: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dependency {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
call_count: number;
|
||||||
|
error_count: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceMap {
|
||||||
|
services: Service[];
|
||||||
|
dependencies: Dependency[];
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServicesPage() {
|
||||||
|
const { data: serviceMap, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['services', 'map'],
|
||||||
|
queryFn: () => api.get('/api/v1/services/map'),
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const services: Service[] = serviceMap?.services ?? [];
|
||||||
|
const dependencies: Dependency[] = serviceMap?.dependencies ?? [];
|
||||||
|
|
||||||
|
const totalCalls = dependencies.reduce((sum, d) => sum + d.call_count, 0);
|
||||||
|
const totalErrors = dependencies.reduce((sum, d) => sum + d.error_count, 0);
|
||||||
|
const errorRate = totalCalls > 0 ? (totalErrors / totalCalls) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Service Map</h1>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Visualize dependencies between services
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="Services"
|
||||||
|
value={services.length}
|
||||||
|
icon={<GitBranch className="h-5 w-5 text-indigo-400" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Dependencies"
|
||||||
|
value={dependencies.length}
|
||||||
|
icon={<GitBranch className="h-5 w-5 text-blue-400" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Total Calls (24h)"
|
||||||
|
value={totalCalls.toLocaleString()}
|
||||||
|
icon={<GitBranch className="h-5 w-5 text-green-400" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Error Rate"
|
||||||
|
value={`${errorRate.toFixed(2)}%`}
|
||||||
|
icon={<AlertCircle className={`h-5 w-5 ${errorRate > 5 ? 'text-red-400' : 'text-gray-400'}`} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Map Visualization */}
|
||||||
|
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4" style={{ height: '500px' }}>
|
||||||
|
{services.length > 0 ? (
|
||||||
|
<ServiceMapGraph services={services} dependencies={dependencies} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500">
|
||||||
|
{isLoading ? 'Loading service map...' : 'No services discovered yet'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services Table */}
|
||||||
|
<div className="bg-gray-900/50 rounded-lg border border-gray-800 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Services</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800">
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Service</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Spans</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Errors</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Avg Duration</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800">
|
||||||
|
{services.map((svc) => (
|
||||||
|
<tr key={svc.name} className="hover:bg-gray-800/50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
|
<span className="text-white font-medium">{svc.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-300">
|
||||||
|
{svc.span_count.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={svc.error_count > 0 ? 'text-red-400' : 'text-gray-400'}>
|
||||||
|
{svc.error_count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-300">
|
||||||
|
{svc.avg_duration_ms?.toFixed(2)} ms
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-sm">
|
||||||
|
{new Date(svc.last_seen).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, icon }: { label: string; value: number | string; icon: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">{label}</span>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white mt-2">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
dashboard/src/app/traces/page.tsx
Normal file
184
dashboard/src/app/traces/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Search, Clock, AlertCircle, ExternalLink } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { TraceTimeline } from '@/components/traces/TraceTimeline';
|
||||||
|
import { formatDuration, formatTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Trace {
|
||||||
|
trace_id: string;
|
||||||
|
services: string[];
|
||||||
|
start_time: string;
|
||||||
|
duration_ns: number;
|
||||||
|
span_count: number;
|
||||||
|
has_error: boolean;
|
||||||
|
root_span?: {
|
||||||
|
operation: string;
|
||||||
|
service: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TracesPage() {
|
||||||
|
const [service, setService] = useState('');
|
||||||
|
const [operation, setOperation] = useState('');
|
||||||
|
const [minDuration, setMinDuration] = useState('');
|
||||||
|
const [onlyErrors, setOnlyErrors] = useState(false);
|
||||||
|
const [selectedTrace, setSelectedTrace] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: traces, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['traces', service, operation, minDuration, onlyErrors],
|
||||||
|
queryFn: () => api.get('/api/v1/traces', {
|
||||||
|
service,
|
||||||
|
operation,
|
||||||
|
min_duration_ms: minDuration,
|
||||||
|
error: onlyErrors ? 'true' : '',
|
||||||
|
from: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: traceDetail } = useQuery({
|
||||||
|
queryKey: ['trace', selectedTrace],
|
||||||
|
queryFn: () => api.get(`/api/v1/traces/${selectedTrace}`),
|
||||||
|
enabled: !!selectedTrace,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Traces</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-800">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Service</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={service}
|
||||||
|
onChange={(e) => setService(e.target.value)}
|
||||||
|
placeholder="All services"
|
||||||
|
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Operation</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={operation}
|
||||||
|
onChange={(e) => setOperation(e.target.value)}
|
||||||
|
placeholder="All operations"
|
||||||
|
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Min Duration</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={minDuration}
|
||||||
|
onChange={(e) => setMinDuration(e.target.value)}
|
||||||
|
placeholder="ms"
|
||||||
|
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<label className="flex items-center gap-2 px-3 py-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyErrors}
|
||||||
|
onChange={(e) => setOnlyErrors(e.target.checked)}
|
||||||
|
className="rounded border-gray-600 bg-gray-800 text-indigo-500 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">Errors only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Trace List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
{isLoading ? 'Loading...' : `${traces?.traces?.length ?? 0} traces`}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2 max-h-[600px] overflow-auto">
|
||||||
|
{traces?.traces?.map((trace: Trace) => (
|
||||||
|
<div
|
||||||
|
key={trace.trace_id}
|
||||||
|
onClick={() => setSelectedTrace(trace.trace_id)}
|
||||||
|
className={`p-4 bg-gray-900/50 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
selectedTrace === trace.trace_id
|
||||||
|
? 'border-indigo-500'
|
||||||
|
: 'border-gray-800 hover:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{trace.has_error && (
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-white">
|
||||||
|
{trace.root_span?.operation || trace.trace_id.slice(0, 16)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{trace.services?.slice(0, 3).map((svc) => (
|
||||||
|
<span
|
||||||
|
key={svc}
|
||||||
|
className="px-2 py-0.5 text-xs bg-gray-800 text-gray-300 rounded"
|
||||||
|
>
|
||||||
|
{svc}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(trace.services?.length ?? 0) > 3 && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
+{trace.services.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm">
|
||||||
|
<div className="flex items-center gap-1 text-gray-400">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatDuration(trace.duration_ns)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">
|
||||||
|
{trace.span_count} spans
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
{formatTime(trace.start_time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isLoading && (!traces?.traces || traces.traces.length === 0) && (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
No traces found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trace Detail */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Trace Timeline</h2>
|
||||||
|
{selectedTrace && traceDetail ? (
|
||||||
|
<TraceTimeline trace={traceDetail} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 bg-gray-900/50 rounded-lg border border-gray-800 text-gray-500">
|
||||||
|
Select a trace to view timeline
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
dashboard/src/components/Providers.tsx
Normal file
24
dashboard/src/components/Providers.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
dashboard/src/components/dashboard/MetricCard.tsx
Normal file
57
dashboard/src/components/dashboard/MetricCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
color?: 'blue' | 'green' | 'red' | 'yellow' | 'gray';
|
||||||
|
trend?: { value: number; isUp: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorClasses = {
|
||||||
|
blue: 'text-blue-400 bg-blue-400/10',
|
||||||
|
green: 'text-green-400 bg-green-400/10',
|
||||||
|
red: 'text-red-400 bg-red-400/10',
|
||||||
|
yellow: 'text-yellow-400 bg-yellow-400/10',
|
||||||
|
gray: 'text-gray-400 bg-gray-400/10',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
color = 'blue',
|
||||||
|
trend,
|
||||||
|
}: MetricCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4 hover:border-gray-700 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">{title}</span>
|
||||||
|
{icon && (
|
||||||
|
<div className={`p-2 rounded-lg ${colorClasses[color]}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-bold text-white">{value}</span>
|
||||||
|
{trend && (
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
trend.isUp ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{trend.isUp ? '↑' : '↓'} {Math.abs(trend.value)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
dashboard/src/components/dashboard/RecentAlerts.tsx
Normal file
91
dashboard/src/components/dashboard/RecentAlerts.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AlertTriangle, CheckCircle, Clock } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { formatTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Alert {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
severity: string;
|
||||||
|
status: string;
|
||||||
|
service?: string;
|
||||||
|
message: string;
|
||||||
|
fired_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentAlerts() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['alerts', 'recent'],
|
||||||
|
queryFn: () => api.get('/api/v1/alerts', { limit: 5 }),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts: Alert[] = data?.alerts ?? [];
|
||||||
|
|
||||||
|
const severityColors: Record<string, string> = {
|
||||||
|
critical: 'text-red-400 bg-red-400/10 border-red-400/20',
|
||||||
|
warning: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20',
|
||||||
|
info: 'text-blue-400 bg-blue-400/10 border-blue-400/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Recent Alerts</h3>
|
||||||
|
<a
|
||||||
|
href="/alerts"
|
||||||
|
className="text-sm text-indigo-400 hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-gray-500">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : alerts.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<CheckCircle className="h-8 w-8 mb-2 text-green-400" />
|
||||||
|
<p>No active alerts</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className={`p-3 rounded-lg border ${
|
||||||
|
severityColors[alert.severity] || severityColors.info
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<span className="font-medium">{alert.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs opacity-75">
|
||||||
|
{alert.status === 'firing' ? '🔴' : '✅'} {alert.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1 opacity-75 line-clamp-2">
|
||||||
|
{alert.message}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-xs opacity-50">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatTime(alert.fired_at)}
|
||||||
|
{alert.service && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{alert.service}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,96 +1,74 @@
|
|||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
Activity,
|
||||||
|
FileText,
|
||||||
|
GitBranch,
|
||||||
|
BarChart2,
|
||||||
|
AlertTriangle,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
const menuItems = [
|
const navigation = [
|
||||||
{ name: 'Overview', icon: '📊', href: '/' },
|
{ name: 'Dashboard', href: '/', icon: Home },
|
||||||
{ name: 'Hosts', icon: '🖥️', href: '/hosts' },
|
{ name: 'Traces', href: '/traces', icon: Activity },
|
||||||
{ name: 'Containers', icon: '🐳', href: '/containers' },
|
{ name: 'Logs', href: '/logs', icon: FileText },
|
||||||
{ name: 'Metrics', icon: '📈', href: '/metrics' },
|
{ name: 'Metrics', href: '/metrics', icon: BarChart2 },
|
||||||
{ name: 'Logs', icon: '📝', href: '/logs' },
|
{ name: 'Services', href: '/services', icon: GitBranch },
|
||||||
{ name: 'Traces', icon: '🔍', href: '/traces' },
|
{ name: 'Alerts', href: '/alerts', icon: AlertTriangle },
|
||||||
{ name: 'Alerts', icon: '🚨', href: '/alerts' },
|
{ name: 'Agents', href: '/agents', icon: Server },
|
||||||
{ name: 'Dashboards', icon: '📋', href: '/dashboards' },
|
];
|
||||||
]
|
|
||||||
|
|
||||||
const bottomItems = [
|
export function Sidebar() {
|
||||||
{ name: 'AI Insights', icon: '🤖', href: '/ai' },
|
const pathname = usePathname();
|
||||||
{ name: 'Settings', icon: '⚙️', href: '/settings' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const pathname = usePathname()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
|
<div className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="p-6 border-b border-slate-800">
|
<div className="h-16 flex items-center px-6 border-b border-gray-800">
|
||||||
<Link href="/" className="flex items-center space-x-3">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<span className="text-3xl">🐍</span>
|
<span className="text-2xl">🐍</span>
|
||||||
<div>
|
<span className="text-xl font-bold text-white">OPHION</span>
|
||||||
<span className="text-xl font-bold bg-gradient-to-r from-green-400 to-emerald-500 bg-clip-text text-transparent">
|
|
||||||
OPHION
|
|
||||||
</span>
|
|
||||||
<span className="ml-2 text-xs bg-purple-600 px-2 py-0.5 rounded-full">AI</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 p-4 space-y-1">
|
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||||
{menuItems.map((item) => {
|
{navigation.map((item) => {
|
||||||
const isActive = pathname === item.href
|
const isActive = pathname === item.href;
|
||||||
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-all ${
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-green-600/20 text-green-400 border-l-2 border-green-400'
|
? 'bg-indigo-600 text-white'
|
||||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-xl">{item.icon}</span>
|
<Icon className="h-5 w-5" />
|
||||||
<span className="font-medium">{item.name}</span>
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-slate-800 space-y-1">
|
<div className="p-4 border-t border-gray-800">
|
||||||
{bottomItems.map((item) => {
|
|
||||||
const isActive = pathname === item.href
|
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
href="/settings"
|
||||||
href={item.href}
|
className="flex items-center gap-3 px-3 py-2 text-gray-400 hover:bg-gray-800 hover:text-white rounded-lg transition-colors"
|
||||||
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-all ${
|
|
||||||
isActive
|
|
||||||
? 'bg-purple-600/20 text-purple-400'
|
|
||||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span className="text-xl">{item.icon}</span>
|
<Settings className="h-5 w-5" />
|
||||||
<span className="font-medium">{item.name}</span>
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
)
|
<p className="text-xs text-gray-600 mt-4 px-3">v0.2.0</p>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User */}
|
|
||||||
<div className="p-4 border-t border-slate-800">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-emerald-600 flex items-center justify-center font-bold">
|
|
||||||
A
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Admin</p>
|
|
||||||
<p className="text-xs text-slate-500">admin@empresa.com</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
192
dashboard/src/components/metrics/MetricsChart.tsx
Normal file
192
dashboard/src/components/metrics/MetricsChart.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
interface MetricsChartProps {
|
||||||
|
title: string;
|
||||||
|
service: string;
|
||||||
|
metric: string;
|
||||||
|
from: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricPoint {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsChart({ title, service, metric, from }: MetricsChartProps) {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['metrics', service, metric, from],
|
||||||
|
queryFn: () => api.get('/api/v1/metrics', { service, name: metric, from }),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const metrics: MetricPoint[] = data?.metrics ?? [];
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: metrics.map((m) => {
|
||||||
|
const date = new Date(m.timestamp);
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: title,
|
||||||
|
data: metrics.map((m) => m.value),
|
||||||
|
borderColor: 'rgb(99, 102, 241)',
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index' as const,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17, 17, 27, 0.9)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#a1a1aa',
|
||||||
|
borderColor: '#3f3f46',
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
displayColors: false,
|
||||||
|
callbacks: {
|
||||||
|
label: (context: any) => {
|
||||||
|
let value = context.parsed.y;
|
||||||
|
if (metric.includes('percent')) {
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
if (metric.includes('bytes')) {
|
||||||
|
if (value > 1e9) return `${(value / 1e9).toFixed(2)} GB`;
|
||||||
|
if (value > 1e6) return `${(value / 1e6).toFixed(2)} MB`;
|
||||||
|
if (value > 1e3) return `${(value / 1e3).toFixed(2)} KB`;
|
||||||
|
return `${value} B`;
|
||||||
|
}
|
||||||
|
return value.toFixed(2);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(63, 63, 70, 0.3)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#71717a',
|
||||||
|
maxTicksLimit: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(63, 63, 70, 0.3)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#71717a',
|
||||||
|
callback: (value: number) => {
|
||||||
|
if (metric.includes('percent')) {
|
||||||
|
return `${value}%`;
|
||||||
|
}
|
||||||
|
if (metric.includes('bytes')) {
|
||||||
|
if (value > 1e9) return `${(value / 1e9).toFixed(0)}G`;
|
||||||
|
if (value > 1e6) return `${(value / 1e6).toFixed(0)}M`;
|
||||||
|
if (value > 1e3) return `${(value / 1e3).toFixed(0)}K`;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: metric.includes('percent') ? 100 : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate current/avg/max
|
||||||
|
const values = metrics.map((m) => m.value);
|
||||||
|
const current = values.length > 0 ? values[values.length - 1] : 0;
|
||||||
|
const avg = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||||||
|
const max = values.length > 0 ? Math.max(...values) : 0;
|
||||||
|
|
||||||
|
const formatValue = (v: number) => {
|
||||||
|
if (metric.includes('percent')) return `${v.toFixed(1)}%`;
|
||||||
|
if (metric.includes('bytes')) {
|
||||||
|
if (v > 1e9) return `${(v / 1e9).toFixed(2)} GB`;
|
||||||
|
if (v > 1e6) return `${(v / 1e6).toFixed(2)} MB`;
|
||||||
|
if (v > 1e3) return `${(v / 1e3).toFixed(2)} KB`;
|
||||||
|
return `${v.toFixed(0)} B`;
|
||||||
|
}
|
||||||
|
return v.toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-gray-400">
|
||||||
|
Current: <span className="text-white">{formatValue(current)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
Avg: <span className="text-white">{formatValue(avg)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
Max: <span className="text-white">{formatValue(max)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-48">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-red-400">
|
||||||
|
Error loading data
|
||||||
|
</div>
|
||||||
|
) : metrics.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500">
|
||||||
|
No data available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Line data={chartData} options={options as any} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
dashboard/src/components/services/ServiceMapGraph.tsx
Normal file
181
dashboard/src/components/services/ServiceMapGraph.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
|
||||||
|
interface Service {
|
||||||
|
name: string;
|
||||||
|
span_count: number;
|
||||||
|
error_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dependency {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
call_count: number;
|
||||||
|
error_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceMapGraphProps {
|
||||||
|
services: Service[];
|
||||||
|
dependencies: Dependency[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceMapGraph({ services, dependencies }: ServiceMapGraphProps) {
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current || services.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll('*').remove();
|
||||||
|
|
||||||
|
const width = svgRef.current.clientWidth;
|
||||||
|
const height = svgRef.current.clientHeight;
|
||||||
|
|
||||||
|
// Create nodes
|
||||||
|
const nodes = services.map((s) => ({
|
||||||
|
id: s.name,
|
||||||
|
...s,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create links
|
||||||
|
const links = dependencies.map((d) => ({
|
||||||
|
...d,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Force simulation
|
||||||
|
const simulation = d3
|
||||||
|
.forceSimulation(nodes as any)
|
||||||
|
.force(
|
||||||
|
'link',
|
||||||
|
d3
|
||||||
|
.forceLink(links)
|
||||||
|
.id((d: any) => d.id)
|
||||||
|
.distance(150)
|
||||||
|
)
|
||||||
|
.force('charge', d3.forceManyBody().strength(-400))
|
||||||
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||||
|
.force('collision', d3.forceCollide().radius(60));
|
||||||
|
|
||||||
|
// Arrow markers
|
||||||
|
svg
|
||||||
|
.append('defs')
|
||||||
|
.append('marker')
|
||||||
|
.attr('id', 'arrowhead')
|
||||||
|
.attr('viewBox', '-0 -5 10 10')
|
||||||
|
.attr('refX', 25)
|
||||||
|
.attr('refY', 0)
|
||||||
|
.attr('orient', 'auto')
|
||||||
|
.attr('markerWidth', 8)
|
||||||
|
.attr('markerHeight', 8)
|
||||||
|
.append('path')
|
||||||
|
.attr('d', 'M 0,-5 L 10,0 L 0,5')
|
||||||
|
.attr('fill', '#4b5563');
|
||||||
|
|
||||||
|
// Links
|
||||||
|
const link = svg
|
||||||
|
.append('g')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(links)
|
||||||
|
.enter()
|
||||||
|
.append('line')
|
||||||
|
.attr('stroke', (d: any) => (d.error_count > 0 ? '#ef4444' : '#4b5563'))
|
||||||
|
.attr('stroke-width', (d: any) => Math.min(Math.log(d.call_count + 1) + 1, 4))
|
||||||
|
.attr('stroke-opacity', 0.6)
|
||||||
|
.attr('marker-end', 'url(#arrowhead)');
|
||||||
|
|
||||||
|
// Link labels
|
||||||
|
const linkLabels = svg
|
||||||
|
.append('g')
|
||||||
|
.selectAll('text')
|
||||||
|
.data(links)
|
||||||
|
.enter()
|
||||||
|
.append('text')
|
||||||
|
.attr('font-size', '10px')
|
||||||
|
.attr('fill', '#9ca3af')
|
||||||
|
.text((d: any) => d.call_count.toLocaleString());
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
const node = svg
|
||||||
|
.append('g')
|
||||||
|
.selectAll('g')
|
||||||
|
.data(nodes)
|
||||||
|
.enter()
|
||||||
|
.append('g')
|
||||||
|
.call(
|
||||||
|
d3.drag<SVGGElement, any>()
|
||||||
|
.on('start', (event, d) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
|
d.fx = d.x;
|
||||||
|
d.fy = d.y;
|
||||||
|
})
|
||||||
|
.on('drag', (event, d) => {
|
||||||
|
d.fx = event.x;
|
||||||
|
d.fy = event.y;
|
||||||
|
})
|
||||||
|
.on('end', (event, d) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
|
d.fx = null;
|
||||||
|
d.fy = null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Node circles
|
||||||
|
node
|
||||||
|
.append('circle')
|
||||||
|
.attr('r', (d: any) => Math.min(Math.log(d.span_count + 1) * 5 + 20, 40))
|
||||||
|
.attr('fill', (d: any) =>
|
||||||
|
d.error_count > 0 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(99, 102, 241, 0.2)'
|
||||||
|
)
|
||||||
|
.attr('stroke', (d: any) =>
|
||||||
|
d.error_count > 0 ? '#ef4444' : '#6366f1'
|
||||||
|
)
|
||||||
|
.attr('stroke-width', 2);
|
||||||
|
|
||||||
|
// Node labels
|
||||||
|
node
|
||||||
|
.append('text')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', 4)
|
||||||
|
.attr('font-size', '12px')
|
||||||
|
.attr('font-weight', '500')
|
||||||
|
.attr('fill', '#e5e7eb')
|
||||||
|
.text((d: any) => d.id.length > 15 ? d.id.slice(0, 15) + '...' : d.id);
|
||||||
|
|
||||||
|
// Span count labels
|
||||||
|
node
|
||||||
|
.append('text')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', 18)
|
||||||
|
.attr('font-size', '10px')
|
||||||
|
.attr('fill', '#9ca3af')
|
||||||
|
.text((d: any) => `${d.span_count.toLocaleString()} spans`);
|
||||||
|
|
||||||
|
simulation.on('tick', () => {
|
||||||
|
link
|
||||||
|
.attr('x1', (d: any) => d.source.x)
|
||||||
|
.attr('y1', (d: any) => d.source.y)
|
||||||
|
.attr('x2', (d: any) => d.target.x)
|
||||||
|
.attr('y2', (d: any) => d.target.y);
|
||||||
|
|
||||||
|
linkLabels
|
||||||
|
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
|
||||||
|
.attr('y', (d: any) => (d.source.y + d.target.y) / 2);
|
||||||
|
|
||||||
|
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
simulation.stop();
|
||||||
|
};
|
||||||
|
}, [services, dependencies]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ minHeight: '400px' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
dashboard/src/components/traces/TraceTimeline.tsx
Normal file
160
dashboard/src/components/traces/TraceTimeline.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { formatDuration } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Span {
|
||||||
|
trace_id: string;
|
||||||
|
span_id: string;
|
||||||
|
parent_span_id?: string;
|
||||||
|
service: string;
|
||||||
|
operation: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
duration_ns: number;
|
||||||
|
status: { code: string; message?: string };
|
||||||
|
kind: string;
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Trace {
|
||||||
|
trace_id: string;
|
||||||
|
spans: Span[];
|
||||||
|
duration_ns: number;
|
||||||
|
start_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TraceTimelineProps {
|
||||||
|
trace: Trace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraceTimeline({ trace }: TraceTimelineProps) {
|
||||||
|
const { spans, minTime, maxTime, duration } = useMemo(() => {
|
||||||
|
if (!trace.spans || trace.spans.length === 0) {
|
||||||
|
return { spans: [], minTime: 0, maxTime: 0, duration: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const times = trace.spans.map((s) => ({
|
||||||
|
start: new Date(s.start_time).getTime(),
|
||||||
|
end: new Date(s.end_time).getTime(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const minTime = Math.min(...times.map((t) => t.start));
|
||||||
|
const maxTime = Math.max(...times.map((t) => t.end));
|
||||||
|
const duration = maxTime - minTime;
|
||||||
|
|
||||||
|
// Sort spans by start time
|
||||||
|
const sortedSpans = [...trace.spans].sort(
|
||||||
|
(a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return { spans: sortedSpans, minTime, maxTime, duration };
|
||||||
|
}, [trace]);
|
||||||
|
|
||||||
|
const serviceColors: Record<string, string> = {};
|
||||||
|
const colors = [
|
||||||
|
'bg-indigo-500',
|
||||||
|
'bg-blue-500',
|
||||||
|
'bg-green-500',
|
||||||
|
'bg-yellow-500',
|
||||||
|
'bg-purple-500',
|
||||||
|
'bg-pink-500',
|
||||||
|
'bg-cyan-500',
|
||||||
|
];
|
||||||
|
|
||||||
|
spans.forEach((span, i) => {
|
||||||
|
if (!serviceColors[span.service]) {
|
||||||
|
serviceColors[span.service] = colors[Object.keys(serviceColors).length % colors.length];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (spans.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64 bg-gray-900/50 rounded-lg border border-gray-800 text-gray-500">
|
||||||
|
No spans in this trace
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/50 rounded-lg border border-gray-800 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-white">
|
||||||
|
Trace: {trace.trace_id.slice(0, 16)}...
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{spans.length} spans • {formatDuration(trace.duration_ns)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(serviceColors).map(([service, color]) => (
|
||||||
|
<span
|
||||||
|
key={service}
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-400"
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded ${color}`} />
|
||||||
|
{service}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="p-4 space-y-2 max-h-[400px] overflow-auto">
|
||||||
|
{spans.map((span, i) => {
|
||||||
|
const startOffset = new Date(span.start_time).getTime() - minTime;
|
||||||
|
const spanDuration = span.duration_ns / 1000000; // ns to ms
|
||||||
|
|
||||||
|
const left = duration > 0 ? (startOffset / duration) * 100 : 0;
|
||||||
|
const width = duration > 0 ? Math.max((spanDuration / (duration / 1000000)) * 100, 1) : 100;
|
||||||
|
|
||||||
|
const hasError = span.status?.code === 'ERROR';
|
||||||
|
const color = hasError ? 'bg-red-500' : serviceColors[span.service];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={span.span_id} className="group">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs text-gray-500 w-20 text-right font-mono">
|
||||||
|
{formatDuration(span.duration_ns)}
|
||||||
|
</span>
|
||||||
|
<span className={`w-2 h-2 rounded ${color}`} />
|
||||||
|
<span className="text-sm text-gray-300 truncate flex-1">
|
||||||
|
{span.service}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-20" />
|
||||||
|
<div className="flex-1 h-6 bg-gray-800 rounded relative">
|
||||||
|
<div
|
||||||
|
className={`absolute h-full rounded ${color} opacity-75 hover:opacity-100 transition-opacity cursor-pointer`}
|
||||||
|
style={{
|
||||||
|
left: `${left}%`,
|
||||||
|
width: `${Math.max(width, 0.5)}%`,
|
||||||
|
minWidth: '4px',
|
||||||
|
}}
|
||||||
|
title={`${span.operation}\n${formatDuration(span.duration_ns)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<div className="w-20" />
|
||||||
|
<span className="text-xs text-gray-500 truncate">
|
||||||
|
{span.operation}
|
||||||
|
{hasError && (
|
||||||
|
<span className="ml-2 text-red-400">
|
||||||
|
⚠ {span.status.message || 'Error'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
dashboard/src/lib/api.ts
Normal file
87
dashboard/src/lib/api.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
|
constructor(baseUrl: string = '') {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(path: string, params?: Record<string, any>): Promise<any> {
|
||||||
|
const url = new URL(path, this.baseUrl || window.location.origin);
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(path: string, data: any): Promise<any> {
|
||||||
|
const url = new URL(path, this.baseUrl || window.location.origin);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(path: string, data?: any): Promise<any> {
|
||||||
|
const url = new URL(path, this.baseUrl || window.location.origin);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(path: string): Promise<any> {
|
||||||
|
const url = new URL(path, this.baseUrl || window.location.origin);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiClient(API_URL);
|
||||||
78
dashboard/src/lib/utils.ts
Normal file
78
dashboard/src/lib/utils.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(nanoseconds: number): string {
|
||||||
|
const ms = nanoseconds / 1_000_000;
|
||||||
|
|
||||||
|
if (ms < 1) {
|
||||||
|
return `${(nanoseconds / 1000).toFixed(0)}μs`;
|
||||||
|
}
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${ms.toFixed(1)}ms`;
|
||||||
|
}
|
||||||
|
if (ms < 60000) {
|
||||||
|
return `${(ms / 1000).toFixed(2)}s`;
|
||||||
|
}
|
||||||
|
return `${(ms / 60000).toFixed(1)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
|
||||||
|
// Within last hour, show relative time
|
||||||
|
if (diff < 3600000) {
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) {
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
return `${minutes}m ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today, show time only
|
||||||
|
if (date.toDateString() === now.toDateString()) {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show date and time
|
||||||
|
return date.toLocaleString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(num: number): string {
|
||||||
|
if (num >= 1_000_000) {
|
||||||
|
return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (num >= 1_000) {
|
||||||
|
return `${(num / 1_000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str;
|
||||||
|
return str.slice(0, length) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
@@ -25,10 +25,6 @@ COPY internal/ ./internal/
|
|||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
go build -ldflags="-s -w" -o ophion-server ./cmd/server
|
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)
|
# Stage 2: Build Dashboard (Next.js)
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@@ -38,7 +34,7 @@ WORKDIR /build
|
|||||||
|
|
||||||
# Dependências
|
# Dependências
|
||||||
COPY dashboard/package*.json ./
|
COPY dashboard/package*.json ./
|
||||||
RUN npm ci --only=production
|
RUN npm install
|
||||||
|
|
||||||
# Código fonte
|
# Código fonte
|
||||||
COPY dashboard/ ./
|
COPY dashboard/ ./
|
||||||
@@ -76,7 +72,6 @@ WORKDIR /app
|
|||||||
|
|
||||||
# Copiar binários Go
|
# Copiar binários Go
|
||||||
COPY --from=go-builder /build/ophion-server /app/bin/
|
COPY --from=go-builder /build/ophion-server /app/bin/
|
||||||
COPY --from=go-builder /build/ophion-agent /app/bin/
|
|
||||||
|
|
||||||
# Copiar Dashboard
|
# Copiar Dashboard
|
||||||
COPY --from=web-builder /build/.next /app/web/.next
|
COPY --from=web-builder /build/.next /app/web/.next
|
||||||
|
|||||||
57
deploy/docker/Dockerfile.agent
Normal file
57
deploy/docker/Dockerfile.agent
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 🐍 OPHION Agent - Dockerfile
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
# Copy go modules first (cache layer)
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY cmd/ ./cmd/
|
||||||
|
COPY internal/ ./internal/
|
||||||
|
|
||||||
|
# Build the agent binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -ldflags="-s -w" -o ophion-agent ./cmd/agent
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="OPHION Agent"
|
||||||
|
LABEL org.opencontainers.image.description="Observability Agent - Metrics, Logs, Traces Collector"
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1000 ophion && \
|
||||||
|
adduser -u 1000 -G ophion -s /bin/sh -D ophion
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /build/ophion-agent /app/
|
||||||
|
|
||||||
|
USER ophion
|
||||||
|
|
||||||
|
# Environment defaults
|
||||||
|
ENV TZ=America/Sao_Paulo \
|
||||||
|
OPHION_SERVER=http://localhost:8080 \
|
||||||
|
OPHION_INTERVAL=30s \
|
||||||
|
OPHION_DOCKER=true \
|
||||||
|
OPHION_LOGS=true \
|
||||||
|
OPHION_OTLP=true \
|
||||||
|
OPHION_OTLP_PORT=4318
|
||||||
|
|
||||||
|
# OTLP receiver port
|
||||||
|
EXPOSE 4318
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/ophion-agent"]
|
||||||
@@ -1,13 +1,59 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 🐍 OPHION Server - Dockerfile
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Build stage
|
||||||
FROM golang:1.22-alpine AS builder
|
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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
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
|
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
|
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
|
EXPOSE 8080
|
||||||
CMD ["./ophion-server"]
|
|
||||||
|
ENTRYPOINT ["/app/ophion-server"]
|
||||||
|
|||||||
@@ -1,35 +1,24 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ophion-server:
|
ophion:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: deploy/docker/Dockerfile.server
|
dockerfile: deploy/docker/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8090:8080"
|
||||||
|
- "3001:3000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://ophion:ophion@postgres:5432/ophion
|
- DATABASE_URL=${DATABASE_URL:-postgres://ophion:ophion@postgres:5432/ophion}
|
||||||
- CLICKHOUSE_URL=clickhouse://clickhouse:9000/ophion
|
- CLICKHOUSE_URL=${CLICKHOUSE_URL:-clickhouse://clickhouse:9000/ophion}
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@ophion.com.br}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-ophion123}
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- clickhouse
|
- clickhouse
|
||||||
- redis
|
- redis
|
||||||
restart: unless-stopped
|
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:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ case "$MODE" in
|
|||||||
exec npm start
|
exec npm start
|
||||||
;;
|
;;
|
||||||
all)
|
all)
|
||||||
echo "Starting all services with supervisor..."
|
echo "Starting all services..."
|
||||||
exec supervisord -c /etc/supervisord.conf
|
# Start server in background
|
||||||
|
/app/bin/ophion-server &
|
||||||
|
# Start web
|
||||||
|
cd /app/web && npm start
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown mode: $MODE"
|
echo "Unknown mode: $MODE"
|
||||||
|
|||||||
123
docker-compose.yml
Normal file
123
docker-compose.yml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 🐍 OPHION - Docker Compose
|
||||||
|
# Observability Platform with ClickHouse, PostgreSQL, Redis
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# OPHION Server (Go API)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: deploy/docker/Dockerfile.server
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
- DATABASE_URL=postgres://ophion:ophion@postgres:5432/ophion?sslmode=disable
|
||||||
|
- CLICKHOUSE_URL=clickhouse://default:@clickhouse:9000/ophion
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
clickhouse:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- ophion
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# OPHION Dashboard (Next.js)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
dashboard:
|
||||||
|
build:
|
||||||
|
context: ./dashboard
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=http://server:8080
|
||||||
|
- NODE_ENV=production
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- ophion
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# PostgreSQL (Metadata, Users, Alerts)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ophion
|
||||||
|
POSTGRES_PASSWORD: ophion
|
||||||
|
POSTGRES_DB: ophion
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- ophion
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ophion"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# ClickHouse (Metrics, Traces, Logs)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:24.1
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # Native protocol
|
||||||
|
- "8123:8123" # HTTP interface
|
||||||
|
volumes:
|
||||||
|
- clickhouse_data:/var/lib/clickhouse
|
||||||
|
- ./configs/clickhouse:/etc/clickhouse-server/config.d
|
||||||
|
environment:
|
||||||
|
- CLICKHOUSE_DB=ophion
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- ophion
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# Redis (Cache, Pub/Sub)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- ophion
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ophion:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
clickhouse_data:
|
||||||
|
redis_data:
|
||||||
424
docs/OPHION_Manual_Completo.pdf
Normal file
424
docs/OPHION_Manual_Completo.pdf
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||||
|
1 0 obj
|
||||||
|
<<
|
||||||
|
/F1 2 0 R /F2 5 0 R /F3 9 0 R
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<<
|
||||||
|
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<<
|
||||||
|
/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 200 /Length 1086 /SMask 4 0 R
|
||||||
|
/Subtype /Image /Type /XObject /Width 200
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gb"0S6-e1<&:dVQdXBHg1\C=Q%Z;s$I\B#cJN05=^F,<nS]G/JLL)S5zzzzzz!&3='hqu[kp-e!8=HpDl7_:'kXlI4ImuL(EL##f'VuY\68m3M]gV@H!8ZS!d!U+9<hZU185j*?@rVgm88?8,%qrp]<h5I,LbfAI7q^WaVoe`dZpWYt\+)gkeoiY7TBoTZA7escs]h>adrbbfcb2n_Zg/lbRC6":RED9^jjm]5B/uu$A4=t;?=*nWjC7_+gm!pamhk/K`nJL5<<I;3UC\.:D?,_$AP'rEmq_M\P%WU>#=_Z7?V5m%T^LX&gDp"7-W]XQA_Q<DC<FKs89N5$:>r#]NoQ7?5?8>@mF$9%PX-]$+Wf0bI-E7uYXdYu!#e4i2atCPNrT.""LZ)NYNpD`-N^Ted)bm9o-iN$H?t)l">HY+q%7W_SW,@3Ja%#u^eP5Q'fZ;Lk79Z[$M<a*>0TVU9Kf6l1T?+d$mR]8#Z4cPTpMs!?RRG3hNEjr8VFFW9#$spMW,@cY?!b+o^^XQQPjpX#;<m+59"b?+8G]-a.dqBU;>0h/fS_6D\eMZ.)Ce0u;r]L9/(rJ>&WW+GG)C-gQ)%^o;$Bp,7<B87A+.LH,(&qE\r5q/3G"07;W<h.>u+OY(+&kUQ'CM_qu"FsM@n!IKm>&LC@g;HnU2nOfX8CD.el5UYCU,Yh@4O#]:,O$jdL?Y\mg>g>@EgX=-i9n0N%j#9D;noX"8;YL;Cpj?C%`lm*tr(d8bP:-D2&W@Eb]a$f4n-WH_!9N"C@eN&D4F2K)];f$S8@C@.a-V<'S=J:P8jhGa'j)>IO:Xs2**+[S>tV@UInJoi<=H?@+3ds"/V<Ah%^a1&gU^IqY]9mgJs;UmZOXm,"07M?L]oL*i[E^)]fM5m@G6:2RfS9g%Mr^CYe-=mEecj9sPP7M\-f,<*t9/!;&4[&9S'nE5`G/(s#Ah\OX-&Vju&/@,/T!`mITW<3W*jjWm*#0idoGk4rJ+ETNU8"?rO:OO]DiQi(N^fc&".iI#+t<d/'n^n+PGm.*5tdeKo[q?!Dp)&nSZW(oD]4)G#ljr*zzzzz!!%PEr!-a14G3~>endstream
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<<
|
||||||
|
/BitsPerComponent 8 /ColorSpace /DeviceGray /Decode [ 0 1 ] /Filter [ /ASCII85Decode /FlateDecode ] /Height 200 /Length 865
|
||||||
|
/Subtype /Image /Type /XObject /Width 200
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gb"/jgN%p#$q1'o5HR[("J"D.,SR@DHQHnVfi(O!V)0Jb#U+j463n`f&4-XGKFgHU+bUCnaD>BsFp]nnAp+dtOtf$mMdfSH5U)@`;*Qq27EScJ2/l!!;$2+!7?uTY0L\/M?F/L(5`4>b4"nPB\;tf<M;hA@[U1S"^kn:SPna9^C8'X4<509cj-=9iO)h$=*mI)-4?<O4n;qs[n"5Hpd1p=,%=7r0hE3ML3W_T_FDhDoa"'nd+Ckr3@np,879*VXNiHOYGVYU$FX0Kc<Kgcj4;Z(nY/t$d!(AFbU>3Lj?n6B4rNLWVM&7TNRYZ*<Y2Y!Z@Qr/C7[F#^ba&abA&NBl^=NFC\8ILL4FFVb.sYNY3:FRY6la,0>X(!*0o#Se,7G/s<@*a5d+@@ApDB19&RgL9A<@HB#TGP?@n7<[g+aXtr1aH^_jpkF6i9AmXInZ!1.#Kd+m;<`LgX5J3=!L,>1(7Z)pn5N/*@:`[WsZY=R@ig+Jp50^p&+gnPp>o<F(fUdQfYb8!J]iCer1s1gD75BKKHD$d-@a=\p%1<]=(>@i.@:cG0Nl@=_EY12[@uPAdPdTml%ca^A<54:?LWTae&^m([pi.iqH_6sWs9%R(n,Mp_^3a`isg!pcQ7&6t3jlLZ2?XaJ2.MTV+BDd`>]eV);]Oh*t\"*t00BOhdW(fGc,2?CAD/s]tT:$3bg;Zsk82O^c)4.GCW/WU_ea%IJmW)5DNP?*"&<HM=We&KqZnm8L(n46]?89R#?G,5$Q3GrT$P7/)'1HSnrf1$%IdFaN5iA<*[;%]3:9Zt+;30!QA!r3\?MgSj)4s&s]G0_"a,pWa>KFgHU+bUCn#U+j463n`f&4-X/`#gs::,)~>endstream
|
||||||
|
endobj
|
||||||
|
5 0 obj
|
||||||
|
<<
|
||||||
|
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||||
|
/FormXob.c0e18debeba036958ca41c3f85fba57a 3 0 R
|
||||||
|
>>
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
8 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
9 0 obj
|
||||||
|
<<
|
||||||
|
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
10 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 31 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
11 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 32 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
12 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 33 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
13 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 34 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
14 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 35 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
15 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 36 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
16 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 37 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
17 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 38 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
18 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 39 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
19 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 40 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
20 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 41 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
21 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 42 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
22 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 43 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
23 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 44 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
24 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 45 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 27 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
25 0 obj
|
||||||
|
<<
|
||||||
|
/PageMode /UseNone /Pages 27 0 R /Type /Catalog
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
26 0 obj
|
||||||
|
<<
|
||||||
|
/Author (\(anonymous\)) /CreationDate (D:20260206102714-03'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260206102714-03'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||||
|
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
27 0 obj
|
||||||
|
<<
|
||||||
|
/Count 18 /Kids [ 6 0 R 7 0 R 8 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R
|
||||||
|
17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R ] /Type /Pages
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
28 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 806
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
GasbX?#S1G(kqGM/)EoaRci;[^+<b-&_l#rJ<1<Y8XbZ)Rqudt1l3=#Rsq/.f2OS)eB#\PF,l<@_!/`/4^S3%:Z2-X!<j`)5fFE_f5R-GNtH8p7PNkV8$LDqA$)DLd9Rk+MDMH#-`(?!Pc]Ij;)7CCgShso)Etp9S&Mit@lZ3h9oH*o;D*s\/l*5EEp!!F9=7LQ6VSZ742tb9f/frBGrgNMU(]XY$t];<7M[i.1236j(7P,V6GP4?1Vk5;:auOr3Y`gl9Mc,:^%\<nVXS1[qha6lAP':lr+/Sl>R'kHfP%k:[0HmOG#`LTtQJrG^G<guMA_I-0bIhh0$hPH/n_'`;;`)H-IdFmsWY8bE)$TkOEVBOr42GFFJT2j@TGZ4A"?3tm.i%QVgS)1Y+,UChf2#+I^c5O6c#]>=o5aL*PBja.;*FUoH8A?Ai9_^:4m51Wh?hn&TJajn6c1K%`.UNmj_Hq+G57'C%oQkgn,1t"DSM.5iF70j\\:bWHha1K(g^>]YdO:EBUIZ+p5j$]0b6(;O3+Q#EOB&Jo/'6Yp^7m\2(S-fRdK-K'%8ub[=>*4Z7;3Aoh^`rpoY:gka:@CL/[94'=?'=ld-+0gk`b*iQO9dF7ZeQQ@t9W'D#5o9MI10)?^jg&)E^1$n\"\R/Zhn3)8]Im9@&pX<2nWFiEot_6PE?L8!f6QSQcFoRgREWB('2CSeRc/b#XSSdN`ei/SMb*D+@-UZIM`'E^e:.eBCPmOM%?$P?4+4<0CUQ;22Eh:_:s>gCf5q7D<iRQaS'b@2>W)/?WS&H)^?RXnr~>endstream
|
||||||
|
endobj
|
||||||
|
29 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 664
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gat=i_2d5'&;KX9`>iS<>h^M63MnBFOgYm>U>?>6i$u"JEVfU(7-mO(gob3ki\H_fkF$gK(f3>2r]TrT*uk"EK`U5\S:G/rKB<KKc/Q0>pIrD[.!`q-a:#jW;;ud:-G7P>gL(B1Bt9oT\FTd&Z'Pj&A<4\iE&n`sHn*C?+>h86N&N=>GG`n\oa[GJd(G.alR66?^_9%;(PM@eQr(X+4F]r4"gHVf1Vp=6Nm>\9@EAfQdY"pr<B8aWA93CcZmDe]B=tS<Ga.n>q`gTeSVY)g<:2YZYPdTp!99:1YLY,^eI]P_=X(nTi.D7f48Jg4f,-C9?/GhtRkbQu,PY5ZdV!A`(np4*HKmj#78Y=.j9.ZiiGu0N[:)cXGETu`#*GoZJWe[Si9.Od!XN#Z?EH+L64G,7<Fmloj/P3,@98.P0arBtOi,7<27^OmN8C_O.G8EV%PENX.V_<S:DIJ5T^JZJ:j'q:g]#QZ.3N+)_GYNu."T'1BVJg;'8"$`+$J6nQOl",1sn1]1X8-CMb\Q`hS)0,;e=(t/Sd4(KK4e"'%M#Z\:9\l89B-Ug0P^4s1_h(]/1aM7[,<BOX`s]g#^[8r-okaCEmAOkK.I,Rr"T8&"OUkf)"lVm[_=M5%ZP?/6S,!QM#L1mVYISbqNK#a%=)+034_V3W~>endstream
|
||||||
|
endobj
|
||||||
|
30 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 596
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
GatUp9i'Ou&;KZN/*=?@9,a:Ip9pe>+@4]/ZmRI>B/[7lia=ipkcaG#:]qJhBNLKD41==?GPp.r6>g9/WYXN&!Umc/&/'jPLa\(P\:DoEibfn\<[KRmEg[;+j"q<mP4Ll/%V-UeOn"SW%eD61^t&mR-6<0o'J0QV+HeIhP[ipR#iM%mB](CDClY1`%E5rApmp/T<-e%f)G\a78Kt`9Z&WeOEK@]bS=O#8"+m48kSs0(23tihJLMVm*L=:a(%V-;+AepeFbe>,[p#J06"=ZMgV$$EDO^sfH"qFqS^R9dpEa"H\!m=Rd%f\&VUOC(CoR"1H>CmnSI+0(RcNE`X[+@'R`#cYs!5XG`BBV'B9V=j;M0,3I@.iC_`-j16]eM&ob];'8d%)re'62R>d#e^n4>]Q\5*jq*6h"s:A.KA@QYOIojnZu5?Jd^!uT/YJLlg-Q\21C$MK,?l5<!Xm8#i%]l&dao/6$q(-RcFBlk5jJhu>4S+hZ@#B,XKLfn!maFfdAXNV)fG^uoF[!\-Hh2q:cc83`U0[Rg;V3TZ0F5qV?Yide_T7Rs9oFN+<].FKGMX<JbXt.,.8,B&e=Sr?\mp)d~>endstream
|
||||||
|
endobj
|
||||||
|
31 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 490
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gat=g?VeNm'ZJu*'^'K2:tfgQ>2Bhj->65Apqk4)18eMi2iJ8rGh[s$&Rq'&adVq[H[b-i]aWCB(;@sDE1UQY`P=J(2-<0Xj:be\^%WT&B*qCeieemP1W#AQY0M_-<s`BbV3$ok8JBa^kLA,p5TnrFE=O>.39OU/iWLM'^8i1.ltk'>rdt'l'-H!j7?,aul2cE6E)0k-2fP_Yd'.>CBVt0P[K-td%,klUdI\4+AoLa%p<imLE7WRTP?o6X08fUO@1'W`^&Dih9ARGdc+7'qQ27UC2DI*/h3\u6$PdkS:JKZ0<mMg6a8A$T'WY\S=u7lfe^_K\;_;?pbc]ME*S>+0e:WT,gUIfcTAcPO<V;2U:b85]$ZO6Zr&]$ncqGe]]=ZaCo?n=EWecm364sU*Ys&Q,DG4QZ=dfLmLCLaCQPi7ML@jVUV'"6eYt\(++fV9)7rE/*I=*38e0!N,%`^D_\_E@`rITh]oqs82^&]rTrsJ~>endstream
|
||||||
|
endobj
|
||||||
|
32 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 530
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gat=gbAQ&g'L_]pMHL+#S5:IW;P\M2?RV!9D5r$b/^q.3]qS#Z76eL3Q.iKATLM13K#i<-bT'Ffe1DH-Gm2u6Ld!RJNbef`O9F5g5%M!T)1/BJS4Lm^6Rl!N[+)8)*>>Rh`fqtsJnd^fFPE4Q`qBHH=lkBZ0^4@6mRPT5K$SR(R*t&mH%YGgZ:ckGeZF=X`o<C7%EGA5HkLHKBLjiah:m^ik_l/!Qf&7aqss[maetU3mbcl,iI-piZlOBXki*!ePTRFYPd+0p&H9P*fVaa^))[g:D6Z"Bs!pYe8:5rh,u%Bke$g!mI9MLrBVL_l:dh"`fNa0Ac=3k8'Bom0Y00fLO"lm.kW?JuI-aI1Du+C]fF:4&0;E2r#L]ukU8^`@<+OP<2ccg5SWQ;/B/s9am*=+U.F00W;PIMcN(\BL&'0bK1$*n=l7\YjXCJK%Di4XAg!o`2X>jW(XbuL"5"8>;mQ?0nWTO!pdl&hqk55:+$Y&F/`'0Wi/T(8B1p!U%lR')8D_\]1?Crpgljr_3knX~>endstream
|
||||||
|
endobj
|
||||||
|
33 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 504
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gas1[D,5d<&H9CNEV]u9XB^=Z9uMik%D"K2'4]OR3n^#19W#h%mfCn^Q^B*Oo^8;tmcfA^d_^;PAH;T3[Z:u]K,FdfbQlStD-G7;>\"2eK]3?4*OH\6Tr=!gN%5uI;@3e:-!_V88W1sbX+:87&NC"0'fb@k[,Wr%..ZnMnljb0/`>>kC&W:hYlWtfb\#E&N7MA7?#R-c&oLqMnFIn@3FGkP#Du*P,kuJN_K`DA?(jel?;4<Q=`N1[nj$5TBdQVq%,1>R:;H*)O-<o"e`"*bAn=$4lS[FSXqMh!j'JIl;;;KF>rKt%dVQom7CGlM\M2.94rA#JesFZWEtkr;T?j.&[.t_Yh5B`"nKtQR/FF$j08bs,&1=CP17URH0ufMfUHA<s(j2&@^.D#u<;48UpqL8FM(<$ZaVRne$;A#f3cAQ1<>5fUaWa@m=[;OSoqtR/V-b[#H,+2XJ=pY,2&<]*]7KE\6#g+(-haAbV\WmQZk,`C5DF^>#>K]C5l~>endstream
|
||||||
|
endobj
|
||||||
|
34 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 385
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gat>Qh+iSV'F4,ST&O!M03D#$Z?!G!cXb%oI=]O%^^N<ZDSEggVCn'#6CRPDVs!e=9bE1gf^="oYRiM%5sPcd!uQeJL_X0Qi$6$I-)2XDPTutq)BrhLht+l3jY>c=Q\5\(1D5<h!oFH:!!]1kbpacNRT%qISg5Ia8R&oDC"=(ChEY=(hNM;pAsAgQUcgncB]o.68396&b]@?tcCf8sB=$iEXm>8cdTJsD);nCTEDJQ?oK&h>Y#W63_;t-Zbb7q,/b0irP/3'?DJu4F7W&H!+R.MaGM5+4j#=g*g*lj5c?M0HIo+EiH&mGjjJL)m&3],bop%.Ukf)E@Ee)>rZ0m"?9])U0X2;q$,:B79gf!U@X`f3]ALSYTcd-P9^&[bQk0<~>endstream
|
||||||
|
endobj
|
||||||
|
35 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 464
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gatn"d7V;1'Sc)R'^'KH9U`=_P9IdIRg=Vpo`[\,g1]*h//,Ii2sE`>+^!M*UnQu4s8JOY&.%)7h41YK-5k_Z!C@r6!f9b_f`OpSi&r2iR>`=:W$%0K.3a?M\'Ka?'epleV4](J4H.)jJQ!fN!!_-!M,PP@Mc8o;#7SFnILjcR7<Sjf.qD#.HZJ.*AAh@;K+ok1GmpTX#HiXQX`5NrkCNe+*/oL2YgYb>jjIDT)=n`k&"Z5*]fl*S&sOZ@>F>Nsj,GW\i9/,DS[>u-7kdfg-j)E!o7:c8<4uYqp,EFg#A4@Tn8ZQ`#]sF[cDSF=IEeqRDX-Yh<3if@%oPsE,2r@X_Vo%+nHZl\AW>!eIq'AZ(W9?Kd>+.0(M(Z\);Bqh_Z*PqM&PPOX/j8"]Tfi03^0<s:=4)l)rFW+0!MEg[G@f'%\iR+ld.n4e;MWNgKNWB[PHHXHf>$W:DrC/?i~>endstream
|
||||||
|
endobj
|
||||||
|
36 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 345
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
GatUlh+GRM&;BTE'Sg"oi0r?]VsB>l;R%@Q_S/:>gDJ%Ee(phppeG!).>ig7YV=;Fq9E,0bXMF'eH5I`2\-%Scl5e*''L^eFgR.lCGV.u;c44u1`)rC`HOE[;C+^E?4gnQ-?0)o7]]):J.1/;\--Aq\<MX<74tfL4YE&HR(9Z*?$?VR%Xg3"?atOeO4,3/GX"BHaTIKc`;:ebfJJ72FJe:2(/qSo02OMF?^T49^?^1j0`Ni8X^/G"_2"h9I)s']>[t-]*o#Pe+W.tpf%dQj!]A_RGG>i@EIJ+@k*jY$LUD[jf(l=r9\rP'Anl]/fCHZq[,.pi$fK*;eWe*Z7%Z^SRqM~>endstream
|
||||||
|
endobj
|
||||||
|
37 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 306
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gat=d5tf-M&;BTO(%8*LNLamTD7m!DGgM)+57k-*7bX`:h$(V:n$FShM[,Aq&EZ^DpEkQFMjDh7^pV#?/V,1k1T2!6bWTi;p6O+XUln&YW<o&/,=j?0moU?'QUE]lLoSgE;'Ik8]?rQ&_/sKG6#&<EEje04"h+:WY3rr>6f4EO]Y[pta@EDjYj:QZ[TMThPWY[bO(-f&Lt]H(a"':Jcj(A'_W]Gl\2`X?UU.'s3SMghX>oNPI#\mglU'sKe&H"t6TDXUB=N`,M]?W$hH+FKiraHA;EWTT^@DMOFpdX_!4#CXfPM#~>endstream
|
||||||
|
endobj
|
||||||
|
38 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 307
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gat=c_+qm%&4H!_ME+QKRB2mRF`.Eu$&K%i--f/&MH_G44)j-DEDhHd`;rshm:CJ(bn,ah2!q.BXu3A"#pc3K:ao$#HYdftQI5G</Oce;+tTaJ+B>K7QXh7>8[M/=U+4Zu-kUW*Eha^A%G`=O$*$L:.7`\3-*Zbr$s5S)L,ahB6XPkpLGXKhkLD5%1!-=AGsnJM`RNq(\m)Ab=uQRRb:`XI=!-g;5CC]&p^iDE`ql9JfOrYD[^?R>iV1D!Cd7r^:!V)/G/HJt\:QWaliMAk[OKEDa/3A4E]feg([hk^&WhDA'O>OP~>endstream
|
||||||
|
endobj
|
||||||
|
39 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 270
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gat>N_.phP&-h'>T&MuC"kT;"fsoYfp/E3Pq>b)jelC1qM&uNe:83P:Tb7Rl1,JsN&@</Eh:KHK_t]3V%/1'#<=8J\=-rh1FP8RQN+GZ'ZWrVM<pjM"fVV&8=](Tq;k9ZY8gKDAJ:XaIS3a"[BC;K981PBK<<g2Gn[j4&>JiUHj?i,bG*6.8\fY9XXMID"*msp27r*dEd/"$_W^=?c@N$lDX#`/RfN')`4!^@-Q\s^2Mcm=_h_B:0gmRQaSNqWCpZ2j<j8^=qIa.~>endstream
|
||||||
|
endobj
|
||||||
|
40 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 427
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gat=&>>)jf(k(;F30/-bZsVOa8`.0S&-h,pHl[@<CUKM^G-ZZ`hPA]HNI`:)j<<\J[tJ/T7GY[/J?'74/V,mu"QV<Si=qJpn2?pmUu4opXpDc)1K$R(3IT/^Ef$TH0[EeJG#>FIRZ7"B!b%eE3Ck!-c40H.LuE@hQgFsn(PF"Xp7U"t5t<]n+K0t\9,FM(Q7YC7eFaN>;FsD+;9)3s*NsYM-r-A;+ibYsPX]gK*Cs\g9H'8@9q>da2"N0E[Ts-(24r)6mr%Q+J[D7jAS'=[Gs76^'%WlR5dPa=n.[bE++fXM&^9[G:YXpCYKiqhR_HK$W_B]5XLMP>eN1sTdcrFNU*:+s<u04ql9CPSTteU@e)M'4RX\7`\$p;I\9SLP\:0M)hPTg'e1Co<$U-hMFgcOSG6.QMMsY\7&efmnCji7;~>endstream
|
||||||
|
endobj
|
||||||
|
41 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 301
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
GarW392!2W'SZ;['kc4i&US#WC#$+jpfaU0E&4!3M'ZWc0iOA5pYH#b.rld>S<NF%&i(McBA3Nr_9"!S,g8%m(HK\[/kki%d!&D[TsW=&A[)rFQ0*Q9$`S3AOt39JFID7[?,AGp&La$<i=_pf>fqRl8IDP8p[4NED&+fY0F'o=_"3UdC&L[cj.DW'5S4rZaFYasj#a'W\9Thh7!4!=>J&d$SZ]+F=fD!;H@qSXFmJeVM8OMl9oPR>O3M5*l?YD"Tpl4.Faf&UB0WL__RR"fR7;YYNJi"el.tiA59sU$MHW+~>endstream
|
||||||
|
endobj
|
||||||
|
42 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 275
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
GarW3b>,r/&4Q?hMS"NoL5bLZ1WrgN'cSFu5Y)t"aFgkUnaVR5MM_b#A8C9'3+eC5]UWgLC_@e\)!m@Xi'7>-)3:cl.eo`sNhSRGe5]5E;9`b"j>8QK8Z4+=b(0:a=;q2T7&8\V*B<2#kAp28Hbt*`G=-R:btZrOpIOaDX'GL1o3\a=o%.?1AM!ql\()#IC^lYmL^Je<o+(ZJP&$k"*ETX?rj&!9PRFUKAD$7m[,^f0N<<LVp>\9o)jgLcD4B#B\po`qj39ebquE&`;k<~>endstream
|
||||||
|
endobj
|
||||||
|
43 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 284
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
GarVH9hWDY&A@g>]V@(r4)(//>SREN01,Y'>e>oslddsW#l]']MR^1;5i.U8Y7PFh>0b@n\4O3E-]gjPJq=A$CYYcOUS8iBoIc";6J$C@,`:mZfm*llX;':;_KEZ(X43,X"!Z/ZbrmJD\bB]aTc(@G5?oDG,-<E];<P^(L0\kXP?<#KE/kk5%Pha3`A%aQ*FnH@>qNeEY7XQIVj+q*<Mdp9@fs/sVR2@^a^`.'VK*Xj`r:(Bb<5%[G2*rtjir64auhfLdd*'OQ`GQ7H1jC#hdJcO.K~>endstream
|
||||||
|
endobj
|
||||||
|
44 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 324
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
GarW3_+qm%&4H!_ME-D4*3U;CG&DiY',QPF]qfOD8E/X)s,S0D5S[I:f90cZh@b&a>'i=4%i3-E5Q^W^.Tm)TKZ7Vr)1=$#@*/aBW+<lg-kl@72\W&6dS[k,2k$'=>o)8[-U>M.d9M3$1)D-;k9Lp]nCP.cc+WkpH0Xg*hdJGmp,U]RdD@U5)f6:mFG\`i!@CWH/YulZ)\&g76?"o$<oZDl;.nO=W0pCk`GQ'S5iY:;,=kl*'ftE:ITd6Eab@:FdC12MT_7?acOPTrk*8-hkP(O:Q5]-J%-X&>;A[;FZ[Zic[H<no]fQWqIrd0H0>NZUec~>endstream
|
||||||
|
endobj
|
||||||
|
45 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 382
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gas2D;,>%_'SYH=/+2FOZ1MA66?uHBN'nm?NDI4&R'Xq9fLk.U5MiOFPX*GeR^Dh]4#mOsqn[6K"i-+PmO\JQkVkcC"R$(-l"Cah:+;#k71,`\oGK-Xr'.r.U]f\m93B']J.&=HaOBWd+@-eb:4TX$'/etj-%kH*D(Crq5_"<uB^D>hS?kW]@sLJ?JB%X!$$]ss_c/Y7;rb/,f_)k],ti*k(00j>%-*[jD242la,U;YKur0EX4oi;b"'G+XG2hjd`n>r3edPP670QNe-dGF$NqIQS&cDgWL&\JG1nCQSaPOHnWAV:UelNDQOqL&FOgKP^7iZth0AJrhf(`5f;s!>c2?\[e>mjtIi9/jbAHVrPL]OIQcH=9.YrNR[F'f+~>endstream
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 46
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000061 00000 n
|
||||||
|
0000000112 00000 n
|
||||||
|
0000000219 00000 n
|
||||||
|
0000001509 00000 n
|
||||||
|
0000002581 00000 n
|
||||||
|
0000002693 00000 n
|
||||||
|
0000002961 00000 n
|
||||||
|
0000003166 00000 n
|
||||||
|
0000003371 00000 n
|
||||||
|
0000003476 00000 n
|
||||||
|
0000003682 00000 n
|
||||||
|
0000003888 00000 n
|
||||||
|
0000004094 00000 n
|
||||||
|
0000004300 00000 n
|
||||||
|
0000004506 00000 n
|
||||||
|
0000004712 00000 n
|
||||||
|
0000004918 00000 n
|
||||||
|
0000005124 00000 n
|
||||||
|
0000005330 00000 n
|
||||||
|
0000005536 00000 n
|
||||||
|
0000005742 00000 n
|
||||||
|
0000005948 00000 n
|
||||||
|
0000006154 00000 n
|
||||||
|
0000006360 00000 n
|
||||||
|
0000006566 00000 n
|
||||||
|
0000006636 00000 n
|
||||||
|
0000006917 00000 n
|
||||||
|
0000007098 00000 n
|
||||||
|
0000007995 00000 n
|
||||||
|
0000008750 00000 n
|
||||||
|
0000009437 00000 n
|
||||||
|
0000010018 00000 n
|
||||||
|
0000010639 00000 n
|
||||||
|
0000011234 00000 n
|
||||||
|
0000011710 00000 n
|
||||||
|
0000012265 00000 n
|
||||||
|
0000012701 00000 n
|
||||||
|
0000013098 00000 n
|
||||||
|
0000013496 00000 n
|
||||||
|
0000013857 00000 n
|
||||||
|
0000014375 00000 n
|
||||||
|
0000014767 00000 n
|
||||||
|
0000015133 00000 n
|
||||||
|
0000015508 00000 n
|
||||||
|
0000015923 00000 n
|
||||||
|
trailer
|
||||||
|
<<
|
||||||
|
/ID
|
||||||
|
[<e304fee0e103b9acf5e15719aff8b081><e304fee0e103b9acf5e15719aff8b081>]
|
||||||
|
% ReportLab generated PDF document -- digest (opensource)
|
||||||
|
|
||||||
|
/Info 26 0 R
|
||||||
|
/Root 25 0 R
|
||||||
|
/Size 46
|
||||||
|
>>
|
||||||
|
startxref
|
||||||
|
16396
|
||||||
|
%%EOF
|
||||||
BIN
docs/ophion_logo.png
Normal file
BIN
docs/ophion_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/ophion_logo_small.png
Normal file
BIN
docs/ophion_logo_small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/ophion_snake_logo.png
Normal file
BIN
docs/ophion_snake_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
30
go.mod
30
go.mod
@@ -4,8 +4,32 @@ go 1.22
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gofiber/fiber/v2 v2.52.0
|
github.com/gofiber/fiber/v2 v2.52.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/redis/go-redis/v9 v9.4.0
|
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
|
github.com/shirou/gopsutil/v3 v3.24.1
|
||||||
golang.org/x/crypto v0.18.0
|
golang.org/x/crypto v0.14.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.0 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||||
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
||||||
|
golang.org/x/sys v0.16.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
86
go.sum
Normal file
86
go.sum
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||||
|
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||||
|
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||||
|
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.1 h1:R3t6ondCEvmARp3wxODhXMTLC/klMa87h2PHUw5m7QI=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.1/go.mod h1:UU7a2MSBQa+kW1uuDq8DeEBS8kmrnQwsv2b5O513rwU=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||||
|
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||||
|
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||||
|
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||||
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
Reference in New Issue
Block a user