475 lines
14 KiB
JavaScript
475 lines
14 KiB
JavaScript
|
const { useState, useEffect, useRef } = React;
|
||
|
|
||
|
const MetricCard = ({ title, stats }) => (
|
||
|
<div className="bg-white shadow rounded-lg p-4">
|
||
|
<div className="pb-2">
|
||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||
|
</div>
|
||
|
<div className="space-y-2">
|
||
|
<div className="grid grid-cols-2 gap-2">
|
||
|
<div>Avg: {stats.avg} ms</div>
|
||
|
<div>P50: {stats.p50} ms</div>
|
||
|
<div>P95: {stats.p95} ms</div>
|
||
|
<div>P99: {stats.p99} ms</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
|
||
|
const DBActivityCard = ({ stats }) => (
|
||
|
<div className="bg-white shadow rounded-lg p-4">
|
||
|
<div className="pb-2">
|
||
|
<h2 className="text-lg font-semibold">Database Activity</h2>
|
||
|
</div>
|
||
|
<div className="space-y-2">
|
||
|
<div className="grid grid-cols-2 gap-2">
|
||
|
<div>Rows Read: {stats.rowsRead}</div>
|
||
|
<div>Rows Written: {stats.rowsWritten}</div>
|
||
|
<div>Total Rows: {stats.totalRows}</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
|
||
|
const CacheActivityCard = ({ stats }) => (
|
||
|
<div className="bg-white shadow rounded-lg p-4">
|
||
|
<div className="pb-2">
|
||
|
<h2 className="text-lg font-semibold">Cache Activity</h2>
|
||
|
</div>
|
||
|
<div className="space-y-2">
|
||
|
<div className="grid grid-cols-2 gap-2">
|
||
|
<div>Cache Hits: {stats.hits}</div>
|
||
|
<div>Cache Misses: {stats.misses}</div>
|
||
|
<div>Hit Rate: {stats.hitRate}%</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
|
||
|
const MetricsDashboard = () => {
|
||
|
const [data, setData] = useState([]);
|
||
|
const [timeRange, setTimeRange] = useState("30m");
|
||
|
const [customStart, setCustomStart] = useState("");
|
||
|
const [customEnd, setCustomEnd] = useState("");
|
||
|
const [stats, setStats] = useState({
|
||
|
service: { avg: 0, p50: 0, p95: 0, p99: 0 },
|
||
|
db: { avg: 0, p50: 0, p95: 0, p99: 0 },
|
||
|
cache: { avg: 0, p50: 0, p95: 0, p99: 0 },
|
||
|
dbActivity: { rowsRead: 0, rowsWritten: 0, totalRows: 0 },
|
||
|
cacheActivity: { hits: 0, misses: 0, hitRate: 0 },
|
||
|
});
|
||
|
|
||
|
const chartRef = useRef(null);
|
||
|
const chartInstance = useRef(null);
|
||
|
|
||
|
const getTimeRangeParams = () => {
|
||
|
const now = Date.now();
|
||
|
switch (timeRange) {
|
||
|
case "30m":
|
||
|
return `?start=${now - 30 * 60 * 1000}&end=${now}`;
|
||
|
case "1h":
|
||
|
return `?start=${now - 60 * 60 * 1000}&end=${now}`;
|
||
|
case "24h":
|
||
|
return `?start=${now - 24 * 60 * 60 * 1000}&end=${now}`;
|
||
|
case "7d":
|
||
|
return `?start=${now - 7 * 24 * 60 * 60 * 1000}&end=${now}`;
|
||
|
case "custom":
|
||
|
if (customStart && customEnd) {
|
||
|
return `?start=${new Date(customStart).getTime()}&end=${new Date(customEnd).getTime()}`;
|
||
|
}
|
||
|
return "";
|
||
|
case "all":
|
||
|
return "";
|
||
|
default:
|
||
|
return `?start=${now - 30 * 60 * 1000}&end=${now}`;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const bucketDataForChart = (data, bucketSizeMs = 1000) => {
|
||
|
const buckets = {};
|
||
|
|
||
|
data.forEach((point) => {
|
||
|
const bucketKey =
|
||
|
Math.floor(point.timestamp / bucketSizeMs) * bucketSizeMs;
|
||
|
|
||
|
if (!buckets[bucketKey]) {
|
||
|
buckets[bucketKey] = {
|
||
|
timestamp: bucketKey,
|
||
|
service_time: [],
|
||
|
db_time: [],
|
||
|
cache_time: [],
|
||
|
db_rows_read: [],
|
||
|
db_rows_written: [],
|
||
|
cache_hits: [],
|
||
|
cache_misses: [],
|
||
|
};
|
||
|
}
|
||
|
|
||
|
buckets[bucketKey].service_time.push(point.service_time);
|
||
|
buckets[bucketKey].db_time.push(point.db_time);
|
||
|
buckets[bucketKey].cache_time.push(point.cache_time);
|
||
|
buckets[bucketKey].db_rows_read.push(point.db_rows_read);
|
||
|
buckets[bucketKey].db_rows_written.push(point.db_rows_written);
|
||
|
buckets[bucketKey].cache_hits.push(point.cache_hits);
|
||
|
buckets[bucketKey].cache_misses.push(point.cache_misses);
|
||
|
});
|
||
|
|
||
|
return Object.values(buckets).map((bucket) => ({
|
||
|
timestamp: bucket.timestamp,
|
||
|
service_time: _.mean(bucket.service_time),
|
||
|
db_time: _.mean(bucket.db_time),
|
||
|
cache_time: _.mean(bucket.cache_time),
|
||
|
db_rows_read: _.sum(bucket.db_rows_read),
|
||
|
db_rows_written: _.sum(bucket.db_rows_written),
|
||
|
cache_hits: _.sum(bucket.cache_hits),
|
||
|
cache_misses: _.sum(bucket.cache_misses),
|
||
|
}));
|
||
|
};
|
||
|
|
||
|
const calculateStats = (data) => {
|
||
|
// Input validation with early return
|
||
|
if (!data?.length) {
|
||
|
return {
|
||
|
service: { avg: 0, p50: 0, p95: 0, p99: 0 },
|
||
|
db: { avg: 0, p50: 0, p95: 0, p99: 0 },
|
||
|
cache: { avg: 0, p50: 0, p95: 0, p99: 0 },
|
||
|
dbActivity: { rowsRead: 0, rowsWritten: 0, totalRows: 0 },
|
||
|
cacheActivity: { hits: 0, misses: 0, hitRate: 0 },
|
||
|
};
|
||
|
}
|
||
|
|
||
|
// Create separate arrays for each metric type and sort them independently
|
||
|
const serviceValues = data
|
||
|
.map((d) => Number(d.service_time) || 0)
|
||
|
.sort((a, b) => a - b);
|
||
|
const dbValues = data
|
||
|
.map((d) => Number(d.db_time) || 0)
|
||
|
.sort((a, b) => a - b);
|
||
|
const cacheValues = data
|
||
|
.map((d) => Number(d.cache_time) || 0)
|
||
|
.sort((a, b) => a - b);
|
||
|
|
||
|
// Calculate percentile indices
|
||
|
const len = data.length;
|
||
|
const p50idx = Math.floor(len * 0.5);
|
||
|
const p95idx = Math.floor(len * 0.95);
|
||
|
const p99idx = Math.floor(len * 0.99);
|
||
|
|
||
|
// Log the actual values we're using
|
||
|
console.log("Sorted Values Sample:", {
|
||
|
service: serviceValues.slice(0, 5),
|
||
|
db: dbValues.slice(0, 5),
|
||
|
cache: cacheValues.slice(0, 5),
|
||
|
});
|
||
|
|
||
|
console.log("Median Values:", {
|
||
|
service: serviceValues[p50idx],
|
||
|
db: dbValues[p50idx],
|
||
|
cache: cacheValues[p50idx],
|
||
|
});
|
||
|
|
||
|
// Get latest values for activity metrics
|
||
|
const latest = data[0] || {
|
||
|
cache_hits: 0,
|
||
|
cache_misses: 0,
|
||
|
db_rows_read: 0,
|
||
|
db_rows_written: 0,
|
||
|
db_total_rows: 0,
|
||
|
};
|
||
|
|
||
|
const totalCacheRequests = latest.cache_hits + latest.cache_misses;
|
||
|
const hitRate =
|
||
|
totalCacheRequests > 0
|
||
|
? (latest.cache_hits / totalCacheRequests) * 100
|
||
|
: 0;
|
||
|
|
||
|
const stats = {
|
||
|
service: {
|
||
|
avg: _.round(_.mean(serviceValues), 2),
|
||
|
p50: _.round(serviceValues[p50idx] || 0, 2),
|
||
|
p95: _.round(serviceValues[p95idx] || 0, 2),
|
||
|
p99: _.round(serviceValues[p99idx] || 0, 2),
|
||
|
},
|
||
|
db: {
|
||
|
avg: _.round(_.mean(dbValues), 2),
|
||
|
p50: _.round(dbValues[p50idx] || 0, 2),
|
||
|
p95: _.round(dbValues[p95idx] || 0, 2),
|
||
|
p99: _.round(dbValues[p99idx] || 0, 2),
|
||
|
},
|
||
|
cache: {
|
||
|
avg: _.round(_.mean(cacheValues), 2),
|
||
|
p50: _.round(cacheValues[p50idx] || 0, 2),
|
||
|
p95: _.round(cacheValues[p95idx] || 0, 2),
|
||
|
p99: _.round(cacheValues[p99idx] || 0, 2),
|
||
|
},
|
||
|
dbActivity: {
|
||
|
rowsRead: latest.db_rows_read,
|
||
|
rowsWritten: latest.db_rows_written,
|
||
|
totalRows: latest.db_total_rows,
|
||
|
},
|
||
|
cacheActivity: {
|
||
|
hits: latest.cache_hits,
|
||
|
misses: latest.cache_misses,
|
||
|
hitRate: _.round(hitRate, 1),
|
||
|
},
|
||
|
};
|
||
|
|
||
|
// Log final calculated stats
|
||
|
console.log("Final Stats:", stats);
|
||
|
|
||
|
return stats;
|
||
|
};
|
||
|
|
||
|
const updateChart = (forceReset = false) => {
|
||
|
if (!chartRef.current) return;
|
||
|
|
||
|
if (!chartInstance.current || forceReset) {
|
||
|
if (chartInstance.current) {
|
||
|
chartInstance.current.destroy();
|
||
|
}
|
||
|
initializeChart();
|
||
|
}
|
||
|
|
||
|
const bucketedData = bucketDataForChart(data);
|
||
|
|
||
|
chartInstance.current.data.datasets[0].data = bucketedData.map((d) => ({
|
||
|
x: d.timestamp,
|
||
|
y: d.service_time,
|
||
|
}));
|
||
|
chartInstance.current.data.datasets[1].data = bucketedData.map((d) => ({
|
||
|
x: d.timestamp,
|
||
|
y: d.db_time,
|
||
|
}));
|
||
|
chartInstance.current.data.datasets[2].data = bucketedData.map((d) => ({
|
||
|
x: d.timestamp,
|
||
|
y: d.cache_time,
|
||
|
}));
|
||
|
|
||
|
chartInstance.current.update("none");
|
||
|
};
|
||
|
|
||
|
const initializeChart = () => {
|
||
|
if (!chartRef.current) {
|
||
|
console.log("Chart ref not ready");
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
console.log("Initializing chart");
|
||
|
const ctx = chartRef.current.getContext("2d");
|
||
|
|
||
|
chartInstance.current = new Chart(ctx, {
|
||
|
type: "line",
|
||
|
data: {
|
||
|
datasets: [
|
||
|
{
|
||
|
label: "Service Time",
|
||
|
borderColor: "#8884d8",
|
||
|
data: [],
|
||
|
tension: 0.1,
|
||
|
},
|
||
|
{
|
||
|
label: "DB Time",
|
||
|
borderColor: "#82ca9d",
|
||
|
data: [],
|
||
|
tension: 0.1,
|
||
|
},
|
||
|
{
|
||
|
label: "Cache Time",
|
||
|
borderColor: "#ffc658",
|
||
|
data: [],
|
||
|
tension: 0.1,
|
||
|
},
|
||
|
],
|
||
|
},
|
||
|
options: {
|
||
|
responsive: true,
|
||
|
maintainAspectRatio: false,
|
||
|
animation: false,
|
||
|
scales: {
|
||
|
x: {
|
||
|
type: "time",
|
||
|
time: {
|
||
|
parser: "MM/DD/YYYY HH:mm",
|
||
|
tooltipFormat: "ll HH:mm",
|
||
|
unit: "second",
|
||
|
displayFormats: {
|
||
|
second: "HH:mm:ss",
|
||
|
},
|
||
|
},
|
||
|
title: {
|
||
|
display: true,
|
||
|
text: "Time",
|
||
|
},
|
||
|
},
|
||
|
y: {
|
||
|
beginAtZero: true,
|
||
|
title: {
|
||
|
display: true,
|
||
|
text: "Time (ms)",
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
});
|
||
|
};
|
||
|
|
||
|
const fetchMetrics = async () => {
|
||
|
try {
|
||
|
console.log("Fetching metrics with params:", getTimeRangeParams());
|
||
|
const response = await fetch(`/api/metrics${getTimeRangeParams()}`);
|
||
|
const newData = await response.json();
|
||
|
console.log("Received metrics data:", newData);
|
||
|
|
||
|
if (!newData || newData.length === 0) {
|
||
|
console.log("No data received");
|
||
|
setData([]);
|
||
|
setStats(calculateStats([]));
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const newStats = calculateStats(newData);
|
||
|
console.log("Calculated stats:", newStats);
|
||
|
if (newStats) {
|
||
|
setStats(newStats);
|
||
|
}
|
||
|
|
||
|
setData(newData || []);
|
||
|
} catch (error) {
|
||
|
console.error("Error fetching metrics:", error);
|
||
|
setData([]);
|
||
|
setStats(calculateStats([]));
|
||
|
}
|
||
|
};
|
||
|
|
||
|
useEffect(() => {
|
||
|
console.log("Initial fetch and chart setup");
|
||
|
fetchMetrics();
|
||
|
updateChart(true);
|
||
|
|
||
|
let interval;
|
||
|
if (timeRange !== "all" && timeRange !== "custom") {
|
||
|
interval = setInterval(fetchMetrics, 1000);
|
||
|
console.log("Set up polling interval");
|
||
|
}
|
||
|
return () => {
|
||
|
if (interval) {
|
||
|
console.log("Cleaning up interval");
|
||
|
clearInterval(interval);
|
||
|
}
|
||
|
};
|
||
|
}, [timeRange, customStart, customEnd]);
|
||
|
|
||
|
useEffect(() => {
|
||
|
console.log("Data updated:", data.length, "points");
|
||
|
if (data.length > 0 && chartRef.current) {
|
||
|
updateChart();
|
||
|
}
|
||
|
}, [data]);
|
||
|
|
||
|
const exportCSV = () => {
|
||
|
try {
|
||
|
console.log("Exporting data:", data);
|
||
|
const csv = Papa.unparse(data);
|
||
|
const blob = new Blob([csv], { type: "text/csv" });
|
||
|
const url = window.URL.createObjectURL(blob);
|
||
|
const a = document.createElement("a");
|
||
|
a.href = url;
|
||
|
a.download = `metrics_export_${new Date().toISOString()}.csv`;
|
||
|
document.body.appendChild(a);
|
||
|
a.click();
|
||
|
document.body.removeChild(a);
|
||
|
window.URL.revokeObjectURL(url);
|
||
|
} catch (error) {
|
||
|
console.error("Error exporting CSV:", error);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
return (
|
||
|
<div className="container mx-auto p-4 space-y-4">
|
||
|
<div className="flex justify-between items-center">
|
||
|
<h1 className="text-2xl font-bold">Service Performance Metrics</h1>
|
||
|
<div className="flex items-center space-x-4">
|
||
|
<div className="flex items-center space-x-2">
|
||
|
<select
|
||
|
value={timeRange}
|
||
|
onChange={(e) => setTimeRange(e.target.value)}
|
||
|
className="rounded border p-2"
|
||
|
>
|
||
|
<option value="30m">Last 30m</option>
|
||
|
<option value="1h">Last 1h</option>
|
||
|
<option value="24h">Last 24h</option>
|
||
|
<option value="7d">Last 7d</option>
|
||
|
<option value="custom">Custom Range</option>
|
||
|
<option value="all">All Data</option>
|
||
|
</select>
|
||
|
</div>
|
||
|
|
||
|
{timeRange === "custom" && (
|
||
|
<div className="flex items-center space-x-2">
|
||
|
<input
|
||
|
type="datetime-local"
|
||
|
value={customStart}
|
||
|
onChange={(e) => setCustomStart(e.target.value)}
|
||
|
className="rounded border p-2"
|
||
|
/>
|
||
|
<span>to</span>
|
||
|
<input
|
||
|
type="datetime-local"
|
||
|
value={customEnd}
|
||
|
onChange={(e) => setCustomEnd(e.target.value)}
|
||
|
className="rounded border p-2"
|
||
|
/>
|
||
|
</div>
|
||
|
)}
|
||
|
|
||
|
<div className="space-x-2">
|
||
|
<button
|
||
|
onClick={exportCSV}
|
||
|
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||
|
>
|
||
|
Export CSV
|
||
|
</button>
|
||
|
|
||
|
<button
|
||
|
onClick={async () => {
|
||
|
await fetch("/api/clear", { method: "POST" });
|
||
|
setData([]);
|
||
|
if (chartInstance.current) {
|
||
|
chartInstance.current.data.datasets.forEach((dataset) => {
|
||
|
dataset.data = [];
|
||
|
});
|
||
|
chartInstance.current.update();
|
||
|
}
|
||
|
fetchMetrics();
|
||
|
}}
|
||
|
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
|
||
|
>
|
||
|
Clear Data
|
||
|
</button>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
|
<MetricCard title="Service Time" stats={stats.service} />
|
||
|
<MetricCard title="Database Time" stats={stats.db} />
|
||
|
<MetricCard title="Cache Time" stats={stats.cache} />
|
||
|
</div>
|
||
|
|
||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
|
<DBActivityCard stats={stats.dbActivity} />
|
||
|
<CacheActivityCard stats={stats.cacheActivity} />
|
||
|
</div>
|
||
|
|
||
|
<div className="w-full h-96">
|
||
|
<canvas ref={chartRef}></canvas>
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
// Render the app
|
||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||
|
<MetricsDashboard />,
|
||
|
);
|