feat: premium tier features — auto-backup scheduling, resource alerting, bundled workflows, one-click revert, notification manager

This commit is contained in:
Hermes
2026-05-27 23:39:46 -07:00
parent 6ce0a18f98
commit 11823a1466
19 changed files with 3686 additions and 407 deletions
+172 -57
View File
@@ -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();
});
}