Add 10 UX/feature improvements across dashboard, status page, and API
- Incident acknowledge: Ack button in dashboard rail and ResourceDetail incidents table - Status page 90-day uptime history bars (teal ≥99%, amber ≥95%, red <95%, grey = no data) - Theme setting applied: data-theme attribute wired to saved setting with CSS variables + light mode overrides - Sparkline replaced with colored status bars (teal=up, red=down, grey=unknown) per check tick - ResourceDetail chart time-range selector: 1h / 6h / 24h / 7d toggle fetches from checks endpoint - Bulk actions: checkbox per row, select-all header, bulk delete and maintenance toggle bar - CSV export adds 6 new fields (http_method, http_body, heartbeat_timeout, is_public, consecutive_failures_threshold, response_time_threshold) - CSV import handles those 6 new fields on INSERT - Check detail expansion: click expandable rows in Recent Checks to show full details JSON - Browser push notifications: request permission opt-in, new Notification() on WS message when tab unfocused Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5cfea703fb
commit
b83a19e9cf
20 files changed
+580
-56
No files matched your search
+226
-4
@@ -1,3 +1,53 @@
|
||||
/* ── Theme variables ── */
|
||||
:root {
|
||||
--app-bg: #020617;
|
||||
--surface: #0f172a;
|
||||
--surface-2: #1e293b;
|
||||
--border: #334155;
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dim: #64748b;
|
||||
--accent: #14b8a6;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--app-bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f1f5f9;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
--text-muted: #475569;
|
||||
--text-dim: #94a3b8;
|
||||
--accent: #0d9488;
|
||||
}
|
||||
|
||||
[data-theme="light"] .App {
|
||||
background: var(--app-bg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .navbar {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-link {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-link:hover {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
box-shadow: inset 0 0 0 1px #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="light"] .container,
|
||||
[data-theme="light"] .detail-section,
|
||||
[data-theme="light"] .resource-row {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
@@ -310,7 +360,44 @@
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.cc-incident-item,
|
||||
.cc-incident-item {
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.2rem 0.35rem 0.2rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cc-incident-item:hover {
|
||||
background: #111f34;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.cc-incident-nav {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
padding: 0.25rem 0.55rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cc-ack-btn {
|
||||
flex-shrink: 0;
|
||||
color: #4ade80;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.cc-group-item {
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
@@ -324,7 +411,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cc-incident-item:hover,
|
||||
.cc-group-item:hover {
|
||||
background: #111f34;
|
||||
border-color: #475569;
|
||||
@@ -363,7 +449,7 @@
|
||||
|
||||
.resource-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||
grid-template-columns: 28px minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||
gap: 1rem;
|
||||
padding: 0.35rem 0.6rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
@@ -391,7 +477,7 @@
|
||||
|
||||
.resource-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||
grid-template-columns: 28px minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
@@ -1255,6 +1341,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-metrics {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
@@ -1394,3 +1484,135 @@
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bulk action bar ── */
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Row checkbox ── */
|
||||
.row-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-checkbox input[type="checkbox"],
|
||||
.header-select-all input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: #14b8a6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resource-row-selected {
|
||||
border-color: #14b8a6 !important;
|
||||
background: rgba(20, 184, 166, 0.06) !important;
|
||||
}
|
||||
|
||||
/* ── Status sparkline bars ── */
|
||||
.status-spark-bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: stretch;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spark-bar {
|
||||
flex: 1;
|
||||
min-width: 3px;
|
||||
border-radius: 1px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.spark-bar:hover { opacity: 0.65; }
|
||||
.spark-bar-up { background: #14b8a6; }
|
||||
.spark-bar-down { background: #ef4444; }
|
||||
.spark-bar-unknown { background: #475569; }
|
||||
|
||||
/* ── Chart time-range buttons ── */
|
||||
.chart-range-btns {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.chart-range-btn {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #94a3b8;
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.chart-range-btn:hover {
|
||||
border-color: #14b8a6;
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.chart-range-btn.active {
|
||||
background: #14b8a6;
|
||||
border-color: #14b8a6;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Check row expansion ── */
|
||||
.check-row-expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-row-expandable:hover {
|
||||
background: rgba(20, 184, 166, 0.05);
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
background: #0a0f1a;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Section title row ── */
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-title-row h2 {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -26,6 +26,8 @@ function Navbar() {
|
||||
localStorage.setItem('serverTimezone', response.data.timezone);
|
||||
localStorage.setItem('serverTimezoneTime', Date.now().toString());
|
||||
}
|
||||
const theme = response.data.theme || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (error) {
|
||||
// Notifications check error handled
|
||||
}
|
||||
|
||||
+109
-21
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { LineChart, Line, ResponsiveContainer } from 'recharts';
|
||||
import NotificationCenter from './NotificationCenter';
|
||||
import { formatLocalTime } from './utils/timeUtils';
|
||||
|
||||
@@ -46,6 +45,7 @@ function Dashboard() {
|
||||
const [sortKey, setSortKey] = useState('severity');
|
||||
const [renderLimit, setRenderLimit] = useState(120);
|
||||
const [groupData, setGroupData] = useState({ name: '', description: '' });
|
||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||
const wsRef = useRef(null);
|
||||
const wsReconnectTimer = useRef(null);
|
||||
const wsReconnectDelay = useRef(1000);
|
||||
@@ -218,6 +218,54 @@ function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcknowledge = async (incidentId) => {
|
||||
try {
|
||||
await axios.post(`/api/incidents/${incidentId}/acknowledge`);
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Failed to acknowledge incident', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (!window.confirm(`Delete ${selectedIds.size} resource(s)?`)) return;
|
||||
try {
|
||||
await Promise.all([...selectedIds].map(id => axios.delete(`/api/resources/${id}`)));
|
||||
setSelectedIds(new Set());
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Some deletes failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkMaintenance = async (enable) => {
|
||||
try {
|
||||
await Promise.all([...selectedIds].map(id =>
|
||||
axios.patch(`/api/resources/${id}/maintenance-mode`, { maintenance_mode: enable })
|
||||
));
|
||||
setSelectedIds(new Set());
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Some maintenance updates failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === visibleResources.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(visibleResources.map(r => r.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/resources/export', { responseType: 'blob' });
|
||||
@@ -330,6 +378,10 @@ function Dashboard() {
|
||||
|
||||
const activeIncidents = sortedResources.filter((r) => r.hasActiveIncident || r.status === 'down').slice(0, 8);
|
||||
|
||||
const allTags = [...new Set(
|
||||
resources.flatMap(r => (r.tags || '').split(',').map(t => t.trim()).filter(Boolean))
|
||||
)].sort();
|
||||
|
||||
const groupCounts = groups
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
@@ -341,20 +393,21 @@ function Dashboard() {
|
||||
const ungroupedCount = sortedResources.filter((r) => !r.group_id).length;
|
||||
|
||||
const renderResourceRow = (resource) => {
|
||||
const sparkData = (resource.recentChecks || []).map((c, idx) => ({
|
||||
idx,
|
||||
responseTime: c.response_time || 0,
|
||||
statusValue: c.status === 'up' ? 1 : 0,
|
||||
}));
|
||||
const checks = resource.recentChecks || [];
|
||||
const groupName = groupNameById[resource.group_id] || 'Ungrouped';
|
||||
const trend = getTrend(resource.recentChecks || []);
|
||||
const trend = getTrend(checks);
|
||||
const isSelected = selectedIds.has(resource.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="resource-row"
|
||||
onClick={(e) => !e.target.closest('.row-actions') && navigate(`/resource/${resource.id}`)}
|
||||
className={`resource-row${isSelected ? ' resource-row-selected' : ''}`}
|
||||
onClick={(e) => !e.target.closest('.row-actions') && !e.target.closest('.row-checkbox') && navigate(`/resource/${resource.id}`)}
|
||||
>
|
||||
<label className="row-checkbox" onClick={(e) => e.stopPropagation()} title="Select for bulk actions">
|
||||
<input type="checkbox" checked={isSelected} onChange={() => toggleSelect(resource.id)} />
|
||||
</label>
|
||||
|
||||
<div className="row-main">
|
||||
<div className="row-title-line">
|
||||
<h3 className="resource-name row-name">{resource.name}</h3>
|
||||
@@ -388,12 +441,16 @@ function Dashboard() {
|
||||
<p className="stat-label">Avg Resp</p>
|
||||
</div>
|
||||
<div className="row-sparkline">
|
||||
{sparkData.length > 1 ? (
|
||||
<ResponsiveContainer width="100%" height={44}>
|
||||
<LineChart data={sparkData} margin={{ top: 2, bottom: 0, left: 0, right: 0 }}>
|
||||
<Line type="monotone" dataKey="responseTime" stroke="#14b8a6" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{checks.length > 0 ? (
|
||||
<div className="status-spark-bars" title="Recent check status (newest on right)">
|
||||
{checks.map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`spark-bar spark-bar-${c.status || 'unknown'}`}
|
||||
title={`${c.status}${c.response_time ? ` — ${c.response_time}ms` : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="last-check">No recent data</span>
|
||||
)}
|
||||
@@ -542,9 +599,30 @@ function Dashboard() {
|
||||
<p>Try adjusting your tag/group filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="bulk-action-bar">
|
||||
<span className="bulk-count">{selectedIds.size} selected</span>
|
||||
<button className="btn btn-secondary" onClick={() => handleBulkMaintenance(true)}>Enable Maintenance</button>
|
||||
<button className="btn btn-secondary" onClick={() => handleBulkMaintenance(false)}>End Maintenance</button>
|
||||
<button className="btn btn-danger" onClick={handleBulkDelete}>Delete Selected</button>
|
||||
<button className="btn" onClick={() => setSelectedIds(new Set())}>Clear</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<datalist id="tag-suggestions">
|
||||
{allTags.map(tag => <option key={tag} value={tag} />)}
|
||||
</datalist>
|
||||
|
||||
<div className="cc-layout">
|
||||
<section className="resource-list-shell cc-main">
|
||||
<div className="resource-list-header">
|
||||
<label className="header-select-all" title="Select / deselect all visible">
|
||||
<input type="checkbox"
|
||||
checked={visibleResources.length > 0 && selectedIds.size === visibleResources.length}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</label>
|
||||
<div>Resource</div>
|
||||
<div>Metrics</div>
|
||||
<div>Status</div>
|
||||
@@ -566,10 +644,19 @@ function Dashboard() {
|
||||
) : (
|
||||
<div className="cc-incident-list">
|
||||
{activeIncidents.map((incident) => (
|
||||
<button key={incident.id} className="cc-incident-item" onClick={() => navigate(`/resource/${incident.id}`)}>
|
||||
<span className="cc-incident-name">{incident.name}</span>
|
||||
<span className={`status-badge status-${incident.status}`}>{incident.status}</span>
|
||||
</button>
|
||||
<div key={incident.id} className="cc-incident-item">
|
||||
<button className="cc-incident-nav" onClick={() => navigate(`/resource/${incident.id}`)}>
|
||||
<span className="cc-incident-name">{incident.name}</span>
|
||||
<span className={`status-badge status-${incident.status}`}>{incident.status}</span>
|
||||
</button>
|
||||
{incident.activeIncidentId && (
|
||||
<button
|
||||
className="btn-icon cc-ack-btn"
|
||||
title="Acknowledge incident"
|
||||
onClick={(e) => { e.stopPropagation(); handleAcknowledge(incident.activeIncidentId); }}
|
||||
>✓</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -594,6 +681,7 @@ function Dashboard() {
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showGroupModal && (
|
||||
@@ -726,7 +814,7 @@ function Dashboard() {
|
||||
|
||||
<div className="form-group">
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input type="text" placeholder="frontend,production,critical" value={formData.tags || ''} onChange={(e) => setFormData({ ...formData, tags: e.target.value })} />
|
||||
<input type="text" list="tag-suggestions" placeholder="frontend,production,critical" value={formData.tags || ''} onChange={(e) => setFormData({ ...formData, tags: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
@@ -870,7 +958,7 @@ function Dashboard() {
|
||||
|
||||
<div className="form-group">
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input type="text" placeholder="Enter tags to organize resources" value={editData.tags || ''} onChange={(e) => setEditData({ ...editData, tags: e.target.value })} />
|
||||
<input type="text" list="tag-suggestions" placeholder="Enter tags to organize resources" value={editData.tags || ''} onChange={(e) => setEditData({ ...editData, tags: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
|
||||
@@ -277,6 +277,11 @@
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
/* ── Push prompt ── */
|
||||
.push-prompt {
|
||||
padding: 0.4rem 0.875rem 0;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 600px) {
|
||||
.notification-popup {
|
||||
|
||||
@@ -10,6 +10,9 @@ function NotificationCenter() {
|
||||
const [filter, setFilter] = useState('all'); // all, unread, read
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [pushPermission, setPushPermission] = useState(
|
||||
'Notification' in window ? Notification.permission : 'unsupported'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
@@ -41,8 +44,11 @@ function NotificationCenter() {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'notification') {
|
||||
// New notification received - reload notifications
|
||||
loadNotifications();
|
||||
if ('Notification' in window && Notification.permission === 'granted' && !document.hasFocus()) {
|
||||
const n = message.data || {};
|
||||
new Notification(n.title || 'SkyWatch Alert', { body: n.message || '', icon: '/favicon.ico' });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
@@ -103,6 +109,12 @@ function NotificationCenter() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestPush = async () => {
|
||||
if (!('Notification' in window)) return;
|
||||
const perm = await Notification.requestPermission();
|
||||
setPushPermission(perm);
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
|
||||
|
||||
@@ -173,6 +185,14 @@ function NotificationCenter() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pushPermission !== 'granted' && pushPermission !== 'unsupported' && (
|
||||
<div className="push-prompt">
|
||||
<button className="filter-btn" onClick={handleRequestPush}>
|
||||
{pushPermission === 'denied' ? '🔕 Push blocked' : '🔔 Enable push alerts'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="notification-list">
|
||||
{loading ? (
|
||||
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>Loading...</p>
|
||||
|
||||
@@ -29,6 +29,9 @@ function ResourceDetail() {
|
||||
const [editingIncident, setEditingIncident] = useState(null);
|
||||
const [incidentDescription, setIncidentDescription] = useState('');
|
||||
const [updatingIncident, setUpdatingIncident] = useState(false);
|
||||
const [expandedCheckId, setExpandedCheckId] = useState(null);
|
||||
const [chartRange, setChartRange] = useState('24h');
|
||||
const [chartChecks, setChartChecks] = useState([]);
|
||||
|
||||
const [sla, setSla] = useState(null);
|
||||
const [slaLoading, setSlaLoading] = useState(false);
|
||||
@@ -51,11 +54,17 @@ function ResourceDetail() {
|
||||
loadIncidents();
|
||||
loadSla();
|
||||
loadMaintenanceWindows();
|
||||
loadChartChecks();
|
||||
const interval = setInterval(loadResource, 5000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadChartChecks();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartRange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadChecks();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -104,6 +113,29 @@ function ResourceDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const rangeMs = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000 };
|
||||
|
||||
const loadChartChecks = async () => {
|
||||
try {
|
||||
const from = new Date(Date.now() - rangeMs[chartRange]).toISOString();
|
||||
const resp = await axios.get(`/api/resources/${id}/checks`, {
|
||||
params: { from, limit: 200, sort: 'asc' },
|
||||
});
|
||||
setChartChecks(resp.data.checks || []);
|
||||
} catch (error) {
|
||||
// chart load error handled
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcknowledge = async (incidentId) => {
|
||||
try {
|
||||
await axios.post(`/api/incidents/${incidentId}/acknowledge`);
|
||||
loadIncidents();
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Failed to acknowledge incident', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditIncident = (incident) => {
|
||||
setEditingIncident(incident);
|
||||
setIncidentDescription(incident.description || '');
|
||||
@@ -214,7 +246,7 @@ function ResourceDetail() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const chartData = resource.stats.checks.map((check) => ({
|
||||
const chartData = chartChecks.map((check) => ({
|
||||
time: formatChartTime(check.checked_at),
|
||||
responseTime: check.response_time,
|
||||
status: check.status === 'up' ? 1 : 0,
|
||||
@@ -274,7 +306,14 @@ function ResourceDetail() {
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h2>Response Time (Last 50 Checks)</h2>
|
||||
<div className="section-title-row">
|
||||
<h2>Response Time</h2>
|
||||
<div className="chart-range-btns">
|
||||
{['1h', '6h', '24h', '7d'].map(r => (
|
||||
<button key={r} className={`chart-range-btn${chartRange === r ? ' active' : ''}`} onClick={() => setChartRange(r)}>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
@@ -325,14 +364,37 @@ function ResourceDetail() {
|
||||
<tr><td colSpan="4">Loading checks...</td></tr>
|
||||
) : checks.length === 0 ? (
|
||||
<tr><td colSpan="4">No checks found</td></tr>
|
||||
) : checks.map((check) => (
|
||||
<tr key={check.id}>
|
||||
<td>{formatLocalTime(check.checked_at)}</td>
|
||||
<td><span className={`status-badge status-${check.status}`}>{check.status}</span></td>
|
||||
<td className="text-right">{check.response_time ? `${check.response_time}ms` : '-'}</td>
|
||||
<td className="muted-cell">{check.error_message || 'OK'}</td>
|
||||
</tr>
|
||||
))}
|
||||
) : checks.map((check) => {
|
||||
const isExpanded = expandedCheckId === check.id;
|
||||
let parsedDetails = null;
|
||||
if (check.details) {
|
||||
try { parsedDetails = JSON.parse(check.details); } catch {}
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={check.id}>
|
||||
<tr
|
||||
className={`check-row${parsedDetails ? ' check-row-expandable' : ''}`}
|
||||
onClick={() => parsedDetails && setExpandedCheckId(isExpanded ? null : check.id)}
|
||||
title={parsedDetails ? 'Click to expand details' : undefined}
|
||||
>
|
||||
<td>{formatLocalTime(check.checked_at)}</td>
|
||||
<td><span className={`status-badge status-${check.status}`}>{check.status}</span></td>
|
||||
<td className="text-right">{check.response_time ? `${check.response_time}ms` : '-'}</td>
|
||||
<td className="muted-cell">
|
||||
{check.error_message || 'OK'}
|
||||
{parsedDetails && <span className="expand-indicator">{isExpanded ? ' ▼' : ' ▶'}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && parsedDetails && (
|
||||
<tr className="expanded-row">
|
||||
<td colSpan="4" className="expanded-cell">
|
||||
<pre className="detail-json">{JSON.stringify(parsedDetails, null, 2)}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -364,7 +426,7 @@ function ResourceDetail() {
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr><th className="w-25">Started</th><th className="w-25">Resolved</th><th className="w-15">Duration</th><th className="w-35">Reason</th></tr>
|
||||
<tr><th className="w-20">Started</th><th className="w-20">Resolved</th><th className="w-15">Duration</th><th className="w-35">Reason</th><th className="w-10"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incidentsLoading ? (
|
||||
@@ -393,10 +455,19 @@ function ResourceDetail() {
|
||||
</span>
|
||||
<button className="btn btn-secondary btn-compact" onClick={() => handleEditIncident(incident)}>Edit</button>
|
||||
</td>
|
||||
<td>
|
||||
{!incident.resolved_at && (
|
||||
<button
|
||||
className="btn btn-secondary btn-compact"
|
||||
title="Acknowledge — mark as seen"
|
||||
onClick={() => handleAcknowledge(incident.id)}
|
||||
>Ack</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="expanded-row">
|
||||
<td colSpan="4" className="expanded-cell">{description}</td>
|
||||
<td colSpan="5" className="expanded-cell">{description}</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -319,3 +319,54 @@
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 90-day uptime bars ── */
|
||||
.uptime-history {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 28px;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.uptime-day-bar {
|
||||
flex: 1;
|
||||
min-width: 1px;
|
||||
border-radius: 1px;
|
||||
cursor: default;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.uptime-day-bar:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.uptime-history-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Spark bars in dashboard ── */
|
||||
.status-spark-bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: stretch;
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spark-bar {
|
||||
flex: 1;
|
||||
min-width: 3px;
|
||||
border-radius: 1px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.spark-bar:hover { opacity: 0.65; }
|
||||
.spark-bar-up { background: #14b8a6; }
|
||||
.spark-bar-down { background: #ef4444; }
|
||||
.spark-bar-unknown { background: #475569; }
|
||||
@@ -125,6 +125,19 @@ function Status() {
|
||||
return m.resource_id === resource.id && now >= start && now <= end;
|
||||
});
|
||||
|
||||
const daily = resource.dailyUptime || [];
|
||||
const last90Days = (() => {
|
||||
const days = [];
|
||||
for (let i = 89; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const entry = daily.find(e => e.day === key);
|
||||
days.push({ day: key, uptime: entry ? entry.uptime : null });
|
||||
}
|
||||
return days;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
@@ -145,6 +158,28 @@ function Status() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="uptime-history" title="90-day uptime — hover for details">
|
||||
{last90Days.map((d) => {
|
||||
const color = d.uptime === null ? '#334155'
|
||||
: d.uptime >= 99 ? '#14b8a6'
|
||||
: d.uptime >= 95 ? '#f59e0b'
|
||||
: '#ef4444';
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
className="uptime-day-bar"
|
||||
style={{ background: color }}
|
||||
title={d.uptime !== null ? `${d.day}: ${d.uptime}%` : `${d.day}: no data`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="uptime-history-labels">
|
||||
<span>90 days ago</span>
|
||||
<span>{daily.length > 0 ? `${daily[daily.length - 1]?.uptime ?? '—'}% today` : ''}</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
|
||||
{activeIncident && (
|
||||
<div className="incident-info">
|
||||
<p className="incident-title">Active Incident</p>
|
||||
|
||||
Reference in new issue
Block a user