feat(dashboard): add real system metrics endpoint and fix UI dark-theme issues
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled

- Add /api/system/metrics endpoint reading real data from /proc (CPU, memory, disk, network, load avg, uptime, connections)
- Replace hardcoded fake monitoring metrics with live API data
- Replace random chart data with real latency/error-rate/client-request charts from DB logs
- Fix light-mode colors leaking into dark theme (monitoring stream bg, settings tokens, warning card)
- Add 'models' to page title map, fix System Health card structure
- Move inline styles to CSS classes (monitoring-layout, monitoring-stream, token-item, warning-card)
- Prevent duplicate style injection in monitoring page
This commit is contained in:
2026-03-02 10:52:15 -05:00
parent 8613f30c7b
commit d386820d16
6 changed files with 315 additions and 135 deletions

View File

@@ -92,6 +92,7 @@ pub fn router(state: AppState) -> Router {
) )
.route("/api/providers/{name}/test", post(providers::handle_test_provider)) .route("/api/providers/{name}/test", post(providers::handle_test_provider))
.route("/api/system/health", get(system::handle_system_health)) .route("/api/system/health", get(system::handle_system_health))
.route("/api/system/metrics", get(system::handle_system_metrics))
.route("/api/system/logs", get(system::handle_system_logs)) .route("/api/system/logs", get(system::handle_system_logs))
.route("/api/system/backup", post(system::handle_system_backup)) .route("/api/system/backup", post(system::handle_system_backup))
.route( .route(

View File

@@ -7,6 +7,11 @@ use tracing::warn;
use super::{ApiResponse, DashboardState}; use super::{ApiResponse, DashboardState};
/// Read a value from /proc files, returning None on any failure.
fn read_proc_file(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
pub(super) async fn handle_system_health(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> { pub(super) async fn handle_system_health(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
let mut components = HashMap::new(); let mut components = HashMap::new();
components.insert("api_server".to_string(), "online".to_string()); components.insert("api_server".to_string(), "online".to_string());
@@ -37,8 +42,7 @@ pub(super) async fn handle_system_health(State(state): State<DashboardState>) ->
} }
// Read real memory usage from /proc/self/status // Read real memory usage from /proc/self/status
let memory_mb = std::fs::read_to_string("/proc/self/status") let memory_mb = read_proc_file("/proc/self/status")
.ok()
.and_then(|s| s.lines().find(|l| l.starts_with("VmRSS:")).map(|l| l.to_string())) .and_then(|s| s.lines().find(|l| l.starts_with("VmRSS:")).map(|l| l.to_string()))
.and_then(|l| l.split_whitespace().nth(1).and_then(|v| v.parse::<f64>().ok())) .and_then(|l| l.split_whitespace().nth(1).and_then(|v| v.parse::<f64>().ok()))
.map(|kb| kb / 1024.0) .map(|kb| kb / 1024.0)
@@ -60,6 +64,162 @@ pub(super) async fn handle_system_health(State(state): State<DashboardState>) ->
}))) })))
} }
/// Real system metrics from /proc (Linux only).
pub(super) async fn handle_system_metrics(
State(state): State<DashboardState>,
) -> Json<ApiResponse<serde_json::Value>> {
// --- CPU usage (aggregate across all cores) ---
// /proc/stat first line: cpu user nice system idle iowait irq softirq steal guest guest_nice
let cpu_percent = read_proc_file("/proc/stat")
.and_then(|s| {
let line = s.lines().find(|l| l.starts_with("cpu "))?.to_string();
let fields: Vec<u64> = line
.split_whitespace()
.skip(1)
.filter_map(|v| v.parse().ok())
.collect();
if fields.len() >= 4 {
let idle = fields[3];
let total: u64 = fields.iter().sum();
if total > 0 {
Some(((total - idle) as f64 / total as f64 * 100.0 * 10.0).round() / 10.0)
} else {
None
}
} else {
None
}
})
.unwrap_or(0.0);
// --- Memory (system-wide from /proc/meminfo) ---
let meminfo = read_proc_file("/proc/meminfo").unwrap_or_default();
let parse_meminfo = |key: &str| -> u64 {
meminfo
.lines()
.find(|l| l.starts_with(key))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0)
};
let mem_total_kb = parse_meminfo("MemTotal:");
let mem_available_kb = parse_meminfo("MemAvailable:");
let mem_used_kb = mem_total_kb.saturating_sub(mem_available_kb);
let mem_total_mb = mem_total_kb as f64 / 1024.0;
let mem_used_mb = mem_used_kb as f64 / 1024.0;
let mem_percent = if mem_total_kb > 0 {
(mem_used_kb as f64 / mem_total_kb as f64 * 100.0 * 10.0).round() / 10.0
} else {
0.0
};
// --- Process-specific memory (VmRSS) ---
let process_rss_mb = read_proc_file("/proc/self/status")
.and_then(|s| s.lines().find(|l| l.starts_with("VmRSS:")).map(|l| l.to_string()))
.and_then(|l| l.split_whitespace().nth(1).and_then(|v| v.parse::<f64>().ok()))
.map(|kb| (kb / 1024.0 * 10.0).round() / 10.0)
.unwrap_or(0.0);
// --- Disk usage of the data directory ---
let (disk_total_gb, disk_used_gb, disk_percent) = {
// statvfs via libc would be ideal; use df as a simple fallback
std::process::Command::new("df")
.args(["-BM", "--output=size,used,pcent", "."])
.output()
.ok()
.and_then(|o| {
let out = String::from_utf8_lossy(&o.stdout);
let line = out.lines().nth(1)?.to_string();
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
let total = parts[0].trim_end_matches('M').parse::<f64>().unwrap_or(0.0) / 1024.0;
let used = parts[1].trim_end_matches('M').parse::<f64>().unwrap_or(0.0) / 1024.0;
let pct = parts[2].trim_end_matches('%').parse::<f64>().unwrap_or(0.0);
Some(((total * 10.0).round() / 10.0, (used * 10.0).round() / 10.0, pct))
} else {
None
}
})
.unwrap_or((0.0, 0.0, 0.0))
};
// --- Uptime ---
let uptime_seconds = read_proc_file("/proc/uptime")
.and_then(|s| s.split_whitespace().next().and_then(|v| v.parse::<f64>().ok()))
.unwrap_or(0.0) as u64;
// --- Load average ---
let (load_1, load_5, load_15) = read_proc_file("/proc/loadavg")
.and_then(|s| {
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() >= 3 {
Some((
parts[0].parse::<f64>().unwrap_or(0.0),
parts[1].parse::<f64>().unwrap_or(0.0),
parts[2].parse::<f64>().unwrap_or(0.0),
))
} else {
None
}
})
.unwrap_or((0.0, 0.0, 0.0));
// --- Network (from /proc/net/dev, aggregate non-lo interfaces) ---
let (net_rx_bytes, net_tx_bytes) = read_proc_file("/proc/net/dev")
.map(|s| {
s.lines()
.skip(2) // skip header lines
.filter(|l| !l.trim().starts_with("lo:"))
.fold((0u64, 0u64), |(rx, tx), line| {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 10 {
let r = parts[1].parse::<u64>().unwrap_or(0);
let t = parts[9].parse::<u64>().unwrap_or(0);
(rx + r, tx + t)
} else {
(rx, tx)
}
})
})
.unwrap_or((0, 0));
// --- Database pool ---
let db_pool_size = state.app_state.db_pool.size();
let db_pool_idle = state.app_state.db_pool.num_idle();
// --- Active WebSocket listeners ---
let ws_listeners = state.app_state.dashboard_tx.receiver_count();
Json(ApiResponse::success(serde_json::json!({
"cpu": {
"usage_percent": cpu_percent,
"load_average": [load_1, load_5, load_15],
},
"memory": {
"total_mb": (mem_total_mb * 10.0).round() / 10.0,
"used_mb": (mem_used_mb * 10.0).round() / 10.0,
"usage_percent": mem_percent,
"process_rss_mb": process_rss_mb,
},
"disk": {
"total_gb": disk_total_gb,
"used_gb": disk_used_gb,
"usage_percent": disk_percent,
},
"network": {
"rx_bytes": net_rx_bytes,
"tx_bytes": net_tx_bytes,
},
"uptime_seconds": uptime_seconds,
"connections": {
"db_active": db_pool_size - db_pool_idle as u32,
"db_idle": db_pool_idle,
"websocket_listeners": ws_listeners,
},
"timestamp": chrono::Utc::now().to_rfc3339(),
})))
}
pub(super) async fn handle_system_logs(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> { pub(super) async fn handle_system_logs(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool; let pool = &state.app_state.db_pool;

View File

@@ -1120,3 +1120,41 @@ body {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--bg4); background: var(--bg4);
} }
/* Monitoring Layout */
.monitoring-layout {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
}
.monitoring-stream {
height: 400px;
overflow-y: auto;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
margin-top: 1rem;
padding: 1rem;
}
.monitoring-charts {
margin-top: 1.5rem;
}
/* Settings: Token Items */
.token-item {
display: flex;
gap: 0.5rem;
align-items: center;
background: var(--bg0);
padding: 0.5rem;
border-radius: 6px;
border: 1px solid var(--bg2);
}
/* Settings: Warning Card */
.warning-card {
border: 1px dashed var(--warning);
background: rgba(215, 153, 33, 0.08);
}

View File

@@ -101,7 +101,8 @@ class Dashboard {
'providers': 'Providers', 'providers': 'Providers',
'monitoring': 'Monitoring', 'monitoring': 'Monitoring',
'settings': 'Settings', 'settings': 'Settings',
'logs': 'Logs' 'logs': 'Logs',
'models': 'Models'
}; };
if (titleElement) titleElement.textContent = titles[page] || 'Dashboard'; if (titleElement) titleElement.textContent = titles[page] || 'Dashboard';
@@ -193,8 +194,10 @@ class Dashboard {
</div> </div>
<div class="grid-2"> <div class="grid-2">
<div class="card"> <div class="card">
<h3 class="card-title">System Health</h3> <div class="card-header">
<div id="system-health" style="margin-top: 1rem;"></div> <h3 class="card-title">System Health</h3>
</div>
<div class="card-body" id="system-health"></div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -400,10 +403,10 @@ class Dashboard {
<i class="fas fa-pause"></i> Pause Stream <i class="fas fa-pause"></i> Pause Stream
</button> </button>
</div> </div>
<div class="grid-2" style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;"> <div class="monitoring-layout">
<div> <div>
<h4>Incoming Requests</h4> <h4>Incoming Requests</h4>
<div id="request-stream" class="monitoring-stream" style="height: 400px; overflow-y: auto; background: #f8fafc; border-radius: 8px; margin-top: 1rem; padding: 1rem;"></div> <div id="request-stream" class="monitoring-stream"></div>
</div> </div>
<div> <div>
<h4>System Performance</h4> <h4>System Performance</h4>
@@ -411,7 +414,7 @@ class Dashboard {
</div> </div>
</div> </div>
</div> </div>
<div class="grid-3" style="margin-top: 1.5rem;"> <div class="grid-3 monitoring-charts">
<div class="chart-container"> <div class="chart-container">
<h3 class="card-title">Latency (ms)</h3> <h3 class="card-title">Latency (ms)</h3>
<canvas id="response-time-chart" height="200"></canvas> <canvas id="response-time-chart" height="200"></canvas>

View File

@@ -27,31 +27,40 @@ class MonitoringPage {
const container = document.getElementById('system-metrics'); const container = document.getElementById('system-metrics');
if (!container) return; if (!container) return;
const metrics = [ try {
{ label: 'CPU Usage', value: '24%', trend: 'down', color: 'success' }, const data = await window.api.get('/system/metrics');
{ label: 'Memory Usage', value: '1.8 GB', trend: 'stable', color: 'warning' }, const metrics = [
{ label: 'Disk I/O', value: '45 MB/s', trend: 'up', color: 'primary' }, { label: 'CPU Usage', value: `${data.cpu.usage_percent}%`, trend: data.cpu.usage_percent > 80 ? 'up' : data.cpu.usage_percent < 30 ? 'down' : 'stable' },
{ label: 'Network', value: '125 KB/s', trend: 'up', color: 'info' }, { label: 'Memory', value: `${(data.memory.used_mb / 1024).toFixed(1)} / ${(data.memory.total_mb / 1024).toFixed(1)} GB`, trend: data.memory.usage_percent > 80 ? 'up' : 'stable' },
{ label: 'Active Connections', value: '42', trend: 'stable', color: 'success' }, { label: 'Disk', value: `${data.disk.used_gb.toFixed(1)} / ${data.disk.total_gb.toFixed(1)} GB`, trend: data.disk.usage_percent > 80 ? 'up' : 'stable' },
{ label: 'Queue Length', value: '3', trend: 'down', color: 'success' } { label: 'Process RSS', value: `${data.memory.process_rss_mb} MB`, trend: 'stable' },
]; { label: 'Load Average', value: data.cpu.load_average.map(v => v.toFixed(2)).join(' / '), trend: data.cpu.load_average[0] > 2 ? 'up' : 'down' },
{ label: 'Connections', value: `${data.connections.db_active} DB, ${data.connections.websocket_listeners} WS`, trend: 'stable' },
];
container.innerHTML = metrics.map(metric => ` container.innerHTML = metrics.map(metric => `
<div class="metric-item"> <div class="metric-item">
<div class="metric-label">${metric.label}</div> <div class="metric-label">${metric.label}</div>
<div class="metric-value">${metric.value}</div> <div class="metric-value">${metric.value}</div>
<div class="metric-trend ${metric.trend}"> <div class="metric-trend ${metric.trend}">
<i class="fas fa-arrow-${metric.trend === 'up' ? 'up' : metric.trend === 'down' ? 'down' : 'minus'}"></i> <i class="fas fa-arrow-${metric.trend === 'up' ? 'up' : metric.trend === 'down' ? 'down' : 'minus'}"></i>
</div>
</div> </div>
</div> `).join('');
`).join(''); } catch (error) {
console.error('Error loading system metrics:', error);
container.innerHTML = '<div class="metric-item"><div class="metric-label" style="color: var(--danger);">Failed to load metrics</div></div>';
}
// Add CSS for metrics // Add CSS for metrics
this.addMetricStyles(); this.addMetricStyles();
} }
addMetricStyles() { addMetricStyles() {
// Avoid injecting duplicate styles
if (document.getElementById('monitoring-metric-styles')) return;
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'monitoring-metric-styles';
style.textContent = ` style.textContent = `
.metric-item { .metric-item {
display: flex; display: flex;
@@ -100,15 +109,6 @@ class MonitoringPage {
color: var(--text-secondary); color: var(--text-secondary);
} }
.monitoring-stream {
height: 300px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 0.5rem;
background-color: var(--bg-secondary);
}
.stream-entry { .stream-entry {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -148,67 +148,19 @@ class MonitoringPage {
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 0.125rem; margin-top: 0.125rem;
} }
.log-stream {
height: 400px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 0.5rem;
background-color: var(--bg-secondary);
font-family: monospace;
font-size: 0.75rem;
}
.log-entry {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: var(--text-light);
min-width: 80px;
}
.log-level {
width: 24px;
text-align: center;
}
.log-info .log-level {
color: var(--info);
}
.log-warn .log-level {
color: var(--warning);
}
.log-error .log-level {
color: var(--danger);
}
.log-debug .log-level {
color: var(--text-light);
}
.log-message {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
} }
async loadCharts() { async loadCharts() {
// Fetch recent logs for chart data
try {
const logs = await window.api.get('/system/logs');
this.recentLogs = Array.isArray(logs) ? logs : [];
} catch (error) {
console.error('Error loading logs for charts:', error);
this.recentLogs = [];
}
await this.loadResponseTimeChart(); await this.loadResponseTimeChart();
await this.loadErrorRateChart(); await this.loadErrorRateChart();
await this.loadRateLimitChart(); await this.loadRateLimitChart();
@@ -216,14 +168,21 @@ class MonitoringPage {
async loadResponseTimeChart() { async loadResponseTimeChart() {
try { try {
// Generate demo data for response time // Bucket recent logs by minute for latency chart
const labels = Array.from({ length: 20 }, (_, i) => `${i + 1}m`); const buckets = this.bucketByMinute(this.recentLogs, 20);
const labels = buckets.map(b => b.label);
const values = buckets.map(b => {
if (b.items.length === 0) return 0;
const total = b.items.reduce((sum, r) => sum + (r.duration || 0), 0);
return Math.round(total / b.items.length);
});
const data = { const data = {
labels: labels, labels,
datasets: [{ datasets: [{
label: 'Response Time (ms)', label: 'Avg Response Time (ms)',
data: labels.map(() => Math.floor(Math.random() * 200) + 300), data: values,
color: '#3b82f6', color: '#83a598',
fill: true fill: true
}] }]
}; };
@@ -231,14 +190,11 @@ class MonitoringPage {
window.chartManager.createLineChart('response-time-chart', data, { window.chartManager.createLineChart('response-time-chart', data, {
scales: { scales: {
y: { y: {
title: { title: { display: true, text: 'Milliseconds' },
display: true, beginAtZero: true
text: 'Milliseconds'
}
} }
} }
}); });
} catch (error) { } catch (error) {
console.error('Error loading response time chart:', error); console.error('Error loading response time chart:', error);
} }
@@ -246,13 +202,20 @@ class MonitoringPage {
async loadErrorRateChart() { async loadErrorRateChart() {
try { try {
const labels = Array.from({ length: 20 }, (_, i) => `${i + 1}m`); const buckets = this.bucketByMinute(this.recentLogs, 20);
const labels = buckets.map(b => b.label);
const values = buckets.map(b => {
if (b.items.length === 0) return 0;
const errors = b.items.filter(r => r.status === 'error').length;
return parseFloat((errors / b.items.length * 100).toFixed(1));
});
const data = { const data = {
labels: labels, labels,
datasets: [{ datasets: [{
label: 'Error Rate (%)', label: 'Error Rate (%)',
data: labels.map(() => Math.random() * 5), data: values,
color: '#ef4444', color: '#fb4934',
fill: true fill: true
}] }]
}; };
@@ -260,19 +223,14 @@ class MonitoringPage {
window.chartManager.createLineChart('error-rate-chart', data, { window.chartManager.createLineChart('error-rate-chart', data, {
scales: { scales: {
y: { y: {
title: { title: { display: true, text: 'Percentage' },
display: true, beginAtZero: true,
text: 'Percentage'
},
ticks: { ticks: {
callback: function(value) { callback: function(value) { return value + '%'; }
return value + '%';
}
} }
} }
} }
}); });
} catch (error) { } catch (error) {
console.error('Error loading error rate chart:', error); console.error('Error loading error rate chart:', error);
} }
@@ -280,37 +238,57 @@ class MonitoringPage {
async loadRateLimitChart() { async loadRateLimitChart() {
try { try {
const labels = ['Web App', 'Mobile App', 'API Integration', 'Internal Tools', 'Testing']; // Show requests-per-client from recent logs
const clientCounts = {};
for (const log of this.recentLogs) {
const client = log.client_id || 'unknown';
clientCounts[client] = (clientCounts[client] || 0) + 1;
}
const sorted = Object.entries(clientCounts).sort((a, b) => b[1] - a[1]).slice(0, 8);
const labels = sorted.map(([c]) => c.length > 16 ? c.substring(0, 14) + '...' : c);
const values = sorted.map(([, v]) => v);
const data = { const data = {
labels: labels, labels: labels.length > 0 ? labels : ['No data'],
datasets: [{ datasets: [{
label: 'Rate Limit Usage', label: 'Requests by Client',
data: [65, 45, 78, 34, 60], data: values.length > 0 ? values : [0],
color: '#10b981' color: '#8ec07c'
}] }]
}; };
window.chartManager.createBarChart('rate-limit-chart', data, { window.chartManager.createBarChart('rate-limit-chart', data, {
scales: { scales: {
y: { y: {
title: { title: { display: true, text: 'Request Count' },
display: true, beginAtZero: true
text: 'Percentage'
},
ticks: {
callback: function(value) {
return value + '%';
}
}
} }
} }
}); });
} catch (error) { } catch (error) {
console.error('Error loading rate limit chart:', error); console.error('Error loading rate limit chart:', error);
} }
} }
/** Bucket log entries into N-minute-wide bins ending at now. */
bucketByMinute(logs, count) {
const now = Date.now();
const buckets = Array.from({ length: count }, (_, i) => {
const minutesAgo = count - 1 - i;
return { label: minutesAgo === 0 ? 'now' : `${minutesAgo}m`, items: [], start: now - (minutesAgo + 1) * 60000, end: now - minutesAgo * 60000 };
});
for (const log of logs) {
const ts = new Date(log.timestamp).getTime();
for (const bucket of buckets) {
if (ts >= bucket.start && ts < bucket.end) {
bucket.items.push(log);
break;
}
}
}
return buckets;
}
setupEventListeners() { setupEventListeners() {
// Pause/resume monitoring button // Pause/resume monitoring button
const pauseBtn = document.getElementById('pause-monitoring'); const pauseBtn = document.getElementById('pause-monitoring');

View File

@@ -42,7 +42,7 @@ class SettingsPage {
<label>Authentication Tokens</label> <label>Authentication Tokens</label>
<div class="tokens-list" style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;"> <div class="tokens-list" style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;">
${this.settings.server.auth_tokens.map(token => ` ${this.settings.server.auth_tokens.map(token => `
<div class="token-item" style="display: flex; gap: 0.5rem; align-items: center; background: #f8fafc; padding: 0.5rem; border-radius: 6px; border: 1px solid #e2e8f0;"> <div class="token-item">
<code style="flex: 1;">${token}</code> <code style="flex: 1;">${token}</code>
<button class="btn-action" title="Copy" onclick="navigator.clipboard.writeText('${token}')"> <button class="btn-action" title="Copy" onclick="navigator.clipboard.writeText('${token}')">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
@@ -105,7 +105,7 @@ class SettingsPage {
</div> </div>
</div> </div>
<div class="card" style="border: 1px dashed var(--warning); background: #fffbeb;"> <div class="card warning-card">
<div class="card-body" style="display: flex; align-items: center; gap: 1rem;"> <div class="card-body" style="display: flex; align-items: center; gap: 1rem;">
<i class="fas fa-exclamation-triangle" style="font-size: 1.5rem; color: var(--warning);"></i> <i class="fas fa-exclamation-triangle" style="font-size: 1.5rem; color: var(--warning);"></i>
<div> <div>