snapshot
This commit is contained in:
@@ -112,6 +112,8 @@
|
||||
margin-bottom: 15px;
|
||||
letter-spacing: 1px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#backoffInfo {
|
||||
@@ -244,66 +246,6 @@
|
||||
#toast.error {
|
||||
background: #f890e7;
|
||||
}
|
||||
|
||||
/* Admin panel styles */
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -328,43 +270,21 @@
|
||||
<button id="silence" onclick="sendSilence()">Silence</button>
|
||||
</div>
|
||||
|
||||
<!-- Admin Panel -->
|
||||
<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="setLogLevel('DEBUG')">Log: Debug</button>
|
||||
<button class="admin-btn" onclick="setLogLevel('INFO')">Log: Info</button>
|
||||
<button class="admin-btn" onclick="setLogLevel('WARN')">Log: Warn</button>
|
||||
<button class="admin-btn" onclick="setLogLevel('ERROR')">Log: Error</button>
|
||||
<button class="admin-btn" onclick="setLogLevel('NONE')">Log: Off</button> <button class="admin-btn" onclick="showHelp()">Help</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast">Sent</div>
|
||||
|
||||
<script>
|
||||
// ============== TOPIC URLS ==============
|
||||
// ============== 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 ADMIN_POST_URL = 'https://ntfy.sh/ADMIN_klubhaus_topic'; // NEW
|
||||
const STATUS_WS_URL = 'wss://ntfy.sh/STATUS_klubhaus_topic/ws';
|
||||
|
||||
// ============== BACKOFF CONFIGURATION ==============
|
||||
const ACTIVE_DURATION = 180000;
|
||||
const BASE_INTERVAL = 5000;
|
||||
const BACKOFF_INTERVALS = [
|
||||
10000,
|
||||
30000,
|
||||
60000,
|
||||
120000,
|
||||
300000
|
||||
];
|
||||
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() {
|
||||
@@ -378,7 +298,6 @@
|
||||
new Date().getTimezoneOffset(),
|
||||
Math.random().toString(36).substring(2, 8)
|
||||
].join('|');
|
||||
|
||||
id = 'web-' + hashString(seed).substring(0, 12);
|
||||
localStorage.setItem('klubhaus_client_id', id);
|
||||
}
|
||||
@@ -396,69 +315,85 @@
|
||||
|
||||
const CLIENT_ID = getOrCreateClientId();
|
||||
|
||||
// ============== METRICS ==============
|
||||
function loadMetrics() {
|
||||
const saved = localStorage.getItem('klubhaus_metrics');
|
||||
// ============== 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);
|
||||
return {
|
||||
totalPosted: parsed.totalPosted || 0,
|
||||
totalConfirmed: parsed.totalConfirmed || 0,
|
||||
totalReceived: parsed.totalReceived || 0,
|
||||
totalErrors: parsed.totalErrors || 0,
|
||||
roundTripMs: parsed.roundTripMs || [],
|
||||
sessionStart: parsed.sessionStart || Date.now()
|
||||
};
|
||||
counters.sent = parsed.sent || 0;
|
||||
counters.success = parsed.success || 0;
|
||||
counters.confirmed = parsed.confirmed || 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to load metrics:', 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 {
|
||||
totalPosted: 0,
|
||||
totalConfirmed: 0,
|
||||
totalReceived: 0,
|
||||
totalErrors: 0,
|
||||
roundTripMs: [],
|
||||
sessionStart: Date.now()
|
||||
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 saveMetrics() {
|
||||
localStorage.setItem('klubhaus_metrics', JSON.stringify(metrics));
|
||||
function generateMessageId() {
|
||||
return CLIENT_ID + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 6);
|
||||
}
|
||||
|
||||
const metrics = loadMetrics();
|
||||
|
||||
// ============== BACKOFF STATE ==============
|
||||
let lastInteraction = Date.now();
|
||||
let currentInterval = BASE_INTERVAL;
|
||||
let ws = null;
|
||||
let isProcessing = false;
|
||||
let lastConfirmedMessage = '';
|
||||
let metricsIntervalId = null;
|
||||
let metricsTimerId = null;
|
||||
let metricsNextScheduledTime = 0;
|
||||
|
||||
function recordInteraction() {
|
||||
const wasIdle = (Date.now() - lastInteraction) >= ACTIVE_DURATION;
|
||||
lastInteraction = Date.now();
|
||||
currentInterval = BASE_INTERVAL;
|
||||
|
||||
if (wasIdle) {
|
||||
console.log('[BACKOFF] Reset to active mode (interaction detected)');
|
||||
rescheduleMetrics(); // Immediate reschedule to faster interval
|
||||
}
|
||||
|
||||
updateBackoffDisplay();
|
||||
console.log('Interaction recorded, speed: 100%');
|
||||
}
|
||||
|
||||
function getCurrentInterval() {
|
||||
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;
|
||||
|
||||
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];
|
||||
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() {
|
||||
@@ -468,31 +403,29 @@
|
||||
|
||||
if (idleTime < ACTIVE_DURATION) {
|
||||
const remaining = Math.ceil((ACTIVE_DURATION - idleTime) / 1000);
|
||||
el.textContent = `Active mode: ${remaining}s remaining`;
|
||||
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}min | Poll: ${interval/1000}s`;
|
||||
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');
|
||||
const interval = getCurrentInterval();
|
||||
|
||||
connEl.className = 'connecting';
|
||||
connEl.textContent = `WebSocket: connecting (poll: ${interval/1000}s)...`;
|
||||
connEl.textContent = 'WebSocket: connecting...';
|
||||
|
||||
ws = new WebSocket(STATUS_WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
connEl.className = 'connected';
|
||||
connEl.textContent = 'WebSocket: connected';
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -504,27 +437,23 @@
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
ws.onerror = () => {
|
||||
connEl.className = 'error';
|
||||
connEl.textContent = 'WebSocket: error';
|
||||
console.error('WebSocket error:', err);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connEl.className = 'backoff';
|
||||
const nextInterval = getCurrentInterval();
|
||||
connEl.textContent = `WebSocket: reconnect in ${nextInterval/1000}s...`;
|
||||
console.log(`WebSocket closed, reconnecting in ${nextInterval}ms...`);
|
||||
setTimeout(connectWebSocket, nextInterval);
|
||||
connEl.textContent = 'WebSocket: reconnecting...';
|
||||
setTimeout(connectWebSocket, getCurrentInterval());
|
||||
};
|
||||
}
|
||||
|
||||
function handleStatusMessage(data) {
|
||||
if (data.event === 'open') {
|
||||
console.log('Subscription confirmed');
|
||||
return;
|
||||
}
|
||||
// ============== MESSAGE LIFECYCLE ==============
|
||||
let lastSentMessage = '';
|
||||
|
||||
function handleStatusMessage(data) {
|
||||
if (data.event === 'open') return;
|
||||
if (data.event !== 'message' || !data.message) return;
|
||||
|
||||
let payload;
|
||||
@@ -535,220 +464,140 @@
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.totalReceived++;
|
||||
saveMetrics();
|
||||
const deviceState = payload.state;
|
||||
const deviceMessage = payload.message || '';
|
||||
|
||||
updateStatusDisplay(payload);
|
||||
updateMetricsDisplay();
|
||||
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);
|
||||
|
||||
if (isProcessing && payload.state === 'ALERTING' && payload.message === lastConfirmedMessage) {
|
||||
finishConfirmation(true, payload.message);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusDisplay(data) {
|
||||
const statusEl = document.getElementById('status');
|
||||
const state = data.state || 'UNKNOWN';
|
||||
const message = data.message || '';
|
||||
|
||||
if (isProcessing && state === 'ALERTING') return;
|
||||
|
||||
if (state === 'ALERTING') {
|
||||
statusEl.className = 'alerting';
|
||||
statusEl.innerHTML = `<span>ALERTING</span><span class="status-detail">${escapeHtml(message)}</span>`;
|
||||
} else if (state === 'SILENT') {
|
||||
statusEl.className = 'silent';
|
||||
statusEl.innerHTML = `<span>SILENT</span><span class="status-detail">System Ready</span>`;
|
||||
} else if (state === 'CONFIG') {
|
||||
statusEl.className = 'confirming';
|
||||
statusEl.innerHTML = `<span>CONFIG</span><span class="status-detail">${escapeHtml(message)}</span>`;
|
||||
setTimeout(() => updateStatusDisplay({state: 'SILENT'}), 2000);
|
||||
} else {
|
||||
statusEl.className = 'offline';
|
||||
statusEl.innerHTML = `<span>UNKNOWN</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ============== ALERT SENDING ==============
|
||||
async function sendAlert(message) {
|
||||
recordInteraction();
|
||||
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
lastConfirmedMessage = message;
|
||||
lastSentMessage = message;
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const startTime = performance.now();
|
||||
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">Posting...</span>`;
|
||||
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' }
|
||||
headers: {
|
||||
'Title': 'KLUBHAUS ALERT',
|
||||
'X-Klubhaus-Id': msgId
|
||||
}
|
||||
});
|
||||
|
||||
metrics.totalPosted++;
|
||||
saveMetrics();
|
||||
|
||||
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 waitForWebSocketConfirmation(message, 8000);
|
||||
const roundTripMs = Math.round(performance.now() - startTime);
|
||||
|
||||
if (confirmed) {
|
||||
finishConfirmation(true, message, roundTripMs);
|
||||
} else {
|
||||
statusEl.innerHTML = `<span>CHECKING</span><span class="status-detail">Fallback...</span>`;
|
||||
const fallback = await fallbackPollForConfirmation(message, 3000);
|
||||
finishConfirmation(fallback, message, roundTripMs);
|
||||
const confirmed = await waitForConfirmation(message, 10000);
|
||||
if (!confirmed) {
|
||||
finishConfirmation(false, message, 0);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
metrics.totalErrors++;
|
||||
saveMetrics();
|
||||
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 waitForWebSocketConfirmation(expectedMessage, timeoutMs) {
|
||||
return new Promise(resolve => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const check = setInterval(() => {
|
||||
if (!isProcessing || Date.now() > deadline) {
|
||||
clearInterval(check);
|
||||
resolve(!isProcessing);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async function fallbackPollForConfirmation(expectedMessage, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch('https://ntfy.sh/STATUS_klubhaus_topic/json', {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
});
|
||||
if (!response.ok) continue;
|
||||
|
||||
const text = await response.text();
|
||||
const lines = text.split('\n').filter(l => l.trim());
|
||||
if (lines.length === 0) continue;
|
||||
|
||||
const data = JSON.parse(lines[0]);
|
||||
if (data.state === 'ALERTING' && data.message === expectedMessage) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
await sleep(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function finishConfirmation(success, message, roundTripMs = 0) {
|
||||
isProcessing = false;
|
||||
setButtonsDisabled(false);
|
||||
|
||||
if (success) {
|
||||
metrics.totalConfirmed++;
|
||||
if (roundTripMs > 0) {
|
||||
metrics.roundTripMs.push(roundTripMs);
|
||||
if (metrics.roundTripMs.length > 10) metrics.roundTripMs.shift();
|
||||
}
|
||||
saveMetrics();
|
||||
showToast(`ALERT: ${message}${roundTripMs > 0 ? ' (' + roundTripMs + 'ms)' : ''}`);
|
||||
publishSuccessMetric(message, roundTripMs, true);
|
||||
} else {
|
||||
showToast('POSTED — UNCONFIRMED', true);
|
||||
publishSuccessMetric(message, roundTripMs, false);
|
||||
}
|
||||
|
||||
updateMetricsDisplay();
|
||||
}
|
||||
|
||||
async function publishSuccessMetric(message, roundTripMs, confirmed) {
|
||||
const payload = {
|
||||
client: CLIENT_ID,
|
||||
timestamp: Date.now(),
|
||||
event: 'publish_success',
|
||||
message: message.substring(0, 20),
|
||||
roundTripMs: roundTripMs,
|
||||
confirmed: confirmed
|
||||
};
|
||||
|
||||
try {
|
||||
await fetch(METRICS_POST_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (e) {}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// ============== ADMIN COMMANDS ==============
|
||||
function toggleAdmin() {
|
||||
const panel = document.getElementById('adminPanel');
|
||||
panel.classList.toggle('visible');
|
||||
}
|
||||
|
||||
async function sendAdmin(command) {
|
||||
recordInteraction();
|
||||
|
||||
try {
|
||||
const response = await fetch(ADMIN_POST_URL, {
|
||||
method: 'POST',
|
||||
body: command,
|
||||
headers: { 'Title': 'KLUBHAUS ADMIN' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast(`ADMIN: ${command}`);
|
||||
} else {
|
||||
showToast('ADMIN FAILED', true);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('NETWORK ERROR', true);
|
||||
function finishConfirmation(success, message, roundTripMs) {
|
||||
isProcessing = false;
|
||||
setButtonsDisabled(false);
|
||||
if (success) {
|
||||
showToast(`CONFIRMED: ${message} (${roundTripMs}ms)`);
|
||||
} else {
|
||||
showToast('SENT — TIMEOUT', true);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
alert('Admin Commands:\n\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');
|
||||
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;
|
||||
@@ -757,14 +606,12 @@
|
||||
showToast('TOO LONG — MAX 60', true);
|
||||
return;
|
||||
}
|
||||
|
||||
sendAlert(message);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function sendSilence() {
|
||||
recordInteraction();
|
||||
|
||||
if (isProcessing) return;
|
||||
isProcessing = true;
|
||||
|
||||
@@ -787,13 +634,11 @@
|
||||
})
|
||||
]);
|
||||
|
||||
metrics.totalPosted += 2;
|
||||
saveMetrics();
|
||||
counters.sent += 2;
|
||||
saveCounters();
|
||||
showToast('SILENCE SENT');
|
||||
|
||||
} catch (e) {
|
||||
metrics.totalErrors++;
|
||||
saveMetrics();
|
||||
showToast('SILENCE FAILED', true);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
@@ -817,87 +662,141 @@
|
||||
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');
|
||||
const avgRtt = metrics.roundTripMs.length > 0
|
||||
? Math.round(metrics.roundTripMs.reduce((a,b) => a+b, 0) / metrics.roundTripMs.length)
|
||||
: 0;
|
||||
const sessionHours = ((Date.now() - metrics.sessionStart) / 3600000).toFixed(1);
|
||||
el.textContent = `${CLIENT_ID} | posted:${metrics.totalPosted} confirmed:${metrics.totalConfirmed} errors:${metrics.totalErrors} avg:${avgRtt}ms | session:${sessionHours}h`;
|
||||
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`;
|
||||
}
|
||||
|
||||
// ============== AGGREGATE METRICS ==============
|
||||
// ============== METRICS PUBLISH WITH CORRECT BACKOFF ==============
|
||||
let lastPrometheusHash = null;
|
||||
let lastPrometheusTime = 0;
|
||||
|
||||
let lastMetricsHash = null;
|
||||
let lastMetricsTimestamp = 0;
|
||||
function buildPrometheusPayload() {
|
||||
const successStats = getLatencyStats('success');
|
||||
const confirmStats = getLatencyStats('confirmed');
|
||||
const now = Date.now();
|
||||
|
||||
function hashMetricsPayload(payload) {
|
||||
const canonical = JSON.stringify({
|
||||
client: payload.client,
|
||||
totalPosted: payload.metrics.totalPosted,
|
||||
totalConfirmed: payload.metrics.totalConfirmed,
|
||||
totalReceived: payload.metrics.totalReceived,
|
||||
totalErrors: payload.metrics.totalErrors,
|
||||
avgRoundTripMs: payload.metrics.avgRoundTripMs
|
||||
});
|
||||
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}`,
|
||||
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < canonical.length; i++) {
|
||||
hash ^= canonical.charCodeAt(i);
|
||||
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
`# 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 >>> 0).toString(16);
|
||||
return hash.toString(16);
|
||||
}
|
||||
|
||||
function scheduleMetricsPublish() {
|
||||
if (metricsIntervalId) clearInterval(metricsIntervalId);
|
||||
|
||||
// CRITICAL FIX: Single timer, rescheduled after each execution
|
||||
function scheduleMetrics() {
|
||||
const now = Date.now();
|
||||
const interval = getCurrentInterval();
|
||||
console.log(`Scheduling metrics publish: ${interval}ms`);
|
||||
|
||||
metricsIntervalId = setInterval(async () => {
|
||||
const payload = {
|
||||
client: CLIENT_ID,
|
||||
timestamp: Date.now(),
|
||||
metrics: {
|
||||
totalPosted: metrics.totalPosted,
|
||||
totalConfirmed: metrics.totalConfirmed,
|
||||
totalReceived: metrics.totalReceived,
|
||||
totalErrors: metrics.totalErrors,
|
||||
avgRoundTripMs: metrics.roundTripMs.length > 0
|
||||
? Math.round(metrics.roundTripMs.reduce((a,b) => a+b, 0) / metrics.roundTripMs.length)
|
||||
: 0,
|
||||
currentPollInterval: getCurrentInterval()
|
||||
}
|
||||
};
|
||||
// Clear any existing timer
|
||||
if (metricsTimerId) {
|
||||
clearTimeout(metricsTimerId);
|
||||
metricsTimerId = null;
|
||||
}
|
||||
|
||||
const currentHash = hashMetricsPayload(payload);
|
||||
const now = Date.now();
|
||||
metricsNextScheduledTime = now + interval;
|
||||
|
||||
if (currentHash === lastMetricsHash && (now - lastMetricsTimestamp) < 300000) {
|
||||
console.log('Metrics unchanged, skipping emission');
|
||||
} else {
|
||||
try {
|
||||
await fetch(METRICS_POST_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
console.log('Aggregate metrics published');
|
||||
|
||||
lastMetricsHash = currentHash;
|
||||
lastMetricsTimestamp = now;
|
||||
|
||||
} catch (e) {
|
||||
console.error('Metrics publish failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleMetricsPublish();
|
||||
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();
|
||||
@@ -907,14 +806,15 @@
|
||||
document.addEventListener('touchstart', recordInteraction);
|
||||
document.addEventListener('keydown', recordInteraction);
|
||||
|
||||
loadCounters();
|
||||
connectWebSocket();
|
||||
scheduleMetricsPublish();
|
||||
scheduleMetrics(); // Start metrics loop
|
||||
updateMetricsDisplay();
|
||||
updateBackoffDisplay();
|
||||
|
||||
console.log('KLUBHAUS ALERT v4.3 initialized');
|
||||
console.log('KLUBHAUS ALERT v4.4 — Metrics with correct backoff');
|
||||
console.log('Client ID:', CLIENT_ID);
|
||||
console.log('Loaded metrics:', metrics);
|
||||
console.log('Initial counters:', counters);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user