From 59078362da01e3b0c71d8e7b0dbfc9f84cbecd19 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:01:03 -0400 Subject: [PATCH] Fix per-resource settings: tags saving, is_public bug, global setting inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - tags field was missing from INSERT/UPDATE SQL — tags never persisted - is_public: `0 !== false` is true in JS, so private resources (is_public=0) were silently flipped to public on every edit; fixed with explicit 0/false check Global setting inheritance: - One-time DB migration nulls out check_interval and timeout on all existing resources so they inherit from global monitoring settings going forward - Scheduler reads global check_interval and timeout from DB each cycle and uses them as the fallback when per-resource values are null - Create and edit modals: empty field = use global; placeholder shows the current global value so it's clear what will be used Co-Authored-By: Claude Sonnet 4.6 --- client/src/Dashboard.js | 22 +++++++++++++++------- server/database.js | 10 ++++++++++ server/index.js | 18 ++++++++++-------- server/scheduler.js | 17 ++++++++++++++--- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/client/src/Dashboard.js b/client/src/Dashboard.js index 256d07b..a67081f 100644 --- a/client/src/Dashboard.js +++ b/client/src/Dashboard.js @@ -22,8 +22,8 @@ function Dashboard() { name: '', url: '', type: 'http', - check_interval: 60000, - timeout: 5000, + check_interval: null, + timeout: null, group_id: null, http_keyword: '', http_headers: '', @@ -48,6 +48,8 @@ function Dashboard() { const [sortKey, setSortKey] = useState('severity'); const [renderLimit, setRenderLimit] = useState(120); const [refreshInterval, setRefreshInterval] = useState(15000); + const [globalCheckInterval, setGlobalCheckInterval] = useState(60000); + const [globalTimeout, setGlobalTimeout] = useState(5000); const [loaded, setLoaded] = useState(false); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [pendingBulkDelete, setPendingBulkDelete] = useState(false); @@ -144,6 +146,8 @@ function Dashboard() { if (s.items_per_page) setRenderLimit(parseInt(s.items_per_page)); if (s.default_sort) setSortKey(s.default_sort); if (s.refresh_interval) setRefreshInterval(parseInt(s.refresh_interval)); + if (s.check_interval) setGlobalCheckInterval(parseInt(s.check_interval)); + if (s.timeout) setGlobalTimeout(parseInt(s.timeout)); }).catch(() => {}); }, []); @@ -159,7 +163,7 @@ function Dashboard() { }, [connectWebSocket, loadResources, refreshInterval]); const emptyForm = { - name: '', url: '', type: 'http', check_interval: 60000, timeout: 5000, + name: '', url: '', type: 'http', check_interval: null, timeout: null, group_id: null, http_keyword: '', http_headers: '', quiet_hours_start: '', quiet_hours_end: '', cert_expiry_days: 30, sla_target: 99.9, email_to: '', maintenance_mode: false, tags: '', consecutive_failures_threshold: 1, @@ -825,13 +829,15 @@ function Dashboard() {
- setFormData({ ...formData, check_interval: parseInt(e.target.value) })} min="10000" /> + setFormData({ ...formData, check_interval: e.target.value ? parseInt(e.target.value) : null })} min="10000" /> + Leave empty to use global setting ({globalCheckInterval}ms)
{formData.type !== 'heartbeat' && (
- setFormData({ ...formData, timeout: parseInt(e.target.value) })} min="1000" /> + setFormData({ ...formData, timeout: e.target.value ? parseInt(e.target.value) : null })} min="1000" /> + Leave empty to use global setting ({globalTimeout}ms)
)} @@ -965,12 +971,14 @@ function Dashboard() {
- setEditData({ ...editData, check_interval: parseInt(e.target.value) })} min="10000" /> + setEditData({ ...editData, check_interval: e.target.value ? parseInt(e.target.value) : null })} min="10000" /> + Leave empty to use global setting ({globalCheckInterval}ms)
- setEditData({ ...editData, timeout: parseInt(e.target.value) })} min="1000" /> + setEditData({ ...editData, timeout: e.target.value ? parseInt(e.target.value) : null })} min="1000" /> + Leave empty to use global setting ({globalTimeout}ms)
{(editData.type === 'http' || editData.type === 'https' || editData.type === 'health') && ( diff --git a/server/database.js b/server/database.js index 8ab03a7..f355609 100644 --- a/server/database.js +++ b/server/database.js @@ -333,4 +333,14 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_resources_heartbeat_token ON resources(heartbeat_token); `); +// One-time migration: null out per-resource check_interval and timeout so all resources +// inherit from the global monitoring settings. Runs once, tracked by a settings flag. +try { + const alreadyMigrated = db.prepare("SELECT value FROM settings WHERE key = 'migrated_global_defaults_v1'").get(); + if (!alreadyMigrated) { + db.prepare("UPDATE resources SET check_interval = NULL, timeout = NULL").run(); + db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('migrated_global_defaults_v1', 'true')").run(); + } +} catch (e) {} + module.exports = db; diff --git a/server/index.js b/server/index.js index 6fd140a..cd70a37 100644 --- a/server/index.js +++ b/server/index.js @@ -343,7 +343,7 @@ app.get('/api/resources/:id', (req, res) => { // Create resource app.post('/api/resources', (req, res) => { - const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body } = req.body; + const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body, tags } = req.body; const validationError = validateResourceBody(req.body); if (validationError) return res.status(400).json({ error: validationError }); @@ -354,8 +354,8 @@ app.post('/api/resources', (req, res) => { const effectiveUrl = isHeartbeat ? `heartbeat://${name}` : url; const stmt = db.prepare(` - INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_token, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_token, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body, tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( @@ -374,13 +374,14 @@ app.post('/api/resources', (req, res) => { email_to || null, maintenance_mode ? 1 : 0, retention_days || null, - is_public !== false ? 1 : 0, + (is_public === false || is_public === 0) ? 0 : 1, token, heartbeat_timeout || 300000, consecutive_failures_threshold || 1, response_time_threshold || null, http_method || 'GET', - http_body || null + http_body || null, + tags || null ); // Invalidate related cache entries @@ -401,7 +402,7 @@ app.post('/api/resources', (req, res) => { // Update resource app.put('/api/resources/:id', (req, res) => { - const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body } = req.body; + const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body, tags } = req.body; const validationError = validateResourceBody(req.body); if (validationError) return res.status(400).json({ error: validationError }); @@ -412,7 +413,7 @@ app.put('/api/resources/:id', (req, res) => { const stmt = db.prepare(` UPDATE resources - SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?, retention_days = ?, is_public = ?, heartbeat_timeout = ?, consecutive_failures_threshold = ?, response_time_threshold = ?, http_method = ?, http_body = ? + SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?, retention_days = ?, is_public = ?, heartbeat_timeout = ?, consecutive_failures_threshold = ?, response_time_threshold = ?, http_method = ?, http_body = ?, tags = ? WHERE id = ? `); @@ -433,12 +434,13 @@ app.put('/api/resources/:id', (req, res) => { email_to || null, maintenance_mode ? 1 : 0, retention_days || null, - is_public !== false ? 1 : 0, + (is_public === false || is_public === 0) ? 0 : 1, heartbeat_timeout || 300000, consecutive_failures_threshold || 1, response_time_threshold || null, http_method || 'GET', http_body || null, + tags || null, req.params.id ); diff --git a/server/scheduler.js b/server/scheduler.js index a55e63c..18ea34d 100644 --- a/server/scheduler.js +++ b/server/scheduler.js @@ -121,6 +121,12 @@ class Scheduler { async runChecks() { const resources = db.prepare(`SELECT * FROM resources WHERE enabled = 1`).all(); + // Read global defaults once per cycle so per-resource nulls inherit correctly + const globalIntervalRow = db.prepare(`SELECT value FROM settings WHERE key = 'check_interval'`).get(); + const globalTimeoutRow = db.prepare(`SELECT value FROM settings WHERE key = 'timeout'`).get(); + const globalCheckInterval = parseInt(globalIntervalRow?.value) || 60000; + const globalTimeout = parseInt(globalTimeoutRow?.value) || 5000; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const jitter = (maxMs = 1500) => Math.floor(Math.random() * maxMs); const baseSpacingMs = 250; @@ -129,16 +135,21 @@ class Scheduler { const pendingAlerts = []; const tasks = resources.map((resource, index) => (async () => { - // Honour per-resource check_interval — skip if not yet due + // Honour per-resource check_interval, falling back to global setting const now = Date.now(); const lastChecked = this.lastCheckedAt.get(resource.id) || 0; - const interval = resource.check_interval || 60000; + const interval = resource.check_interval || globalCheckInterval; if (now - lastChecked < interval) return; this.lastCheckedAt.set(resource.id, now); + // Resolve effective timeout: per-resource override or global default + const effectiveResource = resource.timeout + ? resource + : { ...resource, timeout: globalTimeout }; + await sleep(index * baseSpacingMs + jitter()); try { - const result = await monitorService.checkResource(resource); + const result = await monitorService.checkResource(effectiveResource); monitorService.saveCheck(result); // Rolling cap per resource (configurable via settings; default 10000)