Spaces:
Running
Running
| 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); | |
| }); | |