Apply all settings and alerts live without Save buttons
Release rolling / release (push) Has been cancelled

Auto-persist client preferences on change with footer feedback, and
apply server alert engine settings/channels/rules immediately.
This commit is contained in:
Raven Scott
2026-07-17 19:48:28 -04:00
parent f6f059ab94
commit e2fa2ff85a
5 changed files with 533 additions and 172 deletions
+286 -62
View File
@@ -11,6 +11,15 @@ import { syncAlertsCacheFromServer } from '../client/alertsCache.js'
let alertsState = null
/** @type {object|null} */
let alertsMeta = null
/** Skip auto-apply while hydrating form from server */
let suppressLiveApply = false
/** @type {ReturnType<typeof setTimeout>|null} */
let globalApplyTimer = null
let globalApplyInFlight = false
/** @type {ReturnType<typeof setTimeout>|null} */
let channelApplyTimer = null
/** @type {ReturnType<typeof setTimeout>|null} */
let ruleApplyTimer = null
function escapeHtml(s) {
return String(s ?? '')
@@ -84,22 +93,30 @@ export async function loadAlertsPanel() {
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'
suppressLiveApply = true
try {
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'
} finally {
// Next tick so change events from programmatic set don't fire apply
setTimeout(() => {
suppressLiveApply = false
}, 0)
}
const statusEl = document.getElementById('alerts-engine-status')
if (statusEl && status) {
@@ -351,14 +368,12 @@ function fillRuleForm(r) {
set('alert-rule-disk', r?.match?.diskPercent != null ? String(r.match.diskPercent) : '')
}
async function saveGlobalSettings() {
const config = {
function readGlobalConfigFromUi() {
return {
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,
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: {
@@ -367,67 +382,276 @@ async function saveGlobalSettings() {
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
}
/**
* Push engine settings to the server immediately (live apply).
* @param {{ quiet?: boolean }} [opts]
*/
async function applyGlobalSettingsLive(opts = {}) {
if (suppressLiveApply || globalApplyInFlight) return
if (!manager.active?.connected) {
setStatus('Connect as admin to change alert settings', 'danger')
return
}
showAlert('success', 'Alert settings saved on server')
await loadAlertsPanel()
globalApplyInFlight = true
setStatus('Applying…')
try {
const config = readGlobalConfigFromUi()
const res = await rpc(Methods.updateAlertsConfig || 'updateAlertsConfig', { config })
alertsState = res?.config || { ...(alertsState || {}), ...config }
try {
await syncAlertsCacheFromServer({
request: (method, args) => rpc(method, args),
serverId: manager.active?.id || null,
})
} catch {
// ignore cache sync
}
// Light status refresh without full re-render (avoids form thrash)
try {
const stRes = await rpc(Methods.getAlertsStatus || 'getAlertsStatus')
const statusEl = document.getElementById('alerts-engine-status')
if (statusEl && stRes?.status) {
const s = stRes.status
statusEl.textContent = [
s.enabled ? 'Enabled' : 'Disabled',
`${s.channelCount} channel(s)`,
`${s.ruleCount} rule(s)`,
s.lastDaemonOk ? 'Docker OK' : 'Docker DOWN',
s.quietHoursActive ? 'Quiet hours' : null,
]
.filter(Boolean)
.join(' · ')
}
} catch {
// ignore
}
const label = config.enabled ? 'Enabled' : 'Disabled'
setStatus(`Applied live · engine ${label.toLowerCase()}`, 'success')
if (!opts.quiet) {
// Soft feedback — avoid toast spam on every keystroke of quiet hours
if (opts.toast) showAlert('success', `Alerts ${label.toLowerCase()} (live)`)
}
} catch (err) {
setStatus(err.message || 'Apply failed', 'danger')
if (!opts.quiet) showAlert('danger', err.message || 'Failed to apply alert settings')
} finally {
globalApplyInFlight = false
}
}
function scheduleGlobalApply(ms = 400) {
if (suppressLiveApply) return
if (globalApplyTimer) clearTimeout(globalApplyTimer)
globalApplyTimer = setTimeout(() => {
globalApplyTimer = null
applyGlobalSettingsLive({ quiet: true }).catch(() => {})
}, ms)
}
/**
* Auto-upsert channel when form has enough data (live).
*/
async function applyChannelFormLive() {
if (suppressLiveApply) return
if (!manager.active?.connected) return
const channel = readChannelForm()
if (!channel.name?.trim()) {
setStatus('Channel: enter a name to apply', 'muted')
return
}
// New Discord/Slack/etc. need a URL (or telegram tokens) before first save
if (!channel.id) {
if (channel.type === 'telegram') {
if (!channel.botToken || !channel.chatId) return
} else if (channel.type === 'gotify') {
if (!channel.url || !channel.token) return
} else if (channel.type === 'ntfy') {
// topic optional (defaults)
} else if (!channel.url) {
return
}
}
setStatus('Applying channel…')
try {
const res = await rpc(Methods.upsertAlertChannel || 'upsertAlertChannel', { channel })
if (res?.channel?.id) {
const idEl = document.getElementById('alert-ch-id')
if (idEl && !idEl.value) idEl.value = res.channel.id
}
try {
await syncAlertsCacheFromServer({
request: (method, args) => rpc(method, args),
serverId: manager.active?.id || null,
})
} catch {
// ignore
}
// Refresh list without wiping form (re-load channels only)
try {
const cfgRes = await rpc(Methods.getAlertsConfig || 'getAlertsConfig')
alertsState = cfgRes?.config || alertsState
renderChannels(alertsState?.channels || [])
} catch {
// ignore
}
setStatus(`Channel “${channel.name}” applied live`, 'success')
} catch (err) {
setStatus(err.message || 'Channel apply failed', 'danger')
}
}
function scheduleChannelApply(ms = 500) {
if (suppressLiveApply) return
if (channelApplyTimer) clearTimeout(channelApplyTimer)
channelApplyTimer = setTimeout(() => {
channelApplyTimer = null
applyChannelFormLive().catch(() => {})
}, ms)
}
async function applyRuleFormLive() {
if (suppressLiveApply) return
if (!manager.active?.connected) return
const rule = readRuleForm()
if (!rule.name?.trim()) {
setStatus('Rule: enter a name to apply', 'muted')
return
}
setStatus('Applying rule…')
try {
const res = await rpc(Methods.upsertAlertRule || 'upsertAlertRule', { rule })
if (res?.rule?.id) {
const idEl = document.getElementById('alert-rule-id')
if (idEl && !idEl.value) idEl.value = res.rule.id
}
try {
await syncAlertsCacheFromServer({
request: (method, args) => rpc(method, args),
serverId: manager.active?.id || null,
})
} catch {
// ignore
}
try {
const cfgRes = await rpc(Methods.getAlertsConfig || 'getAlertsConfig')
alertsState = cfgRes?.config || alertsState
renderRules(alertsState?.rules || [])
} catch {
// ignore
}
setStatus(`Rule “${rule.name}” applied live`, 'success')
} catch (err) {
setStatus(err.message || 'Rule apply failed', 'danger')
}
}
function scheduleRuleApply(ms = 500) {
if (suppressLiveApply) return
if (ruleApplyTimer) clearTimeout(ruleApplyTimer)
ruleApplyTimer = setTimeout(() => {
ruleApplyTimer = null
applyRuleFormLive().catch(() => {})
}, ms)
}
export function initAlertsSettings() {
document.getElementById('alerts-save-global')?.addEventListener('click', () => {
saveGlobalSettings().catch((err) => {
setStatus(err.message, 'danger')
showAlert('danger', err.message)
// Engine settings — live apply (no Save button)
const liveSelects = [
'alerts-enabled',
'alerts-min-severity',
'alerts-include-hostname',
'alerts-quiet-enabled',
]
for (const id of liveSelects) {
document.getElementById(id)?.addEventListener('change', () => {
// Enable/disable should feel instant + toast
const toast = id === 'alerts-enabled'
applyGlobalSettingsLive({ quiet: !toast, toast }).catch(() => {})
})
})
}
for (const id of [
'alerts-poll-ms',
'alerts-rate-limit',
'alerts-quiet-start',
'alerts-quiet-end',
]) {
const el = document.getElementById(id)
el?.addEventListener('change', () => scheduleGlobalApply(200))
el?.addEventListener('input', () => scheduleGlobalApply(500))
}
document.getElementById('alerts-refresh')?.addEventListener('click', () => {
loadAlertsPanel().catch(() => {})
})
document.getElementById('alert-ch-type')?.addEventListener('change', updateChannelFormHints)
document.getElementById('alert-ch-type')?.addEventListener('change', () => {
updateChannelFormHints()
scheduleChannelApply(200)
})
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')
}
// Channel form — live apply (no Save)
for (const id of [
'alert-ch-name',
'alert-ch-url',
'alert-ch-token',
'alert-ch-topic',
'alert-ch-bot-token',
'alert-ch-chat-id',
'alert-ch-username',
]) {
document.getElementById(id)?.addEventListener('input', () => scheduleChannelApply(550))
document.getElementById(id)?.addEventListener('change', () => scheduleChannelApply(200))
}
document.getElementById('alert-ch-enabled')?.addEventListener('change', () => {
scheduleChannelApply(100)
})
document.getElementById('alert-ch-min-sev')?.addEventListener('change', () => {
scheduleChannelApply(100)
})
document.getElementById('alert-ch-reset')?.addEventListener('click', () => {
if (channelApplyTimer) {
clearTimeout(channelApplyTimer)
channelApplyTimer = null
}
fillChannelForm(null)
setStatus('Channel form cleared', 'muted')
})
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')
}
})
// Rule form — live apply
for (const id of [
'alert-rule-name',
'alert-rule-types',
'alert-rule-actions',
'alert-rule-health',
'alert-rule-cooldown',
'alert-rule-name-regex',
'alert-rule-name-include',
'alert-rule-name-exclude',
'alert-rule-disk',
]) {
document.getElementById(id)?.addEventListener('input', () => scheduleRuleApply(550))
document.getElementById(id)?.addEventListener('change', () => scheduleRuleApply(200))
}
for (const id of [
'alert-rule-enabled',
'alert-rule-severity',
'alert-rule-kind',
'alert-rule-recover',
]) {
document.getElementById(id)?.addEventListener('change', () => scheduleRuleApply(100))
}
document.getElementById('alert-rule-reset')?.addEventListener('click', () => {
if (ruleApplyTimer) {
clearTimeout(ruleApplyTimer)
ruleApplyTimer = null
}
fillRuleForm(null)
setStatus('Rule form cleared', 'muted')
})
document.getElementById('alerts-channels-list')?.addEventListener('click', async (e) => {
+177 -80
View File
@@ -2198,6 +2198,167 @@ export function showSettingsTab(tab) {
}
}
/** Suppress live-apply while hydrating the settings form from disk */
let settingsLiveSuppress = false
/** @type {ReturnType<typeof setTimeout>|null} */
let settingsLiveTimer = null
/** @type {ReturnType<typeof setTimeout>|null} */
let settingsLiveStatusClearTimer = null
/**
* Footer status line for live settings apply feedback.
* @param {string} html
* @param {'muted'|'success'|'danger'} [tone]
*/
function setSettingsLiveStatus(html, tone = 'muted') {
const el = document.getElementById('settings-live-status')
if (!el) return
const toneClass =
tone === 'success' ? 'text-success' : tone === 'danger' ? 'text-danger' : 'text-muted'
el.className = `settings-live-status small ${toneClass}`
el.innerHTML = html
if (settingsLiveStatusClearTimer) clearTimeout(settingsLiveStatusClearTimer)
if (tone === 'success') {
settingsLiveStatusClearTimer = setTimeout(() => {
setSettingsLiveStatus(
'<i class="fas fa-bolt me-1" aria-hidden="true"></i>Changes apply automatically',
'muted'
)
}, 2800)
}
}
/**
* Persist full settings form + backup policy to disk and apply UI (live, no Save button).
* @param {{ detail?: string, toast?: boolean }} [opts]
*/
function applyAllSettingsLive(opts = {}) {
if (settingsLiveSuppress) return
try {
setSettingsLiveStatus(
'<i class="fas fa-circle-notch fa-spin me-1" aria-hidden="true"></i>Applying…',
'muted'
)
setTemplateListUrls(templateUrlsDraft)
const partial = readSettingsForm()
partial.templateListUrls = getTemplateListUrls()
const next = saveSettings(partial)
startListAutoRefresh(next.refreshSeconds)
persistBackupPolicyFromForm()
if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.()
const detail = opts.detail ? ` · ${opts.detail}` : ''
setSettingsLiveStatus(
`<i class="fas fa-check me-1" aria-hidden="true"></i>Applied${detail}`,
'success'
)
if (opts.toast) {
showAlert('success', 'Preferences applied', { badge: false })
}
} catch (err) {
setSettingsLiveStatus(
`<i class="fas fa-triangle-exclamation me-1" aria-hidden="true"></i>${escape(err?.message || 'Failed to apply')}`,
'danger'
)
showAlert('danger', err?.message || 'Could not apply preferences', { badge: false })
}
}
/**
* @param {number} [ms]
* @param {{ detail?: string }} [opts]
*/
function scheduleSettingsLiveApply(ms = 350, opts = {}) {
if (settingsLiveSuppress) return
if (settingsLiveTimer) clearTimeout(settingsLiveTimer)
setSettingsLiveStatus(
'<i class="fas fa-circle-notch fa-spin me-1" aria-hidden="true"></i>Applying…',
'muted'
)
settingsLiveTimer = setTimeout(() => {
settingsLiveTimer = null
applyAllSettingsLive(opts)
}, ms)
}
/**
* Whether a form control is owned by the server-side alerts live apply path.
* @param {Element} el
*/
function isServerAlertsLiveField(el) {
if (!el || !el.id) return false
if (el.classList?.contains('alerts-live-field')) return true
const id = el.id
return (
id.startsWith('alert-ch-') ||
id.startsWith('alert-rule-') ||
id === 'alerts-enabled' ||
id === 'alerts-min-severity' ||
id === 'alerts-poll-ms' ||
id === 'alerts-rate-limit' ||
id === 'alerts-include-hostname' ||
id === 'alerts-quiet-enabled' ||
id === 'alerts-quiet-start' ||
id === 'alerts-quiet-end'
)
}
/**
* Wire Settings view for auto-save on change (appearance, behavior, jobs, backup policy, …).
*/
function wireSettingsLiveApply() {
const view = document.getElementById('settings-view')
if (!view || view.dataset.liveApplyWired === '1') return
view.dataset.liveApplyWired = '1'
const onFieldChange = (e) => {
const t = e.target
if (!t || !(t instanceof HTMLElement)) return
if (t.matches('button, [type="button"], [type="submit"], [type="file"]')) return
if (!t.matches('select, input, textarea')) return
// Action-only / temporary inputs
if (
t.id === 'settings-backup-label' ||
t.id === 'settings-backup-file-input' ||
t.id === 'settings-template-url-input' ||
t.id === 'settings-hidden-label-name' ||
t.id === 'settings-hidden-label-value'
) {
return
}
if (isServerAlertsLiveField(t)) return
const immediate =
t.tagName === 'SELECT' ||
t.type === 'checkbox' ||
t.type === 'radio' ||
t.classList?.contains('form-check-input')
scheduleSettingsLiveApply(immediate ? 80 : 450, {
detail: t.id ? t.id.replace(/^settings-/, '').replace(/-/g, ' ') : undefined,
})
}
view.addEventListener('change', onFieldChange)
view.addEventListener('input', (e) => {
const t = e.target
if (!t || !(t instanceof HTMLElement)) return
if (!t.matches('input[type="number"], input[type="text"], input:not([type]), textarea')) {
return
}
if (isServerAlertsLiveField(t)) return
if (
t.id === 'settings-backup-label' ||
t.id === 'settings-template-url-input' ||
t.id === 'settings-hidden-label-name' ||
t.id === 'settings-hidden-label-value'
) {
return
}
scheduleSettingsLiveApply(500, {
detail: t.id ? t.id.replace(/^settings-/, '').replace(/-/g, ' ') : undefined,
})
})
}
/**
* Collect form values from settings panels into a partial settings object.
*/
@@ -2591,6 +2752,7 @@ async function runRestorePackage(pkg, opts = {}) {
* @param {string} [forceTab] — open a specific settings subtab (e.g. peers)
*/
export function loadSettingsView(forceTab) {
settingsLiveSuppress = true
const s = loadSettings()
setSelectValue('settings-density', s.density || 'comfortable')
setSelectValue('settings-theme', normalizeThemePreference(s.theme))
@@ -2693,6 +2855,13 @@ export function loadSettingsView(forceTab) {
fillBackupForm()
renderBackupHistory()
setTimeout(() => {
settingsLiveSuppress = false
setSettingsLiveStatus(
'<i class="fas fa-bolt me-1" aria-hidden="true"></i>Changes apply automatically',
'muted'
)
}, 0)
}
function renderTemplateUrlsEditor() {
@@ -2720,6 +2889,7 @@ function renderTemplateUrlsEditor() {
if (!Number.isFinite(i)) return
templateUrlsDraft = templateUrlsDraft.filter((_, j) => j !== i)
renderTemplateUrlsEditor()
applyAllSettingsLive({ detail: 'template lists' })
})
})
}
@@ -2751,6 +2921,7 @@ function renderHiddenLabelFiltersEditor() {
if (!Number.isFinite(i)) return
hiddenLabelFiltersDraft = hiddenLabelFiltersDraft.filter((_, j) => j !== i)
renderHiddenLabelFiltersEditor()
applyAllSettingsLive({ detail: 'hide filters' })
})
})
}
@@ -2777,6 +2948,7 @@ function addHiddenLabelFilterFromInput() {
if (nameEl) nameEl.value = ''
if (valueEl) valueEl.value = ''
renderHiddenLabelFiltersEditor()
applyAllSettingsLive({ detail: 'hide filter added' })
}
function addTemplateUrlFromInput() {
@@ -2802,6 +2974,7 @@ function addTemplateUrlFromInput() {
templateUrlsDraft = normalizeTemplateListUrls([...templateUrlsDraft, href])
if (input) input.value = ''
renderTemplateUrlsEditor()
applyAllSettingsLive({ detail: 'template list added' })
}
/**
@@ -3100,85 +3273,8 @@ export function initOpsApp({ navigateToView, sendCommand }) {
showSettingsTab(btn.getAttribute('data-settings-tab'))
})
// Persist color mode immediately so it survives reboot (also covered by Save all)
document.getElementById('settings-theme')?.addEventListener('change', (e) => {
const pref = normalizeThemePreference(e.target?.value || 'dark')
try {
// saveSettings → applySettings → applyColorTheme + settings.json cache
saveSettings({ theme: pref })
} catch {
applyColorTheme(pref)
}
})
document.getElementById('settings-accent')?.addEventListener('change', (e) => {
const accent = String(e.target?.value || 'teal')
if (['teal', 'cyan', 'violet'].includes(accent)) {
document.body.dataset.accent = accent
}
})
document.getElementById('settings-save-btn')?.addEventListener('click', (e) => {
const btn = e.currentTarget
if (btn?.classList.contains('is-saving') || btn?.classList.contains('is-saved')) return
const idleHtml =
btn.dataset.idleHtml ||
'<i class="fas fa-floppy-disk me-1" aria-hidden="true"></i><span class="settings-save-label">Save all</span>'
if (btn) btn.dataset.idleHtml = idleHtml
clearTimeout(btn?._saveFeedbackTimer)
if (btn) {
btn.classList.remove('is-saved', 'is-error')
btn.classList.add('is-saving')
btn.disabled = true
btn.setAttribute('aria-busy', 'true')
btn.innerHTML =
'<i class="fas fa-circle-notch fa-spin me-1" aria-hidden="true"></i><span class="settings-save-label">Saving…</span>'
}
// Brief saving state so the control always gives tactile feedback (save is local/sync).
const finish = (ok, errMsg) => {
if (!btn) return
btn.classList.remove('is-saving')
btn.removeAttribute('aria-busy')
if (ok) {
btn.classList.add('is-saved')
btn.innerHTML =
'<i class="fas fa-check me-1 settings-save-check" aria-hidden="true"></i><span class="settings-save-label">Saved</span>'
btn._saveFeedbackTimer = setTimeout(() => {
btn.classList.remove('is-saved')
btn.disabled = false
btn.innerHTML = btn.dataset.idleHtml || idleHtml
}, 2000)
} else {
btn.classList.add('is-error')
btn.disabled = false
btn.innerHTML =
'<i class="fas fa-triangle-exclamation me-1" aria-hidden="true"></i><span class="settings-save-label">Failed</span>'
btn._saveFeedbackTimer = setTimeout(() => {
btn.classList.remove('is-error')
btn.innerHTML = btn.dataset.idleHtml || idleHtml
}, 2400)
if (errMsg) showAlert('danger', errMsg, { badge: false })
}
}
window.setTimeout(() => {
try {
setTemplateListUrls(templateUrlsDraft)
const partial = readSettingsForm()
partial.templateListUrls = getTemplateListUrls()
const next = saveSettings(partial)
startListAutoRefresh(next.refreshSeconds)
persistBackupPolicyFromForm()
if (typeof window !== 'undefined') window.__peardockClearDeployTemplateCache?.()
finish(true)
showAlert('success', 'Preferences saved', { badge: false })
} catch (err) {
finish(false, err?.message || 'Could not save preferences')
}
}, 220)
})
// Live-apply all Settings form controls (no Save button)
wireSettingsLiveApply()
// —— Backup & restore ——
document.getElementById('settings-backup-create-btn')?.addEventListener('click', () => {
@@ -3298,7 +3394,8 @@ export function initOpsApp({ navigateToView, sendCommand }) {
document.getElementById('settings-template-url-reset')?.addEventListener('click', () => {
templateUrlsDraft = [...DEFAULT_TEMPLATE_LIST_URLS]
renderTemplateUrlsEditor()
showAlert('info', 'Default template list restored (save preferences to apply)')
applyAllSettingsLive({ detail: 'template lists reset' })
showAlert('info', 'Default template list restored', { badge: false })
})
document.getElementById('settings-template-reload')?.addEventListener('click', () => {
saveTemplateListSettings({ reload: true })
+10 -2
View File
@@ -937,7 +937,7 @@
font-size: 0.78rem;
}
/* Settings page: scrollable body + fixed footer with Save all bottom-right */
/* Settings page: scrollable body + live-apply status footer */
#settings-view.view {
display: flex;
flex-direction: column;
@@ -963,7 +963,7 @@
padding-bottom: 0.5rem;
}
/* Save control only — no full-width bar / gradient strip */
/* Live-apply status — no full-width bar / gradient strip */
#settings-view .settings-footer {
flex: 0 0 auto;
display: flex;
@@ -977,6 +977,14 @@
box-shadow: none;
}
#settings-view .settings-footer .settings-live-status {
min-height: 1.5rem;
padding: 0.35rem 0.75rem;
border-radius: 0.45rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
#settings-view .settings-footer .settings-save-btn {
min-width: 8.75rem;
padding: 0.55rem 1.35rem;