Apply all settings and alerts live without Save buttons
Release rolling / release (push) Has been cancelled
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:
+286
-62
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user