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:
nmemmertandClaude Sonnet 4.6 committed 2026-07-31 11:01:24 -04:00
1 parent 5cfea703fb
commit b83a19e9cf
20 files changed
+580 -56

No files matched your search

+226 -4
View File
@@ -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;
}
+2
View File
@@ -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
View File
@@ -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">
+5
View File
@@ -277,6 +277,11 @@
background: #475569;
}
/* ── Push prompt ── */
.push-prompt {
padding: 0.4rem 0.875rem 0;
}
/* ── Mobile ── */
@media (max-width: 600px) {
.notification-popup {
+21 -1
View File
@@ -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>
+83 -12
View File
@@ -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>
+51
View File
@@ -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; }
+35
View File
@@ -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>