Add experimental server alerts and webhook notifications
Release rolling / release (push) Successful in 7m30s

Server-side alerting for Docker/container/stack health with Discord,
Slack, Teams, ntfy, Gotify, Telegram, and generic webhooks. Settings
tab, client cache, and backup/restore coverage; marked experimental.
This commit is contained in:
Raven Scott
2026-07-17 18:10:43 -04:00
parent 366333e6bd
commit 5b8fb142bb
18 changed files with 2886 additions and 19 deletions
+500
View File
@@ -0,0 +1,500 @@
/**
* Settings → Alerts & Notifications
* Server-side webhook channels, rules, history + local bell prefs (shared panel).
*/
import { Methods } from '../shared/protocol.js'
import { manager } from '../client/manager.js'
import { presentError } from '../client/errors.js'
import { syncAlertsCacheFromServer } from '../client/alertsCache.js'
/** @type {object|null} */
let alertsState = null
/** @type {object|null} */
let alertsMeta = null
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function showAlert(type, msg) {
if (typeof window.showAlert === 'function') window.showAlert(type, msg)
else if (typeof window.peardockOps?.showAlert === 'function') {
window.peardockOps.showAlert(type, msg)
}
}
async function rpc(method, args = {}) {
if (!manager.active?.connected) {
throw new Error('Connect to a PearDock server to manage alerts')
}
return manager.request(method, args)
}
function setStatus(msg, tone = 'muted') {
const el = document.getElementById('alerts-status')
if (!el) return
el.className = `small mt-2 text-${tone === 'danger' ? 'danger' : tone === 'success' ? 'success' : 'muted'}`
el.textContent = msg || ''
}
/**
* Load config + status + history from the active server.
*/
export async function loadAlertsPanel() {
const statusEl = document.getElementById('alerts-engine-status')
const offline = document.getElementById('alerts-offline-hint')
if (!manager.active?.connected) {
if (offline) offline.classList.remove('hidden')
if (statusEl) statusEl.textContent = 'Not connected'
return
}
if (offline) offline.classList.add('hidden')
try {
const [cfgRes, stRes, histRes] = await Promise.all([
rpc(Methods.getAlertsConfig || 'getAlertsConfig'),
rpc(Methods.getAlertsStatus || 'getAlertsStatus'),
rpc(Methods.listAlertHistory || 'listAlertHistory', { limit: 30 }),
])
alertsState = cfgRes?.config || null
alertsMeta = cfgRes?.meta || null
renderAlertsGlobal(alertsState, stRes?.status)
renderChannels(alertsState?.channels || [])
renderRules(alertsState?.rules || [])
renderHistory(histRes?.history || [])
setStatus('')
// Admin: mirror full config (with secrets) into client cache for backup
try {
await syncAlertsCacheFromServer({
request: (method, args) => rpc(method, args),
serverId: manager.active?.id || null,
})
} catch {
// viewer / denied — keep prior cache
}
} catch (err) {
setStatus(err.message || 'Failed to load alerts', 'danger')
if (statusEl) statusEl.textContent = 'Error loading'
presentError?.(err, 'getAlertsConfig', { showAlert })
}
}
function renderAlertsGlobal(config, status) {
if (!config) return
const en = document.getElementById('alerts-enabled')
if (en) en.value = config.enabled === false ? '0' : '1'
const min = document.getElementById('alerts-min-severity')
if (min) min.value = config.minSeverity || 'info'
const poll = document.getElementById('alerts-poll-ms')
if (poll) poll.value = String(Math.round((config.pollIntervalMs || 60000) / 1000))
const rate = document.getElementById('alerts-rate-limit')
if (rate) rate.value = String(config.rateLimitPerMinute || 40)
const qh = document.getElementById('alerts-quiet-enabled')
if (qh) qh.value = config.quietHours?.enabled ? '1' : '0'
const qs = document.getElementById('alerts-quiet-start')
if (qs) qs.value = config.quietHours?.start || '22:00'
const qe = document.getElementById('alerts-quiet-end')
if (qe) qe.value = config.quietHours?.end || '07:00'
const host = document.getElementById('alerts-include-hostname')
if (host) host.value = config.includeHostname === false ? '0' : '1'
const statusEl = document.getElementById('alerts-engine-status')
if (statusEl && status) {
const parts = [
status.enabled ? 'Enabled' : 'Disabled',
`${status.channelCount} channel(s)`,
`${status.ruleCount} rule(s)`,
status.lastDaemonOk ? 'Docker OK' : 'Docker DOWN',
status.quietHoursActive ? 'Quiet hours' : null,
].filter(Boolean)
statusEl.textContent = parts.join(' · ')
}
}
function channelTypeLabel(t) {
const map = {
discord: 'Discord',
slack: 'Slack',
teams: 'Microsoft Teams',
generic: 'Generic webhook',
ntfy: 'ntfy',
gotify: 'Gotify',
telegram: 'Telegram',
}
return map[t] || t
}
function renderChannels(channels) {
const host = document.getElementById('alerts-channels-list')
if (!host) return
if (!channels.length) {
host.innerHTML =
'<div class="text-muted small p-2">No channels yet. Add a Discord, Slack, or generic webhook below.</div>'
return
}
host.innerHTML = channels
.map((ch) => {
const badge = ch.enabled
? '<span class="badge status-running">on</span>'
: '<span class="badge status-exited">off</span>'
return `<div class="list-group-item settings-alert-channel" data-id="${escapeHtml(ch.id)}">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<div>
<strong>${escapeHtml(ch.name)}</strong> ${badge}
<div class="small text-muted">${escapeHtml(channelTypeLabel(ch.type))} · min ${escapeHtml(ch.minSeverity || 'info')}
${ch.hasUrl ? ' · URL set' : ''} ${ch.hasToken ? ' · token set' : ''}
</div>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary" data-alert-ch="test" data-id="${escapeHtml(ch.id)}" title="Send test">Test</button>
<button type="button" class="btn btn-outline-secondary" data-alert-ch="edit" data-id="${escapeHtml(ch.id)}">Edit</button>
<button type="button" class="btn btn-outline-danger" data-alert-ch="del" data-id="${escapeHtml(ch.id)}">Delete</button>
</div>
</div>
</div>`
})
.join('')
}
function renderRules(rules) {
const host = document.getElementById('alerts-rules-list')
if (!host) return
if (!rules.length) {
host.innerHTML = '<div class="text-muted small p-2">No rules configured.</div>'
return
}
host.innerHTML = rules
.map((r) => {
const badge = r.enabled
? '<span class="badge status-running">on</span>'
: '<span class="badge status-exited">off</span>'
const sev =
r.severity === 'critical'
? 'text-danger'
: r.severity === 'warning'
? 'text-warning'
: 'text-info'
const matchBits = []
if (r.match?.actions?.length) matchBits.push(`actions: ${r.match.actions.join(', ')}`)
if (r.match?.healthStatus?.length) matchBits.push(`health: ${r.match.healthStatus.join(', ')}`)
if (r.match?.diskPercent) matchBits.push(`disk ≥ ${r.match.diskPercent}%`)
if (r.match?.nameRegex) matchBits.push(`name ~ /${r.match.nameRegex}/`)
return `<div class="list-group-item settings-alert-rule" data-id="${escapeHtml(r.id)}">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<div class="flex-grow-1">
<strong>${escapeHtml(r.name)}</strong> ${badge}
<span class="small ${sev} ms-1">${escapeHtml(r.severity)}</span>
<div class="small text-muted">
${escapeHtml(r.kind)} · cooldown ${Number(r.cooldownSeconds) || 0}s
${matchBits.length ? ' · ' + escapeHtml(matchBits.join(' · ')) : ''}
</div>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" data-alert-rule="toggle" data-id="${escapeHtml(r.id)}">${r.enabled ? 'Disable' : 'Enable'}</button>
<button type="button" class="btn btn-outline-secondary" data-alert-rule="edit" data-id="${escapeHtml(r.id)}">Edit</button>
<button type="button" class="btn btn-outline-danger" data-alert-rule="del" data-id="${escapeHtml(r.id)}">Delete</button>
</div>
</div>
</div>`
})
.join('')
}
function renderHistory(items) {
const host = document.getElementById('alerts-history-list')
if (!host) return
if (!items.length) {
host.innerHTML = '<div class="text-muted small p-2">No deliveries yet.</div>'
return
}
host.innerHTML = items
.map((h) => {
const ok = (h.deliveries || []).filter((d) => d.ok).length
const fail = (h.deliveries || []).filter((d) => !d.ok).length
const sev = escapeHtml(h.severity || 'info')
return `<div class="list-group-item py-2">
<div class="d-flex justify-content-between gap-2">
<div>
<span class="badge bg-secondary me-1">${sev}</span>
<strong class="small">${escapeHtml(h.title)}</strong>
<div class="small text-muted">${escapeHtml(h.message || '')}</div>
</div>
<div class="small text-muted text-nowrap">
${escapeHtml((h.timestamp || '').replace('T', ' ').slice(0, 19))}
<div>${ok} ok${fail ? ` · ${fail} fail` : ''}</div>
</div>
</div>
</div>`
})
.join('')
}
function readChannelForm() {
return {
id: document.getElementById('alert-ch-id')?.value || undefined,
name: document.getElementById('alert-ch-name')?.value?.trim() || '',
type: document.getElementById('alert-ch-type')?.value || 'discord',
enabled: document.getElementById('alert-ch-enabled')?.value !== '0',
url: document.getElementById('alert-ch-url')?.value?.trim() || '',
token: document.getElementById('alert-ch-token')?.value?.trim() || '',
topic: document.getElementById('alert-ch-topic')?.value?.trim() || '',
botToken: document.getElementById('alert-ch-bot-token')?.value?.trim() || '',
chatId: document.getElementById('alert-ch-chat-id')?.value?.trim() || '',
username: document.getElementById('alert-ch-username')?.value?.trim() || 'PearDock',
minSeverity: document.getElementById('alert-ch-min-sev')?.value || 'info',
}
}
function fillChannelForm(ch) {
const set = (id, v) => {
const el = document.getElementById(id)
if (el) el.value = v ?? ''
}
set('alert-ch-id', ch?.id || '')
set('alert-ch-name', ch?.name || '')
set('alert-ch-type', ch?.type || 'discord')
set('alert-ch-enabled', ch?.enabled === false ? '0' : '1')
set('alert-ch-url', '') // never re-show full secret URL
set('alert-ch-token', '')
set('alert-ch-topic', ch?.topic || '')
set('alert-ch-bot-token', '')
set('alert-ch-chat-id', ch?.chatId || '')
set('alert-ch-username', ch?.username || 'PearDock')
set('alert-ch-min-sev', ch?.minSeverity || 'info')
updateChannelFormHints()
}
function updateChannelFormHints() {
const type = document.getElementById('alert-ch-type')?.value || 'discord'
const urlHelp = document.getElementById('alert-ch-url-help')
const tokenRow = document.getElementById('alert-ch-token-row')
const topicRow = document.getElementById('alert-ch-topic-row')
const tgRow = document.getElementById('alert-ch-telegram-row')
if (urlHelp) {
const hints = {
discord: 'Discord channel → Integrations → Webhooks → Copy webhook URL',
slack: 'Slack Incoming Webhook URL (hooks.slack.com/services/…)',
teams: 'Microsoft Teams Incoming Webhook connector URL',
generic: 'Any HTTPS endpoint that accepts JSON POST',
ntfy: 'ntfy server base URL (default https://ntfy.sh)',
gotify: 'Gotify server base URL (https://gotify.example.com)',
telegram: 'Leave URL empty — uses botToken + chatId',
}
urlHelp.textContent = hints[type] || ''
}
if (tokenRow) tokenRow.classList.toggle('hidden', !['ntfy', 'gotify'].includes(type))
if (topicRow) topicRow.classList.toggle('hidden', type !== 'ntfy')
if (tgRow) tgRow.classList.toggle('hidden', type !== 'telegram')
}
function readRuleForm() {
const actions = (document.getElementById('alert-rule-actions')?.value || '')
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
const health = (document.getElementById('alert-rule-health')?.value || '')
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
const types = (document.getElementById('alert-rule-types')?.value || 'container')
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
return {
id: document.getElementById('alert-rule-id')?.value || undefined,
name: document.getElementById('alert-rule-name')?.value?.trim() || '',
enabled: document.getElementById('alert-rule-enabled')?.value !== '0',
severity: document.getElementById('alert-rule-severity')?.value || 'warning',
kind: document.getElementById('alert-rule-kind')?.value || 'docker_event',
cooldownSeconds: Number(document.getElementById('alert-rule-cooldown')?.value) || 300,
notifyOnRecover: document.getElementById('alert-rule-recover')?.value !== '0',
match: {
types,
actions,
healthStatus: health,
nameRegex: document.getElementById('alert-rule-name-regex')?.value?.trim() || '',
nameInclude: (document.getElementById('alert-rule-name-include')?.value || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
nameExclude: (document.getElementById('alert-rule-name-exclude')?.value || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
diskPercent: Number(document.getElementById('alert-rule-disk')?.value) || undefined,
},
channelIds: null,
}
}
function fillRuleForm(r) {
const set = (id, v) => {
const el = document.getElementById(id)
if (el) el.value = v ?? ''
}
set('alert-rule-id', r?.id || '')
set('alert-rule-name', r?.name || '')
set('alert-rule-enabled', r?.enabled === false ? '0' : '1')
set('alert-rule-severity', r?.severity || 'warning')
set('alert-rule-kind', r?.kind || 'docker_event')
set('alert-rule-cooldown', String(r?.cooldownSeconds ?? 300))
set('alert-rule-recover', r?.notifyOnRecover === false ? '0' : '1')
set('alert-rule-types', (r?.match?.types || ['container']).join(', '))
set('alert-rule-actions', (r?.match?.actions || []).join(', '))
set('alert-rule-health', (r?.match?.healthStatus || []).join(', '))
set('alert-rule-name-regex', r?.match?.nameRegex || '')
set('alert-rule-name-include', (r?.match?.nameInclude || []).join(', '))
set('alert-rule-name-exclude', (r?.match?.nameExclude || []).join(', '))
set('alert-rule-disk', r?.match?.diskPercent != null ? String(r.match.diskPercent) : '')
}
async function saveGlobalSettings() {
const config = {
enabled: document.getElementById('alerts-enabled')?.value !== '0',
minSeverity: document.getElementById('alerts-min-severity')?.value || 'info',
pollIntervalMs: Math.max(
15,
Number(document.getElementById('alerts-poll-ms')?.value) || 60
) * 1000,
rateLimitPerMinute: Number(document.getElementById('alerts-rate-limit')?.value) || 40,
includeHostname: document.getElementById('alerts-include-hostname')?.value !== '0',
quietHours: {
enabled: document.getElementById('alerts-quiet-enabled')?.value === '1',
start: document.getElementById('alerts-quiet-start')?.value || '22:00',
end: document.getElementById('alerts-quiet-end')?.value || '07:00',
},
}
await rpc(Methods.updateAlertsConfig || 'updateAlertsConfig', { config })
// Refresh client cache after mutation (admin export)
try {
await syncAlertsCacheFromServer({
request: (method, args) => rpc(method, args),
serverId: manager.active?.id || null,
})
} catch {
// ignore
}
showAlert('success', 'Alert settings saved on server')
await loadAlertsPanel()
}
export function initAlertsSettings() {
document.getElementById('alerts-save-global')?.addEventListener('click', () => {
saveGlobalSettings().catch((err) => {
setStatus(err.message, 'danger')
showAlert('danger', err.message)
})
})
document.getElementById('alerts-refresh')?.addEventListener('click', () => {
loadAlertsPanel().catch(() => {})
})
document.getElementById('alert-ch-type')?.addEventListener('change', updateChannelFormHints)
updateChannelFormHints()
document.getElementById('alert-ch-save')?.addEventListener('click', async () => {
try {
const channel = readChannelForm()
if (!channel.name) throw new Error('Channel name required')
await rpc(Methods.upsertAlertChannel || 'upsertAlertChannel', { channel })
fillChannelForm(null)
showAlert('success', 'Channel saved')
await loadAlertsPanel() // also re-exports secrets into client cache
} catch (err) {
showAlert('danger', err.message || 'Save failed')
}
})
document.getElementById('alert-ch-reset')?.addEventListener('click', () => {
fillChannelForm(null)
})
document.getElementById('alert-rule-save')?.addEventListener('click', async () => {
try {
const rule = readRuleForm()
if (!rule.name) throw new Error('Rule name required')
await rpc(Methods.upsertAlertRule || 'upsertAlertRule', { rule })
fillRuleForm(null)
showAlert('success', 'Rule saved')
await loadAlertsPanel()
} catch (err) {
showAlert('danger', err.message || 'Save failed')
}
})
document.getElementById('alert-rule-reset')?.addEventListener('click', () => {
fillRuleForm(null)
})
document.getElementById('alerts-channels-list')?.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-alert-ch]')
if (!btn) return
const id = btn.getAttribute('data-id')
const action = btn.getAttribute('data-alert-ch')
if (!id) return
try {
if (action === 'test') {
await rpc(Methods.testAlertChannel || 'testAlertChannel', { id })
showAlert('success', 'Test alert sent')
await loadAlertsPanel()
} else if (action === 'edit') {
const ch = alertsState?.channels?.find((c) => c.id === id)
if (ch) {
fillChannelForm(ch)
document.getElementById('alert-ch-name')?.focus()
}
} else if (action === 'del') {
if (!confirm('Delete this alert channel?')) return
await rpc(Methods.deleteAlertChannel || 'deleteAlertChannel', { id })
showAlert('success', 'Channel deleted')
await loadAlertsPanel()
}
} catch (err) {
showAlert('danger', err.message || 'Action failed')
}
})
document.getElementById('alerts-rules-list')?.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-alert-rule]')
if (!btn) return
const id = btn.getAttribute('data-id')
const action = btn.getAttribute('data-alert-rule')
if (!id) return
try {
const rule = alertsState?.rules?.find((r) => r.id === id)
if (action === 'edit' && rule) {
fillRuleForm(rule)
document.getElementById('alert-rule-name')?.focus()
} else if (action === 'toggle' && rule) {
await rpc(Methods.upsertAlertRule || 'upsertAlertRule', {
rule: { ...rule, enabled: !rule.enabled },
})
await loadAlertsPanel()
} else if (action === 'del') {
if (!confirm('Delete this alert rule?')) return
await rpc(Methods.deleteAlertRule || 'deleteAlertRule', { id })
showAlert('success', 'Rule deleted')
await loadAlertsPanel()
}
} catch (err) {
showAlert('danger', err.message || 'Action failed')
}
})
// Live updates from server push
window.peardockAlerts = {
onServerAlert(a) {
// Prepend to history list if panel is open
const host = document.getElementById('alerts-history-list')
if (!host || host.closest('.hidden')) return
loadAlertsPanel().catch(() => {})
},
loadAlertsPanel,
}
}
export default { initAlertsSettings, loadAlertsPanel }
+97 -2
View File
@@ -2155,6 +2155,24 @@ export function showSettingsTab(tab) {
// ignore
}
}
if (name === 'alerts' || name === 'notifications') {
// Legacy "notifications" tab id redirects to alerts panel
if (name === 'notifications') {
document.querySelectorAll('#settings-tabs .nav-link').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-settings-tab') === 'alerts')
})
document.querySelectorAll('[data-settings-panel]').forEach((panel) => {
panel.classList.toggle('hidden', panel.getAttribute('data-settings-panel') !== 'alerts')
})
}
try {
import('./alerts-settings.js')
.then((m) => m.loadAlertsPanel?.())
.catch(() => {})
} catch {
// ignore
}
}
}
/**
@@ -2382,9 +2400,21 @@ function setBackupStatus(msg, tone = 'muted') {
/**
* @param {boolean} [download]
*/
function runCreateBackup(download = false) {
async function runCreateBackup(download = false) {
try {
persistBackupPolicyFromForm()
// Pull latest server alerts (admin) into cache so the package has webhook secrets
try {
const { syncAlertsCacheFromServer } = await import('../client/alertsCache.js')
if (manager.active?.connected) {
await syncAlertsCacheFromServer({
request: (method, args) => manager.request(method, args),
serverId: manager.active?.id || null,
})
}
} catch {
// ignore — use last cached alerts snapshot
}
const form = readBackupForm()
const label = document.getElementById('settings-backup-label')?.value?.trim() || ''
const pin = Boolean(document.getElementById('settings-backup-pin')?.checked)
@@ -2478,6 +2508,41 @@ async function runRestorePackage(pkg, opts = {}) {
} catch {
// ignore
}
// Push restored alerts config to the connected server (admin)
try {
const pending = window.__peardockPendingAlertsRestore
if (pending?.config && manager.active?.connected) {
const { pushAlertsConfigToServer } = await import('../client/alertsCache.js')
const push = await pushAlertsConfigToServer(pending.config, {
request: (method, args) => manager.request(method, args),
serverId: manager.active?.id || null,
replace: pending.mode !== 'merge',
})
if (push.ok) {
result.warnings = result.warnings || []
// soft note, not a failure
} else if (push.error) {
result.warnings = result.warnings || []
result.warnings.push(
`Alerts saved to local cache; server apply failed: ${push.error}`
)
}
try {
delete window.__peardockPendingAlertsRestore
} catch {
// ignore
}
// Refresh alerts panel if open
try {
window.peardockAlerts?.loadAlertsPanel?.()
} catch {
// ignore
}
}
} catch (err) {
result.warnings = result.warnings || []
result.warnings.push(`Alerts server push: ${err?.message || err}`)
}
} catch {
// ignore post-restore refresh errors
}
@@ -2486,9 +2551,14 @@ async function runRestorePackage(pkg, opts = {}) {
const identityNote = result.restored.includes('identity')
? ' Restart the app for the restored client identity to take effect.'
: ''
const alertsNote = result.restored.includes('alerts')
? manager.active?.connected
? ' Alerts config applied to the connected server when permitted.'
: ' Alerts config cached locally — connect as admin to push webhooks to a server.'
: ''
showAlert(
'success',
`Restored ${result.restored.join(', ')}. Reconnect peers if needed.${identityNote}`,
`Restored ${result.restored.join(', ')}. Reconnect peers if needed.${identityNote}${alertsNote}`,
{ badge: false }
)
return true
@@ -2864,6 +2934,19 @@ export function initOpsApp({ navigateToView, sendCommand }) {
try {
startAutoBackupScheduler({
appVersion: document.getElementById('settings-app-version')?.textContent?.trim(),
prepare: async () => {
// Keep alerts snapshot fresh for packages that include webhook config
try {
if (!manager.active?.connected) return
const { syncAlertsCacheFromServer } = await import('../client/alertsCache.js')
await syncAlertsCacheFromServer({
request: (method, args) => manager.request(method, args),
serverId: manager.active?.id || null,
})
} catch {
// ignore
}
},
onRun: (result) => {
if (result.ran) {
showAlert('info', 'Automatic client backup created', { badge: false })
@@ -2892,6 +2975,18 @@ export function initOpsApp({ navigateToView, sendCommand }) {
})
.catch((err) => console.warn('[ops] track-f-extras failed to load', err))
// Alerts & Notifications (server webhooks + local bell panel)
import('./alerts-settings.js')
.then((mod) => {
mod.initAlertsSettings?.()
if (typeof window !== 'undefined' && window.peardockOps) {
Object.assign(window.peardockOps, {
loadAlertsPanel: mod.loadAlertsPanel,
})
}
})
.catch((err) => console.warn('[ops] alerts-settings failed to load', err))
// Track G UX (shortcuts, go-chords, refresh stamp, scroll-top)
import('./track-g-ux.js')
.then((mod) => {