Files
klubhaus-doorbell/sketches/doorbell/doorbell.html
2026-02-12 12:36:56 -08:00

822 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>KLUBHAUS ALERT</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #fff8fa;
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 20px;
}
h1 {
font-size: 14px;
font-weight: 300;
letter-spacing: 4px;
text-transform: uppercase;
color: #0bd3d3;
margin-bottom: 10px;
text-align: center;
}
#connection {
text-align: center;
font-size: 9px;
margin-bottom: 5px;
letter-spacing: 1px;
font-family: monospace;
}
#connection.connected { color: #64e8ba; }
#connection.connecting { color: #f890e7; }
#connection.error { color: #f890e7; }
#connection.backoff { color: #888; }
#status {
text-align: center;
padding: 15px;
margin-bottom: 15px;
border-radius: 8px;
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
transition: all 0.3s ease;
min-height: 50px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#status.silent {
background: #1a1a1a;
color: #888;
border: 1px solid #333;
}
#status.alerting {
background: linear-gradient(90deg, #0bd3d3 0%, #f890e7 100%);
color: #000;
font-weight: 600;
animation: pulse 1s ease-in-out infinite;
}
#status.sending {
background: #f890e7;
color: #000;
font-weight: 600;
}
#status.confirming {
background: #0bd3d3;
color: #000;
font-weight: 600;
}
#status.offline {
background: #1a1a1a;
color: #f890e7;
border: 1px solid #f890e7;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.status-detail {
font-size: 10px;
font-weight: 400;
opacity: 0.8;
margin-top: 4px;
letter-spacing: 1px;
}
#metrics {
font-size: 9px;
color: #444;
text-align: center;
margin-bottom: 15px;
letter-spacing: 1px;
font-family: monospace;
white-space: pre-wrap;
line-height: 1.4;
}
#backoffInfo {
font-size: 9px;
color: #555;
text-align: center;
margin-bottom: 10px;
letter-spacing: 1px;
font-family: monospace;
}
.buttons {
display: grid;
gap: 15px;
flex: 1;
}
button {
border: none;
border-radius: 12px;
padding: 30px 20px;
font-size: 18px;
font-weight: 500;
letter-spacing: 2px;
text-transform: uppercase;
cursor: pointer;
transition: transform 0.1s ease, opacity 0.2s ease;
color: #fff8fa;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:not(:disabled):active {
transform: scale(0.96);
opacity: 0.8;
}
#frontDoor {
background: linear-gradient(135deg, #0bd3d3 0%, #08a8a8 100%);
box-shadow: 0 10px 40px rgba(11, 211, 211, 0.3);
}
#loadingDock {
background: linear-gradient(135deg, #f890e7 0%, #d660c4 100%);
box-shadow: 0 10px 40px rgba(248, 144, 231, 0.3);
}
#custom {
background: #1a1a1a;
border: 2px solid #fff8fa;
padding: 20px;
}
#customInput {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid #fff8fa;
color: #fff8fa;
font-size: 16px;
padding: 10px 0;
margin-bottom: 15px;
outline: none;
text-transform: uppercase;
letter-spacing: 1px;
}
#customInput::placeholder {
color: #666;
text-transform: uppercase;
font-size: 12px;
}
#sendCustom {
width: 100%;
background: #fff8fa;
color: #000;
border: none;
border-radius: 8px;
padding: 15px;
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
cursor: pointer;
}
#sendCustom:not(:disabled):active {
background: #0bd3d3;
}
#silence {
background: #1a1a1a;
border: 2px solid #64e8ba;
color: #64e8ba;
margin-top: 10px;
}
#silence:not(:disabled):active {
background: #64e8ba;
color: #000;
}
#toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: #64e8ba;
color: #000;
padding: 15px 30px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
opacity: 0;
transition: all 0.3s ease;
pointer-events: none;
}
#toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
#toast.error {
background: #f890e7;
}
</style>
</head>
<body>
<h1>KLUBHAUS ALERT</h1>
<div id="connection" class="connecting">WebSocket: connecting...</div>
<div id="backoffInfo"></div>
<div id="status" class="offline">
<span>Initializing...</span>
</div>
<div id="metrics"></div>
<div class="buttons">
<button id="frontDoor" onclick="sendAlert('FRONT DOOR')">Front Door</button>
<button id="loadingDock" onclick="sendAlert('LOADING DOCK')">Loading Dock</button>
<div id="custom">
<input type="text" id="customInput" maxlength="60" placeholder="Custom message (60 chars)">
<button id="sendCustom" onclick="sendCustom()">Send Custom</button>
</div>
<button id="silence" onclick="sendSilence()">Silence</button>
</div>
<div id="toast">Sent</div>
<script>
// ============== CONFIG ==============
const COMMAND_POST_URL = 'https://ntfy.sh/ALERT_klubhaus_topic';
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
const BASE_INTERVAL = 5000; // 5s when active
const BACKOFF_INTERVALS = [10000, 30000, 60000, 120000, 300000]; // 10s, 30s, 1m, 2m, 5m
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 ==============
function getOrCreateClientId() {
let id = localStorage.getItem('klubhaus_client_id');
if (!id) {
const seed = [
navigator.userAgent.split('/')[0],
screen.width,
screen.height,
navigator.hardwareConcurrency || 'unknown',
new Date().getTimezoneOffset(),
Math.random().toString(36).substring(2, 8)
].join('|');
id = 'web-' + hashString(seed).substring(0, 12);
localStorage.setItem('klubhaus_client_id', id);
}
return id;
}
function hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(16, '0');
}
const CLIENT_ID = getOrCreateClientId();
// ============== PROMETHEUS-STYLE METRICS ==============
const counters = { sent: 0, success: 0, confirmed: 0 };
const latencies = { success: [], confirmed: [] };
const inFlight = new Map();
function loadCounters() {
const saved = localStorage.getItem('klubhaus_counters');
if (saved) {
try {
const parsed = JSON.parse(saved);
counters.sent = parsed.sent || 0;
counters.success = parsed.success || 0;
counters.confirmed = parsed.confirmed || 0;
} catch (e) {
console.error('Failed to load counters:', e);
}
}
}
function saveCounters() {
localStorage.setItem('klubhaus_counters', JSON.stringify(counters));
}
function recordLatency(window, ms) {
if (ms <= 0 || ms > 60000) return;
latencies[window].push(ms);
if (latencies[window].length > LATENCY_WINDOW_SIZE) {
latencies[window].shift();
}
}
function getLatencyStats(window) {
const arr = latencies[window];
if (arr.length === 0) return { count: 0, avg: 0, p50: 0, p95: 0, min: 0, max: 0 };
const sorted = [...arr].sort((a, b) => a - b);
const sum = arr.reduce((a, b) => a + b, 0);
return {
count: arr.length,
avg: Math.round(sum / arr.length),
p50: sorted[Math.floor(sorted.length * 0.5)],
p95: sorted[Math.floor(sorted.length * 0.95)] || sorted[sorted.length - 1],
min: sorted[0],
max: sorted[sorted.length - 1]
};
}
function generateMessageId() {
return CLIENT_ID + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 6);
}
// ============== BACKOFF STATE ==============
let lastInteraction = Date.now();
let ws = null;
let isProcessing = false;
let metricsTimerId = null;
let metricsNextScheduledTime = 0;
function recordInteraction() {
const wasIdle = (Date.now() - lastInteraction) >= ACTIVE_DURATION;
lastInteraction = Date.now();
if (wasIdle) {
console.log('[BACKOFF] Reset to active mode (interaction detected)');
rescheduleMetrics(); // Immediate reschedule to faster interval
}
updateBackoffDisplay();
}
function getCurrentInterval() {
const idleTime = Date.now() - lastInteraction;
if (idleTime < ACTIVE_DURATION) return BASE_INTERVAL;
const idleMinutes = (idleTime - ACTIVE_DURATION) / 60000;
if (idleMinutes < 5) return BACKOFF_INTERVALS[0]; // 10s
if (idleMinutes < 10) return BACKOFF_INTERVALS[1]; // 30s
if (idleMinutes < 20) return BACKOFF_INTERVALS[2]; // 60s
if (idleMinutes < 40) return BACKOFF_INTERVALS[3]; // 120s
return BACKOFF_INTERVALS[4]; // 300s
}
function updateBackoffDisplay() {
const el = document.getElementById('backoffInfo');
const idleTime = Date.now() - lastInteraction;
const interval = getCurrentInterval();
if (idleTime < ACTIVE_DURATION) {
const remaining = Math.ceil((ACTIVE_DURATION - idleTime) / 1000);
el.textContent = `ACTIVE: ${remaining}s | interval: ${interval/1000}s`;
el.style.color = '#64e8ba';
} else {
const minutes = Math.floor((idleTime - ACTIVE_DURATION) / 60000);
el.textContent = `idle:${minutes}m | interval:${interval/1000}s`;
el.style.color = '#555';
}
}
// Update display every 10s to show countdown
setInterval(updateBackoffDisplay, 10000);
// ============== WEBSOCKET ==============
function connectWebSocket() {
const connEl = document.getElementById('connection');
connEl.className = 'connecting';
connEl.textContent = 'WebSocket: connecting...';
ws = new WebSocket(STATUS_WS_URL);
ws.onopen = () => {
connEl.className = 'connected';
connEl.textContent = 'WebSocket: connected';
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleStatusMessage(data);
} catch (e) {
console.error('Invalid JSON:', event.data);
}
};
ws.onerror = () => {
connEl.className = 'error';
connEl.textContent = 'WebSocket: error';
};
ws.onclose = () => {
connEl.className = 'backoff';
connEl.textContent = 'WebSocket: reconnecting...';
setTimeout(connectWebSocket, getCurrentInterval());
};
}
// ============== MESSAGE LIFECYCLE ==============
let lastSentMessage = '';
function handleStatusMessage(data) {
if (data.event === 'open') return;
if (data.event !== 'message' || !data.message) return;
let payload;
try {
payload = JSON.parse(data.message);
} catch (e) {
console.error('Failed to parse payload:', data.message);
return;
}
const deviceState = payload.state;
const deviceMessage = payload.message || '';
if (deviceState === 'ALERTING' && deviceMessage) {
for (const [msgId, tracking] of inFlight) {
if (tracking.message === deviceMessage && tracking.state === 'sent') {
const now = performance.now();
const successMs = Math.round(now - tracking.sentAt);
tracking.state = 'success';
tracking.successAt = now;
counters.success++;
recordLatency('success', successMs);
console.log(`[METRICS] success: ${msgId} latency=${successMs}ms`);
if (!tracking.confirmed) {
tracking.confirmed = true;
const confirmMs = Math.round(now - tracking.sentAt);
counters.confirmed++;
recordLatency('confirmed', confirmMs);
console.log(`[METRICS] confirmed: ${msgId} latency=${confirmMs}ms`);
if (isProcessing && tracking.message === lastSentMessage) {
finishConfirmation(true, tracking.message, confirmMs);
}
}
saveCounters();
updateMetricsDisplay();
break;
}
}
}
}
// ============== ALERT SENDING ==============
async function sendAlert(message) {
recordInteraction();
if (isProcessing) return;
isProcessing = true;
lastSentMessage = message;
const statusEl = document.getElementById('status');
const msgId = generateMessageId();
const sentAt = performance.now();
inFlight.set(msgId, {
message: message,
sentAt: sentAt,
state: 'sent',
confirmed: false
});
if (inFlight.size > 50) {
const oldest = inFlight.keys().next().value;
inFlight.delete(oldest);
}
counters.sent++;
saveCounters();
statusEl.className = 'sending';
statusEl.innerHTML = `<span>SENDING</span><span class="status-detail">${escapeHtml(message)}</span>`;
setButtonsDisabled(true);
updateMetricsDisplay();
try {
const response = await fetch(COMMAND_POST_URL, {
method: 'POST',
body: message,
headers: {
'Title': 'KLUBHAUS ALERT',
'X-Klubhaus-Id': msgId
}
});
if (!response.ok) throw new Error('HTTP ' + response.status);
statusEl.className = 'confirming';
statusEl.innerHTML = `<span>CONFIRMING</span><span class="status-detail">Waiting for device...</span>`;
const confirmed = await waitForConfirmation(message, 10000);
if (!confirmed) {
finishConfirmation(false, message, 0);
}
} catch (e) {
statusEl.className = 'offline';
statusEl.innerHTML = `<span>FAILED</span><span class="status-detail">${escapeHtml(e.message)}</span>`;
showToast('NETWORK ERROR', true);
isProcessing = false;
setButtonsDisabled(false);
}
updateMetricsDisplay();
}
function waitForConfirmation(expectedMessage, timeoutMs) {
return new Promise(resolve => {
const deadline = performance.now() + timeoutMs;
const check = setInterval(() => {
for (const [msgId, tracking] of inFlight) {
if (tracking.message === expectedMessage && tracking.confirmed) {
clearInterval(check);
resolve(true);
return;
}
}
if (performance.now() > deadline) {
clearInterval(check);
resolve(false);
}
}, 100);
});
}
function finishConfirmation(success, message, roundTripMs) {
isProcessing = false;
setButtonsDisabled(false);
if (success) {
showToast(`CONFIRMED: ${message} (${roundTripMs}ms)`);
} else {
showToast('SENT — TIMEOUT', true);
}
updateMetricsDisplay();
}
// ============== CUSTOM & SILENCE ==============
function sendCustom() {
const input = document.getElementById('customInput');
const message = input.value.trim().toUpperCase();
if (message.length === 0) {
showToast('ENTER MESSAGE', true);
return;
}
if (message.length > 60) {
showToast('TOO LONG — MAX 60', true);
return;
}
sendAlert(message);
input.value = '';
}
async function sendSilence() {
recordInteraction();
if (isProcessing) return;
isProcessing = true;
const statusEl = document.getElementById('status');
statusEl.className = 'sending';
statusEl.innerHTML = `<span>SENDING SILENCE</span><span class="status-detail">To all topics...</span>`;
setButtonsDisabled(true);
try {
await Promise.all([
fetch(SILENCE_POST_URL, {
method: 'POST',
body: 'SILENCE',
headers: { 'Title': 'KLUBHAUS SILENCE' }
}),
fetch(COMMAND_POST_URL, {
method: 'POST',
body: 'SILENCE',
headers: { 'Title': 'KLUBHAUS SILENCE' }
})
]);
counters.sent += 2;
saveCounters();
showToast('SILENCE SENT');
} catch (e) {
showToast('SILENCE FAILED', true);
} finally {
isProcessing = false;
setButtonsDisabled(false);
updateMetricsDisplay();
}
}
// ============== UI HELPERS ==============
function setButtonsDisabled(disabled) {
document.getElementById('frontDoor').disabled = disabled;
document.getElementById('loadingDock').disabled = disabled;
document.getElementById('sendCustom').disabled = disabled;
document.getElementById('silence').disabled = disabled;
}
function showToast(text, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = text;
toast.className = isError ? 'show error' : 'show';
setTimeout(() => toast.className = '', 2500);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function updateMetricsDisplay() {
const successStats = getLatencyStats('success');
const confirmStats = getLatencyStats('confirmed');
const el = document.getElementById('metrics');
el.innerHTML =
`sent:${counters.sent} success:${counters.success} confirmed:${counters.confirmed}\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`;
}
// ============== METRICS PUBLISH WITH CORRECT BACKOFF ==============
let lastPrometheusHash = null;
let lastPrometheusTime = 0;
function buildPrometheusPayload() {
const successStats = getLatencyStats('success');
const confirmStats = getLatencyStats('confirmed');
const now = Date.now();
const lines = [
`# HELP klubhaus_sent_total Total messages sent to ntfy`,
`# TYPE klubhaus_sent_total counter`,
`klubhaus_sent_total{client="${CLIENT_ID}"} ${counters.sent} ${now}`,
`# HELP klubhaus_success_total Messages confirmed in queue`,
`# TYPE klubhaus_success_total counter`,
`klubhaus_success_total{client="${CLIENT_ID}"} ${counters.success} ${now}`,
`# HELP klubhaus_confirmed_total Messages confirmed by device state change`,
`# TYPE klubhaus_confirmed_total counter`,
`klubhaus_confirmed_total{client="${CLIENT_ID}"} ${counters.confirmed} ${now}`,
`# HELP klubhaus_success_latency_ms Time from send to queue visibility`,
`# 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.95"} ${successStats.p95}`,
`klubhaus_success_latency_ms_sum{client="${CLIENT_ID}"} ${latencies.success.reduce((a,b)=>a+b,0)}`,
`klubhaus_success_latency_ms_count{client="${CLIENT_ID}"} ${successStats.count}`,
`# HELP klubhaus_confirm_latency_ms Time from send to device confirmation`,
`# TYPE klubhaus_confirm_latency_ms summary`,
`klubhaus_confirm_latency_ms{client="${CLIENT_ID}",quantile="0.5"} ${confirmStats.p50}`,
`klubhaus_confirm_latency_ms{client="${CLIENT_ID}",quantile="0.95"} ${confirmStats.p95}`,
`klubhaus_confirm_latency_ms_sum{client="${CLIENT_ID}"} ${latencies.confirmed.reduce((a,b)=>a+b,0)}`,
`klubhaus_confirm_latency_ms_count{client="${CLIENT_ID}"} ${confirmStats.count}`
];
return lines.join('\n');
}
function hashStringSimple(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
}
return hash.toString(16);
}
// CRITICAL FIX: Single timer, rescheduled after each execution
function scheduleMetrics() {
const now = Date.now();
const interval = getCurrentInterval();
// Clear any existing timer
if (metricsTimerId) {
clearTimeout(metricsTimerId);
metricsTimerId = null;
}
metricsNextScheduledTime = now + interval;
console.log(`[METRICS] Scheduled in ${interval}ms (at ${new Date(metricsNextScheduledTime).toLocaleTimeString()})`);
metricsTimerId = setTimeout(() => {
publishMetricsAndReschedule();
}, interval);
}
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() {
const payload = buildPrometheusPayload();
const hash = hashStringSimple(payload);
const now = Date.now();
// Deduplication: skip if unchanged and < 5min since last publish
const forcedPublishInterval = 300000; // 5 minutes
if (hash !== lastPrometheusHash || (now - lastPrometheusTime) >= forcedPublishInterval) {
try {
await fetch(METRICS_POST_URL, {
method: 'POST',
body: payload,
headers: { 'Content-Type': 'text/plain' }
});
console.log(`[METRICS] Published (${payload.length} bytes)`);
lastPrometheusHash = hash;
lastPrometheusTime = now;
} catch (e) {
console.error('[METRICS] Publish failed:', e.message);
}
} else {
console.log('[METRICS] Unchanged, skipped');
}
// CRITICAL: Schedule next run AFTER this one completes
// 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 ==============
document.getElementById('customInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendCustom();
});
document.addEventListener('click', recordInteraction);
document.addEventListener('touchstart', recordInteraction);
document.addEventListener('keydown', recordInteraction);
loadCounters();
connectWebSocket();
scheduleMetrics(); // Start metrics loop
updateMetricsDisplay();
updateBackoffDisplay();
console.log('KLUBHAUS ALERT v4.4 — Metrics with correct backoff');
console.log('Client ID:', CLIENT_ID);
console.log('Initial counters:', counters);
</script>
</body>
</html>