Spaces:
Sleeping
Sleeping
| <html lang="en" class=""> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>URL Pinger</title> | |
| <meta name="theme-color" content="#e0e5ec"> | |
| <meta name="theme-color" content="#2c303a" media="(prefers-color-scheme: dark)"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| :root { | |
| --light-bg: #e0e5ec; --dark-bg: #2c303a; --light-shadow-outer-1: #a3b1c6; | |
| --light-shadow-outer-2: #ffffff; --dark-shadow-outer-1: #22252e; --dark-shadow-outer-2: #363a46; | |
| --light-shadow-inner-1: #a3b1c6; --light-shadow-inner-2: #ffffff; --dark-shadow-inner-1: #22252e; | |
| --dark-shadow-inner-2: #363a46; --text-light: #4a5568; --text-dark: #e2e8f0; | |
| --text-light-muted: #718096; --text-dark-muted: #a0aec0; --dot-ok: #22c55e; | |
| --dot-error: #ef4444; --dot-pending: #9ca3af; --dot-checking: #3b82f6; | |
| } | |
| html.dark { | |
| --light-bg: #2c303a; --light-shadow-outer-1: #22252e; --light-shadow-outer-2: #363a46; | |
| --light-shadow-inner-1: #22252e; --light-shadow-inner-2: #363a46; | |
| --text-light: #e2e8f0; --text-light-muted: #a0aec0; | |
| } | |
| body { | |
| background-color: var(--light-bg); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| min-height: 100vh; transition: background-color 0.3s ease; color: var(--text-light); | |
| } | |
| .neumorphic-outset { | |
| border-radius: 12px; background: var(--light-bg); | |
| box-shadow: 6px 6px 12px var(--light-shadow-outer-1), -6px -6px 12px var(--light-shadow-outer-2); | |
| transition: box-shadow 0.2s ease-out, background-color 0.3s ease; | |
| } | |
| .neumorphic-outset-sm { | |
| border-radius: 8px; background: var(--light-bg); | |
| box-shadow: 4px 4px 8px var(--light-shadow-outer-1), -4px -4px 8px var(--light-shadow-outer-2); | |
| transition: box-shadow 0.2s ease-out, background-color 0.3s ease; | |
| } | |
| .neumorphic-outset-hover:hover { box-shadow: 4px 4px 8px var(--light-shadow-outer-1), -4px -4px 8px var(--light-shadow-outer-2); } | |
| .neumorphic-outset-active:active, .neumorphic-outset-active:focus { box-shadow: inset 3px 3px 6px var(--light-shadow-inner-1), inset -3px -3px 6px var(--light-shadow-inner-2); } | |
| .neumorphic-inset { | |
| border-radius: 12px; background: var(--light-bg); | |
| box-shadow: inset 6px 6px 12px var(--light-shadow-inner-1), inset -6px -6px 12px var(--light-shadow-inner-2); | |
| transition: box-shadow 0.2s ease-out, background-color 0.3s ease; | |
| } | |
| .neumorphic-inset-sm { | |
| border-radius: 8px; background: var(--light-bg); | |
| box-shadow: inset 4px 4px 8px var(--light-shadow-inner-1), inset -4px -4px 8px var(--light-shadow-inner-2); | |
| transition: box-shadow 0.2s ease-out, background-color 0.3s ease; | |
| } | |
| .status-dot { width: 0.75rem; height: 0.75rem; border-radius: 50%; display: inline-block; flex-shrink: 0; margin-top: 4px; } | |
| .status-ok { background-color: var(--dot-ok); } .status-error { background-color: var(--dot-error); } | |
| .status-pending { background-color: var(--dot-pending); } .status-checking { background-color: var(--dot-checking); } | |
| .loader { | |
| border: 2px solid var(--light-shadow-outer-1); border-top: 2px solid var(--dot-checking); | |
| border-radius: 50%; width: 12px; height: 12px; animation: spin 1s linear infinite; | |
| display: inline-block; flex-shrink: 0; margin-top: 4px; | |
| } | |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | |
| .history-bar { | |
| display: flex; height: 10px; overflow: hidden; flex-direction: row-reverse; | |
| width: 100%; margin-top: 8px; border-radius: 3px; background-color: var(--light-shadow-outer-2); | |
| } | |
| .history-point { width: 4px; height: 100%; margin-right: 1px; flex-shrink: 0; flex-grow: 0; } | |
| .history-bar .history-point:first-child { margin-right: 0; } | |
| .history-ok { background-color: var(--dot-ok); } .history-error { background-color: var(--dot-error); } | |
| #urlList { max-height: calc(100vh - 350px); overflow-y: auto; padding-right: 8px; } | |
| #urlList::-webkit-scrollbar { width: 6px; } | |
| #urlList::-webkit-scrollbar-track { background: transparent; border-radius: 3px; } | |
| #urlList::-webkit-scrollbar-thumb { background: var(--light-shadow-outer-1); border-radius: 3px; } | |
| #urlList::-webkit-scrollbar-thumb:hover { background: var(--text-light-muted); } | |
| input::placeholder { color: var(--text-light-muted); opacity: 0.8; } | |
| .removeUrlBtn svg { width: 0.875rem; height: 0.875rem; pointer-events: none; color: var(--text-light-muted); } | |
| .removeUrlBtn:hover svg { color: #ef4444; } | |
| html.dark .removeUrlBtn:hover svg { color: #f87171; } | |
| </style> | |
| <script> | |
| tailwind.config = { darkMode: 'class', theme: { extend: {} } } | |
| </script> | |
| </head> | |
| <body class="pt-10 pb-10 px-4"> | |
| <div id="app" class="max-w-md mx-auto neumorphic-outset p-6 md:p-8"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h1 class="text-2xl font-semibold text-center flex-grow">URL Pinger</h1> | |
| <button id="theme-toggle" class="neumorphic-outset-sm neumorphic-outset-hover neumorphic-outset-active p-2 focus:outline-none"> | |
| <svg id="theme-toggle-light-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg> | |
| <svg id="theme-toggle-dark-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg> | |
| </button> | |
| </div> | |
| <div class="mb-6"> | |
| <label for="urlInput" class="block text-sm font-medium mb-2" style="color: var(--text-light-muted);">Add URL to Monitor:</label> | |
| <div class="flex space-x-3"> | |
| <input type="url" id="urlInput" placeholder="https://example.com" class="flex-grow p-3 neumorphic-inset border-none focus:outline-none text-sm" style="color: var(--text-light);" required> | |
| <button id="addUrlBtn" class="neumorphic-outset-sm neumorphic-outset-hover neumorphic-outset-active font-semibold py-2 px-5 transition duration-150 ease-in-out focus:outline-none"> | |
| Add | |
| </button> | |
| </div> | |
| <p id="errorMsg" class="text-red-500 text-xs mt-2 h-4"></p> | |
| </div> | |
| <div class="mt-8"> | |
| <h2 class="text-lg font-semibold mb-3">Monitored URLs</h2> | |
| <div id="urlList" class="space-y-4"> | |
| <p class="italic" style="color: var(--text-light-muted);">Loading URLs from server...</p> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const urlInput = document.getElementById('urlInput'); | |
| const addUrlBtn = document.getElementById('addUrlBtn'); | |
| const urlList = document.getElementById('urlList'); | |
| const errorMsg = document.getElementById('errorMsg'); | |
| const themeToggleBtn = document.getElementById('theme-toggle'); | |
| const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon'); | |
| const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon'); | |
| let monitoredUrlsCache = []; | |
| const UI_REFRESH_INTERVAL_MS = 5000; | |
| const HISTORY_DURATION_MS_FOR_DISPLAY = 60 * 60 * 1000; | |
| const MAX_HISTORY_POINTS_FOR_DISPLAY = 90; | |
| const USER_ID_KEY = 'urlPingerUserId'; // Key for localStorage | |
| let currentAppUserId = null; // In-memory cache for the user ID | |
| // --- User ID Management --- | |
| function getAppUserId() { | |
| if (currentAppUserId) return currentAppUserId; | |
| let userId = localStorage.getItem(USER_ID_KEY); | |
| if (!userId) { | |
| if (crypto.randomUUID) { | |
| userId = crypto.randomUUID(); | |
| } else { // Fallback for older browsers (less robust UUID) | |
| userId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { | |
| var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); | |
| return v.toString(16); | |
| }); | |
| } | |
| localStorage.setItem(USER_ID_KEY, userId); | |
| } | |
| currentAppUserId = userId; | |
| return userId; | |
| } | |
| // --- Theme Toggle --- | |
| function applyTheme(isDark) { | |
| if (isDark) { | |
| document.documentElement.classList.add('dark'); | |
| themeToggleLightIcon.classList.remove('hidden'); | |
| themeToggleDarkIcon.classList.add('hidden'); | |
| } else { | |
| document.documentElement.classList.remove('dark'); | |
| themeToggleLightIcon.classList.add('hidden'); | |
| themeToggleDarkIcon.classList.remove('hidden'); | |
| } | |
| // Update meta theme-color and body background after class change | |
| requestAnimationFrame(() => { | |
| const currentBg = getComputedStyle(document.documentElement).getPropertyValue('--light-bg').trim(); | |
| document.querySelector('meta[name="theme-color"]:not([media])').setAttribute('content', currentBg); | |
| document.querySelector('meta[name="theme-color"][media="(prefers-color-scheme: dark)"]').setAttribute('content', currentBg); // Also set dark for consistency if needed | |
| document.body.style.backgroundColor = currentBg; | |
| }); | |
| } | |
| function toggleTheme() { | |
| const isDark = document.documentElement.classList.toggle('dark'); | |
| localStorage.setItem('theme', isDark ? 'dark' : 'light'); | |
| applyTheme(isDark); | |
| } | |
| function initializeTheme() { | |
| const storedTheme = localStorage.getItem('theme'); | |
| const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| applyTheme(storedTheme === 'dark' || (!storedTheme && prefersDark)); | |
| } | |
| initializeTheme(); | |
| themeToggleBtn.addEventListener('click', toggleTheme); | |
| // --- End Theme Toggle --- | |
| // --- API Communication Wrapper --- | |
| async function apiRequest(endpoint, options = {}) { | |
| const userId = getAppUserId(); // Get or generate user ID for each request | |
| const defaultHeaders = { 'X-User-ID': userId }; | |
| // Only set Content-Type if body exists, otherwise let browser handle or set explicitly | |
| if (options.body && typeof options.body === 'string' && (!options.headers || !options.headers['Content-Type'])) { | |
| defaultHeaders['Content-Type'] = 'application/json'; | |
| } | |
| const mergedOptions = { | |
| ...options, | |
| headers: { | |
| ...defaultHeaders, | |
| ...(options.headers || {}), | |
| } | |
| }; | |
| // For GET/HEAD/DELETE (typically no body), ensure Content-Type is not forced if not needed | |
| const httpMethod = (mergedOptions.method || 'GET').toUpperCase(); | |
| if (!mergedOptions.body && (httpMethod === 'GET' || httpMethod === 'HEAD' || httpMethod === 'DELETE')) { | |
| if (mergedOptions.headers && mergedOptions.headers['Content-Type'] === 'application/json') { // Only remove if it was our default | |
| delete mergedOptions.headers['Content-Type']; | |
| } | |
| } | |
| try { | |
| const response = await fetch(endpoint, mergedOptions); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({ message: response.statusText })); | |
| throw new Error(errorData.error || errorData.message || `HTTP Error ${response.status}`); | |
| } | |
| if (response.status === 204 || response.headers.get("content-length") === "0") { | |
| return null; | |
| } | |
| return await response.json(); | |
| } catch (error) { | |
| console.error(`API Request Error for ${endpoint}:`, error); | |
| errorMsg.textContent = `Error: ${error.message}`; | |
| throw error; | |
| } | |
| } | |
| async function fetchAndRenderUrls() { | |
| try { | |
| const dataFromServer = await apiRequest('/api/urls'); // User ID header is added by apiRequest | |
| monitoredUrlsCache = dataFromServer || []; | |
| renderUrlListUI(); | |
| } catch (e) { | |
| urlList.innerHTML = `<p class="italic" style="color: var(--text-light-muted);">Could not load URLs. Server may be down or user ID missing.</p>`; | |
| } | |
| } | |
| function getDisplayMetrics(backendHistoryArray) { | |
| const historyForDisplay = (backendHistoryArray || []).map(h => ({ ...h, timestamp: h.timestamp * 1000 })); | |
| const cutoffTimeMs = Date.now() - HISTORY_DURATION_MS_FOR_DISPLAY; | |
| const relevantHistory = historyForDisplay.filter(entry => entry.timestamp >= cutoffTimeMs); | |
| if (relevantHistory.length === 0) return { percentage: 'N/A', points: [] }; | |
| const okCount = relevantHistory.filter(entry => entry.status === 'ok').length; | |
| const uptimePercent = Math.round((okCount / relevantHistory.length) * 100); | |
| const historyBarPoints = relevantHistory.slice(-MAX_HISTORY_POINTS_FOR_DISPLAY).map(entry => entry.status).reverse(); | |
| return { percentage: `${uptimePercent}%`, points: historyBarPoints }; | |
| } | |
| function createUrlItemDOM(urlData) { | |
| const itemDiv = document.createElement('div'); | |
| itemDiv.className = 'neumorphic-outset-sm p-4 mb-4 last:mb-0 url-item'; | |
| itemDiv.dataset.id = urlData.id; | |
| let statusIndicatorDOM; | |
| if (urlData.status === 'checking') { | |
| statusIndicatorDOM = '<span class="loader" title="Checking..."></span>'; | |
| } else { | |
| const statusClass = urlData.status === 'ok' ? 'status-ok' : (urlData.status === 'error' ? 'status-error' : 'status-pending'); | |
| const statusTitle = urlData.status === 'ok' ? 'Reachable' : (urlData.status === 'error' ? 'Error/Unreachable' : 'Pending'); | |
| statusIndicatorDOM = `<span class="status-dot ${statusClass}" title="${statusTitle}"></span>`; | |
| } | |
| const respTimeStr = urlData.responseTime !== null ? `${urlData.responseTime} ms` : 'N/A'; | |
| const lastCheckStr = urlData.lastChecked ? `Last check: ${new Date(urlData.lastChecked).toLocaleString()}` : 'Not checked'; | |
| const { percentage: uptimeStr, points: historyBarData } = getDisplayMetrics(urlData.history); | |
| let historyBarDOM = `<div class="history-bar" title="Recent History (Newest Left)">`; | |
| historyBarData.forEach(status => { | |
| const historyClass = status === 'ok' ? 'history-ok' : 'history-error'; | |
| historyBarDOM += `<div class="history-point ${historyClass}"></div>`; | |
| }); | |
| historyBarDOM += '</div>'; | |
| const trashIcon = ` | |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> | |
| </svg>`; | |
| itemDiv.innerHTML = ` | |
| <div class="flex items-start justify-between space-x-3"> | |
| <div class="flex items-start space-x-3 flex-grow min-w-0"> | |
| ${statusIndicatorDOM} | |
| <div class="min-w-0"> | |
| <p class="font-medium truncate text-sm" title="${urlData.url}" style="color: var(--text-light);">${urlData.url}</p> | |
| <p class="text-xs mt-1" style="color: var(--text-light-muted);"> | |
| IP: ${urlData.ip || 'N/A'} | Resp: ${respTimeStr} | Uptime (1h): ${uptimeStr} | |
| </p> | |
| <p class="text-xs mt-0.5" style="color: var(--text-light-muted);">${lastCheckStr}</p> | |
| </div> | |
| </div> | |
| <button class="removeUrlBtn flex-shrink-0 neumorphic-outset-sm neumorphic-outset-hover neumorphic-outset-active p-1.5 focus:outline-none" title="Stop Monitoring"> | |
| ${trashIcon} | |
| </button> | |
| </div> | |
| ${historyBarDOM}`; | |
| itemDiv.querySelector('.removeUrlBtn').addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| handleRemoveUrl(urlData.id); | |
| }); | |
| return itemDiv; | |
| } | |
| function renderUrlListUI() { | |
| urlList.innerHTML = ''; | |
| if (monitoredUrlsCache.length === 0) { | |
| urlList.innerHTML = `<p class="italic" style="color: var(--text-light-muted);">No URLs being monitored by you. Add one to begin.</p>`; | |
| return; | |
| } | |
| monitoredUrlsCache.forEach(urlData => { | |
| const urlItemElement = createUrlItemDOM(urlData); | |
| urlList.appendChild(urlItemElement); | |
| }); | |
| } | |
| async function handleAddUrl() { | |
| let urlToAdd = urlInput.value.trim(); | |
| errorMsg.textContent = ''; | |
| if (!urlToAdd) { | |
| errorMsg.textContent = 'Please enter a URL.'; return; | |
| } | |
| if (!urlToAdd.startsWith('http://') && !urlToAdd.startsWith('https://')) { | |
| urlToAdd = 'https://' + urlToAdd; | |
| } | |
| try { | |
| new URL(urlToAdd); | |
| } catch (_) { | |
| errorMsg.textContent = 'Invalid URL format.'; return; | |
| } | |
| const normalizedUrl = urlToAdd.replace(/\/+$/, '').toLowerCase(); | |
| if (monitoredUrlsCache.some(u => u.url.replace(/\/+$/, '').toLowerCase() === normalizedUrl)) { | |
| errorMsg.textContent = 'This URL appears to be already monitored by you.'; return; | |
| } | |
| addUrlBtn.disabled = true; addUrlBtn.textContent = '...'; urlInput.disabled = true; | |
| try { | |
| await apiRequest('/api/urls', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, // Explicitly set for POST with JSON body | |
| body: JSON.stringify({ url: urlToAdd }) | |
| }); | |
| urlInput.value = ''; | |
| await fetchAndRenderUrls(); | |
| } catch (e) { | |
| // apiRequest already displayed the error | |
| } finally { | |
| addUrlBtn.disabled = false; addUrlBtn.textContent = 'Add'; urlInput.disabled = false; | |
| } | |
| } | |
| async function handleRemoveUrl(urlIdToRemove) { | |
| try { | |
| await apiRequest(`/api/urls/${urlIdToRemove}`, { method: 'DELETE' }); | |
| monitoredUrlsCache = monitoredUrlsCache.filter(url => url.id !== urlIdToRemove); | |
| renderUrlListUI(); | |
| } catch (e) { | |
| // apiRequest already displayed the error | |
| } | |
| } | |
| function setupPeriodicDataRefresh() { | |
| setInterval(fetchAndRenderUrls, UI_REFRESH_INTERVAL_MS); | |
| } | |
| addUrlBtn.addEventListener('click', handleAddUrl); | |
| urlInput.addEventListener('keypress', (event) => { | |
| if (event.key === 'Enter') { event.preventDefault(); handleAddUrl(); } | |
| }); | |
| document.addEventListener('DOMContentLoaded', () => { | |
| getAppUserId(); // Ensure user ID is generated or loaded on start | |
| fetchAndRenderUrls(); | |
| setupPeriodicDataRefresh(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |