Pingurls / index.html
devendergarg14's picture
Update index.html
6541ee1 verified
<!DOCTYPE html>
<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>