1141 lines
40 KiB
HTML
1141 lines
40 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; }
|
|
#connection.ratelimited { color: #ff4444; }
|
|
|
|
#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;
|
|
}
|
|
|
|
.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>
|
|
</head>
|
|
<body>
|
|
<h1>KLUBHAUS ALERT</h1>
|
|
<div id="authStatus">Checking auth...</div>
|
|
<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 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>
|
|
|
|
<script>
|
|
// ============== CONFIG ==============
|
|
const NTFY_TOKEN_RAW = 'tk_sw5g3hikzdccurvhyxfh0sqmpfqza'; // ← SET YOUR TOKEN HERE: 'tk_...'
|
|
|
|
// Build auth parameter: raw base64 of "Bearer tk_..." with NO trailing =
|
|
function buildAuthParam(token) {
|
|
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;
|
|
|
|
// ============== 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();
|
|
|
|
// ============== 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 ==============
|
|
const counters = { sent: 0, success: 0, confirmed: 0, errors: 0, rateLimited: 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;
|
|
counters.errors = parsed.errors || 0;
|
|
counters.rateLimited = parsed.rateLimited || 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;
|
|
|
|
function recordInteraction() {
|
|
const wasIdle = (Date.now() - lastInteraction) >= ACTIVE_DURATION;
|
|
lastInteraction = Date.now();
|
|
if (wasIdle) {
|
|
console.log('[BACKOFF] WebSocket reset to active mode');
|
|
}
|
|
updateBackoffDisplay();
|
|
}
|
|
|
|
function getWebSocketInterval() {
|
|
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];
|
|
if (idleMinutes < 10) return BACKOFF_INTERVALS[1];
|
|
if (idleMinutes < 20) return BACKOFF_INTERVALS[2];
|
|
if (idleMinutes < 40) return BACKOFF_INTERVALS[3];
|
|
return BACKOFF_INTERVALS[4];
|
|
}
|
|
|
|
function updateBackoffDisplay() {
|
|
const el = document.getElementById('backoffInfo');
|
|
const idleTime = Date.now() - lastInteraction;
|
|
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) {
|
|
const remaining = Math.ceil((ACTIVE_DURATION - idleTime) / 1000);
|
|
el.textContent = `ACTIVE:${remaining}s WS:${wsInterval/1000}s METRICS:${METRICS_MIN_INTERVAL_MS/1000}s${rateLimitText}`;
|
|
el.style.color = rateLimitRemaining > 0 ? '#ff4444' : '#64e8ba';
|
|
} else {
|
|
const minutes = Math.floor((idleTime - ACTIVE_DURATION) / 60000);
|
|
el.textContent = `idle:${minutes}m WS:${wsInterval/1000}s METRICS:${METRICS_MIN_INTERVAL_MS/1000}s${rateLimitText}`;
|
|
el.style.color = rateLimitRemaining > 0 ? '#ff4444' : '#555';
|
|
}
|
|
}
|
|
|
|
setInterval(updateBackoffDisplay, 5000);
|
|
|
|
// ============== 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, getWebSocketInterval());
|
|
};
|
|
}
|
|
|
|
// ============== 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) {
|
|
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();
|
|
if (isProcessing) return;
|
|
isProcessing = true;
|
|
lastSentMessage = message;
|
|
lastAlertTime = now;
|
|
|
|
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.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.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(e.message.includes('RATE') ? 'RATE LIMITED' :
|
|
e.message.includes('UNAUTHORIZED') ? 'AUTH FAILED' : '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();
|
|
}
|
|
|
|
// ============== 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 ==============
|
|
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() {
|
|
if (isRateLimited()) {
|
|
const remaining = Math.ceil((rateLimitBackoffUntil - Date.now()) / 1000);
|
|
showToast(`RATE LIMITED: ${remaining}s`, true);
|
|
return;
|
|
}
|
|
|
|
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 {
|
|
const [r1, r2] = 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' }
|
|
})
|
|
]);
|
|
|
|
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;
|
|
saveCounters();
|
|
recordSuccess();
|
|
showToast('SILENCE SENT');
|
|
|
|
} catch (e) {
|
|
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 {
|
|
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} 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` +
|
|
`confirm_lat: n=${confirmStats.count} avg=${confirmStats.avg}ms p95=${confirmStats.p95}ms`;
|
|
}
|
|
|
|
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 lastPrometheusTime = 0;
|
|
let metricsTimerId = null;
|
|
|
|
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_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`,
|
|
`# 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);
|
|
}
|
|
|
|
function scheduleMetrics() {
|
|
const now = Date.now();
|
|
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;
|
|
|
|
if (metricsTimerId) {
|
|
clearTimeout(metricsTimerId);
|
|
}
|
|
|
|
metricsTimerId = setTimeout(() => {
|
|
publishMetricsAndReschedule();
|
|
}, delay);
|
|
}
|
|
|
|
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 hash = hashStringSimple(payload);
|
|
const now = Date.now();
|
|
const timeSinceLast = now - lastPrometheusTime;
|
|
|
|
if (timeSinceLast < METRICS_MIN_INTERVAL_MS) {
|
|
console.log(`[METRICS] Too soon (${timeSinceLast}ms), rescheduling`);
|
|
scheduleMetrics();
|
|
return;
|
|
}
|
|
|
|
if (hash !== lastPrometheusHash || timeSinceLast >= METRICS_FORCE_INTERVAL_MS) {
|
|
try {
|
|
const response = await fetch(METRICS_POST_URL, {
|
|
method: 'POST',
|
|
body: payload,
|
|
headers: { 'Content-Type': 'text/plain' }
|
|
});
|
|
|
|
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;
|
|
lastPrometheusTime = now;
|
|
recordSuccess();
|
|
|
|
} catch (e) {
|
|
console.error('[METRICS] Publish failed:', e.message);
|
|
counters.errors++;
|
|
saveCounters();
|
|
updateMetricsDisplay();
|
|
}
|
|
} else {
|
|
console.log(`[METRICS] Unchanged, skipped (last: ${timeSinceLast}ms ago)`);
|
|
}
|
|
|
|
scheduleMetrics();
|
|
}
|
|
|
|
// ============== 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();
|
|
updateAuthStatus();
|
|
connectWebSocket();
|
|
scheduleMetrics();
|
|
updateMetricsDisplay();
|
|
updateBackoffDisplay();
|
|
|
|
console.log('KLUBHAUS ALERT v4.8 — Base64 auth in query param');
|
|
console.log('Client ID:', CLIENT_ID);
|
|
console.log('Auth:', NTFY_TOKEN_RAW ? 'Token configured' : 'Anonymous');
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|