Bugs fixed: - Status page: autoRefresh checkbox now actually pauses the interval - Dashboard edit modal: hide URL field for heartbeat resources (was always shown as required) - Dashboard: apply items_per_page, default_sort, and refresh_interval from settings on mount - Dashboard: call loadResources() immediately on mount (was waiting 15s for fallback) - Observability: listen for WS 'metrics' messages so tab updates live, not just on poll UX improvements: - Dashboard: replaced window.confirm() with inline Confirm/Cancel buttons for single and bulk delete - Dashboard: skeleton rows shown while initial data loads (no more flash of empty state) - Notifications: WebSocket moved from window global to useRef; exponential backoff on reconnect - Notifications: replaced window.confirm() with inline Confirm/Cancel for Clear All - ResourceDetail: initial API calls run in parallel via Promise.all New features: - ntfy.sh push notification channel: enable in Settings, enter topic, get mobile push alerts - Network Discovery: Actions → Discover Network scans LAN for reachable hosts, probes HTTP ports, and lets you add any found host to monitoring in one click Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
307 lines
11 KiB
JavaScript
307 lines
11 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import axios from 'axios';
|
|
import { formatLocalTime } from './utils/timeUtils';
|
|
import './Observability.css';
|
|
|
|
function Observability() {
|
|
const [activeTab, setActiveTab] = useState('metrics');
|
|
const [metrics, setMetrics] = useState(null);
|
|
const [errors, setErrors] = useState([]);
|
|
const [auditLogs, setAuditLogs] = useState([]);
|
|
const [auditSummary, setAuditSummary] = useState(null);
|
|
const [expandedAuditRow, setExpandedAuditRow] = useState(null);
|
|
const [filters, setFilters] = useState({
|
|
entityType: '',
|
|
entityId: '',
|
|
userId: '',
|
|
action: ''
|
|
});
|
|
const wsRef = useRef(null);
|
|
const wsReconnectTimer = useRef(null);
|
|
const wsReconnectDelay = useRef(1000);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
const interval = setInterval(loadData, 30000);
|
|
return () => clearInterval(interval);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeTab, filters]);
|
|
|
|
useEffect(() => {
|
|
const connect = () => {
|
|
try {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === 'metrics' && activeTab === 'metrics') {
|
|
setMetrics(msg.data);
|
|
}
|
|
} catch (_) {}
|
|
};
|
|
ws.onopen = () => { wsReconnectDelay.current = 1000; };
|
|
ws.onclose = () => {
|
|
wsRef.current = null;
|
|
wsReconnectTimer.current = setTimeout(() => {
|
|
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
|
|
connect();
|
|
}, wsReconnectDelay.current);
|
|
};
|
|
ws.onerror = () => {};
|
|
wsRef.current = ws;
|
|
} catch (_) {}
|
|
};
|
|
connect();
|
|
return () => {
|
|
clearTimeout(wsReconnectTimer.current);
|
|
if (wsRef.current) wsRef.current.close();
|
|
};
|
|
}, [activeTab]);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
if (activeTab === 'metrics') {
|
|
const resp = await axios.get('/api/observability/metrics');
|
|
setMetrics(resp.data);
|
|
} else if (activeTab === 'errors') {
|
|
const resp = await axios.get('/api/observability/errors?limit=50');
|
|
setErrors(resp.data.errors);
|
|
} else if (activeTab === 'audit') {
|
|
const auditResp = await axios.get('/api/observability/audit-logs', { params: filters });
|
|
setAuditLogs(auditResp.data.logs);
|
|
setAuditSummary(auditResp.data.summary);
|
|
}
|
|
} catch (error) {
|
|
// Error loading data
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="container observability-page">
|
|
<h2>System Observability</h2>
|
|
|
|
<div className="obs-tabs">
|
|
<button
|
|
onClick={() => setActiveTab('metrics')}
|
|
className={`obs-tab ${activeTab === 'metrics' ? 'is-active' : ''}`}
|
|
>
|
|
API Metrics
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('errors')}
|
|
className={`obs-tab ${activeTab === 'errors' ? 'is-active' : ''}`}
|
|
>
|
|
Errors
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('audit')}
|
|
className={`obs-tab ${activeTab === 'audit' ? 'is-active' : ''}`}
|
|
>
|
|
Audit Logs
|
|
</button>
|
|
</div>
|
|
|
|
{/* Metrics Tab */}
|
|
{activeTab === 'metrics' && metrics && (
|
|
<div>
|
|
<div className="obs-summary-grid">
|
|
<div className="obs-summary-card">
|
|
<div className="obs-card-label">Server Uptime</div>
|
|
<div className="obs-card-value">
|
|
{metrics.uptimeFormatted || `${Math.floor(metrics.uptime / 3600)}h`}
|
|
</div>
|
|
</div>
|
|
<div className="obs-summary-card">
|
|
<div className="obs-card-label">Total API Requests</div>
|
|
<div className="obs-card-value">
|
|
{metrics.apiStats.reduce((sum, s) => sum + s.totalRequests, 0)}
|
|
</div>
|
|
</div>
|
|
<div className="obs-summary-card">
|
|
<div className="obs-card-label">Errors Logged</div>
|
|
<div className={`obs-card-value ${metrics.errorCount > 0 ? 'is-danger' : 'is-ok'}`}>
|
|
{metrics.errorCount}
|
|
</div>
|
|
</div>
|
|
<div className="obs-summary-card">
|
|
<div className="obs-card-label">Unique Errors</div>
|
|
<div className="obs-card-value">
|
|
{metrics.uniqueErrors}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h3>API Endpoint Performance</h3>
|
|
<div className="obs-table-wrap obs-spacing-bottom">
|
|
<table className="obs-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Endpoint</th>
|
|
<th className="obs-right">Requests</th>
|
|
<th className="obs-right">Success Rate</th>
|
|
<th className="obs-right">Avg Response</th>
|
|
<th className="obs-right">Min/Max</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{metrics.apiStats.map((stat, i) => (
|
|
<tr key={i}>
|
|
<td>
|
|
<code>{stat.method} {stat.endpoint}</code>
|
|
</td>
|
|
<td className="obs-right">{stat.totalRequests}</td>
|
|
<td className="obs-right">
|
|
<span className={`obs-rate-pill ${stat.successRate >= 95 ? 'is-good' : 'is-warn'}`}>
|
|
{stat.successRate}%
|
|
</span>
|
|
</td>
|
|
<td className="obs-right">{stat.avgResponseTime}ms</td>
|
|
<td className="obs-right">
|
|
{stat.minResponseTime}ms / {stat.maxResponseTime}ms
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Errors Tab */}
|
|
{activeTab === 'errors' && (
|
|
<div>
|
|
{errors.length === 0 ? (
|
|
<p className="obs-empty-copy">No errors logged</p>
|
|
) : (
|
|
<div className="obs-table-wrap">
|
|
<table className="obs-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Error Message</th>
|
|
<th>Context</th>
|
|
<th>Occurred At</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{errors.map((error, i) => (
|
|
<tr key={i} className={i % 2 ? 'obs-zebra' : ''}>
|
|
<td>
|
|
<code className="obs-error-code">{error.error_message}</code>
|
|
</td>
|
|
<td className="obs-muted-sm">
|
|
{error.context ? JSON.stringify(JSON.parse(error.context)).substring(0, 50) : '-'}
|
|
</td>
|
|
<td className="obs-small">
|
|
{formatLocalTime(error.occurred_at)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Audit Logs Tab */}
|
|
{activeTab === 'audit' && (
|
|
<div>
|
|
{auditSummary && (
|
|
<div className="obs-audit-summary">
|
|
<p><strong>Summary (Last 7 Days):</strong> {auditSummary.totalEvents} events</p>
|
|
{auditSummary.actionBreakdown.map(action => (
|
|
<p key={action.action} className="obs-audit-summary-line">
|
|
{action.action}: {action.count}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="obs-filters-grid">
|
|
<input
|
|
type="text"
|
|
placeholder="Entity Type"
|
|
value={filters.entityType}
|
|
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
|
|
className="obs-filter-input"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Entity ID"
|
|
value={filters.entityId}
|
|
onChange={(e) => setFilters({ ...filters, entityId: e.target.value })}
|
|
className="obs-filter-input"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="User ID"
|
|
value={filters.userId}
|
|
onChange={(e) => setFilters({ ...filters, userId: e.target.value })}
|
|
className="obs-filter-input"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Action"
|
|
value={filters.action}
|
|
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
|
|
className="obs-filter-input"
|
|
/>
|
|
</div>
|
|
|
|
{auditLogs.length === 0 ? (
|
|
<p className="obs-empty-copy">No audit logs found</p>
|
|
) : (
|
|
<div className="obs-table-wrap">
|
|
<table className="obs-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Action</th>
|
|
<th>Entity</th>
|
|
<th>User</th>
|
|
<th>Changes</th>
|
|
<th>Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{auditLogs.map((log, i) => (
|
|
<React.Fragment key={i}>
|
|
<tr className={`obs-click-row ${i % 2 ? 'obs-zebra' : ''}`} onClick={() => setExpandedAuditRow(expandedAuditRow === i ? null : i)}>
|
|
<td><strong>{log.action}</strong></td>
|
|
<td className="obs-muted-sm">
|
|
{log.entity_type} #{log.entity_id}
|
|
</td>
|
|
<td className="obs-small">{log.user_id}</td>
|
|
<td className="obs-small">
|
|
<code className="obs-toggle-code">
|
|
{expandedAuditRow === i ? '▼ Hide' : '▶ Show'} Changes
|
|
</code>
|
|
</td>
|
|
<td className="obs-small">
|
|
{formatLocalTime(log.created_at)}
|
|
</td>
|
|
</tr>
|
|
{expandedAuditRow === i && (
|
|
<tr className="obs-expanded-row">
|
|
<td colSpan="5" className="obs-expanded-cell">
|
|
<strong>Full Changes:</strong>
|
|
<pre className="obs-pre">
|
|
{JSON.stringify(log.changes, null, 2)}
|
|
</pre>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Observability;
|