This commit is contained in:
2026-02-12 16:12:44 -08:00
parent 049fe7f28d
commit 77f8236347

View File

@@ -44,6 +44,7 @@
#connection.connecting { color: #f890e7; } #connection.connecting { color: #f890e7; }
#connection.error { color: #f890e7; } #connection.error { color: #f890e7; }
#connection.backoff { color: #888; } #connection.backoff { color: #888; }
#connection.ratelimited { color: #ff4444; }
#status { #status {
text-align: center; text-align: center;
@@ -246,10 +247,83 @@
#toast.error { #toast.error {
background: #f890e7; background: #f890e7;
} }
.admin-toggle {
text-align: center;
margin-top: 20px;
padding: 10px;
font-size: 10px;
color: #555;
cursor: pointer;
letter-spacing: 1px;
text-transform: uppercase;
}
.admin-toggle:hover {
color: #888;
}
#adminPanel {
display: none;
background: #111;
border: 1px solid #333;
border-radius: 8px;
padding: 15px;
margin-top: 10px;
}
#adminPanel.visible {
display: block;
}
.admin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.admin-btn {
padding: 12px;
font-size: 11px;
background: #1a1a1a;
border: 1px solid #444;
color: #888;
border-radius: 6px;
cursor: pointer;
}
.admin-btn:hover {
border-color: #0bd3d3;
color: #0bd3d3;
}
.admin-btn.danger {
border-color: #f890e7;
color: #f890e7;
}
.admin-btn.danger:hover {
background: #f890e7;
color: #000;
}
#authStatus {
text-align: center;
font-size: 9px;
color: #0bd3d3;
margin-bottom: 5px;
letter-spacing: 1px;
font-family: monospace;
}
#authStatus.unauth {
color: #f890e7;
}
</style> </style>
</head> </head>
<body> <body>
<h1>KLUBHAUS ALERT</h1> <h1>KLUBHAUS ALERT</h1>
<div id="authStatus">Checking auth...</div>
<div id="connection" class="connecting">WebSocket: connecting...</div> <div id="connection" class="connecting">WebSocket: connecting...</div>
<div id="backoffInfo"></div> <div id="backoffInfo"></div>
@@ -270,21 +344,64 @@
<button id="silence" onclick="sendSilence()">Silence</button> <button id="silence" onclick="sendSilence()">Silence</button>
</div> </div>
<div class="admin-toggle" onclick="toggleAdmin()">⚙ Admin</div>
<div id="adminPanel">
<div class="admin-grid">
<button class="admin-btn" onclick="sendAdmin('MODE_SCREEN')">Screen Mode</button>
<button class="admin-btn" onclick="sendAdmin('MODE_LED')">LED Mode</button>
<button class="admin-btn" onclick="sendAdmin('SILENCE')">Force Silence</button>
<button class="admin-btn" onclick="sendAdmin('PING')">Ping Device</button>
<button class="admin-btn danger" onclick="sendAdmin('REBOOT')">Reboot</button>
<button class="admin-btn" onclick="resetCounters()">Reset Counters</button>
<button class="admin-btn" onclick="forceMetricsPublish()">Force Metrics</button>
<button class="admin-btn" onclick="showRawMetrics()">Raw Metrics</button>
<button class="admin-btn" onclick="showHelp()">Help</button>
</div>
</div>
<div id="toast">Sent</div> <div id="toast">Sent</div>
<script> <script>
// ============== CONFIG ============== // ============== CONFIG ==============
const COMMAND_POST_URL = 'https://ntfy.sh/ALERT_klubhaus_topic'; const NTFY_TOKEN_RAW = 'tk_sw5g3hikzdccurvhyxfh0sqmpfqza'; // ← SET YOUR TOKEN HERE: 'tk_...'
const SILENCE_POST_URL = 'https://ntfy.sh/SILENCE_klubhaus_topic';
const METRICS_POST_URL = 'https://ntfy.sh/METRICS_klubhaus_topic';
const STATUS_WS_URL = 'wss://ntfy.sh/STATUS_klubhaus_topic/ws';
const ACTIVE_DURATION = 180000; // 3 min full speed after interaction // Build auth parameter: raw base64 of "Bearer tk_..." with NO trailing =
const BASE_INTERVAL = 5000; // 5s when active function buildAuthParam(token) {
const BACKOFF_INTERVALS = [10000, 30000, 60000, 120000, 300000]; // 10s, 30s, 1m, 2m, 5m if (!token) return null;
// Step 1: Create the Authorization header value
const authHeader = 'Bearer ' + token;
// Step 2: Base64 encode
let b64 = btoa(authHeader);
// Step 3: Strip trailing = padding (raw base64)
b64 = b64.replace(/=+$/, '');
return b64;
}
const NTFY_AUTH_PARAM = buildAuthParam(NTFY_TOKEN_RAW);
// Build authenticated URLs
function authUrl(baseUrl) {
if (!NTFY_AUTH_PARAM) return baseUrl;
const separator = baseUrl.includes('?') ? '&' : '?';
return baseUrl + separator + 'auth=' + encodeURIComponent(NTFY_AUTH_PARAM);
}
const COMMAND_POST_URL = authUrl('https://ntfy.sh/ALERT_klubhaus_topic');
const SILENCE_POST_URL = authUrl('https://ntfy.sh/SILENCE_klubhaus_topic');
const METRICS_POST_URL = authUrl('https://ntfy.sh/METRICS_klubhaus_topic');
const ADMIN_POST_URL = authUrl('https://ntfy.sh/ADMIN_klubhaus_topic');
const STATUS_WS_URL = 'wss://ntfy.sh/STATUS_klubhaus_topic/ws';
// Rate limits (conservative even with auth)
const METRICS_MIN_INTERVAL_MS = 120000;
const METRICS_MAX_INTERVAL_MS = 600000;
const METRICS_FORCE_INTERVAL_MS = 600000;
const ALERT_COOLDOWN_MS = 2000;
// Backoff for WebSocket only
const ACTIVE_DURATION = 180000;
const BASE_INTERVAL = 5000;
const BACKOFF_INTERVALS = [10000, 30000, 60000, 120000, 300000];
const LATENCY_WINDOW_SIZE = 100; const LATENCY_WINDOW_SIZE = 100;
const METRICS_MIN_INTERVAL = 5000; // Never faster than 5s
const METRICS_MAX_INTERVAL = 300000; // Never slower than 5m
// ============== CLIENT ID ============== // ============== CLIENT ID ==============
function getOrCreateClientId() { function getOrCreateClientId() {
@@ -315,8 +432,32 @@
const CLIENT_ID = getOrCreateClientId(); const CLIENT_ID = getOrCreateClientId();
// ============== RATE LIMIT STATE ==============
let lastAlertTime = 0;
let rateLimitBackoffUntil = 0;
let consecutive429s = 0;
function isRateLimited() {
return Date.now() < rateLimitBackoffUntil;
}
function record429() {
consecutive429s++;
const backoffMs = Math.min(30000 * Math.pow(2, consecutive429s - 1), 480000);
rateLimitBackoffUntil = Date.now() + backoffMs;
console.error(`[RATE LIMIT] 429 received, backing off ${backoffMs}ms`);
return backoffMs;
}
function recordSuccess() {
if (consecutive429s > 0) {
console.log(`[RATE LIMIT] Success after ${consecutive429s} 429s, resetting`);
consecutive429s = 0;
}
}
// ============== PROMETHEUS-STYLE METRICS ============== // ============== PROMETHEUS-STYLE METRICS ==============
const counters = { sent: 0, success: 0, confirmed: 0 }; const counters = { sent: 0, success: 0, confirmed: 0, errors: 0, rateLimited: 0 };
const latencies = { success: [], confirmed: [] }; const latencies = { success: [], confirmed: [] };
const inFlight = new Map(); const inFlight = new Map();
@@ -328,6 +469,8 @@
counters.sent = parsed.sent || 0; counters.sent = parsed.sent || 0;
counters.success = parsed.success || 0; counters.success = parsed.success || 0;
counters.confirmed = parsed.confirmed || 0; counters.confirmed = parsed.confirmed || 0;
counters.errors = parsed.errors || 0;
counters.rateLimited = parsed.rateLimited || 0;
} catch (e) { } catch (e) {
console.error('Failed to load counters:', e); console.error('Failed to load counters:', e);
} }
@@ -369,51 +512,50 @@
let lastInteraction = Date.now(); let lastInteraction = Date.now();
let ws = null; let ws = null;
let isProcessing = false; let isProcessing = false;
let metricsTimerId = null;
let metricsNextScheduledTime = 0;
function recordInteraction() { function recordInteraction() {
const wasIdle = (Date.now() - lastInteraction) >= ACTIVE_DURATION; const wasIdle = (Date.now() - lastInteraction) >= ACTIVE_DURATION;
lastInteraction = Date.now(); lastInteraction = Date.now();
if (wasIdle) { if (wasIdle) {
console.log('[BACKOFF] Reset to active mode (interaction detected)'); console.log('[BACKOFF] WebSocket reset to active mode');
rescheduleMetrics(); // Immediate reschedule to faster interval
} }
updateBackoffDisplay(); updateBackoffDisplay();
} }
function getCurrentInterval() { function getWebSocketInterval() {
const idleTime = Date.now() - lastInteraction; const idleTime = Date.now() - lastInteraction;
if (idleTime < ACTIVE_DURATION) return BASE_INTERVAL; if (idleTime < ACTIVE_DURATION) return BASE_INTERVAL;
const idleMinutes = (idleTime - ACTIVE_DURATION) / 60000; const idleMinutes = (idleTime - ACTIVE_DURATION) / 60000;
if (idleMinutes < 5) return BACKOFF_INTERVALS[0]; // 10s if (idleMinutes < 5) return BACKOFF_INTERVALS[0];
if (idleMinutes < 10) return BACKOFF_INTERVALS[1]; // 30s if (idleMinutes < 10) return BACKOFF_INTERVALS[1];
if (idleMinutes < 20) return BACKOFF_INTERVALS[2]; // 60s if (idleMinutes < 20) return BACKOFF_INTERVALS[2];
if (idleMinutes < 40) return BACKOFF_INTERVALS[3]; // 120s if (idleMinutes < 40) return BACKOFF_INTERVALS[3];
return BACKOFF_INTERVALS[4]; // 300s return BACKOFF_INTERVALS[4];
} }
function updateBackoffDisplay() { function updateBackoffDisplay() {
const el = document.getElementById('backoffInfo'); const el = document.getElementById('backoffInfo');
const idleTime = Date.now() - lastInteraction; const idleTime = Date.now() - lastInteraction;
const interval = getCurrentInterval(); const wsInterval = getWebSocketInterval();
const rateLimitRemaining = Math.max(0, rateLimitBackoffUntil - Date.now());
let rateLimitText = '';
if (rateLimitRemaining > 0) {
rateLimitText = ` [RL:${Math.ceil(rateLimitRemaining/1000)}s]`;
}
if (idleTime < ACTIVE_DURATION) { if (idleTime < ACTIVE_DURATION) {
const remaining = Math.ceil((ACTIVE_DURATION - idleTime) / 1000); const remaining = Math.ceil((ACTIVE_DURATION - idleTime) / 1000);
el.textContent = `ACTIVE: ${remaining}s | interval: ${interval/1000}s`; el.textContent = `ACTIVE:${remaining}s WS:${wsInterval/1000}s METRICS:${METRICS_MIN_INTERVAL_MS/1000}s${rateLimitText}`;
el.style.color = '#64e8ba'; el.style.color = rateLimitRemaining > 0 ? '#ff4444' : '#64e8ba';
} else { } else {
const minutes = Math.floor((idleTime - ACTIVE_DURATION) / 60000); const minutes = Math.floor((idleTime - ACTIVE_DURATION) / 60000);
el.textContent = `idle:${minutes}m | interval:${interval/1000}s`; el.textContent = `idle:${minutes}m WS:${wsInterval/1000}s METRICS:${METRICS_MIN_INTERVAL_MS/1000}s${rateLimitText}`;
el.style.color = '#555'; el.style.color = rateLimitRemaining > 0 ? '#ff4444' : '#555';
} }
} }
// Update display every 10s to show countdown setInterval(updateBackoffDisplay, 5000);
setInterval(updateBackoffDisplay, 10000);
// ============== WEBSOCKET ============== // ============== WEBSOCKET ==============
function connectWebSocket() { function connectWebSocket() {
@@ -445,7 +587,7 @@
ws.onclose = () => { ws.onclose = () => {
connEl.className = 'backoff'; connEl.className = 'backoff';
connEl.textContent = 'WebSocket: reconnecting...'; connEl.textContent = 'WebSocket: reconnecting...';
setTimeout(connectWebSocket, getCurrentInterval()); setTimeout(connectWebSocket, getWebSocketInterval());
}; };
} }
@@ -472,12 +614,10 @@
if (tracking.message === deviceMessage && tracking.state === 'sent') { if (tracking.message === deviceMessage && tracking.state === 'sent') {
const now = performance.now(); const now = performance.now();
const successMs = Math.round(now - tracking.sentAt); const successMs = Math.round(now - tracking.sentAt);
tracking.state = 'success'; tracking.state = 'success';
tracking.successAt = now; tracking.successAt = now;
counters.success++; counters.success++;
recordLatency('success', successMs); recordLatency('success', successMs);
console.log(`[METRICS] success: ${msgId} latency=${successMs}ms`); console.log(`[METRICS] success: ${msgId} latency=${successMs}ms`);
if (!tracking.confirmed) { if (!tracking.confirmed) {
@@ -485,14 +625,11 @@
const confirmMs = Math.round(now - tracking.sentAt); const confirmMs = Math.round(now - tracking.sentAt);
counters.confirmed++; counters.confirmed++;
recordLatency('confirmed', confirmMs); recordLatency('confirmed', confirmMs);
console.log(`[METRICS] confirmed: ${msgId} latency=${confirmMs}ms`); console.log(`[METRICS] confirmed: ${msgId} latency=${confirmMs}ms`);
if (isProcessing && tracking.message === lastSentMessage) { if (isProcessing && tracking.message === lastSentMessage) {
finishConfirmation(true, tracking.message, confirmMs); finishConfirmation(true, tracking.message, confirmMs);
} }
} }
saveCounters(); saveCounters();
updateMetricsDisplay(); updateMetricsDisplay();
break; break;
@@ -503,11 +640,24 @@
// ============== ALERT SENDING ============== // ============== ALERT SENDING ==============
async function sendAlert(message) { async function sendAlert(message) {
const now = Date.now();
if (now - lastAlertTime < ALERT_COOLDOWN_MS) {
showToast('WAIT 2 SECONDS', true);
return;
}
if (isRateLimited()) {
const remaining = Math.ceil((rateLimitBackoffUntil - now) / 1000);
showToast(`RATE LIMITED: ${remaining}s`, true);
return;
}
recordInteraction(); recordInteraction();
if (isProcessing) return; if (isProcessing) return;
isProcessing = true; isProcessing = true;
lastSentMessage = message; lastSentMessage = message;
lastAlertTime = now;
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const msgId = generateMessageId(); const msgId = generateMessageId();
@@ -543,7 +693,26 @@
} }
}); });
if (!response.ok) throw new Error('HTTP ' + response.status); if (response.status === 429) {
const backoffMs = record429();
counters.rateLimited++;
saveCounters();
throw new Error(`RATE LIMITED (429) - back off ${Math.ceil(backoffMs/1000)}s`);
}
if (response.status === 401) {
counters.errors++;
saveCounters();
throw new Error('UNAUTHORIZED (401) - check token');
}
if (!response.ok) {
counters.errors++;
saveCounters();
throw new Error('HTTP ' + response.status);
}
recordSuccess();
statusEl.className = 'confirming'; statusEl.className = 'confirming';
statusEl.innerHTML = `<span>CONFIRMING</span><span class="status-detail">Waiting for device...</span>`; statusEl.innerHTML = `<span>CONFIRMING</span><span class="status-detail">Waiting for device...</span>`;
@@ -556,11 +725,11 @@
} catch (e) { } catch (e) {
statusEl.className = 'offline'; statusEl.className = 'offline';
statusEl.innerHTML = `<span>FAILED</span><span class="status-detail">${escapeHtml(e.message)}</span>`; statusEl.innerHTML = `<span>FAILED</span><span class="status-detail">${escapeHtml(e.message)}</span>`;
showToast('NETWORK ERROR', true); showToast(e.message.includes('RATE') ? 'RATE LIMITED' :
e.message.includes('UNAUTHORIZED') ? 'AUTH FAILED' : 'NETWORK ERROR', true);
isProcessing = false; isProcessing = false;
setButtonsDisabled(false); setButtonsDisabled(false);
} }
updateMetricsDisplay(); updateMetricsDisplay();
} }
@@ -594,6 +763,90 @@
updateMetricsDisplay(); updateMetricsDisplay();
} }
// ============== ADMIN COMMANDS ==============
function toggleAdmin() {
const panel = document.getElementById('adminPanel');
panel.classList.toggle('visible');
}
async function sendAdmin(command) {
if (isRateLimited()) {
const remaining = Math.ceil((rateLimitBackoffUntil - Date.now()) / 1000);
showToast(`RATE LIMITED: ${remaining}s`, true);
return;
}
recordInteraction();
try {
const response = await fetch(ADMIN_POST_URL, {
method: 'POST',
body: command,
headers: { 'Title': 'KLUBHAUS ADMIN' }
});
if (response.status === 429) {
record429();
showToast('ADMIN RATE LIMITED', true);
return;
}
if (response.status === 401) {
showToast('ADMIN AUTH FAILED', true);
return;
}
if (response.ok) {
showToast(`ADMIN: ${command}`);
recordSuccess();
} else {
showToast('ADMIN FAILED', true);
}
} catch (e) {
showToast('NETWORK ERROR', true);
}
}
function resetCounters() {
counters.sent = 0;
counters.success = 0;
counters.confirmed = 0;
counters.errors = 0;
counters.rateLimited = 0;
latencies.success = [];
latencies.confirmed = [];
saveCounters();
updateMetricsDisplay();
showToast('COUNTERS RESET');
}
async function forceMetricsPublish() {
if (isRateLimited()) {
showToast('RATE LIMITED — CANNOT FORCE', true);
return;
}
lastPrometheusHash = null;
await publishMetricsAndReschedule();
showToast('METRICS FORCED');
}
function showRawMetrics() {
const payload = buildPrometheusPayload();
console.log('=== RAW PROMETHEUS METRICS ===');
console.log(payload);
alert('Metrics logged to console (F12)');
}
function showHelp() {
const authStatus = NTFY_TOKEN_RAW ? 'Token configured' : 'NO TOKEN - anonymous';
alert(`Auth: ${authStatus}\n\nAdmin Commands:\n` +
'MODE_SCREEN - Use display normally\n' +
'MODE_LED - LED only (broken screen)\n' +
'SILENCE - Force silence\n' +
'PING - Test device connectivity\n' +
'REBOOT - Restart device\n\n' +
'Rate limits: 2s between alerts, 2min metrics');
}
// ============== CUSTOM & SILENCE ============== // ============== CUSTOM & SILENCE ==============
function sendCustom() { function sendCustom() {
const input = document.getElementById('customInput'); const input = document.getElementById('customInput');
@@ -611,6 +864,12 @@
} }
async function sendSilence() { async function sendSilence() {
if (isRateLimited()) {
const remaining = Math.ceil((rateLimitBackoffUntil - Date.now()) / 1000);
showToast(`RATE LIMITED: ${remaining}s`, true);
return;
}
recordInteraction(); recordInteraction();
if (isProcessing) return; if (isProcessing) return;
isProcessing = true; isProcessing = true;
@@ -621,7 +880,7 @@
setButtonsDisabled(true); setButtonsDisabled(true);
try { try {
await Promise.all([ const [r1, r2] = await Promise.all([
fetch(SILENCE_POST_URL, { fetch(SILENCE_POST_URL, {
method: 'POST', method: 'POST',
body: 'SILENCE', body: 'SILENCE',
@@ -634,12 +893,32 @@
}) })
]); ]);
if (r1.status === 429 || r2.status === 429) {
record429();
throw new Error('RATE LIMITED');
}
if (r1.status === 401 || r2.status === 401) {
throw new Error('UNAUTHORIZED');
}
counters.sent += 2; counters.sent += 2;
saveCounters(); saveCounters();
recordSuccess();
showToast('SILENCE SENT'); showToast('SILENCE SENT');
} catch (e) { } catch (e) {
showToast('SILENCE FAILED', true); if (e.message.includes('RATE')) {
counters.rateLimited++;
showToast('SILENCE RATE LIMITED', true);
} else if (e.message.includes('UNAUTHORIZED')) {
counters.errors++;
showToast('SILENCE AUTH FAILED', true);
} else {
counters.errors++;
showToast('SILENCE FAILED', true);
}
saveCounters();
} finally { } finally {
isProcessing = false; isProcessing = false;
setButtonsDisabled(false); setButtonsDisabled(false);
@@ -671,17 +950,28 @@
function updateMetricsDisplay() { function updateMetricsDisplay() {
const successStats = getLatencyStats('success'); const successStats = getLatencyStats('success');
const confirmStats = getLatencyStats('confirmed'); const confirmStats = getLatencyStats('confirmed');
const el = document.getElementById('metrics'); const el = document.getElementById('metrics');
el.innerHTML = el.innerHTML =
`sent:${counters.sent} success:${counters.success} confirmed:${counters.confirmed}\n` + `sent:${counters.sent} ok:${counters.success} cnf:${counters.confirmed} err:${counters.errors} rl:${counters.rateLimited}\n` +
`success_lat: n=${successStats.count} avg=${successStats.avg}ms p95=${successStats.p95}ms\n` + `success_lat: n=${successStats.count} avg=${successStats.avg}ms p95=${successStats.p95}ms\n` +
`confirm_lat: n=${confirmStats.count} avg=${confirmStats.avg}ms p95=${confirmStats.p95}ms`; `confirm_lat: n=${confirmStats.count} avg=${confirmStats.avg}ms p95=${confirmStats.p95}ms`;
} }
// ============== METRICS PUBLISH WITH CORRECT BACKOFF ============== function updateAuthStatus() {
const el = document.getElementById('authStatus');
if (NTFY_TOKEN_RAW) {
el.textContent = `AUTH: ${NTFY_TOKEN_RAW.substring(0, 6)}...${NTFY_TOKEN_RAW.slice(-4)} (B64)`;
el.className = '';
} else {
el.textContent = 'AUTH: NONE (anonymous - 250/day limit)';
el.className = 'unauth';
}
}
// ============== METRICS PUBLISH ==============
let lastPrometheusHash = null; let lastPrometheusHash = null;
let lastPrometheusTime = 0; let lastPrometheusTime = 0;
let metricsTimerId = null;
function buildPrometheusPayload() { function buildPrometheusPayload() {
const successStats = getLatencyStats('success'); const successStats = getLatencyStats('success');
@@ -701,6 +991,14 @@
`# TYPE klubhaus_confirmed_total counter`, `# TYPE klubhaus_confirmed_total counter`,
`klubhaus_confirmed_total{client="${CLIENT_ID}"} ${counters.confirmed} ${now}`, `klubhaus_confirmed_total{client="${CLIENT_ID}"} ${counters.confirmed} ${now}`,
`# HELP klubhaus_errors_total Total errors including rate limits`,
`# TYPE klubhaus_errors_total counter`,
`klubhaus_errors_total{client="${CLIENT_ID}"} ${counters.errors} ${now}`,
`# HELP klubhaus_rate_limited_total Total 429 rate limit hits`,
`# TYPE klubhaus_rate_limited_total counter`,
`klubhaus_rate_limited_total{client="${CLIENT_ID}"} ${counters.rateLimited} ${now}`,
`# HELP klubhaus_success_latency_ms Time from send to queue visibility`, `# HELP klubhaus_success_latency_ms Time from send to queue visibility`,
`# TYPE klubhaus_success_latency_ms summary`, `# TYPE klubhaus_success_latency_ms summary`,
`klubhaus_success_latency_ms{client="${CLIENT_ID}",quantile="0.5"} ${successStats.p50}`, `klubhaus_success_latency_ms{client="${CLIENT_ID}",quantile="0.5"} ${successStats.p50}`,
@@ -727,74 +1025,94 @@
return hash.toString(16); return hash.toString(16);
} }
// CRITICAL FIX: Single timer, rescheduled after each execution
function scheduleMetrics() { function scheduleMetrics() {
const now = Date.now(); const now = Date.now();
const interval = getCurrentInterval(); const earliestAllowed = lastPrometheusTime + METRICS_MIN_INTERVAL_MS;
let nextPublishTime = Math.max(now, earliestAllowed);
const jitter = Math.floor(Math.random() * 10000);
nextPublishTime += jitter;
const delay = nextPublishTime - now;
// Clear any existing timer
if (metricsTimerId) { if (metricsTimerId) {
clearTimeout(metricsTimerId); clearTimeout(metricsTimerId);
metricsTimerId = null;
} }
metricsNextScheduledTime = now + interval;
console.log(`[METRICS] Scheduled in ${interval}ms (at ${new Date(metricsNextScheduledTime).toLocaleTimeString()})`);
metricsTimerId = setTimeout(() => { metricsTimerId = setTimeout(() => {
publishMetricsAndReschedule(); publishMetricsAndReschedule();
}, interval); }, delay);
}
function rescheduleMetrics() {
// Called on interaction to potentially speed up
const now = Date.now();
const newInterval = getCurrentInterval();
// Only reschedule if new interval is shorter than remaining time
const remaining = metricsNextScheduledTime - now;
if (newInterval < remaining - 1000) { // 1s threshold to avoid thrashing
console.log(`[METRICS] Rescheduling: ${remaining}ms -> ${newInterval}ms`);
scheduleMetrics();
}
} }
async function publishMetricsAndReschedule() { async function publishMetricsAndReschedule() {
if (isRateLimited()) {
const remaining = rateLimitBackoffUntil - Date.now();
console.log(`[METRICS] Rate limited, delaying ${remaining}ms`);
metricsTimerId = setTimeout(() => {
publishMetricsAndReschedule();
}, remaining + 5000);
return;
}
const payload = buildPrometheusPayload(); const payload = buildPrometheusPayload();
const hash = hashStringSimple(payload); const hash = hashStringSimple(payload);
const now = Date.now(); const now = Date.now();
const timeSinceLast = now - lastPrometheusTime;
// Deduplication: skip if unchanged and < 5min since last publish if (timeSinceLast < METRICS_MIN_INTERVAL_MS) {
const forcedPublishInterval = 300000; // 5 minutes console.log(`[METRICS] Too soon (${timeSinceLast}ms), rescheduling`);
scheduleMetrics();
return;
}
if (hash !== lastPrometheusHash || (now - lastPrometheusTime) >= forcedPublishInterval) { if (hash !== lastPrometheusHash || timeSinceLast >= METRICS_FORCE_INTERVAL_MS) {
try { try {
await fetch(METRICS_POST_URL, { const response = await fetch(METRICS_POST_URL, {
method: 'POST', method: 'POST',
body: payload, body: payload,
headers: { 'Content-Type': 'text/plain' } headers: { 'Content-Type': 'text/plain' }
}); });
console.log(`[METRICS] Published (${payload.length} bytes)`);
if (response.status === 429) {
const backoffMs = record429();
counters.rateLimited++;
saveCounters();
updateMetricsDisplay();
console.error(`[METRICS] 429 received, backing off ${backoffMs}ms`);
metricsTimerId = setTimeout(() => {
publishMetricsAndReschedule();
}, backoffMs);
return;
}
if (response.status === 401) {
counters.errors++;
saveCounters();
updateMetricsDisplay();
console.error('[METRICS] 401 unauthorized - check token');
// Don't retry immediately, keep schedule
scheduleMetrics();
return;
}
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
console.log(`[METRICS] Published (${payload.length} bytes, interval: ${timeSinceLast}ms)`);
lastPrometheusHash = hash; lastPrometheusHash = hash;
lastPrometheusTime = now; lastPrometheusTime = now;
recordSuccess();
} catch (e) { } catch (e) {
console.error('[METRICS] Publish failed:', e.message); console.error('[METRICS] Publish failed:', e.message);
counters.errors++;
saveCounters();
updateMetricsDisplay();
} }
} else { } else {
console.log('[METRICS] Unchanged, skipped'); console.log(`[METRICS] Unchanged, skipped (last: ${timeSinceLast}ms ago)`);
} }
// CRITICAL: Schedule next run AFTER this one completes scheduleMetrics();
// This ensures exactly one timer exists, with dynamic interval
const nextInterval = getCurrentInterval();
metricsNextScheduledTime = now + nextInterval;
console.log(`[METRICS] Next publish in ${nextInterval}ms`);
metricsTimerId = setTimeout(() => {
publishMetricsAndReschedule();
}, nextInterval);
} }
// ============== INIT ============== // ============== INIT ==============
@@ -807,14 +1125,15 @@
document.addEventListener('keydown', recordInteraction); document.addEventListener('keydown', recordInteraction);
loadCounters(); loadCounters();
updateAuthStatus();
connectWebSocket(); connectWebSocket();
scheduleMetrics(); // Start metrics loop scheduleMetrics();
updateMetricsDisplay(); updateMetricsDisplay();
updateBackoffDisplay(); updateBackoffDisplay();
console.log('KLUBHAUS ALERT v4.4Metrics with correct backoff'); console.log('KLUBHAUS ALERT v4.8Base64 auth in query param');
console.log('Client ID:', CLIENT_ID); console.log('Client ID:', CLIENT_ID);
console.log('Initial counters:', counters); console.log('Auth:', NTFY_TOKEN_RAW ? 'Token configured' : 'Anonymous');
</script> </script>
</body> </body>
</html> </html>