Add experimental server alerts and webhook notifications
Release rolling / release (push) Successful in 7m30s
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:
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user