Fix bugs, UX rough edges, add ntfy.sh + network discovery
Bugs fixed: - Status page: autoRefresh checkbox now actually pauses the interval - Dashboard edit modal: hide URL field for heartbeat resources (was always shown as required) - Dashboard: apply items_per_page, default_sort, and refresh_interval from settings on mount - Dashboard: call loadResources() immediately on mount (was waiting 15s for fallback) - Observability: listen for WS 'metrics' messages so tab updates live, not just on poll UX improvements: - Dashboard: replaced window.confirm() with inline Confirm/Cancel buttons for single and bulk delete - Dashboard: skeleton rows shown while initial data loads (no more flash of empty state) - Notifications: WebSocket moved from window global to useRef; exponential backoff on reconnect - Notifications: replaced window.confirm() with inline Confirm/Cancel for Clear All - ResourceDetail: initial API calls run in parallel via Promise.all New features: - ntfy.sh push notification channel: enable in Settings, enter topic, get mobile push alerts - Network Discovery: Actions → Discover Network scans LAN for reachable hosts, probes HTTP ports, and lets you add any found host to monitoring in one click Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
de4619992e
commit
8d50d3cb61
21 files changed
+668
-105
No files matched your search
@@ -13,6 +13,7 @@ const notificationService = require('./notificationService');
|
||||
const cache = require('./cache');
|
||||
const security = require('./securityMiddleware');
|
||||
const metrics = require('./metrics');
|
||||
const { discoverNetwork } = require('./networkDiscovery');
|
||||
const auditLog = require('./auditLog');
|
||||
|
||||
// Initialize notifications table if it doesn't exist
|
||||
@@ -142,6 +143,9 @@ function buildSettingsFromDb(dbSettings = {}) {
|
||||
items_per_page: parseInt(dbSettings.items_per_page || process.env.ITEMS_PER_PAGE) || 20,
|
||||
refresh_interval: parseInt(dbSettings.refresh_interval || process.env.REFRESH_INTERVAL) || 5000,
|
||||
theme: 'dark',
|
||||
ntfy_enabled: (dbSettings.ntfy_enabled || process.env.NTFY_ENABLED) === 'true',
|
||||
ntfy_url: dbSettings.ntfy_url || process.env.NTFY_URL || 'https://ntfy.sh',
|
||||
ntfy_topic: dbSettings.ntfy_topic || process.env.NTFY_TOPIC || '',
|
||||
incident_failure_threshold: parseInt(dbSettings.incident_failure_threshold || process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
|
||||
webhook_template: dbSettings.webhook_template || process.env.WEBHOOK_TEMPLATE || '',
|
||||
};
|
||||
@@ -689,6 +693,9 @@ app.post('/api/settings', (req, res) => {
|
||||
theme,
|
||||
incident_failure_threshold,
|
||||
webhook_template,
|
||||
ntfy_enabled,
|
||||
ntfy_url,
|
||||
ntfy_topic,
|
||||
} = req.body;
|
||||
|
||||
// Validate email configuration if enabled
|
||||
@@ -754,6 +761,9 @@ DEFAULT_SORT=${default_sort || 'name'}
|
||||
ITEMS_PER_PAGE=${items_per_page || 20}
|
||||
REFRESH_INTERVAL=${refresh_interval || 5000}
|
||||
THEME=dark
|
||||
NTFY_ENABLED=${ntfy_enabled || false}
|
||||
NTFY_URL=${ntfy_url || 'https://ntfy.sh'}
|
||||
NTFY_TOPIC=${ntfy_topic || ''}
|
||||
INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
|
||||
WEBHOOK_TEMPLATE=${webhook_template || ''}
|
||||
`;
|
||||
@@ -789,6 +799,9 @@ WEBHOOK_TEMPLATE=${webhook_template || ''}
|
||||
process.env.ITEMS_PER_PAGE = String(items_per_page || 20);
|
||||
process.env.REFRESH_INTERVAL = String(refresh_interval || 5000);
|
||||
process.env.THEME = 'dark';
|
||||
process.env.NTFY_ENABLED = String(ntfy_enabled || false);
|
||||
process.env.NTFY_URL = ntfy_url || 'https://ntfy.sh';
|
||||
process.env.NTFY_TOPIC = ntfy_topic || '';
|
||||
process.env.INCIDENT_FAILURE_THRESHOLD = String(incident_failure_threshold || 10);
|
||||
process.env.WEBHOOK_TEMPLATE = webhook_template || '';
|
||||
|
||||
@@ -821,6 +834,9 @@ WEBHOOK_TEMPLATE=${webhook_template || ''}
|
||||
{ key: 'refresh_interval', value: String(refresh_interval || 5000) },
|
||||
{ key: 'auto_cleanup_enabled', value: String(auto_cleanup_enabled || false) },
|
||||
// theme is always dark; skip saving to avoid stale overrides
|
||||
{ key: 'ntfy_enabled', value: String(ntfy_enabled || false) },
|
||||
{ key: 'ntfy_url', value: ntfy_url || 'https://ntfy.sh' },
|
||||
{ key: 'ntfy_topic', value: ntfy_topic || '' },
|
||||
{ key: 'incident_failure_threshold', value: String(incident_failure_threshold || 10) },
|
||||
];
|
||||
|
||||
@@ -842,6 +858,9 @@ WEBHOOK_TEMPLATE=${webhook_template || ''}
|
||||
webhook_enabled: webhook_enabled === true || webhook_enabled === 'true',
|
||||
webhook_url,
|
||||
webhook_template: webhook_template || '',
|
||||
ntfy_enabled: ntfy_enabled === true || ntfy_enabled === 'true',
|
||||
ntfy_url: ntfy_url || 'https://ntfy.sh',
|
||||
ntfy_topic: ntfy_topic || '',
|
||||
});
|
||||
|
||||
res.json({ message: 'Settings saved successfully' });
|
||||
@@ -1541,6 +1560,32 @@ app.post('/api/test-webhook', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/test-ntfy', async (req, res) => {
|
||||
const axios = require('axios');
|
||||
const { ntfy_url, ntfy_topic } = req.body;
|
||||
if (!ntfy_topic) return res.status(400).json({ error: 'ntfy topic is required' });
|
||||
const base = (ntfy_url || 'https://ntfy.sh').replace(/\/$/, '');
|
||||
try {
|
||||
await axios.post(`${base}/${ntfy_topic}`, 'This is a test notification from SkyWatch', {
|
||||
headers: { Title: '🔔 SkyWatch Test', Priority: 'default', Tags: 'white_check_mark' },
|
||||
});
|
||||
res.json({ message: 'Test notification sent to ntfy!' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Network discovery — scan local network for monitorable hosts
|
||||
app.post('/api/network-discovery', async (req, res) => {
|
||||
try {
|
||||
const hosts = await discoverNetwork();
|
||||
res.json({ hosts });
|
||||
} catch (error) {
|
||||
console.error('Network discovery error:', error);
|
||||
res.status(500).json({ error: 'Network discovery failed', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear cache endpoint for debugging
|
||||
app.post('/api/cache/clear', (req, res) => {
|
||||
cache.clear();
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
const { exec } = require('child_process');
|
||||
const net = require('net');
|
||||
const os = require('os');
|
||||
|
||||
const COMMON_HTTP_PORTS = [80, 443, 8080, 8443, 3000, 3001, 5000, 8000, 8888, 9000, 9443];
|
||||
const PROBE_TIMEOUT_MS = 800;
|
||||
const PING_TIMEOUT_MS = 500;
|
||||
const MAX_HOSTS_TO_SCAN = 50;
|
||||
|
||||
function getLocalSubnets() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
const subnets = [];
|
||||
for (const iface of Object.values(ifaces)) {
|
||||
for (const addr of iface) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) {
|
||||
subnets.push({ ip: addr.address, prefix: addr.cidr });
|
||||
}
|
||||
}
|
||||
}
|
||||
return subnets;
|
||||
}
|
||||
|
||||
function subnetToRange(cidr) {
|
||||
try {
|
||||
const [base, bits] = cidr.split('/');
|
||||
const prefixLen = parseInt(bits);
|
||||
if (prefixLen < 16 || prefixLen > 30) return [];
|
||||
const parts = base.split('.').map(Number);
|
||||
const hostBits = 32 - prefixLen;
|
||||
const totalHosts = Math.min(Math.pow(2, hostBits) - 2, MAX_HOSTS_TO_SCAN);
|
||||
const baseNum = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
|
||||
const networkNum = baseNum & (~0 << hostBits);
|
||||
const ips = [];
|
||||
for (let i = 1; i <= totalHosts; i++) {
|
||||
const n = networkNum + i;
|
||||
ips.push(`${(n >> 24) & 255}.${(n >> 16) & 255}.${(n >> 8) & 255}.${n & 255}`);
|
||||
}
|
||||
return ips;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pingHost(ip) {
|
||||
return new Promise((resolve) => {
|
||||
const cmd = process.platform === 'darwin'
|
||||
? `ping -c 1 -W ${PING_TIMEOUT_MS} ${ip}`
|
||||
: `ping -c 1 -W 1 ${ip}`;
|
||||
exec(cmd, { timeout: PING_TIMEOUT_MS + 500 }, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
|
||||
function probePort(ip, port) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
let done = false;
|
||||
sock.setTimeout(PROBE_TIMEOUT_MS);
|
||||
sock.on('connect', () => { done = true; sock.destroy(); resolve(true); });
|
||||
sock.on('timeout', () => { if (!done) { done = true; sock.destroy(); resolve(false); } });
|
||||
sock.on('error', () => { if (!done) { done = true; resolve(false); } });
|
||||
try { sock.connect(port, ip); } catch { resolve(false); }
|
||||
});
|
||||
}
|
||||
|
||||
async function probeHost(ip) {
|
||||
const openPorts = [];
|
||||
await Promise.all(
|
||||
COMMON_HTTP_PORTS.map(async (port) => {
|
||||
if (await probePort(ip, port)) openPorts.push(port);
|
||||
})
|
||||
);
|
||||
return openPorts;
|
||||
}
|
||||
|
||||
function inferServiceType(ip, openPorts) {
|
||||
if (openPorts.includes(443) || openPorts.includes(8443) || openPorts.includes(9443)) {
|
||||
return { type: 'https', port: openPorts.find(p => [443, 8443, 9443].includes(p)), scheme: 'https' };
|
||||
}
|
||||
if (openPorts.length > 0) {
|
||||
const p = openPorts[0];
|
||||
return { type: 'http', port: p, scheme: 'http' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getArpHosts() {
|
||||
return new Promise((resolve) => {
|
||||
exec('arp -a', { timeout: 3000 }, (err, stdout) => {
|
||||
if (err) return resolve([]);
|
||||
const hosts = [];
|
||||
const lines = stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/\((\d+\.\d+\.\d+\.\d+)\)/);
|
||||
if (match && !line.includes('incomplete')) {
|
||||
const name = line.match(/^(\S+)/)?.[1];
|
||||
hosts.push({ ip: match[1], hostname: name && name !== '?' ? name : null });
|
||||
}
|
||||
}
|
||||
resolve(hosts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function discoverNetwork() {
|
||||
const subnets = getLocalSubnets();
|
||||
const arpHosts = await getArpHosts();
|
||||
const arpIps = new Set(arpHosts.map(h => h.ip));
|
||||
|
||||
// Collect all IPs to scan: ARP table + subnet range
|
||||
const allIps = new Map();
|
||||
for (const h of arpHosts) allIps.set(h.ip, { ip: h.ip, hostname: h.hostname });
|
||||
|
||||
for (const subnet of subnets) {
|
||||
if (!subnet.prefix) continue;
|
||||
const range = subnetToRange(subnet.prefix);
|
||||
for (const ip of range) {
|
||||
if (!allIps.has(ip)) allIps.set(ip, { ip, hostname: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Ping all IPs in parallel (skip ones already in ARP — they're reachable by definition)
|
||||
const candidates = [...allIps.values()];
|
||||
const pingResults = await Promise.all(
|
||||
candidates.map(async (host) => {
|
||||
const alive = arpIps.has(host.ip) || await pingHost(host.ip);
|
||||
return { ...host, alive };
|
||||
})
|
||||
);
|
||||
|
||||
const alive = pingResults.filter(h => h.alive);
|
||||
|
||||
// Probe open HTTP ports on alive hosts
|
||||
const results = await Promise.all(
|
||||
alive.map(async (host) => {
|
||||
const openPorts = await probeHost(host.ip);
|
||||
const service = inferServiceType(host.ip, openPorts);
|
||||
return {
|
||||
ip: host.ip,
|
||||
hostname: host.hostname,
|
||||
openPorts,
|
||||
service,
|
||||
suggestedUrl: service ? `${service.scheme}://${host.ip}${service.port !== 80 && service.port !== 443 ? ':' + service.port : ''}` : null,
|
||||
suggestedType: service ? service.type : 'icmp',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort: HTTP-accessible hosts first, then by IP
|
||||
return results.sort((a, b) => {
|
||||
const aHttp = a.openPorts.length > 0 ? 0 : 1;
|
||||
const bHttp = b.openPorts.length > 0 ? 0 : 1;
|
||||
if (aHttp !== bHttp) return aHttp - bHttp;
|
||||
return a.ip.localeCompare(b.ip, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { discoverNetwork, getLocalSubnets };
|
||||
@@ -21,12 +21,16 @@ class NotificationService {
|
||||
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
|
||||
webhook_url: process.env.WEBHOOK_URL,
|
||||
webhook_template: process.env.WEBHOOK_TEMPLATE || '',
|
||||
ntfy_enabled: process.env.NTFY_ENABLED === 'true',
|
||||
ntfy_url: process.env.NTFY_URL || 'https://ntfy.sh',
|
||||
ntfy_topic: process.env.NTFY_TOPIC || '',
|
||||
});
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
this.emailEnabled = !!config.email_enabled;
|
||||
this.webhookEnabled = !!config.webhook_enabled;
|
||||
this.ntfyEnabled = !!config.ntfy_enabled;
|
||||
this.config = { ...this.config, ...config };
|
||||
|
||||
// Recreate transporter when email config changes; skip if incomplete
|
||||
@@ -103,6 +107,10 @@ class NotificationService {
|
||||
promises.push(this.sendWebhook(resource, message, incident.type));
|
||||
}
|
||||
|
||||
if (this.ntfyEnabled) {
|
||||
promises.push(this.sendNtfy(resource, incident.type));
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
// Save notification to in-app notification center
|
||||
@@ -166,6 +174,20 @@ class NotificationService {
|
||||
if (this.webhookEnabled) {
|
||||
await this.sendGroupedWebhook(pendingAlerts);
|
||||
}
|
||||
if (this.ntfyEnabled) {
|
||||
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
|
||||
const ntfyTopic = this.config.ntfy_topic;
|
||||
if (ntfyTopic) {
|
||||
try {
|
||||
await axios.post(`${ntfyUrl.replace(/\/$/, '')}/${ntfyTopic}`,
|
||||
pendingAlerts.map(({ resource, incident }) =>
|
||||
`${incident.type === 'started' ? '🔴' : '🟢'} ${resource.name}`
|
||||
).join('\n'),
|
||||
{ headers: { Title: `⚠️ ${pendingAlerts.length} monitors changed`, Priority: 'high', Tags: 'warning' } }
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendGroupedEmail(alerts) {
|
||||
@@ -420,6 +442,31 @@ class NotificationService {
|
||||
// Webhook error handled silently
|
||||
}
|
||||
}
|
||||
|
||||
async sendNtfy(resource, type) {
|
||||
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
|
||||
const ntfyTopic = this.config.ntfy_topic;
|
||||
if (!ntfyTopic) return;
|
||||
|
||||
const isDown = type === 'started';
|
||||
const isSlow = type === 'slow';
|
||||
const title = isDown ? `🔴 ${resource.name} is DOWN` : isSlow ? `🟡 ${resource.name} is SLOW` : `🟢 ${resource.name} is UP`;
|
||||
const priority = isDown ? 'high' : isSlow ? 'default' : 'low';
|
||||
const tags = isDown ? 'red_circle,warning' : isSlow ? 'yellow_circle' : 'green_circle,white_check_mark';
|
||||
const body = resource.url ? `${resource.url}` : resource.name;
|
||||
|
||||
try {
|
||||
await axios.post(`${ntfyUrl.replace(/\/$/, '')}/${ntfyTopic}`, body, {
|
||||
headers: {
|
||||
Title: title,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// ntfy error handled silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new NotificationService();
|
||||
Reference in new issue
Block a user