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> |