Refine dark theme readability and chart visibility across dashboard pages
This commit is contained in:
1 parent
b9da742b3d
commit
0475bf5aa5
20 files changed
+929
-320
No files matched your search
+191
-56
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
Reference in new issue
Block a user