Files
bucketeer/internal/webui/index.html
T
StasonJatham a366905a43 Bucketeer UI (dark mode, pagination, virtual scroll), readable+indexed resources, LLM-friendly logs
Logging:
- structured slog with LOG_FORMAT=json|text and a stable key vocabulary
- periodic stats record with queue depths, db pool, goroutines, memory, dns/probe
- no per-certificate logging (hot path is counters only)

UI (Bucketeer branding, DESIGN.md):
- light+dark themes (OS default, persisted header toggle, no first-paint flash)
- official mascot: lossless WebP light/dark logos + favicon, embedded and served
  with cache headers (1.2MB source PNGs -> ~27/22KB webp)
- Assets/Contents views, pagination (offset) and native virtual scrolling
  (content-visibility) so large result sets do not blow up the DOM
- search with debounce/abort + URL state, exposure chips, sort, summary counts

Readability + indexing:
- internal/index listers for S3-compatible, GCS, Azure, Firebase, OpenSearch
  (metadata only: keys/sizes/dates; never object contents)
- asset_entries table + store write/search; readable resources are indexed and
  searchable in the UI
- deep, unauthenticated, read-only DB probes (PROBE_DEEP): Redis PING,
  PostgreSQL StartupMessage, MongoDB OP_MSG listDatabases; OpenSearch/Neptune
  HTTP reads
- Azure account-level listing fixed: 403 is reported reachable, not
  auth_required, since anonymous access is per-container

DNS:
- curated reliable resolvers by default; DNS_SERVERS=fresh adds the compiled-in
  Fresh-Resolvers snapshot with a bounded startup sample (warming all 6.7k at
  once broke the container resolver)
- random healthy selection + failover + per-server cooldown

Other: prober Close fixes a shutdown hang; config/README updated.
2026-09-22 14:33:03 +02:00

411 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bucketeer · CT resource monitor</title>
<link rel="icon" href="/assets/favicon.png">
<script>/* set theme before first paint to avoid a flash */
try{var t=localStorage.getItem('bucketeer-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}</script>
<style>
/* Tokens from DESIGN.md. Neutral-first, no shadows, system fonts, light only. */
:root {
--primary:#262626; --on-primary:#fff; --primary-hover:#404040; --primary-active:#171717;
--text-primary:#000; --text-secondary:#525252; --text-muted:#737373;
--canvas:#fff; --surface-subtle:#fafafa; --surface-control:#f2f2f2; --surface-hover:#ededed;
--border:#e5e5e5; --border-strong:#d4d4d4; --focus:#2563eb;
--cap-bg:#eef2ff; --cap-text:#4338ca; --size-bg:#e0f2fe; --size-text:#075985;
--err-bg:#fef2f2; --err-text:#b91c1c; --ok-bg:#f0fdf4; --ok-text:#166534;
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--page-gutter:16px;
color-scheme: light dark;
}
/* Dark palette (DESIGN.md specifies light only; this is an explicit product
decision). Neutral-first, same hierarchy, contrast-preserving. */
[data-theme=dark]{
--primary:#e7e9ee; --on-primary:#14161a; --primary-hover:#d3d7de; --primary-active:#ffffff;
--text-primary:#f4f5f7; --text-secondary:#b3b9c4; --text-muted:#858d9a;
--canvas:#0f1115; --surface-subtle:#171a20; --surface-control:#1c2027; --surface-hover:#252b35;
--border:#2a2f39; --border-strong:#3a414e; --focus:#69a8ff;
--cap-bg:#1e2440; --cap-text:#aab5ff; --size-bg:#0e2a3a; --size-text:#7cc4ff;
--err-bg:#3a1616; --err-text:#ffb4b4; --ok-bg:#0f2a1a; --ok-text:#7ee2a8;
}
*,*::before,*::after{box-sizing:border-box}
body{margin:0;background:var(--canvas);color:var(--text-primary);font:400 16px/24px var(--font);-webkit-font-smoothing:antialiased}
h1{margin:0;font:500 24px/32px var(--font);letter-spacing:-.01em}
a{color:inherit}
.site-shell,.search-layout{width:calc(100% - 2*var(--page-gutter));margin-inline:auto}
.site-shell{max-width:1280px}
.search-layout{max-width:768px}
@media (min-width:640px){:root{--page-gutter:24px}}
@media (min-width:1024px){:root{--page-gutter:32px}}
header{border-bottom:1px solid var(--border)}
.header{height:72px;display:flex;align-items:center;gap:24px}
.brand{display:flex;align-items:center;gap:8px;font:500 18px/28px var(--font);text-decoration:none}
.brand-logo{width:33px;height:30px;display:block;
background:url('/assets/logo-light.webp') center/contain no-repeat}
[data-theme=dark] .brand-logo{background-image:url('/assets/logo-dark.webp')}
.theme-btn{width:40px;height:40px;display:grid;place-items:center;background:transparent;border:1px solid var(--border);
border-radius:9999px;color:var(--text-secondary);cursor:pointer;padding:0}
.theme-btn:hover{background:var(--surface-hover);color:var(--text-primary)}
.theme-btn svg{width:18px;height:18px;display:block}
nav{display:flex;gap:16px;font:400 18px/28px var(--font)}
nav a{text-decoration:none;color:var(--text-secondary)}
nav a[aria-current=page]{color:var(--text-primary);text-decoration:underline;text-underline-offset:6px}
.header-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.live{display:inline-flex;align-items:center;gap:6px;font:400 14px/20px var(--font);color:var(--text-muted)}
.dot{width:8px;height:8px;border-radius:9999px;background:var(--ok-text)}
.dot.paused{background:var(--text-muted)}
main{padding:48px 0 80px}
.query{position:relative;display:flex;align-items:center}
.query input{width:100%;height:48px;border:1px solid transparent;border-radius:9999px;background:var(--surface-control);
color:var(--text-primary);font:400 16px/24px var(--font);padding:0 48px 0 20px}
.query input::placeholder{color:var(--text-secondary)}
.query input:focus-visible{outline:2px solid var(--focus);outline-offset:3px;background:var(--canvas)}
.query .clear{position:absolute;right:4px;border:0;background:transparent;color:var(--text-secondary);
width:40px;height:40px;border-radius:9999px;cursor:pointer;font-size:18px;display:none}
.query .clear.show{display:block}
.query .clear:hover{background:var(--surface-hover);color:var(--text-primary)}
.controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:16px}
.filters{display:flex;gap:8px;flex-wrap:wrap}
.chip{height:32px;padding:0 12px;border:1px solid var(--border-strong);border-radius:9999px;
background:var(--surface-control);color:var(--text-secondary);font:400 14px/20px var(--font);
cursor:pointer;display:inline-flex;align-items:center;gap:6px}
.chip:hover{background:var(--surface-hover);color:var(--text-primary)}
.chip[aria-pressed=true]{background:var(--primary);border-color:var(--primary);color:var(--on-primary)}
.sort{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--text-secondary);font:400 14px/20px var(--font)}
select{height:40px;border:1px solid var(--border-strong);border-radius:9999px;background:var(--canvas);
color:var(--text-primary);font:400 14px/20px var(--font);padding:0 12px}
.summary{display:flex;gap:16px;flex-wrap:wrap;margin-top:24px;color:var(--text-muted);font:400 14px/20px var(--font)}
.summary strong{color:var(--text-primary);font-weight:500}
ul.result-list{list-style:none;margin:0;padding:0}
/* Native virtualization: off-screen rows are not laid out or painted. */
.result-row{padding-block:24px;border-bottom:1px solid var(--border);
content-visibility:auto;contain-intrinsic-size:auto 128px}
.result-head{display:flex;align-items:flex-start;gap:12px;min-width:0}
.result-title{font:500 20px/28px var(--font);color:var(--text-primary);text-decoration:none;overflow-wrap:anywhere}
.result-title:hover{text-decoration:underline}
.result-description{margin:4px 0 0;color:var(--text-secondary);font:400 16px/24px var(--font);overflow-wrap:anywhere}
.badge-group{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.result-meta{display:flex;gap:8px 16px;flex-wrap:wrap;margin-top:12px;color:var(--text-muted);font:400 14px/20px var(--font)}
.badge{font:500 12px/16px var(--font);padding:2px 6px;border-radius:4px;background:var(--surface-control);color:var(--text-secondary);white-space:nowrap}
.badge-cap{background:var(--cap-bg);color:var(--cap-text)}
.badge-size{background:var(--size-bg);color:var(--size-text)}
.badge-ok{background:var(--ok-bg);color:var(--ok-text)}
.badge-err{background:var(--err-bg);color:var(--err-text)}
.mono{font-family:var(--mono);font-size:14px}
.empty{padding:32px 0;color:var(--text-secondary)}
.error{padding:12px;border-radius:8px;background:var(--err-bg);color:var(--err-text);font:400 14px/20px var(--font)}
.skeleton{height:16px;border-radius:6px;background:var(--surface-control);margin:12px 0}
.skeleton.w60{width:60%}.skeleton.w40{width:40%}
.more{display:flex;justify-content:center;margin-top:24px}
.more button{min-height:40px;padding:10px 20px;border-radius:9999px;border:1px solid var(--border-strong);
background:var(--canvas);color:var(--text-primary);font:500 14px/20px var(--font);cursor:pointer}
.more button:hover{background:var(--surface-hover)}
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
footer{color:var(--text-muted);font:400 14px/20px var(--font);padding:0 0 64px}
footer p{max-width:768px;margin-inline:auto}
:where(a,button,input,select,[tabindex]):focus-visible{outline:2px solid var(--focus);outline-offset:3px}
@media (pointer:coarse){.chip,.clear,.more button{min-height:44px}.chip{min-width:44px}}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body>
<header>
<div class="site-shell header">
<a class="brand" href="/"><span class="brand-logo" role="img" aria-label="Bucketeer"></span>Bucketeer</a>
<nav aria-label="Primary">
<a href="?view=assets" id="nav-assets" aria-current="page">Assets</a>
<a href="?view=contents" id="nav-contents">Contents</a>
<a href="/metrics">Metrics</a>
<a href="/healthz">Health</a>
</nav>
<div class="header-right">
<span class="live" id="live"><span class="dot" id="livedot"></span><span id="livetext">live · 15s</span></span>
<button class="theme-btn" id="themebtn" type="button" aria-label="Switch to dark mode"></button>
</div>
</div>
</header>
<main>
<div class="search-layout">
<h1 class="visually-hidden">Cloud resources discovered in Certificate Transparency</h1>
<div class="query">
<label class="visually-hidden" for="q">Search</label>
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search hostname, bucket, database…">
<button class="clear" id="clear" type="button" aria-label="Clear search">×</button>
</div>
<div class="controls" id="asset-controls">
<div class="filters" id="filters" role="group" aria-label="Filter by exposure">
<button class="chip" data-exposure="" aria-pressed="true">All</button>
<button class="chip" data-exposure="public_read" aria-pressed="false">Public read</button>
<button class="chip" data-exposure="reachable" aria-pressed="false">Reachable</button>
<button class="chip" data-exposure="auth_required" aria-pressed="false">Auth required</button>
<button class="chip" data-exposure="not_found" aria-pressed="false">Not found</button>
<button class="chip" data-exposure="private" aria-pressed="false">Private</button>
</div>
<div class="sort">
<label for="sort">Sort</label>
<select id="sort">
<option value="exposure">Exposure</option>
<option value="last_seen">Recently seen</option>
<option value="first_seen">Newest</option>
<option value="provider">Provider</option>
<option value="hostname">Hostname</option>
</select>
</div>
</div>
<div class="summary" id="summary" aria-live="polite"></div>
<div id="state"></div>
<ul class="result-list" id="results"></ul>
<div class="more" id="more" hidden><button id="morebtn" type="button">Load more</button></div>
</div>
</main>
<footer>
<p>
A detected endpoint is not automatically accessible. <span class="badge badge-ok">public_read</span>
means an anonymous read succeeded at probe time and readable resources are indexed below (keys and
sizes only, never contents). Databases are only reported <span class="badge badge-size">reachable</span>
unless an unauthenticated protocol read was explicitly enabled; no credentials are ever sent.
</p>
</footer>
<script>
const PAGE = 100, REFRESH_MS = 15000;
const $ = (id) => document.getElementById(id);
const params = new URLSearchParams(location.search);
const state = {
view: params.get('view') === 'contents' ? 'contents' : 'assets',
q: params.get('q') || '',
exposure: params.get('exposure') || '',
sort: params.get('sort') || 'exposure',
assets: [], assetsTotal: 0, assetsOffset: 0,
entries: [], entriesTotal: 0, entriesOffset: 0,
loading: false, abort: null, timer: null, countdown: REFRESH_MS / 1000, ok: true,
};
$('q').value = state.q;
$('sort').value = state.sort;
$('clear').classList.toggle('show', state.q.length > 0);
document.querySelectorAll('#filters .chip').forEach((c) =>
c.setAttribute('aria-pressed', String(c.dataset.exposure === state.exposure)));
const SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/></svg>';
const MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
const currentTheme = () => document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
function setTheme(t, persist) {
document.documentElement.setAttribute('data-theme', t);
if (persist) { try { localStorage.setItem('bucketeer-theme', t); } catch (e) {} }
$('themebtn').innerHTML = t === 'dark' ? SUN : MOON;
$('themebtn').setAttribute('aria-label', t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
}
setTheme(currentTheme(), false);
$('themebtn').onclick = () => setTheme(currentTheme() === 'dark' ? 'light' : 'dark', true);
if (window.matchMedia) matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
try { if (!localStorage.getItem('bucketeer-theme')) setTheme(e.matches ? 'dark' : 'light', false); } catch (_) {}
});
const esc = (s) => (s ?? '').toString().replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const key = (a) => a.provider + '|' + a.service + '|' + a.provider_hostname;
const badge = (v, cls) => `<span class="badge ${cls || ''}">${esc(v)}</span>`;
function exposureBadge(v) {
const cls = v === 'public_read' ? 'badge-ok' : v === 'reachable' ? 'badge-size'
: (v === 'not_found' || v === 'private') ? 'badge-err' : '';
return badge(v || 'unknown', cls);
}
function ago(ts) {
if (!ts) return null;
const s = Math.max(0, (Date.now() - new Date(ts)) / 1000);
if (s < 60) return Math.floor(s) + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function bytes(n) {
if (!n) return '';
const u = ['B','KB','MB','GB','TB']; let i = 0, v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i];
}
function timeEl(ts, label) {
const a = ago(ts);
return a ? `<span>${label} <time datetime="${esc(ts)}">${a}</time></span>` : '';
}
function assetRow(a) {
const meta = [];
if (a.region) meta.push(`<span>${esc(a.region)}</span>`);
if (a.hostname && a.hostname !== a.provider_hostname) meta.push(`<span>CT host <span class="mono">${esc(a.hostname)}</span></span>`);
meta.push(timeEl(a.last_seen, 'seen'));
meta.push(timeEl(a.probed_at, 'probed'));
return `<li class="result-row">
<div class="result-head">
<a class="result-title" href="https://${esc(a.provider_hostname)}/" target="_blank" rel="noopener noreferrer nofollow">${esc(a.provider_hostname)}</a>
${exposureBadge(a.exposure_state)}
</div>
${a.exposure_detail ? `<p class="result-description">${esc(a.exposure_detail)}</p>` : ''}
<div class="badge-group">
${badge(a.provider, 'badge-cap')}${badge(a.service, 'badge-size')}
${a.resource_identifier ? badge(a.resource_identifier) : ''}
${a.public_semantic && a.public_semantic !== 'none' ? badge(a.public_semantic) : ''}
</div>
<div class="result-meta">${meta.filter(Boolean).join('')}</div>
</li>`;
}
function entryRow(e) {
const meta = [timeEl(e.indexed_at, 'indexed')];
if (e.size) meta.push(`<span>${bytes(e.size)}</span>`);
if (e.content_type) meta.push(`<span>${esc(e.content_type)}</span>`);
return `<li class="result-row">
<div class="result-head">
<span class="result-title mono">${esc(e.name)}</span>
${badge(e.kind)}
</div>
<div class="badge-group">
${badge(e.provider, 'badge-cap')}${badge(e.service, 'badge-size')}
<span class="badge mono">${esc(e.provider_hostname)}</span>
</div>
<div class="result-meta">${meta.join('')}</div>
</li>`;
}
function render() {
const ul = $('results');
if (state.view === 'assets') {
ul.innerHTML = state.assets.map(assetRow).join('');
if (!state.assets.length) showEmpty('No assets match.');
} else {
ul.innerHTML = state.entries.map(entryRow).join('');
if (!state.entries.length) showEmpty('No indexed contents match. Only resources confirmed publicly readable are indexed.');
}
const loaded = state.view === 'assets' ? state.assets.length : state.entries.length;
const total = state.view === 'assets' ? state.assetsTotal : state.entriesTotal;
$('more').hidden = loaded >= total;
$('morebtn').textContent = `Load more (${loaded} of ${total})`;
}
function showEmpty(msg) {
$('state').innerHTML = `<div class="empty">${esc(msg)}${state.q ? '' : ''}</div>`;
}
async function fetchJSON(url, signal) {
const r = await fetch(url, { signal });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
async function loadAssets(reset) {
if (reset) { state.assets = []; state.assetsOffset = 0; }
const p = new URLSearchParams({ q: state.q, exposure: state.exposure, sort: state.sort,
limit: String(PAGE), offset: String(state.assetsOffset) });
const data = await fetchJSON('api/assets?' + p);
mergeAssets(data.assets || []);
state.assetsTotal = data.total || 0;
state.assetsOffset = state.assets.length;
}
function mergeAssets(rows) {
const seen = new Set(state.assets.map(key));
for (const a of rows) { const k = key(a); if (!seen.has(k)) { seen.add(k); state.assets.push(a); } }
if (state.sort === 'hostname') state.assets.sort((a, b) => a.provider_hostname.localeCompare(b.provider_hostname));
}
async function loadEntries(reset) {
if (reset) { state.entries = []; state.entriesOffset = 0; }
const p = new URLSearchParams({ q: state.q, limit: String(PAGE), offset: String(state.entriesOffset) });
const data = await fetchJSON('api/entries?' + p);
const seen = new Set(state.entries.map((e) => e.id));
for (const e of (data.entries || [])) if (!seen.has(e.id)) state.entries.push(e);
state.entriesTotal = data.total || 0;
state.entriesOffset = state.entries.length;
}
async function reload(showSkeleton) {
if (state.abort) state.abort.abort();
const ac = new AbortController();
state.abort = ac;
if (showSkeleton) $('state').innerHTML = '<div class="skeleton w60"></div><div class="skeleton w40"></div><div class="skeleton w60"></div>';
try {
if (state.view === 'assets') await loadAssets(true); else await loadEntries(true);
$('state').innerHTML = '';
render();
await loadStats();
setLive(true);
} catch (e) {
if (e.name === 'AbortError') return;
$('state').innerHTML = `<div class="error">Could not load data (${esc(e.message)}). Retrying in 15s.</div>`;
setLive(false);
}
}
async function loadStats() {
const s = await fetchJSON('api/stats');
const readable = (s.by_exposure || []).find((x) => x.label === 'public_read');
$('summary').innerHTML =
`<span><strong>${s.total ?? 0}</strong> assets</span>` +
`<span><strong>${readable ? readable.count : 0}</strong> publicly readable</span>` +
`<span><strong>${s.reachable ?? 0}</strong> reachable endpoints</span>` +
`<span><strong>${s.entries ?? 0}</strong> indexed items</span>`;
}
function setLive(ok) {
state.ok = ok;
$('livedot').classList.toggle('paused', !ok);
$('livetext').textContent = (ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
}
function syncURL() {
const p = new URLSearchParams();
p.set('view', state.view);
if (state.q) p.set('q', state.q);
if (state.view === 'assets' && state.exposure) p.set('exposure', state.exposure);
if (state.view === 'assets' && state.sort !== 'exposure') p.set('sort', state.sort);
history.replaceState(null, '', '?' + p);
$('nav-assets').setAttribute('aria-current', state.view === 'assets' ? 'page' : 'false');
$('nav-contents').setAttribute('aria-current', state.view === 'contents' ? 'page' : 'false');
$('asset-controls').hidden = state.view !== 'assets';
$('q').placeholder = state.view === 'assets' ? 'Search hostname, bucket, database…' : 'Search indexed contents…';
}
function setView(v) { state.view = v; syncURL(); reload(true); }
$('nav-assets').onclick = (e) => { e.preventDefault(); setView('assets'); };
$('nav-contents').onclick = (e) => { e.preventDefault(); setView('contents'); };
$('morebtn').onclick = async () => {
if (state.loading) return;
state.loading = true; $('morebtn').disabled = true;
try { if (state.view === 'assets') await loadAssets(false); else await loadEntries(false); render(); }
finally { state.loading = false; $('morebtn').disabled = false; }
};
let debounce;
$('q').addEventListener('input', () => {
state.q = $('q').value.trim();
$('clear').classList.toggle('show', state.q.length > 0);
syncURL();
clearTimeout(debounce);
debounce = setTimeout(() => reload(false), 250);
});
$('clear').onclick = () => { $('q').value = ''; state.q = ''; $('clear').classList.remove('show'); syncURL(); reload(true); $('q').focus(); };
document.querySelectorAll('#filters .chip').forEach((c) => c.onclick = () => {
state.exposure = c.dataset.exposure;
document.querySelectorAll('#filters .chip').forEach((x) => x.setAttribute('aria-pressed', String(x.dataset.exposure === state.exposure)));
syncURL(); reload(true);
});
$('sort').onchange = () => { state.sort = $('sort').value; syncURL(); reload(true); };
document.addEventListener('visibilitychange', () => { if (!document.hidden) reload(false); });
function schedule() {
clearInterval(state.timer);
state.timer = setInterval(() => {
if (document.hidden) return;
state.countdown--;
if (state.countdown <= 0) { state.countdown = REFRESH_MS / 1000; reload(false); }
$('livetext').textContent = (state.ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
}, 1000);
}
syncURL();
reload(true);
schedule();
</script>
</body>
</html>