Fix per-resource settings: tags saving, is_public bug, global setting inheritance

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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-06 17:01:03 -04:00
1 parent 3a3d32ecac
commit 59078362da
4 files changed
+49 -18

No files matched your search

+15 -7
View File
@@ -22,8 +22,8 @@ function Dashboard() {
name: '', name: '',
url: '', url: '',
type: 'http', type: 'http',
check_interval: 60000, check_interval: null,
timeout: 5000, timeout: null,
group_id: null, group_id: null,
http_keyword: '', http_keyword: '',
http_headers: '', http_headers: '',
@@ -48,6 +48,8 @@ function Dashboard() {
const [sortKey, setSortKey] = useState('severity'); const [sortKey, setSortKey] = useState('severity');
const [renderLimit, setRenderLimit] = useState(120); const [renderLimit, setRenderLimit] = useState(120);
const [refreshInterval, setRefreshInterval] = useState(15000); const [refreshInterval, setRefreshInterval] = useState(15000);
const [globalCheckInterval, setGlobalCheckInterval] = useState(60000);
const [globalTimeout, setGlobalTimeout] = useState(5000);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState(null); const [pendingDeleteId, setPendingDeleteId] = useState(null);
const [pendingBulkDelete, setPendingBulkDelete] = useState(false); const [pendingBulkDelete, setPendingBulkDelete] = useState(false);
@@ -144,6 +146,8 @@ function Dashboard() {
if (s.items_per_page) setRenderLimit(parseInt(s.items_per_page)); if (s.items_per_page) setRenderLimit(parseInt(s.items_per_page));
if (s.default_sort) setSortKey(s.default_sort); if (s.default_sort) setSortKey(s.default_sort);
if (s.refresh_interval) setRefreshInterval(parseInt(s.refresh_interval)); 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(() => {}); }).catch(() => {});
}, []); }, []);
@@ -159,7 +163,7 @@ function Dashboard() {
}, [connectWebSocket, loadResources, refreshInterval]); }, [connectWebSocket, loadResources, refreshInterval]);
const emptyForm = { 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: '', group_id: null, http_keyword: '', http_headers: '', quiet_hours_start: '',
quiet_hours_end: '', cert_expiry_days: 30, sla_target: 99.9, email_to: '', quiet_hours_end: '', cert_expiry_days: 30, sla_target: 99.9, email_to: '',
maintenance_mode: false, tags: '', consecutive_failures_threshold: 1, maintenance_mode: false, tags: '', consecutive_failures_threshold: 1,
@@ -825,13 +829,15 @@ function Dashboard() {
<div className="form-group"> <div className="form-group">
<label>Check Interval (ms)</label> <label>Check Interval (ms)</label>
<input type="number" value={formData.check_interval} onChange={(e) => setFormData({ ...formData, check_interval: parseInt(e.target.value) })} min="10000" /> <input type="number" value={formData.check_interval ?? ''} placeholder={`${globalCheckInterval} (global default)`} onChange={(e) => setFormData({ ...formData, check_interval: e.target.value ? parseInt(e.target.value) : null })} min="10000" />
<small>Leave empty to use global setting ({globalCheckInterval}ms)</small>
</div> </div>
{formData.type !== 'heartbeat' && ( {formData.type !== 'heartbeat' && (
<div className="form-group"> <div className="form-group">
<label>Timeout (ms)</label> <label>Timeout (ms)</label>
<input type="number" value={formData.timeout} onChange={(e) => setFormData({ ...formData, timeout: parseInt(e.target.value) })} min="1000" /> <input type="number" value={formData.timeout ?? ''} placeholder={`${globalTimeout} (global default)`} onChange={(e) => setFormData({ ...formData, timeout: e.target.value ? parseInt(e.target.value) : null })} min="1000" />
<small>Leave empty to use global setting ({globalTimeout}ms)</small>
</div> </div>
)} )}
@@ -965,12 +971,14 @@ function Dashboard() {
<div className="form-group"> <div className="form-group">
<label>Check Interval (ms)</label> <label>Check Interval (ms)</label>
<input type="number" value={editData.check_interval || 60000} onChange={(e) => setEditData({ ...editData, check_interval: parseInt(e.target.value) })} min="10000" /> <input type="number" value={editData.check_interval ?? ''} placeholder={`${globalCheckInterval} (global default)`} onChange={(e) => setEditData({ ...editData, check_interval: e.target.value ? parseInt(e.target.value) : null })} min="10000" />
<small>Leave empty to use global setting ({globalCheckInterval}ms)</small>
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Timeout (ms)</label> <label>Timeout (ms)</label>
<input type="number" value={editData.timeout || 5000} onChange={(e) => setEditData({ ...editData, timeout: parseInt(e.target.value) })} min="1000" /> <input type="number" value={editData.timeout ?? ''} placeholder={`${globalTimeout} (global default)`} onChange={(e) => setEditData({ ...editData, timeout: e.target.value ? parseInt(e.target.value) : null })} min="1000" />
<small>Leave empty to use global setting ({globalTimeout}ms)</small>
</div> </div>
{(editData.type === 'http' || editData.type === 'https' || editData.type === 'health') && ( {(editData.type === 'http' || editData.type === 'https' || editData.type === 'health') && (
+10
View File
@@ -333,4 +333,14 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_resources_heartbeat_token ON resources(heartbeat_token); 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; module.exports = db;
+10 -8
View File
@@ -343,7 +343,7 @@ app.get('/api/resources/:id', (req, res) => {
// Create resource // Create resource
app.post('/api/resources', (req, res) => { 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); const validationError = validateResourceBody(req.body);
if (validationError) return res.status(400).json({ error: validationError }); 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 effectiveUrl = isHeartbeat ? `heartbeat://${name}` : url;
const stmt = db.prepare(` 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const result = stmt.run( const result = stmt.run(
@@ -374,13 +374,14 @@ app.post('/api/resources', (req, res) => {
email_to || null, email_to || null,
maintenance_mode ? 1 : 0, maintenance_mode ? 1 : 0,
retention_days || null, retention_days || null,
is_public !== false ? 1 : 0, (is_public === false || is_public === 0) ? 0 : 1,
token, token,
heartbeat_timeout || 300000, heartbeat_timeout || 300000,
consecutive_failures_threshold || 1, consecutive_failures_threshold || 1,
response_time_threshold || null, response_time_threshold || null,
http_method || 'GET', http_method || 'GET',
http_body || null http_body || null,
tags || null
); );
// Invalidate related cache entries // Invalidate related cache entries
@@ -401,7 +402,7 @@ app.post('/api/resources', (req, res) => {
// Update resource // Update resource
app.put('/api/resources/:id', (req, res) => { 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); const validationError = validateResourceBody(req.body);
if (validationError) return res.status(400).json({ error: validationError }); if (validationError) return res.status(400).json({ error: validationError });
@@ -412,7 +413,7 @@ app.put('/api/resources/:id', (req, res) => {
const stmt = db.prepare(` const stmt = db.prepare(`
UPDATE resources 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 = ? WHERE id = ?
`); `);
@@ -433,12 +434,13 @@ app.put('/api/resources/:id', (req, res) => {
email_to || null, email_to || null,
maintenance_mode ? 1 : 0, maintenance_mode ? 1 : 0,
retention_days || null, retention_days || null,
is_public !== false ? 1 : 0, (is_public === false || is_public === 0) ? 0 : 1,
heartbeat_timeout || 300000, heartbeat_timeout || 300000,
consecutive_failures_threshold || 1, consecutive_failures_threshold || 1,
response_time_threshold || null, response_time_threshold || null,
http_method || 'GET', http_method || 'GET',
http_body || null, http_body || null,
tags || null,
req.params.id req.params.id
); );
+14 -3
View File
@@ -121,6 +121,12 @@ class Scheduler {
async runChecks() { async runChecks() {
const resources = db.prepare(`SELECT * FROM resources WHERE enabled = 1`).all(); 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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const jitter = (maxMs = 1500) => Math.floor(Math.random() * maxMs); const jitter = (maxMs = 1500) => Math.floor(Math.random() * maxMs);
const baseSpacingMs = 250; const baseSpacingMs = 250;
@@ -129,16 +135,21 @@ class Scheduler {
const pendingAlerts = []; const pendingAlerts = [];
const tasks = resources.map((resource, index) => (async () => { 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 now = Date.now();
const lastChecked = this.lastCheckedAt.get(resource.id) || 0; 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; if (now - lastChecked < interval) return;
this.lastCheckedAt.set(resource.id, now); 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()); await sleep(index * baseSpacingMs + jitter());
try { try {
const result = await monitorService.checkResource(resource); const result = await monitorService.checkResource(effectiveResource);
monitorService.saveCheck(result); monitorService.saveCheck(result);
// Rolling cap per resource (configurable via settings; default 10000) // Rolling cap per resource (configurable via settings; default 10000)