feat: premium tier features — auto-backup scheduling, resource alerting, bundled workflows, one-click revert, notification manager
This commit is contained in:
+172
-57
@@ -236,70 +236,185 @@
|
||||
async function loadAlerts() {
|
||||
if (!alertsContainer) return;
|
||||
alertsContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';
|
||||
|
||||
const data = cachedMonitoringData;
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
alertsContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🔔</span>No containers found. Open the Live Stats tab first.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
||||
for (const [id, info] of Object.entries(data)) {
|
||||
const alertCfg = info.alertConfig || {};
|
||||
html += `<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
|
||||
<span style="font-weight: 600; flex: 1;">${info.name || id}</span>
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
||||
<input type="checkbox" class="alert-enabled" data-container="${id}" ${alertCfg.enabled ? 'checked' : ''} /> Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label>
|
||||
<input type="number" class="alert-cpu" data-container="${id}" value="${alertCfg.cpuThreshold || 80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label>
|
||||
<input type="number" class="alert-mem" data-container="${id}" value="${alertCfg.memoryThreshold || 85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">Cooldown (min)</label>
|
||||
<input type="number" class="alert-cooldown" data-container="${id}" value="${alertCfg.cooldownMinutes || 15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; align-items: center;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
||||
<input type="checkbox" class="alert-autorestart" data-container="${id}" ${alertCfg.autoRestart ? 'checked' : ''} /> Auto-restart on breach
|
||||
</label>
|
||||
<span style="flex: 1;"></span>
|
||||
<button class="alert-save-btn" data-container="${id}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
alertsContainer.innerHTML = html;
|
||||
|
||||
// Wire up save buttons
|
||||
alertsContainer.querySelectorAll('.alert-save-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const cId = btn.dataset.container;
|
||||
const enabled = alertsContainer.querySelector(`.alert-enabled[data-container="${cId}"]`)?.checked || false;
|
||||
const cpuThreshold = parseInt(alertsContainer.querySelector(`.alert-cpu[data-container="${cId}"]`)?.value) || 80;
|
||||
const memoryThreshold = parseInt(alertsContainer.querySelector(`.alert-mem[data-container="${cId}"]`)?.value) || 85;
|
||||
const cooldownMinutes = parseInt(alertsContainer.querySelector(`.alert-cooldown[data-container="${cId}"]`)?.value) || 15;
|
||||
const autoRestart = alertsContainer.querySelector(`.alert-autorestart[data-container="${cId}"]`)?.checked || false;
|
||||
try {
|
||||
const res = await secureFetch(`/api/v1/monitoring/alerts/${cId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled, cpuThreshold, memoryThreshold, cooldownMinutes, autoRestart })
|
||||
});
|
||||
const data = await res.json();
|
||||
btn.textContent = data.success ? '✅ Saved' : '⚠️ Failed';
|
||||
setTimeout(() => { btn.textContent = 'Save'; }, 2000);
|
||||
} catch (e) {
|
||||
btn.textContent = '❌ Error';
|
||||
setTimeout(() => { btn.textContent = 'Save'; }, 2000);
|
||||
}
|
||||
// Check premium status for config section visibility
|
||||
let isPremium = false;
|
||||
try {
|
||||
const resp = await fetch('/api/v1/license/feature/resource-alerts');
|
||||
const ld = await resp.json();
|
||||
isPremium = ld.available;
|
||||
} catch (_) { isPremium = false; }
|
||||
|
||||
// Fetch alert history
|
||||
let alertHistory = [];
|
||||
try {
|
||||
const hr = await fetch('/api/v1/monitoring/alerts?limit=50');
|
||||
const hd = await hr.json();
|
||||
if (hd.success) alertHistory = hd.history || [];
|
||||
} catch (_) {}
|
||||
|
||||
// Fetch all alert configs
|
||||
let allConfigs = {};
|
||||
try {
|
||||
const cr = await fetch('/api/v1/monitoring/alerts/config');
|
||||
const cd = await cr.json();
|
||||
if (cd.success) allConfigs = cd.configs || {};
|
||||
} catch (_) {}
|
||||
|
||||
const containers = Object.entries(data);
|
||||
const containerRows = containers.map(([id, info]) => {
|
||||
const cfg = allConfigs[id] || { cpuThreshold: 80, memoryThreshold: 90, diskIOThreshold: 50, autoRestart: false, enabled: false };
|
||||
return `
|
||||
<tr data-container="${id}">
|
||||
<td style="font-weight: 600; padding: 8px;">${info.name || id}</td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${cfg.cpuThreshold ?? 80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${isPremium ? '' : 'disabled'} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${cfg.memoryThreshold ?? 90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${isPremium ? '' : 'disabled'} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${cfg.diskIOThreshold ?? 50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${isPremium ? '' : 'disabled'} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${cfg.autoRestart ? 'checked' : ''} ${isPremium ? '' : 'disabled'} /></td>
|
||||
<td style="padding: 4px 8px;">
|
||||
<button class="alert-test-btn btn-xs" data-container="${id}" data-name="${info.name || id}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const historyRows = alertHistory.map(entry => {
|
||||
const time = new Date(entry.timestamp).toLocaleString();
|
||||
const notifiedMark = entry.notified ? '✓' : '—';
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${time}</td>
|
||||
<td style="padding: 6px 8px; font-weight: 500;">${entry.containerName || entry.containerId}</td>
|
||||
<td style="padding: 6px 8px; text-transform: capitalize;">${entry.metric || entry.type}</td>
|
||||
<td style="padding: 6px 8px;">${typeof entry.value === 'number' ? entry.value.toFixed(1) : entry.value}${entry.metric === 'disk' ? ' MB/s' : '%'}</td>
|
||||
<td style="padding: 6px 8px; text-align: center;">${notifiedMark}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${entry.autoRestartTriggered ? '#f39c12' : 'var(--muted)'};">${entry.autoRestartTriggered ? '↻' : ''}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const configSection = isPremium ? `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
|
||||
<h4 style="margin: 0; font-size: 0.9rem;">⚙️ Alert Configuration</h4>
|
||||
<a href="#" id="go-to-notifications" style="font-size: 0.8rem; color: var(--accent); text-decoration: none;">Configure notifications →</a>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--border);">
|
||||
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Container</th>
|
||||
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">CPU %</th>
|
||||
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Mem %</th>
|
||||
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Disk I/O MB/s</th>
|
||||
<th style="text-align: center; padding: 6px 8px; color: var(--muted);">Auto-Restart</th>
|
||||
<th style="padding: 6px 8px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${containerRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top: 12px; display: flex; justify-content: flex-end;">
|
||||
<button id="save-all-alerts" style="padding: 6px 16px; font-size: 0.85rem; background: var(--accent); color: var(--base); border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Save All</button>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div style="margin-bottom: 20px; padding: 12px; background: rgba(241,196,15,0.1); border: 1px solid rgba(241,196,15,0.3); border-radius: 8px; text-align: center;">
|
||||
<span style="color: #f1c40f; font-weight: 600; font-size: 0.85rem;">⭐ Premium Feature</span>
|
||||
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
|
||||
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
alertsContainer.innerHTML = `
|
||||
${configSection}
|
||||
<div>
|
||||
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">📋 Recent Alerts</h4>
|
||||
${historyRows ? `
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--border);">
|
||||
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Time</th>
|
||||
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Container</th>
|
||||
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Metric</th>
|
||||
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Value</th>
|
||||
<th style="text-align: center; padding: 6px 8px; color: var(--muted);">✓?</th>
|
||||
<th style="padding: 6px 8px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${historyRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : '<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire up save-all button
|
||||
document.getElementById('save-all-alerts')?.addEventListener('click', async () => {
|
||||
const configs = {};
|
||||
document.querySelectorAll('#stats-alerts-container tr[data-container]').forEach(row => {
|
||||
const cId = row.dataset.container;
|
||||
configs[cId] = {
|
||||
cpuThreshold: parseInt(row.querySelector('.alert-cpu')?.value) || 80,
|
||||
memoryThreshold: parseInt(row.querySelector('.alert-mem')?.value) || 90,
|
||||
diskIOThreshold: parseInt(row.querySelector('.alert-disk')?.value) || 50,
|
||||
autoRestart: !!row.querySelector('.alert-autorestart')?.checked,
|
||||
enabled: true
|
||||
};
|
||||
});
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/monitoring/alerts/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ configs })
|
||||
});
|
||||
const d = await res.json();
|
||||
const btn = document.getElementById('save-all-alerts');
|
||||
btn.textContent = d.success ? '✅ Saved' : '❌ Failed';
|
||||
setTimeout(() => { btn.textContent = 'Save All'; }, 2000);
|
||||
} catch (e) {
|
||||
const btn = document.getElementById('save-all-alerts');
|
||||
btn.textContent = '❌ Error';
|
||||
setTimeout(() => { btn.textContent = 'Save All'; }, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up notification settings link
|
||||
document.getElementById('go-to-notifications')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
modal.classList.remove('show');
|
||||
stopAutoRefresh();
|
||||
document.getElementById('manage-notifications')?.click();
|
||||
});
|
||||
|
||||
// Wire up test buttons
|
||||
document.querySelectorAll('.alert-test-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '...';
|
||||
try {
|
||||
await secureFetch(`/api/v1/monitoring/alerts/${btn.dataset.container}/test`, { method: 'POST' });
|
||||
btn.textContent = '✅';
|
||||
showNotification('Test alert sent for ' + btn.dataset.name, 'success', 3000);
|
||||
} catch (e) {
|
||||
btn.textContent = '❌';
|
||||
}
|
||||
setTimeout(() => { btn.textContent = orig; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire up upgrade button
|
||||
document.getElementById('upgrade-for-alerts')?.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
stopAutoRefresh();
|
||||
if (typeof openLicenseModal === 'function') openLicenseModal();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user