fa317a3b78
Theme preference stored in localStorage (auto follows the OS setting). The chosen data-theme attribute is applied synchronously in <head> to avoid any flash of unstyled content. CSS custom properties handle all surface, text, border and input colours across every page. The Appearance section on the profile page lets each user switch modes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
624 lines
18 KiB
HTML
624 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
{% include 'head.html' %}
|
|
|
|
<style>
|
|
|
|
html, body {
|
|
height: auto;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
|
|
|
.subtitle {
|
|
color: #666;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.summary-cards {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.summary-card {
|
|
background: white;
|
|
border-radius: 6px;
|
|
padding: 6px 14px;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border-left: 4px solid #ddd;
|
|
}
|
|
|
|
.summary-card.critical { border-left-color: #ea1e0f; }
|
|
.summary-card.warning { border-left-color: #ff9800; }
|
|
.summary-card.ok { border-left-color: #4caf50; }
|
|
|
|
.summary-number {
|
|
font-size: 1.4em;
|
|
font-weight: bold;
|
|
line-height: 1;
|
|
}
|
|
|
|
.summary-number.critical { color: #ea1e0f; }
|
|
.summary-number.warning { color: #ff9800; }
|
|
.summary-number.ok { color: #4caf50; }
|
|
|
|
.summary-label {
|
|
color: #666;
|
|
font-size: 1.00em;
|
|
}
|
|
|
|
.filters {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
display: flex;
|
|
gap: 15px;
|
|
align-items: center;
|
|
}
|
|
|
|
.filter-label {
|
|
font-weight: bold;
|
|
color: #555;
|
|
}
|
|
|
|
.filter-button {
|
|
padding: 8px 16px;
|
|
border: 2px solid #ddd;
|
|
background: white;
|
|
border-radius: 20px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.filter-button:hover {
|
|
border-color: #2196f3;
|
|
}
|
|
|
|
.filter-button.active {
|
|
background: #2196f3;
|
|
color: white;
|
|
border-color: #2196f3;
|
|
}
|
|
|
|
.filter-input {
|
|
padding: 7px 12px;
|
|
border: 2px solid #ddd;
|
|
border-radius: 20px;
|
|
font-size: 0.9em;
|
|
outline: none;
|
|
width: 200px;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.filter-input:focus {
|
|
border-color: #2196f3;
|
|
}
|
|
|
|
.filter-input.invalid {
|
|
border-color: #f44336;
|
|
}
|
|
|
|
.alerts-container {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.alert-item {
|
|
border-left: 5px solid #ddd;
|
|
padding: 15px;
|
|
margin-bottom: 15px;
|
|
background: #fafafa;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.alert-item.acknowledged {
|
|
opacity: 0.8;
|
|
background: #f0f0f0;
|
|
}
|
|
|
|
.alert-item:hover {
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
transform: translateX(5px);
|
|
}
|
|
|
|
.alert-item.critical {
|
|
border-left-color: #f44336;
|
|
background: #ffebee;
|
|
}
|
|
|
|
.alert-item.warning {
|
|
border-left-color: #ff9800;
|
|
background: #fff3e0;
|
|
}
|
|
|
|
.alert-item.unknown {
|
|
border-left-color: #9e9e9e;
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
.alert-main {
|
|
flex: 1;
|
|
}
|
|
|
|
.alert-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.alert-level {
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 0.75em;
|
|
font-weight: bold;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.alert-level.critical {
|
|
background: #f44336;
|
|
color: white;
|
|
}
|
|
|
|
.alert-level.warning {
|
|
background: #ff9800;
|
|
color: white;
|
|
}
|
|
|
|
.alert-level.unknown {
|
|
background: #9e9e9e;
|
|
color: white;
|
|
}
|
|
|
|
.alert-hostname {
|
|
font-weight: bold;
|
|
color: #0066cc;
|
|
font-size: 1.1em;
|
|
text-decoration: none;
|
|
}
|
|
.alert-hostname:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.alert-metric {
|
|
color: #0066cc;
|
|
font-size: 1.1em;
|
|
font-weight: normal;
|
|
}
|
|
|
|
.alert-details {
|
|
display: flex;
|
|
gap: 20px;
|
|
color: #666;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.alert-value {
|
|
font-weight: bold;
|
|
color: #333;
|
|
}
|
|
|
|
.alert-duration {
|
|
color: #999;
|
|
font-size: 1.00em;
|
|
}
|
|
|
|
.alert-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-left: 15px;
|
|
}
|
|
|
|
.acknowledge-btn {
|
|
padding: 8px 16px;
|
|
background: #2196f3;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1.00em;
|
|
transition: all 0.2s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.acknowledge-btn:hover {
|
|
background: #1976d2;
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.acknowledge-btn:disabled {
|
|
background: #ccc;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
.acknowledged-badge {
|
|
padding: 4px 8px;
|
|
background: #4caf50;
|
|
color: white;
|
|
border-radius: 4px;
|
|
font-size: 0.75em;
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.no-alerts {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #999;
|
|
}
|
|
|
|
.no-alerts-icon {
|
|
font-size: 4em;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #666;
|
|
}
|
|
|
|
.error {
|
|
background: #ffebee;
|
|
border-left: 4px solid #f44336;
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
border-radius: 4px;
|
|
color: #c62828;
|
|
}
|
|
|
|
.refresh-info {
|
|
text-align: center;
|
|
color: #999;
|
|
font-size: 1.00em;
|
|
margin-top: 20px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.last-update {
|
|
color: #666;
|
|
font-size: 0.9em;
|
|
text-align: right;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
/* ── Dark mode ── */
|
|
html[data-theme="dark"] h1 { color: var(--text); }
|
|
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
|
|
html[data-theme="dark"] .summary-card { background: var(--surface); }
|
|
html[data-theme="dark"] .summary-label { color: var(--text-sec); }
|
|
html[data-theme="dark"] .filters { background: var(--surface); }
|
|
html[data-theme="dark"] .filter-label { color: var(--text-sec); }
|
|
html[data-theme="dark"] .filter-button { background: var(--surface-2); border-color: var(--border); color: var(--text); }
|
|
html[data-theme="dark"] .filter-button.active { background: #2196f3; color: #fff; border-color: #2196f3; }
|
|
html[data-theme="dark"] .filter-input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
|
html[data-theme="dark"] .alerts-container { background: var(--surface); }
|
|
html[data-theme="dark"] .alert-item { background: var(--surface-2); }
|
|
html[data-theme="dark"] .alert-item.acknowledged { background: var(--surface-3); }
|
|
html[data-theme="dark"] .alert-item.critical { background: #2e0a0a; border-left-color: #f44336; }
|
|
html[data-theme="dark"] .alert-item.warning { background: #2e1a00; border-left-color: #ff9800; }
|
|
html[data-theme="dark"] .alert-item.unknown { background: var(--surface-2); }
|
|
html[data-theme="dark"] .alert-hostname { color: var(--link); }
|
|
html[data-theme="dark"] .alert-details { color: var(--text-sec); }
|
|
html[data-theme="dark"] .alert-value { color: var(--text); }
|
|
html[data-theme="dark"] .alert-duration { color: var(--text-muted); }
|
|
html[data-theme="dark"] .last-update { color: var(--text-sec); }
|
|
html[data-theme="dark"] .refresh-info { color: var(--text-muted); border-top-color: var(--border); }
|
|
html[data-theme="dark"] .no-alerts,
|
|
html[data-theme="dark"] .loading { color: var(--text-muted); }
|
|
</style>
|
|
|
|
<body>
|
|
{% include 'nav.html' %}
|
|
|
|
<div class="container">
|
|
<h1>{{ header }}</h1>
|
|
<p class="subtitle">Real-time monitoring alerts and threshold violations</p>
|
|
|
|
<div class="summary-cards" id="summary-cards">
|
|
<div class="summary-card critical">
|
|
<div class="summary-label">Critical</div>
|
|
<div class="summary-number critical" id="critical-count">-</div>
|
|
</div>
|
|
<div class="summary-card warning">
|
|
<div class="summary-label">Warning</div>
|
|
<div class="summary-number warning" id="warning-count">-</div>
|
|
</div>
|
|
<div class="summary-card ok">
|
|
<div class="summary-label">Total Hosts</div>
|
|
<div class="summary-number ok" id="host-count">-</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filters">
|
|
<span class="filter-label">Show:</span>
|
|
<button class="filter-button active" onclick="filterAlerts('all')">All</button>
|
|
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button>
|
|
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button>
|
|
<input id="host-filter" class="filter-input" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
|
|
</div>
|
|
|
|
<div class="alerts-container">
|
|
<div class="last-update">Last updated: <span id="last-update-time">Never</span></div>
|
|
<div id="alerts-list">
|
|
<div class="loading">Loading alerts...</div>
|
|
</div>
|
|
<div class="refresh-info">
|
|
Auto-refreshing every 15 seconds
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let currentFilter = 'all';
|
|
let allAlerts = [];
|
|
let hostFilterRe = null;
|
|
|
|
async function loadAlerts() {
|
|
try {
|
|
const response = await fetch('/api/0/alerts');
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
allAlerts = data.alerts;
|
|
|
|
// Update summary cards
|
|
document.getElementById('critical-count').textContent = data.summary.critical || 0;
|
|
document.getElementById('warning-count').textContent = data.summary.warning || 0;
|
|
document.getElementById('host-count').textContent = data.host_count || 0;
|
|
|
|
// Update last update time
|
|
document.getElementById('last-update-time').textContent = new Date().toLocaleTimeString();
|
|
|
|
// Render alerts
|
|
renderAlerts(allAlerts);
|
|
|
|
} catch (error) {
|
|
document.getElementById('alerts-list').innerHTML =
|
|
`<div class="error">Failed to load alerts: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderAlerts(alerts) {
|
|
const container = document.getElementById('alerts-list');
|
|
|
|
// Filter alerts based on current filter
|
|
let filteredAlerts = alerts;
|
|
if (currentFilter !== 'all') {
|
|
filteredAlerts = filteredAlerts.filter(alert =>
|
|
alert.level.toLowerCase() === currentFilter
|
|
);
|
|
}
|
|
if (hostFilterRe) {
|
|
filteredAlerts = filteredAlerts.filter(alert => hostFilterRe.test(alert.hostname));
|
|
}
|
|
|
|
if (filteredAlerts.length === 0) {
|
|
if (currentFilter === 'all' && alerts.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="no-alerts">
|
|
<div class="no-alerts-icon">✓</div>
|
|
<h2>All Systems Normal</h2>
|
|
<p>No active alerts at this time</p>
|
|
</div>
|
|
`;
|
|
} else {
|
|
container.innerHTML = `
|
|
<div class="no-alerts">
|
|
<p>No ${currentFilter} alerts</p>
|
|
</div>
|
|
`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
for (const alert of filteredAlerts) {
|
|
html += renderAlert(alert);
|
|
}
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function renderAlert(alert) {
|
|
const level = alert.level.toLowerCase();
|
|
const duration = getDuration(alert.since);
|
|
const acknowledged = alert.acknowledged || false;
|
|
|
|
// Use formatted message if available, otherwise build from individual fields
|
|
let valueText = `Value: <span class="alert-value">${formatValue(alert.last_value)}</span>`;
|
|
if (alert.formatted_message) {
|
|
valueText += ` <span class="threshold-info">${alert.formatted_message}</span>`;
|
|
} else if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
|
|
valueText += ` <span class="threshold-info">(threshold: ${alert.operator} ${formatValue(alert.threshold_value)})</span>`;
|
|
}
|
|
if (alert.recovery_threshold !== undefined && alert.recovery_threshold !== null) {
|
|
const recOp = (alert.operator === '>' || alert.operator === '>=') ? '<' : '>';
|
|
valueText += ` <span class="threshold-info" style="color:#888">(recovers ${recOp} ${formatValue(alert.recovery_threshold)})</span>`;
|
|
}
|
|
|
|
// Build actions section
|
|
let actionsHtml = '';
|
|
if (acknowledged) {
|
|
actionsHtml = `
|
|
<div class="alert-actions">
|
|
<div class="acknowledged-badge">✓ Acknowledged</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
actionsHtml = `
|
|
<div class="alert-actions">
|
|
<button class="acknowledge-btn" onclick="acknowledgeAlert('${alert.hostname}', '${alert.metric_path}', event)">
|
|
Acknowledge
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="alert-item ${level} ${acknowledged ? 'acknowledged' : ''}">
|
|
<div class="alert-main">
|
|
<div class="alert-header">
|
|
<span class="alert-level ${level}">${alert.level}</span>
|
|
<a class="alert-hostname" href="/plugins#${alert.hostname}">${alert.hostname}</a>
|
|
<span class="alert-metric">${(alert.metric_path.includes('.') ? alert.metric_path.slice(alert.metric_path.indexOf('.') + 1) : alert.metric_path).replace(/_status_code$/, '')}</span>
|
|
</div>
|
|
<div class="alert-details">
|
|
<span>${valueText}</span>
|
|
<span class="alert-duration">Active for ${duration}</span>
|
|
</div>
|
|
</div>
|
|
${actionsHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function formatValue(value) {
|
|
if (typeof value === 'number') {
|
|
if (value > 1000) {
|
|
return value.toLocaleString();
|
|
}
|
|
return value.toFixed(2);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function getDuration(timestamp) {
|
|
const now = Date.now() / 1000;
|
|
const seconds = Math.floor(now - timestamp);
|
|
|
|
if (seconds < 60) {
|
|
return `${seconds}s`;
|
|
} else if (seconds < 3600) {
|
|
return `${Math.floor(seconds / 60)}m`;
|
|
} else if (seconds < 86400) {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
return `${hours}h ${minutes}m`;
|
|
} else {
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
return `${days}d ${hours}h`;
|
|
}
|
|
}
|
|
|
|
function filterAlerts(filter) {
|
|
currentFilter = filter;
|
|
|
|
// Update active button
|
|
document.querySelectorAll('.filter-button').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
});
|
|
event.target.classList.add('active');
|
|
|
|
// Re-render with new filter
|
|
renderAlerts(allAlerts);
|
|
}
|
|
|
|
async function acknowledgeAlert(hostname, metricPath, event) {
|
|
// Prevent event bubbling
|
|
if (event) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
// Disable the button
|
|
const button = event.target;
|
|
button.disabled = true;
|
|
button.textContent = 'Acknowledging...';
|
|
|
|
try {
|
|
const response = await fetch('/api/0/alerts/acknowledge', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
hostname: hostname,
|
|
metric_path: metricPath,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Update the alert in our local data
|
|
const alert = allAlerts.find(a => a.hostname === hostname && a.metric_path === metricPath);
|
|
if (alert) {
|
|
alert.acknowledged = true;
|
|
alert.acknowledged_at = result.acknowledged_at;
|
|
}
|
|
|
|
// Re-render alerts
|
|
renderAlerts(allAlerts);
|
|
|
|
} catch (error) {
|
|
alert(`Failed to acknowledge alert: ${error.message}`);
|
|
button.disabled = false;
|
|
button.textContent = 'Acknowledge';
|
|
}
|
|
}
|
|
|
|
function onHostFilterInput(input) {
|
|
const val = input.value.trim();
|
|
if (!val) {
|
|
hostFilterRe = null;
|
|
input.classList.remove('invalid');
|
|
} else {
|
|
try {
|
|
hostFilterRe = new RegExp(val, 'i');
|
|
input.classList.remove('invalid');
|
|
} catch (_) {
|
|
hostFilterRe = null;
|
|
input.classList.add('invalid');
|
|
}
|
|
}
|
|
renderAlerts(allAlerts);
|
|
}
|
|
|
|
// Auto-refresh every 15 seconds
|
|
setInterval(loadAlerts, 15000);
|
|
|
|
// Initialise filter from URL query string (?filter=...)
|
|
(function () {
|
|
const param = new URLSearchParams(window.location.search).get('filter');
|
|
if (param) {
|
|
const input = document.getElementById('host-filter');
|
|
input.value = param;
|
|
onHostFilterInput(input);
|
|
}
|
|
})();
|
|
|
|
// Initial load
|
|
loadAlerts();
|
|
</script>
|
|
</body>
|
|
</html>
|