fix: add go.sum and fixes
This commit is contained in:
43
dashboard/Dockerfile
Normal file
43
dashboard/Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 🐍 OPHION Dashboard - Dockerfile
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# Runtime
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nextjs -u 1001
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,6 +1,15 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
}
|
||||
reactStrictMode: true,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
{
|
||||
"name": "ophion-dashboard",
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@tanstack/react-query": "^5.17.19",
|
||||
"chart.js": "^4.4.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"d3": "^7.8.5",
|
||||
"date-fns": "^3.3.0",
|
||||
"lucide-react": "^0.311.0",
|
||||
"next": "14.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"next-auth": "^4.24.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"recharts": "^2.10.0",
|
||||
"lucide-react": "^0.312.0",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"zustand": "^4.4.0"
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0"
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
0
dashboard/public/.gitkeep
Normal file
0
dashboard/public/.gitkeep
Normal file
@@ -3,24 +3,19 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 2 6 23;
|
||||
--foreground: 248 250 252;
|
||||
--card: 15 23 42;
|
||||
--card-foreground: 248 250 252;
|
||||
--primary: 34 197 94;
|
||||
--primary-foreground: 255 255 255;
|
||||
--secondary: 139 92 246;
|
||||
--muted: 51 65 85;
|
||||
--muted-foreground: 148 163 184;
|
||||
--accent: 34 197 94;
|
||||
--destructive: 239 68 68;
|
||||
--border: 51 65 85;
|
||||
--ring: 34 197 94;
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 10, 10, 20;
|
||||
--background-end-rgb: 10, 10, 20;
|
||||
}
|
||||
|
||||
body {
|
||||
background: rgb(var(--background));
|
||||
color: rgb(var(--foreground));
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@@ -30,34 +25,45 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgb(15, 23, 42);
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(51, 65, 85);
|
||||
background: #3b3b5c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(71, 85, 105);
|
||||
background: #4b4b7c;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
|
||||
/* Timeline span bars */
|
||||
.span-bar {
|
||||
@apply h-6 rounded relative;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.pulse-glow {
|
||||
animation: pulse-glow 2s infinite;
|
||||
.span-bar-inner {
|
||||
@apply absolute inset-0 rounded;
|
||||
background: linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%);
|
||||
}
|
||||
|
||||
/* Card hover effects */
|
||||
.card-hover {
|
||||
transition: all 0.2s ease;
|
||||
.span-bar-error .span-bar-inner {
|
||||
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
/* Log levels */
|
||||
.log-level-ERROR { @apply text-red-400; }
|
||||
.log-level-WARN { @apply text-yellow-400; }
|
||||
.log-level-INFO { @apply text-blue-400; }
|
||||
.log-level-DEBUG { @apply text-gray-400; }
|
||||
.log-level-TRACE { @apply text-gray-500; }
|
||||
|
||||
/* Metric cards */
|
||||
.metric-card {
|
||||
@apply bg-gray-900/50 rounded-lg p-4 border border-gray-800;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
@apply border-indigo-500/50;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { Providers } from '@/components/Providers';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OPHION Dashboard',
|
||||
description: 'Observability Platform with AI',
|
||||
}
|
||||
title: 'OPHION - Observability Platform',
|
||||
description: 'Open Source Observability Platform',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="pt-BR" className="dark">
|
||||
<body className={`${inter.className} bg-slate-950 text-white`}>
|
||||
{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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
212
dashboard/src/app/logs/page.tsx
Normal file
212
dashboard/src/app/logs/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, RefreshCw, Filter, Download } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatTime } from '@/lib/utils';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
service: string;
|
||||
host: string;
|
||||
level: string;
|
||||
message: string;
|
||||
trace_id?: string;
|
||||
span_id?: string;
|
||||
source?: string;
|
||||
container_id?: string;
|
||||
}
|
||||
|
||||
const LOG_LEVELS = ['', 'TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'];
|
||||
|
||||
export default function LogsPage() {
|
||||
const [service, setService] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [traceId, setTraceId] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['logs', service, level, query, traceId],
|
||||
queryFn: () => api.get('/api/v1/logs', {
|
||||
service,
|
||||
level,
|
||||
q: query,
|
||||
trace_id: traceId,
|
||||
from: new Date(Date.now() - 3600000).toISOString(),
|
||||
limit: '200',
|
||||
}),
|
||||
refetchInterval: autoRefresh ? 5000 : false,
|
||||
});
|
||||
|
||||
const logs: LogEntry[] = data?.logs ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Logs</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="rounded border-gray-600 bg-gray-800 text-indigo-500"
|
||||
/>
|
||||
Auto-refresh
|
||||
</label>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-2 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-800">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search logs..."
|
||||
className="w-full pl-10 pr-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<select
|
||||
value={service}
|
||||
onChange={(e) => setService(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All services</option>
|
||||
{/* Services would be populated dynamically */}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<select
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
{LOG_LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l || 'All levels'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-64">
|
||||
<input
|
||||
type="text"
|
||||
value={traceId}
|
||||
onChange={(e) => setTraceId(e.target.value)}
|
||||
placeholder="Trace ID"
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Table */}
|
||||
<div className="bg-gray-900/50 rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Time</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Level</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Service</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{logs.map((log, i) => (
|
||||
<LogRow key={i} log={log} />
|
||||
))}
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
|
||||
No logs found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Showing {logs.length} logs</span>
|
||||
{data?.count && data.count > logs.length && (
|
||||
<span>Limited to 200 results</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogRow({ log }: { log: LogEntry }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
ERROR: 'text-red-400 bg-red-400/10',
|
||||
WARN: 'text-yellow-400 bg-yellow-400/10',
|
||||
INFO: 'text-blue-400 bg-blue-400/10',
|
||||
DEBUG: 'text-gray-400 bg-gray-400/10',
|
||||
TRACE: 'text-gray-500 bg-gray-500/10',
|
||||
FATAL: 'text-red-500 bg-red-500/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="hover:bg-gray-800/50 cursor-pointer"
|
||||
>
|
||||
<td className="px-4 py-2 text-sm text-gray-400 whitespace-nowrap font-mono">
|
||||
{formatTime(log.timestamp)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${levelColors[log.level] || ''}`}>
|
||||
{log.level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-300 whitespace-nowrap">
|
||||
{log.service}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-200 max-w-xl truncate font-mono">
|
||||
{log.message}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="bg-gray-800/30">
|
||||
<td colSpan={4} className="px-4 py-3">
|
||||
<pre className="text-sm text-gray-300 whitespace-pre-wrap font-mono">
|
||||
{log.message}
|
||||
</pre>
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-xs text-gray-500">
|
||||
<span>Host: {log.host}</span>
|
||||
<span>Source: {log.source}</span>
|
||||
{log.container_id && <span>Container: {log.container_id}</span>}
|
||||
{log.trace_id && (
|
||||
<a
|
||||
href={`/traces?id=${log.trace_id}`}
|
||||
className="text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Trace: {log.trace_id.slice(0, 16)}...
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
108
dashboard/src/app/metrics/page.tsx
Normal file
108
dashboard/src/app/metrics/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { RefreshCw, TrendingUp, Cpu, HardDrive, Activity } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { MetricsChart } from '@/components/metrics/MetricsChart';
|
||||
|
||||
const METRIC_PRESETS = [
|
||||
{ name: 'CPU Usage', metric: 'cpu.usage_percent', service: 'system', icon: Cpu },
|
||||
{ name: 'Memory Usage', metric: 'memory.used_percent', service: 'system', icon: Activity },
|
||||
{ name: 'Load Average', metric: 'cpu.load_avg_1', service: 'system', icon: TrendingUp },
|
||||
{ name: 'Disk Usage', metric: 'disk.used_percent', service: 'system', icon: HardDrive },
|
||||
{ name: 'Network Sent', metric: 'network.bytes_sent', service: 'system', icon: TrendingUp },
|
||||
{ name: 'Network Recv', metric: 'network.bytes_recv', service: 'system', icon: TrendingUp },
|
||||
{ name: 'Containers Running', metric: 'containers.running', service: 'docker', icon: Activity },
|
||||
{ name: 'Container CPU', metric: 'container.cpu_percent', service: 'docker', icon: Cpu },
|
||||
];
|
||||
|
||||
export default function MetricsPage() {
|
||||
const [timeRange, setTimeRange] = useState('1h');
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([
|
||||
'cpu.usage_percent',
|
||||
'memory.used_percent',
|
||||
]);
|
||||
|
||||
const getTimeFrom = () => {
|
||||
const ranges: Record<string, number> = {
|
||||
'15m': 15 * 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'6h': 6 * 60 * 60 * 1000,
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return new Date(Date.now() - (ranges[timeRange] || ranges['1h'])).toISOString();
|
||||
};
|
||||
|
||||
const toggleMetric = (metric: string) => {
|
||||
setSelectedMetrics((prev) =>
|
||||
prev.includes(metric)
|
||||
? prev.filter((m) => m !== metric)
|
||||
: [...prev, metric]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Metrics</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="15m">Last 15 minutes</option>
|
||||
<option value="1h">Last hour</option>
|
||||
<option value="6h">Last 6 hours</option>
|
||||
<option value="24h">Last 24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric Selector */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{METRIC_PRESETS.map((preset) => {
|
||||
const Icon = preset.icon;
|
||||
const isSelected = selectedMetrics.includes(preset.metric);
|
||||
return (
|
||||
<button
|
||||
key={preset.metric}
|
||||
onClick={() => toggleMetric(preset.metric)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
|
||||
isSelected
|
||||
? 'bg-indigo-600 border-indigo-500 text-white'
|
||||
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{preset.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{selectedMetrics.map((metric) => {
|
||||
const preset = METRIC_PRESETS.find((p) => p.metric === metric);
|
||||
return (
|
||||
<MetricsChart
|
||||
key={metric}
|
||||
title={preset?.name || metric}
|
||||
service={preset?.service || 'system'}
|
||||
metric={metric}
|
||||
from={getTimeFrom()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedMetrics.length === 0 && (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-900/50 rounded-lg border border-gray-800 text-gray-500">
|
||||
Select metrics to display
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +1,90 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import Header from '@/components/layout/Header'
|
||||
import MetricCard from '@/components/ui/MetricCard'
|
||||
import HostsTable from '@/components/ui/HostsTable'
|
||||
import AlertsList from '@/components/ui/AlertsList'
|
||||
import CpuChart from '@/components/charts/CpuChart'
|
||||
import MemoryChart from '@/components/charts/MemoryChart'
|
||||
import AIInsights from '@/components/ui/AIInsights'
|
||||
import Copilot from '@/components/ui/Copilot'
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Activity, AlertTriangle, Box, Server } from 'lucide-react';
|
||||
import { MetricCard } from '@/components/dashboard/MetricCard';
|
||||
import { RecentAlerts } from '@/components/dashboard/RecentAlerts';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [showCopilot, setShowCopilot] = useState(false)
|
||||
const [metrics, setMetrics] = useState({
|
||||
totalHosts: 0,
|
||||
healthyHosts: 0,
|
||||
activeAlerts: 0,
|
||||
cpuAvg: 0,
|
||||
memoryAvg: 0,
|
||||
diskAvg: 0,
|
||||
})
|
||||
|
||||
// Simulated data - replace with real API calls
|
||||
useEffect(() => {
|
||||
setMetrics({
|
||||
totalHosts: 12,
|
||||
healthyHosts: 11,
|
||||
activeAlerts: 3,
|
||||
cpuAvg: 42.5,
|
||||
memoryAvg: 68.3,
|
||||
diskAvg: 54.2,
|
||||
})
|
||||
}, [])
|
||||
export default function DashboardPage() {
|
||||
const { data: overview, isLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'overview'],
|
||||
queryFn: () => api.get('/api/v1/dashboard/overview'),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
158
dashboard/src/app/services/page.tsx
Normal file
158
dashboard/src/app/services/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { RefreshCw, GitBranch, AlertCircle } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ServiceMapGraph } from '@/components/services/ServiceMapGraph';
|
||||
|
||||
interface Service {
|
||||
name: string;
|
||||
type?: string;
|
||||
span_count: number;
|
||||
error_count: number;
|
||||
avg_duration_ms: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface Dependency {
|
||||
source: string;
|
||||
target: string;
|
||||
call_count: number;
|
||||
error_count: number;
|
||||
avg_duration_ms: number;
|
||||
}
|
||||
|
||||
interface ServiceMap {
|
||||
services: Service[];
|
||||
dependencies: Dependency[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function ServicesPage() {
|
||||
const { data: serviceMap, isLoading, refetch } = useQuery({
|
||||
queryKey: ['services', 'map'],
|
||||
queryFn: () => api.get('/api/v1/services/map'),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const services: Service[] = serviceMap?.services ?? [];
|
||||
const dependencies: Dependency[] = serviceMap?.dependencies ?? [];
|
||||
|
||||
const totalCalls = dependencies.reduce((sum, d) => sum + d.call_count, 0);
|
||||
const totalErrors = dependencies.reduce((sum, d) => sum + d.error_count, 0);
|
||||
const errorRate = totalCalls > 0 ? (totalErrors / totalCalls) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Service Map</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Visualize dependencies between services
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Services"
|
||||
value={services.length}
|
||||
icon={<GitBranch className="h-5 w-5 text-indigo-400" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Dependencies"
|
||||
value={dependencies.length}
|
||||
icon={<GitBranch className="h-5 w-5 text-blue-400" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Calls (24h)"
|
||||
value={totalCalls.toLocaleString()}
|
||||
icon={<GitBranch className="h-5 w-5 text-green-400" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Error Rate"
|
||||
value={`${errorRate.toFixed(2)}%`}
|
||||
icon={<AlertCircle className={`h-5 w-5 ${errorRate > 5 ? 'text-red-400' : 'text-gray-400'}`} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Service Map Visualization */}
|
||||
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4" style={{ height: '500px' }}>
|
||||
{services.length > 0 ? (
|
||||
<ServiceMapGraph services={services} dependencies={dependencies} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
{isLoading ? 'Loading service map...' : 'No services discovered yet'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Services Table */}
|
||||
<div className="bg-gray-900/50 rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white">Services</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Service</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Spans</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Errors</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Avg Duration</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{services.map((svc) => (
|
||||
<tr key={svc.name} className="hover:bg-gray-800/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
<span className="text-white font-medium">{svc.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-300">
|
||||
{svc.span_count.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={svc.error_count > 0 ? 'text-red-400' : 'text-gray-400'}>
|
||||
{svc.error_count.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-300">
|
||||
{svc.avg_duration_ms?.toFixed(2)} ms
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-sm">
|
||||
{new Date(svc.last_seen).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon }: { label: string; value: number | string; icon: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">{label}</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white mt-2">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
dashboard/src/app/traces/page.tsx
Normal file
184
dashboard/src/app/traces/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, Clock, AlertCircle, ExternalLink } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { TraceTimeline } from '@/components/traces/TraceTimeline';
|
||||
import { formatDuration, formatTime } from '@/lib/utils';
|
||||
|
||||
interface Trace {
|
||||
trace_id: string;
|
||||
services: string[];
|
||||
start_time: string;
|
||||
duration_ns: number;
|
||||
span_count: number;
|
||||
has_error: boolean;
|
||||
root_span?: {
|
||||
operation: string;
|
||||
service: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function TracesPage() {
|
||||
const [service, setService] = useState('');
|
||||
const [operation, setOperation] = useState('');
|
||||
const [minDuration, setMinDuration] = useState('');
|
||||
const [onlyErrors, setOnlyErrors] = useState(false);
|
||||
const [selectedTrace, setSelectedTrace] = useState<string | null>(null);
|
||||
|
||||
const { data: traces, isLoading, refetch } = useQuery({
|
||||
queryKey: ['traces', service, operation, minDuration, onlyErrors],
|
||||
queryFn: () => api.get('/api/v1/traces', {
|
||||
service,
|
||||
operation,
|
||||
min_duration_ms: minDuration,
|
||||
error: onlyErrors ? 'true' : '',
|
||||
from: new Date(Date.now() - 3600000).toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: traceDetail } = useQuery({
|
||||
queryKey: ['trace', selectedTrace],
|
||||
queryFn: () => api.get(`/api/v1/traces/${selectedTrace}`),
|
||||
enabled: !!selectedTrace,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Traces</h1>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-800">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-sm text-gray-400 mb-1">Service</label>
|
||||
<input
|
||||
type="text"
|
||||
value={service}
|
||||
onChange={(e) => setService(e.target.value)}
|
||||
placeholder="All services"
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-sm text-gray-400 mb-1">Operation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={operation}
|
||||
onChange={(e) => setOperation(e.target.value)}
|
||||
placeholder="All operations"
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<label className="block text-sm text-gray-400 mb-1">Min Duration</label>
|
||||
<input
|
||||
type="text"
|
||||
value={minDuration}
|
||||
onChange={(e) => setMinDuration(e.target.value)}
|
||||
placeholder="ms"
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 px-3 py-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyErrors}
|
||||
onChange={(e) => setOnlyErrors(e.target.checked)}
|
||||
className="rounded border-gray-600 bg-gray-800 text-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Errors only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Trace List */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{isLoading ? 'Loading...' : `${traces?.traces?.length ?? 0} traces`}
|
||||
</h2>
|
||||
<div className="space-y-2 max-h-[600px] overflow-auto">
|
||||
{traces?.traces?.map((trace: Trace) => (
|
||||
<div
|
||||
key={trace.trace_id}
|
||||
onClick={() => setSelectedTrace(trace.trace_id)}
|
||||
className={`p-4 bg-gray-900/50 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedTrace === trace.trace_id
|
||||
? 'border-indigo-500'
|
||||
: 'border-gray-800 hover:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{trace.has_error && (
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
)}
|
||||
<span className="font-medium text-white">
|
||||
{trace.root_span?.operation || trace.trace_id.slice(0, 16)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{trace.services?.slice(0, 3).map((svc) => (
|
||||
<span
|
||||
key={svc}
|
||||
className="px-2 py-0.5 text-xs bg-gray-800 text-gray-300 rounded"
|
||||
>
|
||||
{svc}
|
||||
</span>
|
||||
))}
|
||||
{(trace.services?.length ?? 0) > 3 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
+{trace.services.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="flex items-center gap-1 text-gray-400">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDuration(trace.duration_ns)}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{trace.span_count} spans
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
{formatTime(trace.start_time)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!isLoading && (!traces?.traces || traces.traces.length === 0) && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No traces found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trace Detail */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-white">Trace Timeline</h2>
|
||||
{selectedTrace && traceDetail ? (
|
||||
<TraceTimeline trace={traceDetail} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-900/50 rounded-lg border border-gray-800 text-gray-500">
|
||||
Select a trace to view timeline
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
dashboard/src/components/Providers.tsx
Normal file
24
dashboard/src/components/Providers.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
57
dashboard/src/components/dashboard/MetricCard.tsx
Normal file
57
dashboard/src/components/dashboard/MetricCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon?: ReactNode;
|
||||
color?: 'blue' | 'green' | 'red' | 'yellow' | 'gray';
|
||||
trend?: { value: number; isUp: boolean };
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
blue: 'text-blue-400 bg-blue-400/10',
|
||||
green: 'text-green-400 bg-green-400/10',
|
||||
red: 'text-red-400 bg-red-400/10',
|
||||
yellow: 'text-yellow-400 bg-yellow-400/10',
|
||||
gray: 'text-gray-400 bg-gray-400/10',
|
||||
};
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon,
|
||||
color = 'blue',
|
||||
trend,
|
||||
}: MetricCardProps) {
|
||||
return (
|
||||
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4 hover:border-gray-700 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">{title}</span>
|
||||
{icon && (
|
||||
<div className={`p-2 rounded-lg ${colorClasses[color]}`}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-white">{value}</span>
|
||||
{trend && (
|
||||
<span
|
||||
className={`text-sm ${
|
||||
trend.isUp ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{trend.isUp ? '↑' : '↓'} {Math.abs(trend.value)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
dashboard/src/components/dashboard/RecentAlerts.tsx
Normal file
91
dashboard/src/components/dashboard/RecentAlerts.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle, CheckCircle, Clock } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatTime } from '@/lib/utils';
|
||||
|
||||
interface Alert {
|
||||
id: string;
|
||||
name: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
service?: string;
|
||||
message: string;
|
||||
fired_at: string;
|
||||
}
|
||||
|
||||
export function RecentAlerts() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['alerts', 'recent'],
|
||||
queryFn: () => api.get('/api/v1/alerts', { limit: 5 }),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const alerts: Alert[] = data?.alerts ?? [];
|
||||
|
||||
const severityColors: Record<string, string> = {
|
||||
critical: 'text-red-400 bg-red-400/10 border-red-400/20',
|
||||
warning: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20',
|
||||
info: 'text-blue-400 bg-blue-400/10 border-blue-400/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900/50 rounded-lg border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">Recent Alerts</h3>
|
||||
<a
|
||||
href="/alerts"
|
||||
className="text-sm text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-500">
|
||||
Loading...
|
||||
</div>
|
||||
) : alerts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<CheckCircle className="h-8 w-8 mb-2 text-green-400" />
|
||||
<p>No active alerts</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`p-3 rounded-lg border ${
|
||||
severityColors[alert.severity] || severityColors.info
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="font-medium">{alert.name}</span>
|
||||
</div>
|
||||
<span className="text-xs opacity-75">
|
||||
{alert.status === 'firing' ? '🔴' : '✅'} {alert.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mt-1 opacity-75 line-clamp-2">
|
||||
{alert.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs opacity-50">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(alert.fired_at)}
|
||||
{alert.service && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{alert.service}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +1,74 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Home,
|
||||
Activity,
|
||||
FileText,
|
||||
GitBranch,
|
||||
BarChart2,
|
||||
AlertTriangle,
|
||||
Server,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'Overview', icon: '📊', href: '/' },
|
||||
{ name: 'Hosts', icon: '🖥️', href: '/hosts' },
|
||||
{ name: 'Containers', icon: '🐳', href: '/containers' },
|
||||
{ name: 'Metrics', icon: '📈', href: '/metrics' },
|
||||
{ name: 'Logs', icon: '📝', href: '/logs' },
|
||||
{ name: 'Traces', icon: '🔍', href: '/traces' },
|
||||
{ name: 'Alerts', icon: '🚨', href: '/alerts' },
|
||||
{ name: 'Dashboards', icon: '📋', href: '/dashboards' },
|
||||
]
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Home },
|
||||
{ name: 'Traces', href: '/traces', icon: Activity },
|
||||
{ name: 'Logs', href: '/logs', icon: FileText },
|
||||
{ name: 'Metrics', href: '/metrics', icon: BarChart2 },
|
||||
{ name: 'Services', href: '/services', icon: GitBranch },
|
||||
{ name: 'Alerts', href: '/alerts', icon: AlertTriangle },
|
||||
{ name: 'Agents', href: '/agents', icon: Server },
|
||||
];
|
||||
|
||||
const bottomItems = [
|
||||
{ name: 'AI Insights', icon: '🤖', href: '/ai' },
|
||||
{ name: 'Settings', icon: '⚙️', href: '/settings' },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
|
||||
<div className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
<Link href="/" className="flex items-center space-x-3">
|
||||
<span className="text-3xl">🐍</span>
|
||||
<div>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-green-400 to-emerald-500 bg-clip-text text-transparent">
|
||||
OPHION
|
||||
</span>
|
||||
<span className="ml-2 text-xs bg-purple-600 px-2 py-0.5 rounded-full">AI</span>
|
||||
</div>
|
||||
<div className="h-16 flex items-center px-6 border-b border-gray-800">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-2xl">🐍</span>
|
||||
<span className="text-xl font-bold text-white">OPHION</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-all ${
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-green-600/20 text-green-400 border-l-2 border-green-400'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{item.icon}</span>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<div className="p-4 border-t border-slate-800 space-y-1">
|
||||
{bottomItems.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user