Refine dark theme readability and chart visibility across dashboard pages

This commit is contained in:
Nate Emmert committed 2026-03-10 14:54:40 -04:00
1 parent b9da742b3d
commit 0475bf5aa5
20 files changed
+929 -320

No files matched your search

+191 -56
View File
@@ -1,18 +1,18 @@
.App {
min-height: 100vh;
background:
radial-gradient(circle at 15% 0%, #dff7f2 0%, transparent 40%),
radial-gradient(circle at 95% 10%, #e8eefc 0%, transparent 38%),
#f8fafc;
radial-gradient(circle at 15% 0%, rgba(30, 64, 175, 0.2) 0%, transparent 42%),
radial-gradient(circle at 95% 10%, rgba(15, 118, 110, 0.14) 0%, transparent 36%),
#020617;
}
.navbar {
background: rgba(255, 255, 255, 0.85);
background: rgba(15, 23, 42, 0.82);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 0.85rem 2rem;
color: #0f172a;
border-bottom: 1px solid #dbe2ea;
color: #e2e8f0;
border-bottom: 1px solid #334155;
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.06);
position: sticky;
top: 0;
@@ -48,7 +48,7 @@
}
.nav-link {
color: #475569;
color: #cbd5e1;
text-decoration: none;
font-weight: 600;
font-size: 0.92rem;
@@ -58,9 +58,9 @@
}
.nav-link:hover {
background: #ffffff;
color: #0f172a;
box-shadow: inset 0 0 0 1px #dbe2ea;
background: #111827;
color: #f8fafc;
box-shadow: inset 0 0 0 1px #334155;
}
.settings-link {
@@ -83,9 +83,9 @@
align-items: center;
gap: 0.45rem;
font-size: 0.82rem;
color: #334155;
background: #ffffff;
border: 1px solid #dbe2ea;
color: #e2e8f0;
background: #111827;
border: 1px solid #334155;
border-radius: 999px;
padding: 0.3rem 0.7rem;
}
@@ -108,11 +108,12 @@
margin: 0;
font-size: 1.85rem;
letter-spacing: -0.02em;
color: #f8fafc;
}
.dashboard-subtitle {
margin: 0.2rem 0 0;
color: #64748b;
color: #94a3b8;
font-size: 0.9rem;
}
@@ -123,12 +124,42 @@
align-items: center;
}
.quick-filter-row {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
margin: 0.2rem 0 0.8rem;
}
.quick-chip {
background: #0f172a;
border: 1px solid #334155;
color: #cbd5e1;
border-radius: 999px;
padding: 0.28rem 0.7rem;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
}
.quick-chip:hover {
background: #111f34;
}
.quick-chip.active {
background: #14b8a6;
border-color: #14b8a6;
color: #052e2b;
}
.tag-filter-input {
padding: 0.5rem 0.75rem;
border-radius: 10px;
border: 1px solid #cbd5e1;
border: 1px solid #334155;
min-width: 180px;
background: rgba(255, 255, 255, 0.9);
background: #111827;
color: #e2e8f0;
}
.actions-menu-wrapper {
@@ -322,10 +353,12 @@
}
.resource-list-shell {
background: rgba(255, 255, 255, 0.82);
border: 1px solid #cbd5e1;
background: rgba(15, 23, 42, 0.9);
border: 1px solid #334155;
border-radius: 12px;
padding: 0.65rem;
max-height: calc(100vh - 250px);
overflow: auto;
}
.resource-list-header {
@@ -336,15 +369,24 @@
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
color: #94a3b8;
border-bottom: 1px solid #334155;
margin-bottom: 0.45rem;
position: sticky;
top: 0;
z-index: 5;
background: rgba(15, 23, 42, 0.98);
}
.resource-list-count {
font-size: 0.75rem;
color: #64748b;
color: #94a3b8;
margin: 0 0.6rem 0.55rem;
position: sticky;
top: 31px;
z-index: 4;
background: rgba(15, 23, 42, 0.95);
padding-bottom: 0.25rem;
}
.resource-row {
@@ -353,18 +395,18 @@
align-items: center;
gap: 1rem;
padding: 0.65rem 0.8rem;
background: rgba(248, 250, 252, 0.96);
border: 1px solid #dbe2ea;
background: rgba(15, 23, 42, 0.96);
border: 1px solid #334155;
border-radius: 10px;
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.05);
box-shadow: 0 4px 12px rgba(2, 6, 23, 0.55);
cursor: pointer;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.resource-row:hover {
transform: translateY(-1px);
border-color: #94a3b8;
box-shadow: 0 9px 18px rgba(15, 23, 42, 0.1);
border-color: #60a5fa;
box-shadow: 0 12px 22px rgba(2, 6, 23, 0.65);
}
.row-main {
@@ -382,13 +424,14 @@
.row-name {
margin: 0;
font-size: 0.98rem;
color: #f8fafc;
}
.resource-group-pill {
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: #e2e8f0;
color: #334155;
background: #1e293b;
color: #cbd5e1;
font-size: 0.72rem;
font-weight: 700;
}
@@ -414,6 +457,7 @@
.row-url,
.row-type {
margin: 0;
color: #94a3b8;
}
.row-url {
@@ -436,6 +480,12 @@
.row-metrics .stat-label {
font-size: 0.64rem;
color: #94a3b8;
}
.row-metrics .stat-value {
color: #e2e8f0;
font-weight: 700;
}
.row-sparkline {
@@ -488,11 +538,40 @@
margin: 0 0 0.2rem 0;
}
.trend-chip {
display: inline-block;
margin-left: 0.3rem;
font-size: 0.72rem;
font-weight: 800;
}
.trend-better {
color: #34d399;
}
.trend-worse {
color: #f87171;
}
.trend-flat {
color: #94a3b8;
}
.resource-url {
color: #64748b;
font-size: 0.8rem;
word-break: break-all;
}
.load-more-btn {
width: 100%;
border: 1px dashed #94a3b8;
background: #f8fafc;
color: #334155;
border-radius: 10px;
padding: 0.65rem;
font-weight: 700;
cursor: pointer;
}
.load-more-btn:hover {
background: #f1f5f9;
}
.resource-type {
margin: 0.15rem 0 0;
@@ -537,7 +616,7 @@
.stat-value {
font-size: 1.2rem;
font-weight: 700;
color: #333;
color: #e2e8f0;
margin: 0;
}
@@ -547,7 +626,7 @@
.stat-label {
font-size: 0.75rem;
color: #666;
color: #94a3b8;
text-transform: uppercase;
margin: 0;
}
@@ -567,7 +646,7 @@
.last-check {
margin: 0;
font-size: 0.75rem;
color: #999;
color: #94a3b8;
}
.group-section {
@@ -671,17 +750,48 @@
}
.btn-icon {
background: none;
border: none;
background: #0b1325;
border: 1px solid #334155;
color: #e2e8f0;
min-width: 34px;
min-height: 30px;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem 0.5rem;
opacity: 0.6;
transition: opacity 0.2s;
opacity: 1;
transition: transform 0.15s, background 0.2s, border-color 0.2s;
}
.btn-icon:hover {
opacity: 1;
transform: translateY(-1px);
background: #111f34;
border-color: #60a5fa;
}
.btn-icon:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.btn-icon.action-edit {
color: #93c5fd;
}
.btn-icon.action-maint {
color: #fbbf24;
}
.btn-icon.action-delete {
color: #fca5a5;
border-color: #7f1d1d;
background: #3f1d1d;
}
.btn-icon.action-delete:hover {
background: #7f1d1d;
border-color: #ef4444;
color: #fee2e2;
}
.btn-sm {
@@ -813,17 +923,18 @@
}
.detail-section {
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e2e8f0;
background: rgba(15, 23, 42, 0.94);
border: 1px solid #334155;
border-radius: 14px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
box-shadow: 0 10px 24px rgba(2, 6, 23, 0.55);
color: #e2e8f0;
}
.detail-section h2 {
margin-top: 0;
color: #333;
color: #f8fafc;
}
.detail-header-row {
@@ -841,7 +952,7 @@
}
.resource-url-detail {
color: #64748b;
color: #94a3b8;
margin: 0;
}
@@ -875,7 +986,15 @@
.filter-control label {
font-size: 0.9rem;
color: #475569;
color: #cbd5e1;
}
.filter-control select {
background: #111827;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 6px;
padding: 0.3rem 0.5rem;
}
.controls-pagination {
@@ -896,9 +1015,9 @@
.data-table th {
padding: 0.75rem;
text-align: left;
border-bottom: 2px solid #dbe2ea;
color: #1e293b;
background: #f1f5f9;
border-bottom: 2px solid #334155;
color: #e2e8f0;
background: #111827;
font-size: 0.86rem;
text-transform: uppercase;
letter-spacing: 0.02em;
@@ -906,11 +1025,12 @@
.data-table td {
padding: 0.75rem;
border-bottom: 1px solid #e5e7eb;
border-bottom: 1px solid #1f2937;
color: #e2e8f0;
}
.data-table tbody tr:hover {
background: #f0f9ff;
background: #1e293b;
}
.text-right {
@@ -918,7 +1038,7 @@
}
.muted-cell {
color: #64748b;
color: #94a3b8;
}
.w-25 {
@@ -942,14 +1062,14 @@
.incident-reason-toggle {
cursor: pointer;
color: #0f766e;
color: #93c5fd;
flex: 1;
}
.expand-indicator {
margin-left: 0.5rem;
font-size: 0.85rem;
color: #64748b;
color: #94a3b8;
}
.btn-compact {
@@ -958,7 +1078,7 @@
}
.expanded-row {
background: #f8fafc;
background: #0b1325;
}
.expanded-cell {
@@ -966,7 +1086,7 @@
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace;
font-size: 0.9rem;
color: #334155;
color: #e2e8f0;
}
.section-title-row {
@@ -978,7 +1098,7 @@
}
.empty-copy {
color: #64748b;
color: #94a3b8;
text-align: center;
padding: 2rem;
}
@@ -986,6 +1106,10 @@
.chart-container {
margin-top: 1rem;
height: 300px;
background: #0b1325;
border: 1px solid #334155;
border-radius: 12px;
padding: 0.5rem;
}
.incident-badge {
@@ -1002,15 +1126,16 @@
display: inline-block;
margin-bottom: 1rem;
padding: 0.5rem 1rem;
background: #6c757d;
color: white;
background: #1e293b;
color: #f8fafc;
text-decoration: none;
border-radius: 6px;
border: 1px solid #334155;
transition: background 0.2s;
}
.back-button:hover {
background: #5a6268;
background: #334155;
}
.empty-state {
@@ -1111,6 +1236,16 @@
position: static;
}
.resource-list-shell {
max-height: none;
overflow: visible;
}
.resource-list-count,
.resource-list-header {
position: static;
}
.resource-row {
grid-template-columns: 1fr;
gap: 0.7rem;
+134 -23
View File
@@ -52,6 +52,9 @@ function Dashboard() {
});
const [tagFilter, setTagFilter] = useState('');
const [groupFilter, setGroupFilter] = useState('all');
const [quickFilter, setQuickFilter] = useState('all');
const [sortKey, setSortKey] = useState('severity');
const [renderLimit, setRenderLimit] = useState(120);
const [groupData, setGroupData] = useState({ name: '', description: '' });
const wsRef = useRef(null);
const actionsRef = useRef(null);
@@ -238,6 +241,17 @@ function Dashboard() {
}
};
const handleToggleMaintenance = async (resource) => {
try {
await axios.patch(`/api/resources/${resource.id}/maintenance-mode`, {
maintenance_mode: !resource.maintenance_mode,
});
loadResources();
} catch (error) {
alert('Error updating maintenance mode');
}
};
const handleExportCSV = async () => {
try {
const response = await axios.get('/api/resources/export', {
@@ -283,7 +297,28 @@ function Dashboard() {
return acc;
}, {});
// Filter resources by tag/group and sort for stable scanning
const getTrend = (recentChecks = []) => {
const withTimes = recentChecks.filter((c) => typeof c.response_time === 'number' && c.response_time > 0);
if (withTimes.length < 2) return 'flat';
const first = withTimes[0].response_time;
const last = withTimes[withTimes.length - 1].response_time;
if (first <= 0) return 'flat';
const deltaPct = ((last - first) / first) * 100;
if (deltaPct <= -12) return 'better';
if (deltaPct >= 12) return 'worse';
return 'flat';
};
const getSeverityScore = (r) => {
const statusScore = r.status === 'down' ? 1000 : r.status === 'unknown' ? 400 : 0;
const incidentScore = r.hasActiveIncident ? 350 : 0;
const maintenanceOffset = r.maintenance_mode ? -300 : 0;
const uptimePenalty = Math.max(0, 100 - parseFloat(r.uptime || 0)) * 3;
const responsePenalty = Math.min(250, Math.round((parseInt(r.avgResponseTime || 0, 10) || 0) / 20));
return statusScore + incidentScore + uptimePenalty + responsePenalty + maintenanceOffset;
};
// Filter resources by tag/group/quick filters
const filteredResources = resources
.filter((r) => {
if (tagFilter) {
@@ -292,9 +327,39 @@ function Dashboard() {
}
if (groupFilter === 'ungrouped') return !r.group_id;
if (groupFilter !== 'all') return String(r.group_id || '') === groupFilter;
if (quickFilter === 'down' && r.status !== 'down') return false;
if (quickFilter === 'maintenance' && !r.maintenance_mode) return false;
if (quickFilter === 'nodata' && (!r.recentChecks || r.recentChecks.length === 0)) return false;
if (quickFilter === 'slow' && (parseInt(r.avgResponseTime || 0, 10) || 0) < 1500) return false;
return true;
})
.sort((a, b) => {
});
const sortedResources = [...filteredResources].sort((a, b) => {
if (sortKey === 'name') {
return String(a.name || '').localeCompare(String(b.name || ''));
}
if (sortKey === 'status') {
const rank = { down: 0, unknown: 1, up: 2 };
return (rank[a.status] ?? 3) - (rank[b.status] ?? 3);
}
if (sortKey === 'uptime') {
return parseFloat(a.uptime || 0) - parseFloat(b.uptime || 0);
}
if (sortKey === 'response') {
return (parseInt(b.avgResponseTime || 0, 10) || 0) - (parseInt(a.avgResponseTime || 0, 10) || 0);
}
if (sortKey === 'lastcheck') {
const ta = a.lastCheck ? new Date(a.lastCheck).getTime() : 0;
const tb = b.lastCheck ? new Date(b.lastCheck).getTime() : 0;
return tb - ta;
}
// Default: severity first
const severityDiff = getSeverityScore(b) - getSeverityScore(a);
if (severityDiff !== 0) return severityDiff;
const groupA = (groupNameById[a.group_id] || 'Ungrouped').toLowerCase();
const groupB = (groupNameById[b.group_id] || 'Ungrouped').toLowerCase();
if (groupA < groupB) return -1;
@@ -302,18 +367,24 @@ function Dashboard() {
return String(a.name || '').localeCompare(String(b.name || ''));
});
const totalCount = filteredResources.length;
const upCount = filteredResources.filter((r) => r.status === 'up').length;
const downCount = filteredResources.filter((r) => r.status === 'down').length;
const maintenanceCount = filteredResources.filter((r) => r.maintenance_mode).length;
useEffect(() => {
setRenderLimit(120);
}, [tagFilter, groupFilter, quickFilter, sortKey]);
const visibleResources = sortedResources.slice(0, renderLimit);
const totalCount = sortedResources.length;
const upCount = sortedResources.filter((r) => r.status === 'up').length;
const downCount = sortedResources.filter((r) => r.status === 'down').length;
const maintenanceCount = sortedResources.filter((r) => r.maintenance_mode).length;
const avgUptime = totalCount
? (filteredResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / totalCount).toFixed(2)
? (sortedResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / totalCount).toFixed(2)
: '0.00';
const avgResponse = totalCount
? Math.round(filteredResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0, 10), 0) / totalCount)
? Math.round(sortedResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0, 10), 0) / totalCount)
: 0;
const activeIncidents = filteredResources
const activeIncidents = sortedResources
.filter((r) => r.hasActiveIncident || r.status === 'down')
.slice(0, 8);
@@ -321,11 +392,11 @@ function Dashboard() {
.map((g) => ({
id: g.id,
name: g.name,
count: filteredResources.filter((r) => String(r.group_id || '') === String(g.id)).length,
count: sortedResources.filter((r) => String(r.group_id || '') === String(g.id)).length,
}))
.filter((g) => g.count > 0);
const ungroupedCount = filteredResources.filter((r) => !r.group_id).length;
const ungroupedCount = sortedResources.filter((r) => !r.group_id).length;
const renderResourceRow = (resource) => {
const sparkData = (resource.recentChecks || []).map((c, idx) => ({
@@ -334,6 +405,7 @@ function Dashboard() {
statusValue: c.status === 'up' ? 1 : 0,
}));
const groupName = groupNameById[resource.group_id] || 'Ungrouped';
const trend = getTrend(resource.recentChecks || []);
return (
<div
@@ -358,7 +430,10 @@ function Dashboard() {
<p className="stat-label">Uptime</p>
</div>
<div>
<p className="stat-value small">{resource.avgResponseTime}ms</p>
<p className="stat-value small">
{resource.avgResponseTime}ms
<span className={`trend-chip trend-${trend}`}>{trend === 'better' ? '↓' : trend === 'worse' ? '↑' : '→'}</span>
</p>
<p className="stat-label">Avg Resp</p>
</div>
<div className="row-sparkline">
@@ -379,8 +454,15 @@ function Dashboard() {
{resource.status}
</span>
<div className="row-actions">
<button className="btn-icon" title="Edit" onClick={(e) => { e.stopPropagation(); openEditModal(resource); }}>✎</button>
<button className="btn-icon" title="Delete" onClick={(e) => { e.stopPropagation(); handleDeleteResource(resource.id); }}>🗑</button>
<button className="btn-icon action-edit" title="Edit" onClick={(e) => { e.stopPropagation(); openEditModal(resource); }}>✎</button>
<button
className="btn-icon action-maint"
title={resource.maintenance_mode ? 'End maintenance' : 'Start maintenance'}
onClick={(e) => { e.stopPropagation(); handleToggleMaintenance(resource); }}
>
{resource.maintenance_mode ? '✅' : '🛠'}
</button>
<button className="btn-icon action-delete" title="Delete" onClick={(e) => { e.stopPropagation(); handleDeleteResource(resource.id); }}>🗑</button>
</div>
<p className="last-check">
Last: {resource.lastCheck ? formatLocalTime(resource.lastCheck) : 'Never'}
@@ -431,6 +513,18 @@ function Dashboard() {
))}
<option value="ungrouped">Ungrouped</option>
</select>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
className="tag-filter-input"
>
<option value="severity">Sort: Severity</option>
<option value="status">Sort: Status</option>
<option value="uptime">Sort: Uptime</option>
<option value="response">Sort: Response</option>
<option value="lastcheck">Sort: Last Check</option>
<option value="name">Sort: Name</option>
</select>
<div ref={actionsRef} className="actions-menu-wrapper">
<button
className="btn btn-secondary"
@@ -487,6 +581,14 @@ function Dashboard() {
</div>
</div>
<div className="quick-filter-row">
<button className={`quick-chip ${quickFilter === 'all' ? 'active' : ''}`} onClick={() => setQuickFilter('all')}>All</button>
<button className={`quick-chip ${quickFilter === 'down' ? 'active' : ''}`} onClick={() => setQuickFilter('down')}>Down</button>
<button className={`quick-chip ${quickFilter === 'maintenance' ? 'active' : ''}`} onClick={() => setQuickFilter('maintenance')}>Maintenance</button>
<button className={`quick-chip ${quickFilter === 'slow' ? 'active' : ''}`} onClick={() => setQuickFilter('slow')}>High Latency</button>
<button className={`quick-chip ${quickFilter === 'nodata' ? 'active' : ''}`} onClick={() => setQuickFilter('nodata')}>No Data</button>
</div>
<div className="cc-metrics-grid">
<div className="cc-metric-card">
<p className="cc-metric-label">Monitors</p>
@@ -519,7 +621,7 @@ function Dashboard() {
<h3>No resources yet</h3>
<p>Add your first resource to start monitoring</p>
</div>
) : filteredResources.length === 0 ? (
) : sortedResources.length === 0 ? (
<div className="empty-state">
<h3>No resources match filter</h3>
<p>Try adjusting your tag/group filters</p>
@@ -532,9 +634,14 @@ function Dashboard() {
<div>Metrics</div>
<div>Status</div>
</div>
<div className="resource-list-count">{filteredResources.length} monitors</div>
<div className="resource-list-count">{visibleResources.length} of {sortedResources.length} monitors</div>
<div className="resource-list">
{filteredResources.map(renderResourceRow)}
{visibleResources.map(renderResourceRow)}
{visibleResources.length < sortedResources.length && (
<button className="load-more-btn" onClick={() => setRenderLimit((prev) => prev + 120)}>
Load 120 more
</button>
)}
</div>
</section>
@@ -1387,11 +1494,15 @@ function ResourceDetail() {
<div className="chart-container">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="responseTime" stroke="#667eea" strokeWidth={2} />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="time" tick={{ fill: '#94a3b8', fontSize: 12 }} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 12 }} />
<Tooltip
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#cbd5e1' }}
itemStyle={{ color: '#e2e8f0' }}
/>
<Line type="monotone" dataKey="responseTime" stroke="#60a5fa" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
+290
View File
@@ -0,0 +1,290 @@
.cc-page {
padding-top: 1.4rem;
color: #e2e8f0;
}
.cc-alert {
padding: 0.85rem 1rem;
margin-bottom: 1rem;
border-radius: 10px;
border: 1px solid #854d0e;
background: #2b1907;
color: #fcd34d;
}
.cc-page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1.2rem;
flex-wrap: wrap;
}
.cc-page-title {
margin: 0;
font-size: 1.65rem;
letter-spacing: -0.02em;
color: #f8fafc !important;
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
}
.cc-page h2 {
color: #f8fafc;
}
.cc-controls {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.cc-pill-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid #334155;
border-radius: 999px;
background: #111827;
color: #f1f5f9;
font-size: 0.82rem;
}
.cc-pill-toggle input {
accent-color: #3b82f6;
}
.cc-pill-toggle:focus-within,
.cc-input-sm:focus,
.cc-select-sm:focus {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.cc-surface {
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
padding: 1rem;
}
.cc-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.65rem;
}
.cc-kpi {
background: #0b1325;
border: 1px solid #334155;
border-radius: 10px;
padding: 0.75rem;
}
.cc-kpi-label {
margin: 0;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #cbd5e1;
}
.cc-kpi-value {
margin: 0.35rem 0 0;
color: #f8fafc;
font-size: 1.45rem;
font-weight: 700;
}
.cc-table-wrap {
overflow: auto;
border: 1px solid #334155;
border-radius: 12px;
background: #0b1325;
}
.cc-table {
width: 100%;
border-collapse: collapse;
}
.cc-table thead tr {
background: #0f172a;
}
.cc-table th {
text-align: left;
padding: 0.75rem;
color: #e2e8f0;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.07em;
border-bottom: 1px solid #334155;
}
.cc-table td {
padding: 0.75rem;
border-bottom: 1px solid #1f2937;
color: #f1f5f9;
}
.cc-table tbody tr:hover {
background: #1e293b;
}
.cc-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1rem;
padding: 1rem;
background: #111827;
border: 1px solid #334155;
border-radius: 10px;
}
.cc-input-sm {
width: 56px;
padding: 0.45rem;
border: 1px solid #334155;
border-radius: 8px;
text-align: center;
background: #111827;
color: #e2e8f0;
}
.cc-select-sm {
padding: 0.45rem;
border: 1px solid #334155;
border-radius: 8px;
background: #111827;
color: #e2e8f0;
}
.cc-empty {
text-align: center;
color: #cbd5e1;
padding: 2.2rem 1rem;
}
.history-resource-card {
margin-bottom: 1.4rem;
}
.history-resource-title {
margin: 0 0 0.45rem;
color: #dbeafe;
}
.history-resource-meta {
margin: 0 0 1rem;
color: #e2e8f0;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.history-resource-meta strong {
color: #f8fafc;
}
.history-chart-box {
height: 400px;
margin-bottom: 1rem;
background: #111827;
padding: 1rem;
border-radius: 10px;
border: 1px solid #334155;
}
.history-trend-row {
display: flex;
gap: 1rem;
margin-bottom: 0.8rem;
color: #e2e8f0;
flex-wrap: wrap;
}
.notifications-list {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.notification-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
border-radius: 10px;
border: 1px solid #334155;
background: #0b1325;
}
.notification-row.is-unread {
border-color: #1d4ed8;
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.35);
}
.notification-content {
flex: 1;
}
.notification-title {
margin: 0 0 0.4rem;
color: #f8fafc;
font-size: 1rem;
}
.notification-message {
margin: 0 0 0.5rem;
color: #e2e8f0;
}
.notification-meta {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
color: #cbd5e1;
font-size: 0.84rem;
}
.notification-chip {
background: #111827;
border: 1px solid #334155;
border-radius: 999px;
padding: 0.2rem 0.55rem;
color: #f1f5f9;
}
.notification-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.notification-actions .btn:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
@media (max-width: 768px) {
.cc-controls {
width: 100%;
}
.cc-controls .btn,
.cc-controls .tag-filter-input,
.cc-controls .cc-pill-toggle {
width: 100%;
}
.cc-pagination {
flex-direction: column;
align-items: stretch;
}
}
+55 -59
View File
@@ -12,6 +12,7 @@ import {
ResponsiveContainer,
} from 'recharts';
import './App.css';
import './CommandCenterPages.css';
import { formatChartTime } from './utils/timeUtils';
const asNumberOrZero = (value) => {
@@ -107,23 +108,16 @@ function History() {
if (error) return <div className="container">Error loading history: {error}</div>;
return (
<div className="container">
<div className="container cc-page">
{limitedByRetention && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
color: '#856404'
}}>
<div className="cc-alert">
⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2>Monitoring History</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div className="cc-page-header">
<h2 className="cc-page-title" style={{ color: '#f8fafc', fontWeight: 800 }}>Monitoring History</h2>
<div className="cc-controls">
<div className="cc-controls">
{[7, 14, 30].map((d) => (
<button
key={d}
@@ -136,21 +130,19 @@ function History() {
</button>
))}
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
<label className="cc-pill-toggle">
<input
type="checkbox"
checked={showAveraged}
onChange={(e) => setShowAveraged(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
<span>Show Averages</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
<label className="cc-pill-toggle">
<input
type="checkbox"
checked={showTrends}
onChange={(e) => setShowTrends(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
<span>Show Week-over-Week Trends</span>
</label>
@@ -169,41 +161,43 @@ function History() {
const chartData = prepareChartData(resource.checks || []);
return (
<div key={resource.id} className="detail-section" style={{ marginBottom: '3rem', background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<h3 style={{ marginBottom: '0.5rem', color: '#667eea', fontSize: '1.5rem' }}>{resource.name}</h3>
<p style={{ color: '#666', marginBottom: '1.5rem', fontSize: '0.95rem' }}>
<span style={{ marginRight: '1.5rem' }}>Type: <strong style={{ color: '#333' }}>{resource.type.toUpperCase()}</strong></span>
<span style={{ marginRight: '1.5rem' }}>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336' }}>{resource.uptime}%</strong></span>
<span>Avg Response: <strong style={{ color: '#333' }}>{resource.avgResponseTime}ms</strong></span>
<div key={resource.id} className="detail-section cc-surface history-resource-card">
<h3 className="history-resource-title">{resource.name}</h3>
<p className="history-resource-meta">
<span>Type: <strong>{resource.type.toUpperCase()}</strong></span>
<span>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</strong></span>
<span>Avg Response: <strong>{resource.avgResponseTime}ms</strong></span>
</p>
{chartData.length > 0 ? (
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
<div className="chart-container history-chart-box">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="label"
tick={{ fontSize: 12, fill: '#666' }}
tick={{ fontSize: 12, fill: '#94a3b8' }}
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis
yAxisId="left"
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
tick={{ fontSize: 12, fill: '#666' }}
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
tick={{ fontSize: 12, fill: '#94a3b8' }}
/>
<YAxis
yAxisId="right"
orientation="right"
domain={[0, 1]}
ticks={[0, 1]}
tick={{ fontSize: 12, fill: '#666' }}
label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#666' } }}
tick={{ fontSize: 12, fill: '#94a3b8' }}
label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#94a3b8' } }}
/>
<Tooltip
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
labelStyle={{ color: '#cbd5e1' }}
itemStyle={{ color: '#e2e8f0' }}
formatter={(value, name) => {
if (name === 'Response Time') return `${value}ms`;
if (name === 'Status') return value === 1 ? '✓ UP' : '✗ DOWN';
@@ -249,18 +243,18 @@ function History() {
</ResponsiveContainer>
</div>
) : (
<div style={{ color: '#999', padding: '2rem', textAlign: 'center', background: '#fafafa', borderRadius: '6px' }}>No check data available</div>
<div className="cc-empty">No check data available</div>
)}
{showTrends && resource.trendsData && (
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
<h4 style={{ marginBottom: '1rem', color: '#667eea' }}>Week-over-Week Comparison</h4>
<div className="chart-container history-chart-box">
<h4 className="history-resource-title">Week-over-Week Comparison</h4>
{(() => {
const comparison = resource.trendsData?.comparison || {};
const responseTimeChange = asNumberOrZero(comparison.response_time_change);
const uptimeChange = asNumberOrZero(comparison.uptime_change);
return (
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.9rem' }}>
<div className="history-trend-row">
<div>
<strong>Response Time Change:</strong>{' '}
<span style={{ color: responseTimeChange >= 0 ? '#f44336' : '#4caf50' }}>
@@ -280,20 +274,22 @@ function History() {
})()}
<ResponsiveContainer width="100%" height="90%">
<ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="day"
tick={{ fontSize: 12, fill: '#666' }}
tick={{ fontSize: 12, fill: '#94a3b8' }}
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
tick={{ fontSize: 12, fill: '#666' }}
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
tick={{ fontSize: 12, fill: '#94a3b8' }}
/>
<Tooltip
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
labelStyle={{ color: '#cbd5e1' }}
itemStyle={{ color: '#e2e8f0' }}
formatter={(value, name) => {
if (name === 'Current Period') return `${value}ms`;
if (name === 'Previous Period') return `${value}ms`;
@@ -327,22 +323,22 @@ function History() {
</div>
)}
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.checks?.length || 0}</p>
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Total Checks</p>
<div className="cc-kpi-grid">
<div className="cc-kpi">
<p className="cc-kpi-label">Total Checks</p>
<p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{resource.checks?.length || 0}</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
<p className="stat-value" style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336', fontSize: '2rem', fontWeight: 'bold' }}>{resource.uptime}%</p>
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Uptime</p>
<div className="cc-kpi">
<p className="cc-kpi-label">Uptime</p>
<p className="cc-kpi-value" style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.avgResponseTime}ms</p>
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Avg Response</p>
<div className="cc-kpi">
<p className="cc-kpi-label">Avg Response</p>
<p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{resource.avgResponseTime}ms</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2rem', fontWeight: 'bold' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p>
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Successful Checks</p>
<div className="cc-kpi">
<p className="cc-kpi-label">Successful Checks</p>
<p className="cc-kpi-value" style={{ color: '#34d399' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p>
</div>
</div>
</div>
@@ -350,7 +346,7 @@ function History() {
})}
{totalItems > pageSize && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
<div className="cc-pagination">
<button
className="btn btn-secondary"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
@@ -358,8 +354,8 @@ function History() {
>
← Previous
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span>
<div className="cc-controls">
<span>Page</span>
<input
type="number"
value={currentPage}
@@ -367,14 +363,14 @@ function History() {
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
setCurrentPage(page);
}}
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }}
className="cc-input-sm"
/>
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span>
<span>of {Math.ceil(totalItems / pageSize)}</span>
</div>
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }}
className="cc-select-sm"
>
<option value={5}>5 per page</option>
<option value={10}>10 per page</option>
+21 -28
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils';
import './CommandCenterPages.css';
function Notifications() {
const [notifications, setNotifications] = useState([]);
@@ -106,11 +107,13 @@ function Notifications() {
const totalPages = Math.ceil(total / itemsPerPage);
return (
<div className="container" style={{ paddingTop: '2rem' }}>
<h2>📬 Notifications</h2>
<div className="container cc-page">
<div className="cc-page-header">
<h2 className="cc-page-title">Notifications</h2>
</div>
<div style={{ marginBottom: '2rem', display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<div className="cc-controls" style={{ marginBottom: '1rem' }}>
<div className="cc-controls">
<button
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilter('all'); setCurrentPage(1); }}
@@ -143,50 +146,40 @@ function Notifications() {
</div>
{loading ? (
<p style={{ textAlign: 'center', color: '#999' }}>Loading...</p>
<p className="cc-empty">Loading...</p>
) : notifications.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#999' }}>
<div className="cc-empty">
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}>
<div className="notifications-list">
{notifications.map((notification) => (
<div
key={notification.id}
style={{
padding: '1rem',
border: '1px solid #ddd',
borderRadius: '6px',
backgroundColor: notification.read ? 'white' : '#f0f8ff',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem'
}}
className={`notification-row ${notification.read ? 'is-read' : 'is-unread'}`}
>
<div style={{ flex: 1 }}>
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '1rem' }}>{notification.title}</h4>
<p style={{ margin: '0 0 0.5rem 0', color: '#666', fontSize: '0.95rem' }}>
<div className="notification-content">
<h4 className="notification-title">{notification.title}</h4>
<p className="notification-message">
{notification.message}
</p>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', color: '#999', flexWrap: 'wrap' }}>
<div className="notification-meta">
{notification.resource_name && (
<span style={{ backgroundColor: '#f0f0f0', padding: '0.25rem 0.5rem', borderRadius: '3px' }}>
📌 {notification.resource_name}
<span className="notification-chip">
{notification.resource_name}
</span>
)}
<span>⏰ {formatLocalTime(notification.created_at)}</span>
<span>{formatLocalTime(notification.created_at)}</span>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<div className="notification-actions">
{!notification.read && (
<button
className="btn btn-sm"
onClick={() => handleMarkAsRead(notification.id)}
title="Mark as read"
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem' }}
>
✓ Read
</button>
@@ -195,7 +188,7 @@ function Notifications() {
className="btn btn-sm"
onClick={() => handleDelete(notification.id)}
title="Delete"
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem', background: '#dc3545' }}
style={{ background: '#b91c1c' }}
>
✕ Delete
</button>
@@ -206,7 +199,7 @@ function Notifications() {
{/* Pagination */}
{totalPages > 1 && (
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<div className="cc-pagination">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
+57 -39
View File
@@ -2,9 +2,14 @@
padding-top: 2rem;
}
.observability-page h2 {
color: #f8fafc;
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
}
.obs-tabs {
margin-bottom: 2rem;
border-bottom: 1px solid #dbe2ea;
border-bottom: 1px solid #334155;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
@@ -19,15 +24,20 @@
background: transparent;
cursor: pointer;
font-weight: 600;
color: #64748b;
color: #cbd5e1;
}
.obs-tab:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.obs-tab.is-active {
border-bottom-color: #0ea5a2;
color: #0f172a;
background: #ffffff;
border-color: #dbe2ea;
border-bottom-color: #0ea5a2;
border-bottom-color: #60a5fa;
color: #f8fafc;
background: #0f172a;
border-color: #334155;
border-bottom-color: #60a5fa;
}
.obs-summary-grid {
@@ -39,30 +49,30 @@
.obs-summary-card {
padding: 1rem;
border: 1px solid #dbe2ea;
border: 1px solid #334155;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
background: linear-gradient(180deg, #0f172a 0%, #0b1325 100%);
box-shadow: none;
}
.obs-card-label {
font-size: 0.85rem;
color: #64748b;
color: #cbd5e1;
}
.obs-card-value {
font-size: 1.5rem;
font-weight: 700;
margin-top: 0.45rem;
color: #0f172a;
color: #f8fafc;
}
.obs-card-value.is-danger {
color: #b91c1c;
color: #f87171;
}
.obs-card-value.is-ok {
color: #0f766e;
color: #34d399;
}
.obs-table-wrap {
@@ -81,9 +91,9 @@
.obs-table th {
text-align: left;
padding: 0.75rem;
background: #f1f5f9;
border-bottom: 2px solid #dbe2ea;
color: #1e293b;
background: #0f172a;
border-bottom: 2px solid #334155;
color: #e2e8f0;
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.02em;
@@ -91,11 +101,12 @@
.obs-table td {
padding: 0.75rem;
border-bottom: 1px solid #e5e7eb;
border-bottom: 1px solid #1f2937;
color: #f1f5f9;
}
.obs-table tbody tr:hover {
background: #f0f9ff;
background: #1e293b;
}
.obs-right {
@@ -105,38 +116,38 @@
.obs-rate-pill {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
font-size: 0.88rem;
font-weight: 600;
}
.obs-rate-pill.is-good {
background: #dcfce7;
color: #166534;
background: #052e16;
color: #4ade80;
}
.obs-rate-pill.is-warn {
background: #fef3c7;
color: #92400e;
background: #3f2a0f;
color: #fbbf24;
}
.obs-empty-copy {
color: #94a3b8;
color: #cbd5e1;
text-align: center;
padding: 2rem;
}
.obs-zebra {
background: #f8fafc;
background: #0b1325;
}
.obs-error-code {
font-size: 0.85rem;
color: #b91c1c;
color: #f87171;
}
.obs-muted-sm {
font-size: 0.85rem;
color: #64748b;
color: #cbd5e1;
}
.obs-small {
@@ -146,15 +157,15 @@
.obs-audit-summary {
margin-bottom: 2rem;
padding: 1rem;
background: #f8fafc;
background: #0f172a;
border-radius: 12px;
border: 1px solid #dbe2ea;
border: 1px solid #334155;
}
.obs-audit-summary-line {
margin: 0.25rem 0;
font-size: 0.9rem;
color: #64748b;
color: #e2e8f0;
}
.obs-filters-grid {
@@ -166,15 +177,21 @@
.obs-filter-input {
padding: 0.5rem;
border: 1px solid #dbe2ea;
border: 1px solid #334155;
border-radius: 10px;
background: #fff;
background: #111827;
color: #e2e8f0;
}
.obs-filter-input:focus {
outline: none;
border-color: #0ea5a2;
box-shadow: 0 0 0 3px rgba(14, 165, 162, 0.18);
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
.obs-toggle-code:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.obs-click-row {
@@ -183,11 +200,11 @@
.obs-toggle-code {
cursor: pointer;
color: #0f766e;
color: #bfdbfe;
}
.obs-expanded-row {
background: #f0f9ff;
background: #111b2e;
}
.obs-expanded-cell {
@@ -196,11 +213,12 @@
.obs-pre {
margin-top: 0.5rem;
background: #fff;
background: #0b1325;
color: #e2e8f0;
padding: 0.75rem;
border-radius: 8px;
overflow-x: auto;
border: 1px solid #dbe2ea;
border: 1px solid #334155;
}
@media (max-width: 768px) {
+54 -60
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './App.css';
import './CommandCenterPages.css';
function SLA() {
const [slaData, setSlaData] = useState([]);
@@ -66,22 +67,15 @@ function SLA() {
} : null;
return (
<div className="container">
<div className="container cc-page">
{limitedByRetention && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
color: '#856404'
}}>
<div className="cc-alert">
⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2>SLA Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div className="cc-page-header">
<h2 className="cc-page-title">SLA Dashboard</h2>
<div className="cc-controls">
{[7, 14, 30, 90].map((d) => (
<button
key={d}
@@ -95,33 +89,33 @@ function SLA() {
))}
</div>
{limitedByRetention && (
<div style={{ background: '#FFF8E1', color: '#8D6E63', padding: '0.5rem 0.75rem', borderRadius: '6px', border: '1px solid #FFECB3' }}>
<div className="cc-alert" style={{ marginBottom: 0 }}>
Limited by data retention ({effectiveDays} days). Increase Retention in Settings to view longer periods.
</div>
)}
</div>
{overallStats && (
<div className="stats-grid" style={{ marginBottom: '2rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.totalResources}</p>
<p className="stat-label">Total Resources</p>
<div className="cc-kpi-grid" style={{ marginBottom: '1rem' }}>
<div className="cc-kpi">
<p className="cc-kpi-label">Total Resources</p>
<p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{overallStats.totalResources}</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2.5rem' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
<p className="stat-label">Meeting SLA Target</p>
<div className="cc-kpi">
<p className="cc-kpi-label">Meeting SLA Target</p>
<p className="cc-kpi-value" style={{ color: '#22c55e' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.avgUptime}%</p>
<p className="stat-label">Average Uptime</p>
<div className="cc-kpi">
<p className="cc-kpi-label">Average Uptime</p>
<p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{overallStats.avgUptime}%</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#ff9800', fontSize: '2.5rem' }}>{overallStats.totalIncidents}</p>
<p className="stat-label">Total Incidents</p>
<div className="cc-kpi">
<p className="cc-kpi-label">Total Incidents</p>
<p className="cc-kpi-value" style={{ color: '#f59e0b' }}>{overallStats.totalIncidents}</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#f44336', fontSize: '2.5rem' }}>{Math.round(overallStats.totalDowntime)}m</p>
<p className="stat-label">Total Downtime</p>
<div className="cc-kpi">
<p className="cc-kpi-label">Total Downtime</p>
<p className="cc-kpi-value" style={{ color: '#ef4444' }}>{Math.round(overallStats.totalDowntime)}m</p>
</div>
</div>
)}
@@ -132,46 +126,46 @@ function SLA() {
<p>Add resources to start tracking SLA metrics</p>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div className="cc-table-wrap">
<table className="cc-table">
<thead>
<tr style={{ background: '#667eea', color: 'white' }}>
<th style={{ padding: '1rem', textAlign: 'left' }}>Resource</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Target</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Actual</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Status</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Checks</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Incidents</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Downtime</th>
<tr>
<th>Resource</th>
<th style={{ textAlign: 'center' }}>Target</th>
<th style={{ textAlign: 'center' }}>Actual</th>
<th style={{ textAlign: 'center' }}>Status</th>
<th style={{ textAlign: 'center' }}>Checks</th>
<th style={{ textAlign: 'center' }}>Incidents</th>
<th style={{ textAlign: 'center' }}>Downtime</th>
</tr>
</thead>
<tbody>
{slaData.map((item, idx) => (
<tr key={item.resource_id} style={{ borderBottom: '1px solid #eee', background: idx % 2 === 0 ? '#fafafa' : '#fff' }}>
<td style={{ padding: '1rem', fontWeight: 'bold' }}>{item.resource_name}</td>
<td style={{ padding: '1rem', textAlign: 'center' }}>{item.sla_target}%</td>
<td style={{ padding: '1rem', textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#4caf50' : '#f44336' }}>
<tr key={item.resource_id} style={{ background: idx % 2 === 0 ? '#0b1325' : '#0f172a' }}>
<td style={{ fontWeight: 'bold' }}>{item.resource_name}</td>
<td style={{ textAlign: 'center' }}>{item.sla_target}%</td>
<td style={{ textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#22c55e' : '#ef4444' }}>
{item.actual_uptime}%
</td>
<td style={{ padding: '1rem', textAlign: 'center' }}>
<td style={{ textAlign: 'center' }}>
<span style={{
padding: '0.25rem 0.75rem',
borderRadius: '12px',
background: item.meets_target ? '#e8f5e9' : '#ffebee',
color: item.meets_target ? '#2e7d32' : '#c62828',
background: item.meets_target ? '#052e16' : '#3f1d1d',
color: item.meets_target ? '#4ade80' : '#fda4af',
fontSize: '0.875rem',
fontWeight: 'bold'
}}>
{item.meets_target ? '✓ PASS' : '✗ FAIL'}
</span>
</td>
<td style={{ padding: '1rem', textAlign: 'center' }}>
<td style={{ textAlign: 'center' }}>
{item.successful_checks}/{item.total_checks}
</td>
<td style={{ padding: '1rem', textAlign: 'center', color: item.incidents > 0 ? '#ff9800' : '#666' }}>
<td style={{ textAlign: 'center', color: item.incidents > 0 ? '#f59e0b' : '#94a3b8' }}>
{item.incidents}
</td>
<td style={{ padding: '1rem', textAlign: 'center' }}>
<td style={{ textAlign: 'center' }}>
{item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'}
</td>
</tr>
@@ -180,7 +174,7 @@ function SLA() {
</table>
{totalItems > pageSize && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
<div className="cc-pagination" style={{ marginTop: '1rem' }}>
<button
className="btn btn-secondary"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
@@ -188,8 +182,8 @@ function SLA() {
>
← Previous
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span>
<div className="cc-controls">
<span>Page</span>
<input
type="number"
value={currentPage}
@@ -197,14 +191,14 @@ function SLA() {
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
setCurrentPage(page);
}}
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }}
className="cc-input-sm"
/>
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span>
<span>of {Math.ceil(totalItems / pageSize)}</span>
</div>
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }}
className="cc-select-sm"
>
<option value={5}>5 per page</option>
<option value={10}>10 per page</option>
@@ -223,13 +217,13 @@ function SLA() {
</div>
)}
<div style={{ marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
<h3 style={{ marginTop: 0, color: '#667eea' }}>SLA Report Summary</h3>
<p style={{ color: '#666', lineHeight: '1.6' }}>
<div className="cc-surface" style={{ marginTop: '1rem' }}>
<h3 className="history-resource-title">SLA Report Summary</h3>
<p style={{ color: '#cbd5e1', lineHeight: '1.6' }}>
This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{effectiveDays} days</strong>.
Resources meeting their target uptime are marked as <strong style={{ color: '#4caf50' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#f44336' }}>FAIL</strong>.
Resources meeting their target uptime are marked as <strong style={{ color: '#22c55e' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#ef4444' }}>FAIL</strong>.
</p>
<p style={{ color: '#666', lineHeight: '1.6', marginBottom: 0 }}>
<p style={{ color: '#cbd5e1', lineHeight: '1.6', marginBottom: 0 }}>
Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets.
</p>
</div>
+77 -39
View File
@@ -1,33 +1,39 @@
.settings-wizard {
max-width: 900px;
max-width: 980px;
margin: 0 auto;
color: #e2e8f0;
}
.settings-wizard > h2 {
color: #f8fafc;
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
}
.settings-description {
color: #666;
margin-bottom: 2rem;
color: #cbd5e1;
margin-bottom: 1.4rem;
}
.settings-section {
background: white;
border-radius: 8px;
padding: 2rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
background: rgba(15, 23, 42, 0.85);
border-radius: 12px;
padding: 1.35rem;
margin-bottom: 1rem;
border: 1px solid #334155;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f0f0f0;
margin-bottom: 1rem;
padding-bottom: 0.8rem;
border-bottom: 1px solid #334155;
}
.settings-header h3 {
margin: 0;
color: #333;
color: #f8fafc;
}
.settings-form {
@@ -43,23 +49,43 @@
.form-group small {
display: block;
color: #999;
color: #cbd5e1;
font-size: 0.85rem;
margin-top: 0.25rem;
}
.settings-wizard .form-group label {
color: #e2e8f0;
}
.settings-wizard .form-group input,
.settings-wizard .form-group select,
.settings-wizard .form-group textarea {
background: #0b1325;
color: #f8fafc;
border: 1px solid #334155;
}
.settings-wizard .form-group input:focus,
.settings-wizard .form-group select:focus,
.settings-wizard .form-group textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
}
.info-box {
background: #f8f9fa;
border-left: 4px solid #667eea;
background: #0b1325;
border-left: 4px solid #60a5fa;
padding: 1rem;
margin: 1.5rem 0;
border-radius: 4px;
border-radius: 8px;
border: 1px solid #334155;
}
.info-box strong {
display: block;
margin-bottom: 0.5rem;
color: #333;
color: #f8fafc;
}
.info-box ol {
@@ -69,11 +95,11 @@
.info-box li {
margin: 0.25rem 0;
color: #666;
color: #e2e8f0;
}
.info-box a {
color: #667eea;
color: #bfdbfe;
text-decoration: none;
}
@@ -82,12 +108,14 @@
}
.info-box pre {
background: white;
background: #111827;
color: #f8fafc;
padding: 1rem;
border-radius: 4px;
border-radius: 8px;
overflow-x: auto;
font-size: 0.85rem;
margin-top: 0.5rem;
border: 1px solid #334155;
}
.toggle-switch {
@@ -110,7 +138,7 @@
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
background-color: #334155;
transition: .4s;
border-radius: 34px;
}
@@ -122,13 +150,17 @@
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
background-color: #e2e8f0;
transition: .4s;
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
background-color: #667eea;
background-color: #2563eb;
}
.toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
}
.toggle-switch input:checked + .toggle-slider:before {
@@ -136,13 +168,19 @@
}
.btn-secondary {
background: #6c757d;
color: white;
background: #1e293b;
color: #f8fafc;
margin-top: 1rem;
border: 1px solid #334155;
}
.btn-secondary:hover:not(:disabled) {
background: #5a6268;
background: #334155;
}
.btn-secondary:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.btn-secondary:disabled {
@@ -157,15 +195,15 @@
}
.test-result.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
background: #052e16;
color: #86efac;
border: 1px solid #166534;
}
.test-result.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
background: #3f1d1d;
color: #fda4af;
border: 1px solid #7f1d1d;
}
.test-result strong {
@@ -201,15 +239,15 @@
}
.save-status.success {
background: #4caf50;
color: white;
border-left: 4px solid #388e3c;
background: #166534;
color: #dcfce7;
border-left: 4px solid #22c55e;
}
.save-status.error {
background: #f44336;
color: white;
border-left: 4px solid #d32f2f;
background: #7f1d1d;
color: #fee2e2;
border-left: 4px solid #ef4444;
}
.save-status strong {
+2 -2
View File
@@ -3,8 +3,8 @@ body {
font-family: 'Manrope', 'IBM Plex Sans', 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #0f172a;
background-color: #f8fafc;
color: #e2e8f0;
background-color: #020617;
}
* {