Files
graphql-monitoring-proxy/admin/dashboard.html
T

1426 lines
54 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GraphQL Proxy Admin Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
h1 {
font-size: 2em;
font-weight: 600;
}
.subtitle {
opacity: 0.9;
font-size: 0.95em;
margin-top: 5px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
}
.card-title {
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #666;
margin-bottom: 12px;
font-weight: 600;
}
.card-value {
font-size: 2.5em;
font-weight: 700;
color: #333;
line-height: 1;
}
.card-label {
font-size: 0.9em;
color: #888;
margin-top: 8px;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.status-healthy {
background: #10b981;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.2);
}
.status-unhealthy {
background: #ef4444;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
}
.status-unknown {
background: #6b7280;
box-shadow: 0 0 0 4px rgba(107, 114, 128, 0.2);
}
.metric-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.metric-row:last-child {
border-bottom: none;
}
.metric-label {
color: #666;
font-size: 0.95em;
}
.metric-value {
font-weight: 600;
color: #333;
}
.btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
transition: background 0.2s;
}
.btn:hover {
background: #5568d3;
}
.btn:active {
transform: scale(0.98);
}
.btn-danger {
background: #ef4444;
}
.btn-danger:hover {
background: #dc2626;
}
.section-title {
font-size: 1.5em;
margin: 40px 0 20px 0;
color: #333;
font-weight: 600;
}
.refresh-info {
text-align: center;
color: #888;
font-size: 0.85em;
margin-top: 30px;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 600;
}
.badge-success {
background: #d1fae5;
color: #065f46;
}
.badge-danger {
background: #fee2e2;
color: #991b1b;
}
.badge-warning {
background: #fef3c7;
color: #92400e;
}
.badge-info {
background: #dbeafe;
color: #1e40af;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.loading {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.chart-container {
position: relative;
height: 300px;
margin-top: 20px;
}
.progress-bar {
width: 100%;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s ease;
}
.ws-status {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75em;
font-weight: 600;
margin-left: 10px;
}
.ws-connected {
background: #d1fae5;
color: #065f46;
}
.ws-disconnected {
background: #fee2e2;
color: #991b1b;
}
.cluster-toggle-container {
background: rgba(255, 255, 255, 0.15);
padding: 12px 20px;
border-radius: 8px;
display: inline-flex;
align-items: center;
gap: 12px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
transition: all 0.3s ease;
}
.cluster-toggle-container:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
}
.toggle-switch {
position: relative;
width: 48px;
height: 24px;
background: rgba(255, 255, 255, 0.3);
border-radius: 12px;
transition: background 0.3s ease;
cursor: pointer;
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
#cluster-mode-toggle {
display: none;
}
#cluster-mode-toggle:checked + .cluster-toggle-container .toggle-switch {
background: #10b981;
}
#cluster-mode-toggle:checked + .cluster-toggle-container .toggle-switch::after {
transform: translateX(24px);
}
#cluster-mode-toggle:disabled + .cluster-toggle-container {
opacity: 0.5;
cursor: not-allowed;
}
#cluster-mode-toggle:disabled + .cluster-toggle-container:hover {
background: rgba(255, 255, 255, 0.15);
transform: none;
}
.cluster-toggle-label {
font-size: 0.95em;
font-weight: 600;
color: white;
letter-spacing: 0.3px;
user-select: none;
}
.cluster-toggle-info {
font-size: 0.8em;
opacity: 0.9;
color: white;
font-weight: 400;
}
.instance-card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.instance-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.instance-title {
font-size: 1.1em;
font-weight: 600;
color: #333;
}
.instance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.instance-metric {
display: flex;
flex-direction: column;
}
.instance-metric-label {
font-size: 0.8em;
color: #666;
margin-bottom: 4px;
}
.instance-metric-value {
font-size: 1.1em;
font-weight: 600;
color: #333;
}
</style>
</head>
<body>
<header>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<div>
<h1>GraphQL Proxy Admin Dashboard</h1>
<div class="subtitle">
Real-time monitoring and management
<span class="ws-status ws-disconnected" id="ws-status">Connecting...</span>
</div>
</div>
<div>
<input type="checkbox" id="cluster-mode-toggle">
<label for="cluster-mode-toggle" class="cluster-toggle-container">
<div class="toggle-switch"></div>
<div>
<div class="cluster-toggle-label">Cluster View</div>
<div class="cluster-toggle-info" id="cluster-info">Checking availability...</div>
</div>
</label>
</div>
</div>
</div>
</header>
<div class="container">
<!-- Cluster Status (shown when cluster mode detected) -->
<div id="cluster-status-section" style="display: none;">
<h2 class="section-title">Cluster Status</h2>
<div class="stats-grid">
<div class="card">
<div class="card-title">Total Instances</div>
<div class="card-value" id="cluster-total-instances">--</div>
<div class="card-label">Proxy nodes</div>
</div>
<div class="card">
<div class="card-title">Healthy Instances</div>
<div class="card-value" id="cluster-healthy-instances">--</div>
<div class="card-label">Active nodes</div>
</div>
</div>
</div>
<!-- System Overview -->
<h2 class="section-title">
<span id="overview-title">System Overview</span>
</h2>
<div class="stats-grid">
<div class="card">
<div class="card-title">Uptime</div>
<div class="card-value" id="uptime">--</div>
<div class="card-label" id="uptime-seconds">-- seconds</div>
</div>
<div class="card">
<div class="card-title">Total Requests</div>
<div class="card-value" id="total-requests">--</div>
<div class="card-label">
<span style="color: #10b981;"><span id="succeeded-requests">--</span></span>
<span style="color: #ef4444;"><span id="failed-requests">--</span></span>
</div>
</div>
<div class="card">
<div class="card-title">Current RPS</div>
<div class="card-value" id="current-rps">--</div>
<div class="card-label">Avg: <span id="avg-rps">--</span> req/s</div>
</div>
<div class="card">
<div class="card-title">Success Rate</div>
<div class="card-value" id="success-rate">--%</div>
<div class="progress-bar">
<div class="progress-fill" id="success-progress" style="width: 0%"></div>
</div>
</div>
</div>
<!-- Cache Statistics -->
<h2 class="section-title">Cache Performance</h2>
<div class="stats-grid">
<div class="card">
<div class="card-title">Cache Hit Rate</div>
<div class="card-value" id="cache-hit-rate">--%</div>
<div class="progress-bar">
<div class="progress-fill" id="cache-hit-progress" style="width: 0%"></div>
</div>
</div>
<div class="card">
<div class="card-title">Cache Hits / Misses</div>
<div class="card-value" id="cache-hits">--</div>
<div class="card-label">
Hits: <span id="cache-hits-detail">--</span> |
Misses: <span id="cache-misses">--</span>
</div>
</div>
<div class="card">
<div class="card-title">Cached Queries</div>
<div class="card-value" id="cached-queries">--</div>
<div class="card-label">Total entries</div>
</div>
<div class="card">
<div class="card-title">Memory Usage</div>
<div class="card-value" id="cache-memory">-- MB</div>
<div class="card-label" id="cache-memory-pct">--%</div>
<div class="progress-bar">
<div class="progress-fill" id="memory-progress" style="width: 0%"></div>
</div>
</div>
</div>
<!-- Real-time Charts -->
<h2 class="section-title">Real-time Metrics</h2>
<div class="stats-grid">
<div class="card">
<div class="card-title">Requests Per Second</div>
<div class="chart-container">
<canvas id="rps-chart"></canvas>
</div>
</div>
<div class="card">
<div class="card-title">Cache Hit Rate Over Time</div>
<div class="chart-container">
<canvas id="cache-chart"></canvas>
</div>
</div>
</div>
<!-- Health Status -->
<h2 class="section-title">Health Status</h2>
<div class="card" id="health-card">
<div class="metric-row">
<span class="metric-label">Backend Status</span>
<span>
<span class="status-indicator status-unknown loading" id="health-indicator"></span>
<span id="health-status">Loading...</span>
</span>
</div>
</div>
<!-- Key Metrics -->
<h2 class="section-title">Advanced Features</h2>
<div class="stats-grid">
<div class="card">
<div class="card-title">Request Coalescing</div>
<div class="card-value" id="coalescing-rate">--%</div>
<div class="card-label">Backend Savings</div>
</div>
<div class="card">
<div class="card-title">Retry Budget</div>
<div class="card-value" id="retry-tokens">--</div>
<div class="card-label">Available Tokens</div>
</div>
<div class="card">
<div class="card-title">WebSocket Connections</div>
<div class="card-value" id="ws-connections">--</div>
<div class="card-label">Active Connections</div>
</div>
<div class="card">
<div class="card-title">Connection Pool</div>
<div class="card-value" id="pool-connections">--</div>
<div class="card-label">Active Connections</div>
</div>
</div>
<!-- Circuit Breaker -->
<h2 class="section-title">Circuit Breaker</h2>
<div class="card" id="circuit-breaker-card">
<div class="metric-row">
<span class="metric-label">Status</span>
<span class="metric-value" id="cb-state">
<span class="badge badge-info loading">Loading...</span>
</span>
</div>
<div class="metric-row">
<span class="metric-label">Enabled</span>
<span class="metric-value" id="cb-enabled">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Total Requests</span>
<span class="metric-value" id="cb-total-requests">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Total Successes</span>
<span class="metric-value" id="cb-total-successes">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Total Failures</span>
<span class="metric-value" id="cb-total-failures">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Consecutive Successes</span>
<span class="metric-value" id="cb-consecutive-successes">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Consecutive Failures</span>
<span class="metric-value" id="cb-consecutive-failures">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Max Failures</span>
<span class="metric-value" id="cb-max-failures">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Timeout</span>
<span class="metric-value" id="cb-timeout">--s</span>
</div>
</div>
<!-- Request Coalescing Details -->
<h2 class="section-title">Request Coalescing</h2>
<div class="card">
<div class="metric-row">
<span class="metric-label">Total Requests</span>
<span class="metric-value" id="coalescing-total">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Primary Requests</span>
<span class="metric-value" id="coalescing-primary">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Coalesced Requests</span>
<span class="metric-value" id="coalescing-coalesced">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Backend Savings</span>
<span class="metric-value" id="coalescing-savings">--%</span>
</div>
<div style="margin-top: 20px;">
<button class="btn" onclick="resetCoalescing()">Reset Statistics</button>
</div>
</div>
<!-- Retry Budget Details -->
<h2 class="section-title">Retry Budget</h2>
<div class="card">
<div class="metric-row">
<span class="metric-label">Current Tokens</span>
<span class="metric-value" id="retry-current-tokens">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Max Tokens</span>
<span class="metric-value" id="retry-max-tokens">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Total Attempts</span>
<span class="metric-value" id="retry-total">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Denied Retries</span>
<span class="metric-value" id="retry-denied">--</span>
</div>
<div class="metric-row">
<span class="metric-label">Denial Rate</span>
<span class="metric-value" id="retry-denial-rate">--%</span>
</div>
<div style="margin-top: 20px;">
<button class="btn" onclick="resetRetryBudget()">Reset Statistics</button>
</div>
</div>
<!-- Instance Details (shown in cluster mode) -->
<div id="instance-details-section" style="display: none;">
<h2 class="section-title">Instance Details</h2>
<div id="instance-list"></div>
</div>
<div class="refresh-info" id="refresh-info">
<span id="connection-mode">Connecting to real-time updates...</span>
</div>
</div>
<script>
// Chart instances
let rpsChart, cacheChart;
let rpsData = { labels: [], data: [] };
let cacheData = { labels: [], data: [] };
const MAX_DATA_POINTS = 60; // Keep last 60 data points
// WebSocket connection
let ws = null;
let wsReconnectInterval = null;
let useWebSocket = true;
// Cluster mode state
let clusterModeEnabled = false;
let clusterModeAvailable = false;
// Smoothing buffers for metrics (10-second moving average at 2s intervals = 5 data points)
const smoothingWindow = 5;
const rpsBuffer = [];
const successRateBuffer = [];
const cacheHitRateBuffer = [];
// Initialize charts
function initCharts() {
const rpsCtx = document.getElementById('rps-chart').getContext('2d');
rpsChart = new Chart(rpsCtx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Requests/sec',
data: [],
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: { precision: 0 }
},
x: {
display: false
}
}
}
});
const cacheCtx = document.getElementById('cache-chart').getContext('2d');
cacheChart = new Chart(cacheCtx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Hit Rate %',
data: [],
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
}
},
x: {
display: false
}
}
}
});
}
// Update chart data
function updateChart(chart, dataStore, value) {
const timestamp = new Date().toLocaleTimeString();
dataStore.labels.push(timestamp);
dataStore.data.push(value);
// Keep only last MAX_DATA_POINTS
if (dataStore.labels.length > MAX_DATA_POINTS) {
dataStore.labels.shift();
dataStore.data.shift();
}
chart.data.labels = dataStore.labels;
chart.data.datasets[0].data = dataStore.data;
chart.update('none'); // Update without animation for smoother real-time updates
}
// Connect to WebSocket
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/admin/ws/stats`;
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
updateWSStatus(true);
if (wsReconnectInterval) {
clearInterval(wsReconnectInterval);
wsReconnectInterval = null;
}
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
updateAllStats(data);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
updateWSStatus(false);
// Try to reconnect after 5 seconds
if (!wsReconnectInterval) {
wsReconnectInterval = setInterval(() => {
connectWebSocket();
}, 5000);
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
updateWSStatus(false);
// Fall back to polling if WebSocket fails
useWebSocket = false;
startPolling();
}
}
// Update WebSocket status indicator
function updateWSStatus(connected) {
const statusEl = document.getElementById('ws-status');
const infoEl = document.getElementById('connection-mode');
if (connected) {
statusEl.className = 'ws-status ws-connected';
statusEl.textContent = 'Live';
infoEl.textContent = 'Real-time updates via WebSocket';
} else {
statusEl.className = 'ws-status ws-disconnected';
statusEl.textContent = 'Reconnecting...';
infoEl.textContent = 'Attempting to reconnect...';
}
}
// Fallback: Fetch and update dashboard data via polling
async function updateDashboard() {
try {
if (clusterModeEnabled) {
// Fetch cluster stats
const [clusterStats, instances] = await Promise.all([
fetch('/admin/api/cluster/stats').then(r => r.json()),
fetch('/admin/api/cluster/instances').then(r => r.json())
]);
if (clusterStats.cluster_mode) {
updateClusterStats(clusterStats, instances);
}
} else {
// Fetch all stats for single instance
const [stats, health, cb, cache, coalescing, retryBudget, wsStats, connections] = await Promise.all([
fetch('/admin/api/stats').then(r => r.json()),
fetch('/admin/api/health').then(r => r.json()),
fetch('/admin/api/circuit-breaker').then(r => r.json()),
fetch('/admin/api/cache').then(r => r.json()),
fetch('/admin/api/coalescing').then(r => r.json()),
fetch('/admin/api/retry-budget').then(r => r.json()),
fetch('/admin/api/websocket').then(r => r.json()),
fetch('/admin/api/connections').then(r => r.json())
]);
const allData = {
stats,
health,
circuit_breaker: cb,
cache,
coalescing,
retry_budget: retryBudget,
websocket: wsStats,
connections
};
updateAllStats(allData);
}
} catch (error) {
console.error('Failed to update dashboard:', error);
}
}
// Check if cluster mode is available
async function checkClusterMode() {
try {
const response = await fetch('/admin/api/cluster/stats');
const data = await response.json();
if (response.ok && data.cluster_mode) {
clusterModeAvailable = true;
document.getElementById('cluster-info').textContent =
`(${data.total_instances} instance${data.total_instances !== 1 ? 's' : ''} available)`;
return true;
}
} catch (error) {
// Cluster mode not available
}
clusterModeAvailable = false;
document.getElementById('cluster-info').textContent = '(not available)';
document.getElementById('cluster-mode-toggle').disabled = true;
return false;
}
// Update cluster statistics
function updateClusterStats(clusterData, instancesData) {
// Show cluster sections
document.getElementById('cluster-status-section').style.display = 'block';
document.getElementById('instance-details-section').style.display = 'block';
document.getElementById('overview-title').textContent = 'Cluster Overview';
// Update cluster status
document.getElementById('cluster-total-instances').textContent = clusterData.total_instances || 0;
document.getElementById('cluster-healthy-instances').textContent = clusterData.healthy_instances || 0;
// Update cluster info in toggle label
const totalInstances = clusterData.total_instances || 0;
document.getElementById('cluster-info').textContent =
`(${totalInstances} instance${totalInstances !== 1 ? 's' : ''} available)`;
// Update combined stats
if (clusterData.stats) {
updateStats({ ...clusterData.stats, cluster_mode: true });
// Update memory usage from cluster stats
if (clusterData.stats.memory) {
updateCacheStats({
memory_usage_mb: clusterData.stats.memory.total_usage_mb || 0
});
}
}
// Update instance list
if (instancesData && instancesData.instances) {
updateInstanceList(instancesData.instances, instancesData.current_instance);
}
}
// Update individual instance list
function updateInstanceList(instances, currentInstanceID) {
const container = document.getElementById('instance-list');
container.innerHTML = '';
instances.forEach(instance => {
// Use JSON field names (snake_case), not Go struct names (PascalCase)
const isCurrent = instance.instance_id === currentInstanceID;
const isHealthy = instance.health && instance.health.status === 'healthy';
const card = document.createElement('div');
card.className = 'instance-card';
const header = document.createElement('div');
header.className = 'instance-header';
const title = document.createElement('div');
title.className = 'instance-title';
title.innerHTML = `
<span class="status-indicator ${isHealthy ? 'status-healthy' : 'status-unhealthy'}"></span>
${instance.hostname || 'unknown'}
${isCurrent ? '<span class="badge badge-info" style="margin-left: 8px;">Current</span>' : ''}
`;
const uptime = document.createElement('span');
uptime.style.fontSize = '0.85em';
uptime.style.color = '#666';
uptime.textContent = `Uptime: ${formatUptime(instance.uptime_seconds || 0)}`;
header.appendChild(title);
header.appendChild(uptime);
const grid = document.createElement('div');
grid.className = 'instance-grid';
// Extract stats - use JSON field names
const stats = instance.stats || {};
const requests = stats.requests || {};
const cache = instance.cache_summary || stats.cache_summary || {};
const failedCount = requests.failed || 0;
const totalCount = requests.total || 0;
const failureInfo = failedCount > 0 ? ` (${failedCount} failed)` : '';
const metrics = [
{
label: 'Total Requests',
value: formatNumber(totalCount),
title: totalCount.toLocaleString() + ' total requests' + failureInfo
},
{
label: 'Success Rate',
value: (requests.success_rate_pct || 0).toFixed(1) + '%',
title: null
},
{
label: 'Current RPS',
value: (requests.current_requests_per_second || 0).toFixed(1),
title: null
},
{
label: 'Cache Hit Rate',
value: (cache.hit_rate_pct || 0).toFixed(1) + '%',
title: null
}
];
metrics.forEach(metric => {
const metricDiv = document.createElement('div');
metricDiv.className = 'instance-metric';
if (metric.title) {
metricDiv.title = metric.title;
}
metricDiv.innerHTML = `
<div class="instance-metric-label">${metric.label}</div>
<div class="instance-metric-value">${metric.value}</div>
`;
grid.appendChild(metricDiv);
});
card.appendChild(header);
card.appendChild(grid);
container.appendChild(card);
});
}
// Format uptime in human readable format
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
// Format large numbers compactly (1.2M, 3.4K, etc)
function formatNumber(num) {
if (num === undefined || num === null) return '0';
const absNum = Math.abs(num);
if (absNum >= 1000000000) {
return (num / 1000000000).toFixed(1) + 'B';
}
if (absNum >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (absNum >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toLocaleString();
}
// Smooth metric values using moving average
function smoothMetric(buffer, newValue) {
buffer.push(newValue);
if (buffer.length > smoothingWindow) {
buffer.shift(); // Remove oldest value
}
// Calculate average
const sum = buffer.reduce((a, b) => a + b, 0);
return sum / buffer.length;
}
// Get trend indicator (↑ ↗ → ↘ ↓)
function getTrendIndicator(buffer) {
if (buffer.length < 2) return '→';
const recent = buffer.slice(-3); // Last 3 values
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
const diff = recent[recent.length - 1] - recent[0];
const percentChange = Math.abs(diff / (avg || 1)) * 100;
if (percentChange < 5) return '→'; // Stable
if (diff > 0) {
return percentChange > 15 ? '↑' : '↗'; // Strong/moderate increase
} else {
return percentChange > 15 ? '↓' : '↘'; // Strong/moderate decrease
}
}
// Update all statistics
function updateAllStats(data) {
// Check if this is cluster mode data
if (data.cluster_mode && data.stats) {
// Cluster mode: data is structured differently
// Stats contains aggregated data with nested objects
const stats = data.stats;
// Update cluster status section
document.getElementById('cluster-status-section').style.display = 'block';
document.getElementById('cluster-total-instances').textContent = data.total_instances || 0;
document.getElementById('cluster-healthy-instances').textContent = data.healthy_instances || 0;
document.getElementById('overview-title').textContent = 'Cluster Overview';
// Update cluster info in toggle
const totalInstances = data.total_instances || 0;
document.getElementById('cluster-info').textContent =
`(${totalInstances} instance${totalInstances !== 1 ? 's' : ''} available)`;
// Build stats object with uptime from cluster_uptime
const statsWithUptime = {
...stats,
uptime_seconds: stats.cluster_uptime || 0,
uptime_human: formatUptime(stats.cluster_uptime || 0)
};
updateStats(statsWithUptime);
// Extract nested objects from stats for cluster mode
if (stats.circuit_breaker) updateCircuitBreaker(stats.circuit_breaker);
if (stats.coalescing) updateCoalescing(stats.coalescing);
if (stats.retry_budget) updateRetryBudget(stats.retry_budget);
if (stats.websocket) updateWebSocket(stats.websocket);
if (stats.connections) updateConnections(stats.connections);
// Handle memory for cluster mode (Redis doesn't track memory per instance)
if (stats.memory) {
const totalMemMB = stats.memory.total_usage_mb;
if (totalMemMB < 0) {
// All instances are using Redis cache
document.getElementById('cache-memory').textContent = 'N/A';
document.getElementById('cache-memory').title = 'Memory tracking not available for Redis cache';
document.getElementById('cache-memory-pct').textContent = 'Redis cache';
document.getElementById('memory-progress').style.width = '0%';
} else {
document.getElementById('cache-memory').textContent = totalMemMB.toFixed(2) + ' MB';
document.getElementById('cache-memory-pct').textContent = 'Cluster total';
}
}
// Update instance list if available
if (data.instances && data.instances.length > 0) {
document.getElementById('instance-details-section').style.display = 'block';
updateInstanceList(data.instances, null);
}
return;
}
// Non-cluster mode: original behavior
if (data.stats) updateStats(data.stats);
if (data.health) updateHealth(data.health);
if (data.circuit_breaker) updateCircuitBreaker(data.circuit_breaker);
if (data.cache) updateCacheStats(data.cache);
if (data.coalescing) updateCoalescing(data.coalescing);
if (data.retry_budget) updateRetryBudget(data.retry_budget);
if (data.websocket) updateWebSocket(data.websocket);
if (data.connections) updateConnections(data.connections);
}
// Update main stats
function updateStats(data) {
// Uptime
document.getElementById('uptime').textContent = data.uptime_human || '--';
document.getElementById('uptime-seconds').textContent =
(data.uptime_seconds || 0).toFixed(0) + ' seconds';
if (data.requests) {
const req = data.requests;
// Total requests with compact formatting
document.getElementById('total-requests').textContent = formatNumber(req.total || 0);
document.getElementById('total-requests').title = (req.total || 0).toLocaleString() + ' total requests';
document.getElementById('succeeded-requests').textContent = formatNumber(req.succeeded || 0);
document.getElementById('succeeded-requests').title = (req.succeeded || 0).toLocaleString() + ' succeeded';
document.getElementById('failed-requests').textContent = formatNumber(req.failed || 0);
document.getElementById('failed-requests').title = (req.failed || 0).toLocaleString() + ' failed';
// Show failure details if there are failures
if (req.failed > 0) {
const failureRate = (req.failed / (req.total || 1) * 100).toFixed(2);
document.getElementById('failed-requests').title += ` (${failureRate}% failure rate)`;
}
// Success rate with smoothing
const rawSuccessRate = req.success_rate_pct || 0;
const smoothedSuccessRate = smoothMetric(successRateBuffer, rawSuccessRate);
const successTrend = getTrendIndicator(successRateBuffer);
document.getElementById('success-rate').textContent =
smoothedSuccessRate.toFixed(1) + '% ' + successTrend;
document.getElementById('success-rate').title =
`10s avg: ${smoothedSuccessRate.toFixed(2)}% | Current: ${rawSuccessRate.toFixed(2)}%`;
document.getElementById('success-progress').style.width =
smoothedSuccessRate + '%';
// RPS with smoothing
const rawRPS = req.current_requests_per_second || 0;
const smoothedRPS = smoothMetric(rpsBuffer, rawRPS);
const rpsTrend = getTrendIndicator(rpsBuffer);
document.getElementById('current-rps').textContent =
smoothedRPS.toFixed(1) + ' ' + rpsTrend;
document.getElementById('current-rps').title =
`10s avg: ${smoothedRPS.toFixed(2)} | Current: ${rawRPS.toFixed(2)}`;
document.getElementById('avg-rps').textContent =
(req.avg_requests_per_second || 0).toFixed(1);
// Update RPS chart with smoothed value
updateChart(rpsChart, rpsData, smoothedRPS);
}
// Cache summary with smoothing
if (data.cache_summary) {
const cache = data.cache_summary;
const rawHitRate = cache.hit_rate_pct || 0;
const smoothedHitRate = smoothMetric(cacheHitRateBuffer, rawHitRate);
const hitRateTrend = getTrendIndicator(cacheHitRateBuffer);
document.getElementById('cache-hit-rate').textContent =
smoothedHitRate.toFixed(1) + '% ' + hitRateTrend;
document.getElementById('cache-hit-rate').title =
`10s avg: ${smoothedHitRate.toFixed(2)}% | Current: ${rawHitRate.toFixed(2)}%`;
document.getElementById('cache-hit-progress').style.width =
smoothedHitRate + '%';
document.getElementById('cache-hits').textContent = formatNumber(cache.hits || 0);
document.getElementById('cache-hits').title = (cache.hits || 0).toLocaleString() + ' cache hits';
document.getElementById('cache-hits-detail').textContent = formatNumber(cache.hits || 0);
document.getElementById('cache-hits-detail').title = (cache.hits || 0).toLocaleString();
document.getElementById('cache-misses').textContent = formatNumber(cache.misses || 0);
document.getElementById('cache-misses').title = (cache.misses || 0).toLocaleString() + ' cache misses';
document.getElementById('cached-queries').textContent = formatNumber(cache.total_cached || 0);
document.getElementById('cached-queries').title = (cache.total_cached || 0).toLocaleString() + ' unique queries cached';
// Update cache hit rate chart with smoothed value
updateChart(cacheChart, cacheData, smoothedHitRate);
}
}
// Update cache detailed stats
function updateCacheStats(data) {
if (data.memory_usage_mb !== undefined) {
if (data.memory_usage_mb === -1) {
// Redis cache - memory tracking not available
document.getElementById('cache-memory').textContent = 'N/A';
document.getElementById('cache-memory').title = 'Memory tracking not available for Redis cache';
document.getElementById('cache-memory-pct').textContent = 'Redis cache';
document.getElementById('memory-progress').style.width = '0%';
} else {
document.getElementById('cache-memory').textContent =
data.memory_usage_mb.toFixed(2) + ' MB';
document.getElementById('cache-memory').title = 'In-memory cache usage';
if (data.memory_usage_pct !== undefined) {
const memPct = data.memory_usage_pct;
document.getElementById('cache-memory-pct').textContent =
memPct.toFixed(1) + '% used';
document.getElementById('memory-progress').style.width =
memPct + '%';
}
}
}
}
function updateHealth(data) {
const indicator = document.getElementById('health-indicator');
const status = document.getElementById('health-status');
indicator.classList.remove('loading');
if (data.status === 'healthy') {
indicator.className = 'status-indicator status-healthy';
status.textContent = 'System Healthy';
} else if (data.status === 'unhealthy') {
indicator.className = 'status-indicator status-unhealthy';
status.textContent = 'System Unhealthy';
} else {
indicator.className = 'status-indicator status-unknown';
status.textContent = 'Status Unknown';
}
}
function updateCircuitBreaker(data) {
const stateEl = document.getElementById('cb-state');
stateEl.classList.remove('loading');
let badgeClass = 'badge-info';
if (data.state === 'closed') badgeClass = 'badge-success';
else if (data.state === 'open') badgeClass = 'badge-danger';
else if (data.state === 'half-open') badgeClass = 'badge-warning';
stateEl.innerHTML = `<span class="badge ${badgeClass}">${data.state || 'Unknown'}</span>`;
document.getElementById('cb-enabled').textContent = data.enabled ? 'Yes' : 'No';
if (data.counts) {
document.getElementById('cb-total-requests').textContent =
(data.counts.requests || 0).toLocaleString();
document.getElementById('cb-total-successes').textContent =
(data.counts.total_successes || 0).toLocaleString();
document.getElementById('cb-total-failures').textContent =
(data.counts.total_failures || 0).toLocaleString();
document.getElementById('cb-consecutive-successes').textContent =
(data.counts.consecutive_successes || 0).toLocaleString();
document.getElementById('cb-consecutive-failures').textContent =
(data.counts.consecutive_failures || 0).toLocaleString();
}
if (data.config) {
document.getElementById('cb-max-failures').textContent = data.config.max_failures || '--';
document.getElementById('cb-timeout').textContent = (data.config.timeout || '--') + 's';
}
}
function updateCoalescing(data) {
document.getElementById('coalescing-rate').textContent =
(data.backend_savings_pct || 0).toFixed(1) + '%';
document.getElementById('coalescing-total').textContent =
(data.total_requests || 0).toLocaleString();
document.getElementById('coalescing-primary').textContent =
(data.primary_requests || 0).toLocaleString();
document.getElementById('coalescing-coalesced').textContent =
(data.coalesced_requests || 0).toLocaleString();
document.getElementById('coalescing-savings').textContent =
(data.backend_savings_pct || 0).toFixed(1) + '%';
}
function updateRetryBudget(data) {
document.getElementById('retry-tokens').textContent =
data.current_tokens || '--';
document.getElementById('retry-current-tokens').textContent =
data.current_tokens || '--';
document.getElementById('retry-max-tokens').textContent =
data.max_tokens || '--';
document.getElementById('retry-total').textContent =
(data.total_attempts || 0).toLocaleString();
document.getElementById('retry-denied').textContent =
(data.denied_retries || 0).toLocaleString();
document.getElementById('retry-denial-rate').textContent =
(data.denial_rate_pct || 0).toFixed(2) + '%';
}
function updateWebSocket(data) {
document.getElementById('ws-connections').textContent =
data.active_connections || 0;
}
function updateConnections(data) {
document.getElementById('pool-connections').textContent =
data.active_connections || 0;
}
async function resetCoalescing() {
try {
await fetch('/admin/api/coalescing/reset', { method: 'POST' });
updateDashboard();
} catch (error) {
alert('Failed to reset coalescing statistics');
}
}
async function resetRetryBudget() {
try {
await fetch('/admin/api/retry-budget/reset', { method: 'POST' });
updateDashboard();
} catch (error) {
alert('Failed to reset retry budget statistics');
}
}
// Start polling (fallback when WebSocket is not available)
function startPolling() {
const statusEl = document.getElementById('ws-status');
const infoEl = document.getElementById('connection-mode');
statusEl.className = 'ws-status ws-disconnected';
statusEl.textContent = 'Polling';
infoEl.textContent = 'Updates every 5 seconds (WebSocket unavailable)';
// Initial load
updateDashboard();
// Refresh every 5 seconds
setInterval(updateDashboard, 5000);
}
// Initialize dashboard
async function initDashboard() {
// Initialize charts first
initCharts();
// Check if cluster mode is available
await checkClusterMode();
// Setup cluster mode toggle
const toggle = document.getElementById('cluster-mode-toggle');
toggle.addEventListener('change', (e) => {
clusterModeEnabled = e.target.checked && clusterModeAvailable;
// Toggle cluster sections visibility
if (clusterModeEnabled) {
document.getElementById('cluster-status-section').style.display = 'block';
document.getElementById('overview-title').textContent = 'Cluster Overview';
} else {
document.getElementById('cluster-status-section').style.display = 'none';
document.getElementById('instance-details-section').style.display = 'none';
document.getElementById('overview-title').textContent = 'System Overview';
}
// Refresh data
updateDashboard();
});
// Try WebSocket connection first
connectWebSocket();
// Set a timeout to fall back to polling if WebSocket doesn't connect
setTimeout(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
useWebSocket = false;
startPolling();
}
}, 3000);
}
// Start when page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDashboard);
} else {
initDashboard();
}
</script>
</body>
</html>