Monitoring_stron / server.js
Codex
Remove manual test endpoints for Email/SMS
25d266a
const express = require('express');
const axios = require('axios');
const { Pool } = require('pg');
const nodemailer = require('nodemailer');
const fs = require('fs');
const path = require('path');
const app = express();
const port = process.env.PORT || 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Basic Auth
app.use((req, res, next) => {
// Default user 'admin' since only password was specified
const user = 'admin';
const pass = 'Klaster1!';
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
if (login && password && login === user && password === pass) {
return next();
}
res.set('WWW-Authenticate', 'Basic realm="Monitor Stron"');
res.status(401).send('Dostęp zabroniony. Wymagane logowanie.');
});
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const WEBSITES_FILE = path.join(DATA_DIR, 'websites.json');
let storageMode = process.env.DATABASE_URL ? 'db' : 'file';
const pool = storageMode === 'db'
? new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false }
})
: null;
let websites = [];
let statuses = {};
const CHECK_INTERVAL = 900000; // 15 minutes
const SMTP_HOST = process.env.SMTP_HOST || 'smtp-relay.brevo.com';
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587', 10);
const SMTP_SECURE = process.env.SMTP_SECURE === 'true' || SMTP_PORT === 465;
const SMTP_TIMEOUT = parseInt(process.env.SMTP_TIMEOUT_MS || '10000', 10);
const BREVO_API_TIMEOUT = parseInt(process.env.BREVO_API_TIMEOUT_MS || '10000', 10);
let emailTransport = null;
let emailConfigChecked = false;
async function sendMailWithTimeout(transport, mailOptions, timeoutMs = SMTP_TIMEOUT) {
return await Promise.race([
transport.sendMail(mailOptions),
new Promise((_, reject) => setTimeout(() => reject(new Error('SMTP send timed out')), timeoutMs))
]);
}
async function sendViaBrevoApi(message) {
const apiKey = process.env.BREVO_API_KEY;
if (!apiKey) {
throw new Error('Brak BREVO_API_KEY do wysyłki przez API.');
}
const recipients = (message.to || '')
.split(',')
.map(s => s.trim())
.filter(Boolean)
.map(email => ({ email }));
if (recipients.length === 0) {
throw new Error('Brak odbiorców e-mail (EMAIL_TO).');
}
const payload = {
sender: { email: message.from },
to: recipients,
subject: message.subject,
textContent: message.text,
htmlContent: message.html
};
await axios.post('https://api.brevo.com/v3/smtp/email', payload, {
headers: {
'api-key': apiKey,
accept: 'application/json',
'content-type': 'application/json'
},
timeout: BREVO_API_TIMEOUT
});
console.log('Email sent via Brevo API');
}
async function sendViaBrevoSms(text) {
const apiKey = process.env.BREVO_API_KEY;
const smsTo = process.env.SMS_TO;
if (!apiKey || !smsTo) return;
try {
await axios.post('https://api.brevo.com/v3/transactionalSMS/sms', {
sender: 'Monitor', // Max 11 alphanumeric characters
recipient: smsTo,
content: text,
type: 'transactional'
}, {
headers: {
'api-key': apiKey,
'accept': 'application/json',
'content-type': 'application/json'
},
timeout: BREVO_API_TIMEOUT
});
console.log('SMS sent via Brevo API');
} catch (err) {
console.error('Failed to send SMS:', err.response ? err.response.data : err.message);
}
}
function describeBrevoError(err) {
const status = err?.response?.status;
const dataMsg = err?.response?.data?.message || err?.response?.data?.error || '';
const text = err?.message || String(err);
return { status, detail: dataMsg || text };
}
function getEmailTransport() {
if (emailTransport || emailConfigChecked) return emailTransport;
const emailFrom = process.env.EMAIL_FROM;
const emailTo = process.env.EMAIL_TO;
const smtpUser = process.env.SMTP_USER || emailFrom;
const smtpPass = process.env.SMTP_PASS || process.env.BREVO_SMTP_PASS || process.env.BREVO_API_KEY;
if (!emailFrom || !emailTo || !smtpUser || !smtpPass) {
console.warn('Email alerts disabled: set EMAIL_FROM, EMAIL_TO, SMTP_USER, SMTP_PASS (lub BREVO_SMTP_PASS / BREVO_API_KEY)');
emailConfigChecked = true;
return null;
}
emailConfigChecked = true;
emailTransport = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: { user: smtpUser, pass: smtpPass },
tls: { rejectUnauthorized: false }
});
return emailTransport;
}
function buildAlertEmail(statusEntry) {
const detectedAt = new Date(statusEntry.lastChecked || Date.now());
const since = statusEntry.statusChangedAt ? formatDuration(detectedAt - new Date(statusEntry.statusChangedAt)) : 'N/A';
const errorText = statusEntry.error || 'Brak dodatkowych informacji';
const statusLabel = statusEntry.status || 'DOWN';
const statusCode = statusEntry.statusCode || 0;
const responseTime = statusEntry.responseTime || 0;
const subject = `ALERT: ${statusEntry.name} niedostępna (${statusCode || statusLabel})`;
const text = [
`ALERT: ${statusEntry.name} jest niedostępna`,
`URL: ${statusEntry.url}`,
`Status: ${statusLabel} (kod: ${statusCode})`,
`Czas odpowiedzi: ${responseTime} ms`,
`Wykryto: ${detectedAt.toISOString()} (UTC)`,
`Nie działa od: ${since}`,
`Błąd: ${errorText}`,
'',
'Monitor Stron'
].join('\n');
const html = `
<!DOCTYPE html>
<html lang="pl">
<body style="font-family: Arial, sans-serif; color: #1f2933;">
<h2>Alert: ${statusEntry.name} jest niedostępna</h2>
<p>Wykryto problem z domeną <strong>${statusEntry.url}</strong>.</p>
<ul>
<li>Status: <strong>${statusLabel}</strong> (kod: <strong>${statusCode}</strong>)</li>
<li>Czas odpowiedzi: ${responseTime} ms</li>
<li>Wykryto: ${detectedAt.toISOString()} (UTC)</li>
<li>Nie działa od: ${since}</li>
</ul>
<p>Ostatni komunikat/treść błędu (jeśli jest):<br>
<code style="background:#f3f4f6; padding:6px 8px; display:inline-block; border-radius:4px;">${errorText}</code></p>
<p>Podgląd strony: <a href="${statusEntry.url}" target="_blank">${statusEntry.url}</a></p>
<hr>
<p style="font-size: 12px; color:#6b7280;">Ta wiadomość została wygenerowana automatycznie przez Monitor Stron.</p>
</body>
</html>
`;
return { subject, text, html };
}
async function sendEmail(message) {
const emailFrom = process.env.EMAIL_FROM;
const emailTo = process.env.EMAIL_TO;
if (!emailFrom || !emailTo) {
throw new Error('Brak EMAIL_FROM/EMAIL_TO do wysyłki e-mail.');
}
// Prefer Brevo API if key is available
if (process.env.BREVO_API_KEY) {
try {
await sendViaBrevoApi(message);
return;
} catch (err) {
const info = describeBrevoError(err);
// If auth error, do not fallback to SMTP (likely wrong key) — surface error early
if (info.status === 401 || info.status === 403) {
throw new Error(`Brevo API auth error (${info.status}): ${info.detail}`);
}
console.error('Brevo API send failed, falling back to SMTP if available:', info.detail || err);
}
}
const transport = getEmailTransport();
if (!transport) {
throw new Error('Brak transportu e-mail (SMTP/API).');
}
await sendMailWithTimeout(transport, {
from: message.from,
to: message.to,
subject: message.subject,
text: message.text,
html: message.html
});
}
async function sendAlertEmail(statusEntry) {
const { subject, text, html } = buildAlertEmail(statusEntry);
const emailFrom = process.env.EMAIL_FROM;
const emailTo = process.env.EMAIL_TO;
await sendEmail({
from: emailFrom,
to: emailTo,
subject,
text,
html
});
console.log(`Alert email sent for ${statusEntry.url}`);
}
function buildStatusReportEmail() {
const now = new Date();
const subject = `Raport statusu stron (${now.toISOString().slice(0, 16)} UTC)`;
const summary = websites.map(site => {
const st = statuses[site.url];
if (!st) {
return {
name: site.name,
url: site.url,
status: 'PENDING',
statusCode: '-',
responseTime: '-',
since: '-',
lastChecked: '-',
error: ''
};
}
return {
name: st.name,
url: st.url,
status: st.status,
statusCode: st.statusCode || '-',
responseTime: `${st.responseTime || 0} ms`,
since: st.statusChangedAt ? formatDuration(now - new Date(st.statusChangedAt)) : 'N/A',
lastChecked: st.lastChecked ? new Date(st.lastChecked).toISOString() : '-',
error: st.error || ''
};
});
const textLines = [
`Raport statusu stron - ${now.toISOString()}`,
''
];
summary.forEach(item => {
textLines.push(
`${item.name} (${item.url})`,
`Status: ${item.status} (kod: ${item.statusCode})`,
`Czas odpowiedzi: ${item.responseTime}`,
`Ostatnio sprawdzono: ${item.lastChecked}`,
`Nie działa od: ${item.since}`,
item.error ? `Błąd: ${item.error}` : 'Błąd: brak',
''
);
});
const text = textLines.join('\n');
const rows = summary.map(item => `
<tr>
<td style="padding:6px 10px;border:1px solid #e5e7eb;">${item.name}</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb;"><a href="${item.url}" target="_blank">${item.url}</a></td>
<td style="padding:6px 10px;border:1px solid #e5e7eb;">${item.status}</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb;">${item.statusCode}</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb;">${item.responseTime}</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb;">${item.since}</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb;">${item.lastChecked}</td>
<td style="padding:6px 10px;border:1px solid #e5e7eb;">${item.error || ''}</td>
</tr>
`).join('');
const html = `
<!DOCTYPE html>
<html lang="pl">
<body style="font-family: Arial, sans-serif; color: #1f2933;">
<h2>Raport statusu stron</h2>
<p>Data wygenerowania: ${now.toISOString()} (UTC)</p>
<table style="border-collapse: collapse; width: 100%; font-size: 14px;">
<thead>
<tr style="background:#f3f4f6;">
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Nazwa</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">URL</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Status</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Kod</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Czas</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Stan od</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Ostatni check</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Błąd</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
<hr>
<p style="font-size: 12px; color:#6b7280;">Raport wygenerowany przez Monitor Stron.</p>
</body>
</html>
`;
return { subject, text, html };
}
async function sendStatusReportEmail() {
const emailFrom = process.env.EMAIL_FROM;
const emailTo = process.env.EMAIL_TO;
if (!emailFrom || !emailTo) {
throw new Error('Brak konfiguracji e-mail: ustaw EMAIL_FROM i EMAIL_TO.');
}
const { subject, text, html } = buildStatusReportEmail();
await sendEmail({
from: emailFrom,
to: emailTo,
subject,
text,
html
});
console.log('Status report email sent');
}
async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS websites (
url TEXT PRIMARY KEY,
name TEXT NOT NULL
);
`);
// Optionally seed from bundled file if table is empty
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM websites;');
if (rows[0].count === 0) {
try {
const seed = require('./websites.json');
if (Array.isArray(seed) && seed.length > 0) {
const values = seed.map(item => `('${item.url.replace(/'/g, "''")}','${item.name.replace(/'/g, "''")}')`).join(',');
await pool.query(`INSERT INTO websites (url, name) VALUES ${values} ON CONFLICT (url) DO NOTHING;`);
console.log(`Seeded ${seed.length} websites into database`);
}
} catch (err) {
console.warn('Seed file websites.json not loaded:', err.message);
}
}
}
async function initFileStorage() {
await fs.promises.mkdir(DATA_DIR, { recursive: true });
console.log(`Using file storage at ${WEBSITES_FILE}`);
// Load from persisted file or fallback to bundled seed
let fileData = [];
try {
const raw = await fs.promises.readFile(WEBSITES_FILE, 'utf-8');
fileData = JSON.parse(raw);
} catch (err) {
console.log('No persisted websites file found, seeding new one.');
}
if (!Array.isArray(fileData) || fileData.length === 0) {
try {
const seed = require('./websites.json');
if (Array.isArray(seed) && seed.length > 0) {
fileData = seed;
}
} catch (err) {
console.warn('Seed file websites.json not loaded:', err.message);
}
}
websites = fileData;
await saveWebsitesToFile();
}
async function saveWebsitesToFile() {
await fs.promises.writeFile(WEBSITES_FILE, JSON.stringify(websites, null, 2), 'utf-8');
}
async function loadWebsites() {
if (storageMode === 'db') {
const { rows } = await pool.query('SELECT url, name FROM websites ORDER BY name;');
websites = rows;
} else {
try {
const raw = await fs.promises.readFile(WEBSITES_FILE, 'utf-8');
websites = JSON.parse(raw);
} catch (err) {
console.warn('Could not read websites file, reinitializing storage:', err.message);
await initFileStorage();
}
}
}
async function checkWebsite(site) {
try {
const start = Date.now();
const response = await axios.get(site.url, { timeout: 300000 }); // 5 minutes
const duration = Date.now() - start;
const newStatus = response.status === 200 ? 'UP' : 'DOWN';
updateStatus(site, newStatus, response.status, duration, null);
} catch (error) {
const status = error.code === 'ECONNABORTED' ? 'TIMEOUT' : 'DOWN';
const statusCode = error.response ? error.response.status : 0;
updateStatus(site, status, statusCode, 0, error.message);
}
}
function updateStatus(site, newStatus, statusCode, duration, errorMsg) {
const now = new Date();
const prevStatus = statuses[site.url];
let statusChangedAt = now;
if (prevStatus && prevStatus.status === newStatus) {
statusChangedAt = prevStatus.statusChangedAt;
}
statuses[site.url] = {
name: site.name,
url: site.url,
status: newStatus,
statusCode: statusCode,
responseTime: duration,
lastChecked: now,
statusChangedAt: statusChangedAt,
error: errorMsg
};
const alertNeeded = newStatus !== 'UP' && (!prevStatus || prevStatus.status !== newStatus);
if (alertNeeded) {
sendAlertEmail(statuses[site.url]).catch(err => {
console.error('Failed to send alert email:', err.message);
});
// Send SMS
const smsText = `ALERT: ${site.name} returns ${newStatus} (Code: ${statusCode}). URL: ${site.url}`;
sendViaBrevoSms(smsText).catch(err => console.error('SMS Error:', err.message));
}
}
async function checkAllWebsites() {
console.log('Checking websites...');
await loadWebsites();
await Promise.all(websites.map(site => checkWebsite(site)));
console.log('Check complete.');
}
// API endpoint to add a new website
app.post('/api/sites', async (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
// Basic URL validation
let validUrl = url;
if (!validUrl.startsWith('http')) {
validUrl = 'https://' + validUrl;
}
try {
// Check for duplicates in storage
if (websites.some(site => site.url === validUrl)) {
return res.status(400).json({ error: 'Website already exists' });
}
// Extract name from URL if not provided
let name = validUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
const newSite = { url: validUrl, name };
if (storageMode === 'db') {
await pool.query('INSERT INTO websites (url, name) VALUES ($1, $2);', [newSite.url, newSite.name]);
await loadWebsites();
} else {
websites.push(newSite);
await saveWebsitesToFile();
}
// Initial check for the new site
checkWebsite(newSite);
res.json({ success: true, site: newSite });
} catch (err) {
console.error('Error saving website:', err);
res.status(500).json({ error: 'Failed to save website' });
}
});
// API endpoint to delete a website
app.delete('/api/sites', async (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
try {
let removed = false;
if (storageMode === 'db') {
const result = await pool.query('DELETE FROM websites WHERE url = $1;', [url]);
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Website not found' });
}
removed = true;
await loadWebsites();
} else {
const initialLength = websites.length;
websites = websites.filter(site => site.url !== url);
removed = websites.length !== initialLength;
if (!removed) {
return res.status(404).json({ error: 'Website not found' });
}
await saveWebsitesToFile();
}
// Remove status
delete statuses[url];
res.json({ success: true });
} catch (err) {
console.error('Error deleting website:', err);
res.status(500).json({ error: 'Failed to save changes' });
}
});
async function start() {
try {
if (storageMode === 'db') {
try {
await initDb();
} catch (err) {
console.warn('Database connection failed, falling back to file storage:', err.message);
storageMode = 'file';
}
}
if (storageMode === 'file') {
await initFileStorage();
} else {
await loadWebsites();
}
await checkAllWebsites();
setInterval(checkAllWebsites, CHECK_INTERVAL);
app.listen(port, () => {
console.log(`Monitor running at http://localhost:${port}`);
});
} catch (err) {
console.error('Failed to start application:', err);
process.exit(1);
}
}
start();
function formatDuration(ms) {
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes} min`);
if (seconds > 0) parts.push(`${seconds} sec`);
return parts.length > 0 ? parts.join(', ') : '0 sec';
}
app.get('/', (req, res) => {
let html = `
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Monitor Stron</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--bg-color: #1a1e23;
--card-bg: #22262b;
--text-primary: #ecf0f1;
--text-secondary: #95a5a6;
--success-color: #2ecc71;
--danger-color: #e74c3c;
--warning-color: #f1c40f;
--border-color: #2c3e50;
--input-bg: #2c3e50;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
margin: 0;
padding: 40px 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { text-align: center; margin-bottom: 30px; font-weight: 300; letter-spacing: 1px; }
.controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-box {
flex: 1;
position: relative;
}
.add-box {
flex: 1;
display: flex;
gap: 10px;
}
.btn-status {
background-color: #3498db;
color: white;
border: none;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s;
min-width: 200px;
text-align: center;
}
.btn-status:hover {
background-color: #2980b9;
}
input {
width: 100%;
padding: 12px 15px;
border-radius: 8px;
border: 1px solid var(--border-color);
background-color: var(--input-bg);
color: var(--text-primary);
font-size: 14px;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: var(--success-color);
}
.btn-add {
padding: 0 20px;
background-color: var(--success-color);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
white-space: nowrap;
}
.btn-add:hover { background-color: #27ae60; }
.monitor-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.monitor-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 15px 20px;
display: flex;
align-items: center;
justify-content: space-between;
transition: transform 0.2s;
}
.monitor-card:hover {
transform: translateY(-2px);
border-color: #34495e;
}
.card-left {
display: flex;
align-items: center;
gap: 15px;
}
.status-icon {
font-size: 24px;
}
.status-up { color: var(--success-color); }
.status-down { color: var(--danger-color); }
.site-details {
display: flex;
flex-direction: column;
}
.site-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
margin-bottom: 4px;
}
.site-name:hover { text-decoration: underline; }
.site-meta {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.badge {
background: #2c3e50;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
font-size: 10px;
letter-spacing: 0.5px;
}
.card-right {
color: var(--text-secondary);
font-size: 14px;
}
.refresh-btn {
position: fixed;
bottom: 30px;
right: 30px;
background: var(--success-color);
color: white;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s;
}
.refresh-btn:hover { background-color: #27ae60; }
/* Dropdown Menu Styles */
.menu-container {
position: relative;
display: inline-block;
}
.menu-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 5px;
font-size: 16px;
}
.menu-btn:hover { color: var(--text-primary); }
.dropdown-content {
display: none;
position: absolute;
right: 0;
top: 100%;
background-color: var(--card-bg);
min-width: 120px;
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
z-index: 1;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.dropdown-content button {
color: var(--text-primary);
padding: 10px 16px;
text-decoration: none;
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
}
.dropdown-content button:hover {
background-color: #34495e;
}
.show-menu { display: block; }
</style>
<meta http-equiv="refresh" content="30">
</head>
<body>
<div class="container">
<h1>Monitor Statusu Stron (${websites.length})</h1>
<div class="controls">
<div class="search-box">
<input type="text" id="searchInput" placeholder="Szukaj domeny...">
</div>
<div class="add-box">
<input type="text" id="addUrlInput" placeholder="Dodaj URL (np. google.com)">
<button class="btn-add" onclick="addWebsite()">Dodaj</button>
</div>
</div>
<div class="monitor-list" id="monitorList">
`;
// Reverse websites to show newest first, or keep order? Let's keep order from JSON.
for (const site of websites) {
const status = statuses[site.url];
let cardHtml = '';
if (!status) {
// Pending state
cardHtml = `
<div class="monitor-card" data-name="${site.name}">
<div class="card-left">
<i class="fas fa-circle-notch fa-spin status-icon" style="color: #95a5a6;"></i>
<div class="site-details">
<a href="${site.url}" target="_blank" class="site-name">${site.name}</a>
<div class="site-meta">
<span class="badge">HTTP</span>
<span>Oczekiwanie...</span>
</div>
</div>
</div>
<div class="card-right">
<div class="menu-container">
<button class="menu-btn" onclick="toggleMenu('${site.url}')"><i class="fas fa-ellipsis-h"></i></button>
<div id="menu-${btoa(site.url)}" class="dropdown-content">
<button onclick="deleteWebsite('${site.url}')"><i class="fas fa-trash-alt" style="margin-right:8px;color:#e74c3c"></i> Usuń</button>
</div>
</div>
</div>
</div>`;
} else {
const isUp = status.status === 'UP';
const iconClass = isUp ? 'fa-arrow-circle-up status-up' : 'fa-exclamation-circle status-down';
const statusLabel = isUp ? 'Up' : (status.error ? 'Down' : 'Issue');
const duration = formatDuration(new Date() - new Date(status.statusChangedAt));
cardHtml = `
<div class="monitor-card" data-name="${status.name}">
<div class="card-left">
<i class="fas ${iconClass} status-icon"></i>
<div class="site-details">
<a href="${status.url}" target="_blank" class="site-name">${status.name}</a>
<div class="site-meta">
<span class="badge">HTTP</span>
<span style="color: ${isUp ? '#2ecc71' : '#e74c3c'}">${statusLabel}</span>
<span>${duration}</span>
</div>
</div>
</div>
<div class="card-right">
<span title="Response Time">${status.responseTime}ms</span>
<span style="margin: 0 10px; color: #34495e;">|</span>
<div class="menu-container">
<button class="menu-btn" onclick="toggleMenu('${status.url}')"><i class="fas fa-ellipsis-h"></i></button>
<div id="menu-${btoa(status.url)}" class="dropdown-content">
<button onclick="deleteWebsite('${status.url}')"><i class="fas fa-trash-alt" style="margin-right:8px;color:#e74c3c"></i> Usuń</button>
</div>
</div>
</div>
</div>
`;
}
html += cardHtml;
}
html += `
</div>
</div>
<button class="refresh-btn" onclick="location.reload()"><i class="fas fa-sync-alt"></i></button>
<script>
// Search Functionality
document.getElementById('searchInput').addEventListener('keyup', function() {
const searchValue = this.value.toLowerCase();
const cards = document.querySelectorAll('.monitor-card');
cards.forEach(card => {
const siteName = card.getAttribute('data-name').toLowerCase();
if (siteName.includes(searchValue)) {
card.style.display = 'flex';
} else {
card.style.display = 'none';
}
});
});
// Add Website Functionality
async function addWebsite() {
const urlInput = document.getElementById('addUrlInput');
const url = urlInput.value.trim();
if (!url) return alert('Wprowadź URL');
try {
const response = await fetch('/api/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await response.json();
if (response.ok) {
urlInput.value = '';
location.reload(); // Reload to show new site
} else {
alert(data.error || 'Błąd dodawania strony');
}
} catch (error) {
console.error('Error:', error);
alert('Wystąpił błąd podczas dodawania strony');
}
}
// Menu Functionality
function toggleMenu(url) {
const menuId = 'menu-' + btoa(url);
const menu = document.getElementById(menuId);
// Close all other menus
document.querySelectorAll('.dropdown-content').forEach(m => {
if (m.id !== menuId) m.classList.remove('show-menu');
});
menu.classList.toggle('show-menu');
}
// Close menu when clicking outside
window.onclick = function(event) {
if (!event.target.matches('.menu-btn') && !event.target.matches('.menu-btn i')) {
document.querySelectorAll('.dropdown-content').forEach(m => {
m.classList.remove('show-menu');
});
}
}
// Delete Website Functionality
async function deleteWebsite(url) {
if (!confirm('Czy na pewno chcesz usunąć tę stronę z monitorowania?')) return;
try {
const response = await fetch('/api/sites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await response.json();
if (response.ok) {
location.reload(); // Reload to update list
} else {
alert(data.error || 'Błąd usuwania strony');
}
} catch (error) {
console.error('Error:', error);
alert('Wystąpił błąd podczas usuwania strony');
}
}
</script>
</body>
</html>
`;
res.send(html);
});