fix: add go.sum and fixes

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

43
dashboard/Dockerfile Normal file
View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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