super_ball/web/thrower.html

1654 lines
59 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Superball Thrower</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #ffffff;
min-height: 100vh;
color: #000000;
}
.section {
margin: 20px 0;
border: 1px solid #ddd;
padding: 15px;
border-radius: 5px;
}
.section h2 {
margin: 0 0 15px 0;
font-size: 18px;
}
input, textarea, button, select {
width: 100%;
padding: 8px;
margin: 5px 0;
border: 1px solid #ccc;
font-family: inherit;
border-radius: 3px;
}
button {
background: #f0f0f0;
cursor: pointer;
width: auto;
padding: 10px 20px;
}
button:hover {
background: #e0e0e0;
}
.button-primary {
background: #007bff;
color: white;
}
.button-primary:hover {
background: #0056b3;
}
.button-danger {
background: #dc3545;
color: white;
}
.button-danger:hover {
background: #c82333;
}
.input-group {
margin: 10px 0;
}
label {
display: block;
font-weight: bold;
margin-bottom: 3px;
}
.relay-item {
display: flex;
align-items: center;
gap: 10px;
margin: 5px 0;
padding: 8px;
border: 1px solid #eee;
border-radius: 3px;
background: #f9f9f9;
}
.relay-url {
flex: 1;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.relay-type {
min-width: 80px;
font-size: 12px;
color: #666;
text-align: center;
}
.relay-actions {
display: flex;
gap: 5px;
}
.relay-actions button {
padding: 4px 8px;
font-size: 12px;
width: auto;
}
.hidden {
display: none;
}
.status-message {
padding: 10px;
margin: 10px 0;
border-radius: 3px;
}
.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.add-relay-form {
display: flex;
gap: 10px;
align-items: end;
}
.add-relay-form input {
flex: 1;
}
.add-relay-form select {
width: 100px;
}
.add-relay-form button {
width: auto;
margin: 5px 0;
}
.pubkey-display {
font-family: monospace;
font-size: 12px;
word-break: break-all;
background: #f8f9fa;
padding: 5px;
border-radius: 3px;
}
.action-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.daemon-control {
margin: 15px 0;
}
#daemon-toggle {
font-size: 16px;
padding: 12px 24px;
margin-bottom: 15px;
border-radius: 5px;
}
#daemon-toggle.running {
background: #dc3545;
}
#daemon-toggle.running:hover {
background: #c82333;
}
#daemon-status {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
background: #f8f9fa;
padding: 10px;
border-radius: 3px;
font-size: 14px;
}
.event-queue-item {
background: #fff3cd;
border: 1px solid #ffeaa7;
padding: 10px;
margin: 5px 0;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
}
.event-queue-item.processing {
background: #d1ecf1;
border-color: #bee5eb;
}
.log-entry {
padding: 8px;
margin: 2px 0;
border-left: 3px solid #ddd;
background: #f8f9fa;
font-family: monospace;
font-size: 12px;
}
.log-entry.success {
border-left-color: #28a745;
background: #d4edda;
}
.log-entry.error {
border-left-color: #dc3545;
background: #f8d7da;
}
.log-entry.info {
border-left-color: #17a2b8;
background: #d1ecf1;
}
.log-timestamp {
color: #666;
font-weight: bold;
}
#processing-log {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 3px;
}
</style>
</head>
<body>
<div id="login-section">
<!-- Login UI if needed -->
</div>
<div id="main-content" class="hidden">
<h1>Superball Thrower Setup</h1>
<!-- Thrower Identity & Information Document Section (SUP-06) -->
<div class="section">
<h2>Thrower Identity & Information Document (SUP-06)</h2>
<p>Configure your Thrower's identity and public information document. This will be published as a kind 12222 event and automatically refreshed when the daemon is running.</p>
<div id="thrower-info-display">
<img id="thrower-banner" alt="Thrower Banner" style="display: none; width: 100%; max-height: 200px; object-fit: cover; border-radius: 5px; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 15px; margin-bottom: 15px;">
<img id="thrower-icon" alt="Thrower Icon" style="display: none; width: 60px; height: 60px; border-radius: 50%; object-fit: cover;">
<div style="flex: 1;">
<div><strong>Thrower Name:</strong> <span id="thrower-display-name">Loading...</span></div>
<div><strong>Description:</strong> <span id="thrower-display-description">Loading...</span></div>
</div>
</div>
<div><strong>Pubkey:</strong></div>
<div class="pubkey-display" id="profile-pubkey"></div>
<div><strong>Status:</strong> <span id="thrower-info-status">Loading...</span></div>
<div><strong>Last Updated:</strong> <span id="thrower-info-updated">Never</span></div>
<div><strong>Refresh Rate:</strong> <span id="thrower-info-refresh">60 seconds</span></div>
<div class="action-buttons">
<button onclick="toggleEditThrowerInfo()">Edit Thrower Info</button>
</div>
</div>
<div id="thrower-info-edit" class="hidden">
<div class="input-group">
<label for="edit-thrower-name">Thrower Name:</label>
<input type="text" id="edit-thrower-name" placeholder="My Superball Thrower">
</div>
<div class="input-group">
<label for="edit-thrower-description">Description:</label>
<textarea id="edit-thrower-description" rows="3" placeholder="Description of this Thrower's services and capabilities"></textarea>
</div>
<div class="input-group">
<label for="edit-thrower-banner">Banner URL (optional):</label>
<input type="url" id="edit-thrower-banner" placeholder="https://example.com/banner.jpg">
</div>
<div class="input-group">
<label for="edit-thrower-icon">Icon URL (optional):</label>
<input type="url" id="edit-thrower-icon" placeholder="https://example.com/icon.png">
</div>
<div class="input-group">
<label for="edit-admin-pubkey">Admin Contact Pubkey (optional):</label>
<input type="text" id="edit-admin-pubkey" placeholder="npub... or hex pubkey">
</div>
<div class="input-group">
<label for="edit-admin-contact">Alternate Contact (optional):</label>
<input type="text" id="edit-admin-contact" placeholder="email@example.com or other contact">
</div>
<div class="input-group">
<label for="edit-supported-sups">Supported SUPs:</label>
<input type="text" id="edit-supported-sups" placeholder="1,2,3,4,5,6" value="1,2,3,4,5,6">
</div>
<div class="input-group">
<label for="edit-software-url">Software URL:</label>
<input type="url" id="edit-software-url" placeholder="https://git.laantungir.net/laantungir/super_ball.git" value="https://git.laantungir.net/laantungir/super_ball.git">
</div>
<div class="input-group">
<label for="edit-version">Version:</label>
<input type="text" id="edit-version" placeholder="1.0.0" value="1.0.0">
</div>
<div class="input-group">
<label for="edit-privacy-policy">Privacy Policy URL (optional):</label>
<input type="url" id="edit-privacy-policy" placeholder="https://example.com/privacy.txt">
</div>
<div class="input-group">
<label for="edit-terms-service">Terms of Service URL (optional):</label>
<input type="url" id="edit-terms-service" placeholder="https://example.com/terms.txt">
</div>
<div class="input-group">
<label for="edit-refresh-rate">Refresh Rate (seconds):</label>
<input type="number" id="edit-refresh-rate" placeholder="60" value="60" min="10" max="3600">
</div>
<div class="input-group">
<label for="edit-thrower-content">Additional Content (optional):</label>
<textarea id="edit-thrower-content" rows="2" placeholder="Any additional information about this Thrower"></textarea>
</div>
<div class="action-buttons">
<button class="button-primary" onclick="saveThrowerInfo()">Save Thrower Info</button>
<button onclick="cancelEditThrowerInfo()">Cancel</button>
</div>
</div>
<div id="thrower-info-status-display"></div>
</div>
<!-- Relay Management Section -->
<div class="section">
<h2>Relay Configuration</h2>
<p>Configure which relays this Superball node will monitor for routing events. Follow NIP-65 standards.</p>
<div class="input-group">
<label>Add New Relay:</label>
<div class="add-relay-form">
<input type="url" id="new-relay-url" placeholder="wss://relay.example.com">
<select id="new-relay-type">
<option value="">Both</option>
<option value="read">Read</option>
<option value="write">Write</option>
</select>
<button class="button-primary" onclick="addRelay()">Add</button>
</div>
</div>
<div id="relay-list">
<!-- Relay items will be populated here -->
</div>
<div class="action-buttons">
<button class="button-primary" onclick="saveRelayList()">Save Relay Configuration</button>
<button onclick="loadRelayList()">Reload from Network</button>
</div>
<div id="relay-status"></div>
</div>
<!-- Superball Daemon Control Section -->
<div class="section">
<h2>Superball Daemon</h2>
<div class="daemon-control">
<button id="daemon-toggle" class="button-primary" onclick="toggleDaemon()">
<span id="daemon-button-text">Start Daemon</span>
</button>
<div id="daemon-status">
<div><strong>Status:</strong> <span id="daemon-status-text">Stopped</span></div>
<div><strong>Monitoring Relays:</strong> <span id="monitoring-relays">0</span></div>
<div><strong>Events Processed:</strong> <span id="events-processed">0</span></div>
<div><strong>Events in Queue:</strong> <span id="events-queued">0</span></div>
</div>
</div>
</div>
<!-- Event Queue Section -->
<div class="section">
<h2>Event Queue</h2>
<div id="event-queue">
<div class="info status-message">No events in queue</div>
</div>
</div>
<!-- Processing Log Section -->
<div class="section">
<h2>Processing Log</h2>
<div id="processing-log">
<div class="info status-message">Daemon stopped - no activity</div>
</div>
</div>
</div>
<!-- Load the official nostr-tools bundle first -->
<script src="./nostr.bundle.js"></script>
<!-- Load NOSTR_LOGIN_LITE main library -->
<script src="./nostr-lite.js"></script>
<script>
// Global variables
let nlLite = null;
let userPubkey = null;
let relayUrl = 'wss://relay.laantungir.net';
let currentProfile = {};
let currentRelays = [];
let currentThrowerInfo = {};
// Daemon variables
let daemonRunning = false;
let monitoringWebSockets = [];
let eventQueue = [];
let processedEvents = 0;
let logEntries = [];
let subscriptionId = null;
// SUP-06 variables
let throwerInfoRefreshInterval = null;
let lastThrowerInfoPublish = null;
// Initialize NOSTR_LOGIN_LITE
async function initializeApp() {
try {
await window.NOSTR_LOGIN_LITE.init({
theme: 'default',
darkMode: false,
methods: {
extension: true,
local: true,
seedphrase: true,
connect: true,
remote: true,
otp: true
},
floatingTab: {
enabled: true,
hPosition: .98,
vPosition: 0,
getUserInfo: true,
getUserRelay: ['wss://relay.laantungir.net'],
appearance: {
style: 'minimal',
theme: 'auto',
icon: '',
text: 'Login',
iconOnly: false
},
behavior: {
hideWhenAuthenticated: false,
showUserInfo: true,
autoSlide: false,
persistent: false
},
animation: {
slideDirection: 'right'
}
},
debug: true
});
nlLite = window.NOSTR_LOGIN_LITE;
console.log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
window.addEventListener('nlMethodSelected', handleAuthEvent);
} catch (error) {
console.log('ERROR', `Initialization failed: ${error.message}`);
}
}
function handleAuthEvent(event) {
const { pubkey, method, error } = event.detail;
console.log('INFO', `Auth event received: method=${method}`);
if (method && pubkey) {
userPubkey = pubkey;
console.log('SUCCESS', `Login successful! Method: ${method}, Pubkey: ${pubkey}`);
document.getElementById('main-content').classList.remove('hidden');
document.getElementById('profile-pubkey').textContent = pubkey;
loadRelayList();
loadThrowerInfo();
// Update profile display with data from nostr-lite.js
setTimeout(updateProfileDisplay, 1000);
} else if (error) {
console.log('ERROR', `Authentication error: ${error}`);
}
}
async function updateProfileDisplay() {
if (!userPubkey) return;
console.log('INFO', `Loading kind 12222 Thrower Information Document for: ${userPubkey}`);
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [12222],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
if (events.length > 0) {
const throwerInfo = events[0];
console.log('SUCCESS', 'Kind 12222 Thrower Information Document received');
// Extract information from tags
let name = '';
let description = '';
let banner = '';
let icon = '';
let software = '';
throwerInfo.tags.forEach(tag => {
switch(tag[0]) {
case 'name':
name = tag[1] || '';
break;
case 'description':
description = tag[1] || '';
break;
case 'banner':
banner = tag[1] || '';
break;
case 'icon':
icon = tag[1] || '';
break;
case 'software':
software = tag[1] || '';
break;
}
});
// Update display elements (using correct IDs from HTML)
const nameElement = document.getElementById('thrower-display-name');
const descElement = document.getElementById('thrower-display-description');
if (nameElement) nameElement.textContent = name || 'Unnamed Thrower';
if (descElement) descElement.textContent = description || 'No description available';
// Update icon
const iconImg = document.getElementById('thrower-icon');
if (icon && iconImg) {
iconImg.src = icon;
iconImg.style.display = 'block';
}
// Update banner
const bannerImg = document.getElementById('thrower-banner');
if (banner && bannerImg) {
bannerImg.src = banner;
bannerImg.style.display = 'block';
}
console.log('SUCCESS', 'Profile display updated with kind 12222 data');
} else {
console.log('INFO', 'No kind 12222 Thrower Information Document found, using defaults');
const nameElement = document.getElementById('thrower-display-name');
const descElement = document.getElementById('thrower-display-description');
if (nameElement) nameElement.textContent = 'Unnamed Thrower';
if (descElement) descElement.textContent = 'No description available';
}
} catch (error) {
console.log('ERROR', `Failed to load kind 12222: ${error.message}`);
const nameElement = document.getElementById('thrower-display-name');
const descElement = document.getElementById('thrower-display-description');
if (nameElement) nameElement.textContent = 'Error loading info';
if (descElement) descElement.textContent = 'Failed to load thrower information';
}
}
// Load relay list (NIP-65)
async function loadRelayList() {
if (!userPubkey) return;
console.log('INFO', `Loading relay list for: ${userPubkey}`);
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [10002],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
currentRelays = [];
if (events.length > 0) {
console.log('SUCCESS', 'Relay list event received');
const relayTags = events[0].tags.filter(tag => tag[0] === 'r');
currentRelays = relayTags.map(tag => ({
url: tag[1],
type: tag[2] || ''
}));
} else {
console.log('INFO', 'No relay list found, using defaults');
currentRelays = [
{ url: 'wss://relay.laantungir.net', type: '' }
];
}
displayRelayList();
} catch (error) {
console.log('ERROR', `Relay list loading failed: ${error.message}`);
showStatus('relay-status', 'Error loading relay list: ' + error.message, 'error');
}
}
// Display relay list
function displayRelayList() {
const container = document.getElementById('relay-list');
container.innerHTML = '';
if (currentRelays.length === 0) {
container.innerHTML = '<div class="info status-message">No relays configured. Add relays above.</div>';
return;
}
currentRelays.forEach((relay, index) => {
const relayItem = document.createElement('div');
relayItem.className = 'relay-item';
relayItem.innerHTML = `
<div class="relay-url">${relay.url}</div>
<div class="relay-type">${relay.type || 'both'}</div>
<div class="relay-actions">
<button onclick="removeRelay(${index})">Remove</button>
</div>
`;
container.appendChild(relayItem);
});
}
// Add new relay
function addRelay() {
const url = document.getElementById('new-relay-url').value.trim();
const type = document.getElementById('new-relay-type').value;
if (!url) {
showStatus('relay-status', 'Please enter a relay URL', 'error');
return;
}
if (!url.startsWith('wss://') && !url.startsWith('ws://')) {
showStatus('relay-status', 'Relay URL must start with wss:// or ws://', 'error');
return;
}
// Check for duplicates
if (currentRelays.some(r => r.url === url)) {
showStatus('relay-status', 'Relay already exists', 'error');
return;
}
currentRelays.push({ url, type });
displayRelayList();
// Clear form
document.getElementById('new-relay-url').value = '';
document.getElementById('new-relay-type').value = '';
showStatus('relay-status', 'Relay added (remember to save)', 'info');
}
// Remove relay
function removeRelay(index) {
currentRelays.splice(index, 1);
displayRelayList();
showStatus('relay-status', 'Relay removed (remember to save)', 'info');
}
// Save relay list (NIP-65)
async function saveRelayList() {
if (!userPubkey) return;
try {
const tags = currentRelays.map(relay => {
const tag = ['r', relay.url];
if (relay.type) {
tag.push(relay.type);
}
return tag;
});
const eventTemplate = {
kind: 10002,
content: '',
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
console.log('DEBUG: Relay event template to sign:', JSON.stringify(eventTemplate, null, 2));
const signedEvent = await window.nostr.signEvent(eventTemplate);
console.log('DEBUG: Signed relay event:', JSON.stringify(signedEvent, null, 2));
// Publish to relays using Promise.any as per nostr-tools example
const relaysToUse = [...new Set([relayUrl, 'wss://relay.laantungir.net'])]; // Remove duplicates
console.log('DEBUG: Publishing relay list to relays:', relaysToUse);
const pool = new window.NostrTools.SimplePool();
try {
// Use Promise.any() to publish to all relays - succeeds if ANY relay accepts
console.log('DEBUG: Using Promise.any() to publish relay list to all relays simultaneously');
await Promise.any(pool.publish(relaysToUse, signedEvent));
pool.close(relaysToUse);
showStatus('relay-status', 'Relay configuration saved successfully!', 'success');
console.log('SUCCESS', 'Relay list published to at least one relay');
} catch (aggregateError) {
pool.close(relaysToUse);
console.log('ERROR: All relay list publish attempts failed:', aggregateError.errors);
// Extract individual error messages
const errorMessages = aggregateError.errors.map((err, index) =>
`${relaysToUse[index]}: ${err.message}`
).join(', ');
throw new Error('Failed to publish relay list to any relay: ' + errorMessages);
}
} catch (error) {
console.log('ERROR', `Failed to save relay list: ${error.message}`);
showStatus('relay-status', 'Failed to save relay list: ' + error.message, 'error');
}
}
// Show status message
function showStatus(containerId, message, type) {
const container = document.getElementById(containerId);
container.innerHTML = `<div class="status-message ${type}">${message}</div>`;
// Auto-clear after 5 seconds for non-error messages
if (type !== 'error') {
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
}
// ============ SUP-06 THROWER INFORMATION DOCUMENT FUNCTIONALITY ============
// Load Thrower Information Document (kind 12222)
async function loadThrowerInfo() {
if (!userPubkey) return;
console.log('INFO', `Loading Thrower Information Document for: ${userPubkey}`);
document.getElementById('thrower-info-status').textContent = 'Loading...';
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [12222],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
if (events.length > 0) {
console.log('SUCCESS', 'Thrower Info Document event received');
const event = events[0];
// Parse tags into thrower info object
currentThrowerInfo = {
name: '',
description: '',
banner: '',
icon: '',
adminPubkey: '',
contact: '',
supportedSups: '1,2,3,4,5,6',
software: 'https://github.com/superball/thrower',
version: '1.0.0',
privacyPolicy: '',
termsOfService: '',
refreshRate: 60,
content: event.content || ''
};
// Parse tags
event.tags.forEach(tag => {
if (tag[0] === 'name') currentThrowerInfo.name = tag[1] || '';
else if (tag[0] === 'description') currentThrowerInfo.description = tag[1] || '';
else if (tag[0] === 'banner') currentThrowerInfo.banner = tag[1] || '';
else if (tag[0] === 'icon') currentThrowerInfo.icon = tag[1] || '';
else if (tag[0] === 'pubkey') currentThrowerInfo.adminPubkey = tag[1] || '';
else if (tag[0] === 'contact') currentThrowerInfo.contact = tag[1] || '';
else if (tag[0] === 'supported_sups') currentThrowerInfo.supportedSups = tag[1] || '1,2,3,4,5,6';
else if (tag[0] === 'software') currentThrowerInfo.software = tag[1] || 'https://github.com/superball/thrower';
else if (tag[0] === 'version') currentThrowerInfo.version = tag[1] || '1.0.0';
else if (tag[0] === 'privacy_policy') currentThrowerInfo.privacyPolicy = tag[1] || '';
else if (tag[0] === 'terms_of_service') currentThrowerInfo.termsOfService = tag[1] || '';
else if (tag[0] === 'refresh_rate') currentThrowerInfo.refreshRate = parseInt(tag[1]) || 60;
});
lastThrowerInfoPublish = event.created_at;
displayThrowerInfo(currentThrowerInfo);
} else {
console.log('INFO', 'No Thrower Info Document found, using defaults');
currentThrowerInfo = {
name: 'My Superball Thrower',
description: 'A privacy-focused Superball Thrower node',
banner: '',
icon: '',
adminPubkey: '',
contact: '',
supportedSups: '1,2,3,4,5,6',
software: 'https://github.com/superball/thrower',
version: '1.0.0',
privacyPolicy: '',
termsOfService: '',
refreshRate: 60,
content: ''
};
displayThrowerInfo(currentThrowerInfo);
}
} catch (error) {
console.log('ERROR', `Thrower Info loading failed: ${error.message}`);
showStatus('thrower-info-status-display', 'Error loading Thrower Info: ' + error.message, 'error');
}
}
// Display thrower info data
function displayThrowerInfo(info) {
document.getElementById('thrower-info-status').textContent = 'Loaded';
document.getElementById('thrower-info-refresh').textContent = `${info.refreshRate} seconds`;
if (lastThrowerInfoPublish) {
const lastUpdate = new Date(lastThrowerInfoPublish * 1000);
document.getElementById('thrower-info-updated').textContent = lastUpdate.toLocaleString();
}
console.log('SUCCESS', `Thrower Info displayed: ${info.name}`);
}
// Toggle thrower info editing
function toggleEditThrowerInfo() {
const display = document.getElementById('thrower-info-display');
const edit = document.getElementById('thrower-info-edit');
display.classList.add('hidden');
edit.classList.remove('hidden');
// Populate edit fields
document.getElementById('edit-thrower-name').value = currentThrowerInfo.name || '';
document.getElementById('edit-thrower-description').value = currentThrowerInfo.description || '';
document.getElementById('edit-thrower-banner').value = currentThrowerInfo.banner || '';
document.getElementById('edit-thrower-icon').value = currentThrowerInfo.icon || '';
document.getElementById('edit-admin-pubkey').value = currentThrowerInfo.adminPubkey || '';
document.getElementById('edit-admin-contact').value = currentThrowerInfo.contact || '';
document.getElementById('edit-supported-sups').value = currentThrowerInfo.supportedSups || '1,2,3,4,5,6';
document.getElementById('edit-software-url').value = currentThrowerInfo.software || 'https://github.com/superball/thrower';
document.getElementById('edit-version').value = currentThrowerInfo.version || '1.0.0';
document.getElementById('edit-privacy-policy').value = currentThrowerInfo.privacyPolicy || '';
document.getElementById('edit-terms-service').value = currentThrowerInfo.termsOfService || '';
document.getElementById('edit-refresh-rate').value = currentThrowerInfo.refreshRate || 60;
document.getElementById('edit-thrower-content').value = currentThrowerInfo.content || '';
}
function cancelEditThrowerInfo() {
document.getElementById('thrower-info-display').classList.remove('hidden');
document.getElementById('thrower-info-edit').classList.add('hidden');
}
// Save Thrower Information Document
async function saveThrowerInfo() {
if (!userPubkey) return;
const name = document.getElementById('edit-thrower-name').value.trim();
const description = document.getElementById('edit-thrower-description').value.trim();
const banner = document.getElementById('edit-thrower-banner').value.trim();
const icon = document.getElementById('edit-thrower-icon').value.trim();
const adminPubkey = document.getElementById('edit-admin-pubkey').value.trim();
const contact = document.getElementById('edit-admin-contact').value.trim();
const supportedSups = document.getElementById('edit-supported-sups').value.trim();
const software = document.getElementById('edit-software-url').value.trim();
const version = document.getElementById('edit-version').value.trim();
const privacyPolicy = document.getElementById('edit-privacy-policy').value.trim();
const termsOfService = document.getElementById('edit-terms-service').value.trim();
const refreshRate = parseInt(document.getElementById('edit-refresh-rate').value) || 60;
const content = document.getElementById('edit-thrower-content').value.trim();
try {
// Build tags array according to SUP-06
const tags = [];
if (name) tags.push(['name', name]);
if (description) tags.push(['description', description]);
if (banner) tags.push(['banner', banner]);
if (icon) tags.push(['icon', icon]);
if (adminPubkey) tags.push(['pubkey', adminPubkey]);
if (contact) tags.push(['contact', contact]);
if (supportedSups) tags.push(['supported_sups', supportedSups]);
if (software) tags.push(['software', software]);
if (version) tags.push(['version', version]);
if (privacyPolicy) tags.push(['privacy_policy', privacyPolicy]);
if (termsOfService) tags.push(['terms_of_service', termsOfService]);
tags.push(['refresh_rate', refreshRate.toString()]);
const eventTemplate = {
kind: 12222,
content: content,
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
console.log('DEBUG: Thrower Info event template to sign:', JSON.stringify(eventTemplate, null, 2));
const signedEvent = await window.nostr.signEvent(eventTemplate);
console.log('DEBUG: Signed Thrower Info event:', JSON.stringify(signedEvent, null, 2));
// Publish to relays
const relaysToUse = [...new Set([relayUrl, 'wss://relay.laantungir.net'])];
console.log('DEBUG: Publishing Thrower Info to relays:', relaysToUse);
const pool = new window.NostrTools.SimplePool();
try {
await Promise.any(pool.publish(relaysToUse, signedEvent));
pool.close(relaysToUse);
// Update current thrower info
currentThrowerInfo = {
name: name || 'My Superball Thrower',
description: description || 'A privacy-focused Superball Thrower node',
banner,
icon,
adminPubkey,
contact,
supportedSups: supportedSups || '1,2,3,4,5,6',
software: software || 'https://github.com/superball/thrower',
version: version || '1.0.0',
privacyPolicy,
termsOfService,
refreshRate,
content
};
lastThrowerInfoPublish = signedEvent.created_at;
displayThrowerInfo(currentThrowerInfo);
cancelEditThrowerInfo();
showStatus('thrower-info-status-display', '✅ Thrower Information Document saved successfully!', 'success');
console.log('SUCCESS', 'Thrower Info published to at least one relay');
} catch (aggregateError) {
pool.close(relaysToUse);
console.log('ERROR: All Thrower Info publish attempts failed:', aggregateError.errors);
const errorMessages = aggregateError.errors.map((err, index) =>
`${relaysToUse[index]}: ${err.message}`
).join(', ');
throw new Error('Failed to publish Thrower Info to any relay: ' + errorMessages);
}
} catch (error) {
console.log('ERROR', `Failed to save Thrower Info: ${error.message}`);
showStatus('thrower-info-status-display', 'Failed to save Thrower Info: ' + error.message, 'error');
}
}
// Auto-republish thrower info when daemon is running
function startThrowerInfoAutoPublish() {
if (!currentThrowerInfo || !currentThrowerInfo.refreshRate) return;
// Clear existing interval
if (throwerInfoRefreshInterval) {
clearInterval(throwerInfoRefreshInterval);
}
// Schedule republishing 10 seconds before refresh rate expires
const intervalMs = Math.max(10000, (currentThrowerInfo.refreshRate - 10) * 1000);
console.log('INFO', `Starting Thrower Info auto-publish every ${intervalMs/1000} seconds`);
throwerInfoRefreshInterval = setInterval(async () => {
if (daemonRunning && currentThrowerInfo.name) {
try {
console.log('INFO', 'Auto-publishing Thrower Information Document...');
await republishThrowerInfo();
showStatus('thrower-info-status-display', '🔄 Thrower Info auto-published', 'info');
} catch (error) {
console.log('ERROR', `Auto-publish failed: ${error.message}`);
}
}
}, intervalMs);
}
function stopThrowerInfoAutoPublish() {
if (throwerInfoRefreshInterval) {
clearInterval(throwerInfoRefreshInterval);
throwerInfoRefreshInterval = null;
console.log('INFO', 'Stopped Thrower Info auto-publish');
}
}
// Republish current thrower info
async function republishThrowerInfo() {
if (!userPubkey || !currentThrowerInfo.name) return;
try {
const tags = [];
if (currentThrowerInfo.name) tags.push(['name', currentThrowerInfo.name]);
if (currentThrowerInfo.description) tags.push(['description', currentThrowerInfo.description]);
if (currentThrowerInfo.banner) tags.push(['banner', currentThrowerInfo.banner]);
if (currentThrowerInfo.icon) tags.push(['icon', currentThrowerInfo.icon]);
if (currentThrowerInfo.adminPubkey) tags.push(['pubkey', currentThrowerInfo.adminPubkey]);
if (currentThrowerInfo.contact) tags.push(['contact', currentThrowerInfo.contact]);
if (currentThrowerInfo.supportedSups) tags.push(['supported_sups', currentThrowerInfo.supportedSups]);
if (currentThrowerInfo.software) tags.push(['software', currentThrowerInfo.software]);
if (currentThrowerInfo.version) tags.push(['version', currentThrowerInfo.version]);
if (currentThrowerInfo.privacyPolicy) tags.push(['privacy_policy', currentThrowerInfo.privacyPolicy]);
if (currentThrowerInfo.termsOfService) tags.push(['terms_of_service', currentThrowerInfo.termsOfService]);
tags.push(['refresh_rate', currentThrowerInfo.refreshRate.toString()]);
const eventTemplate = {
kind: 12222,
content: currentThrowerInfo.content || '',
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
const signedEvent = await window.nostr.signEvent(eventTemplate);
const relaysToUse = [...new Set([relayUrl, 'wss://relay.laantungir.net'])];
const pool = new window.NostrTools.SimplePool();
await Promise.any(pool.publish(relaysToUse, signedEvent));
pool.close(relaysToUse);
lastThrowerInfoPublish = signedEvent.created_at;
document.getElementById('thrower-info-updated').textContent = new Date().toLocaleString();
console.log('SUCCESS', 'Thrower Info auto-republished');
} catch (error) {
console.log('ERROR', `Failed to republish Thrower Info: ${error.message}`);
throw error;
}
}
// ============ SUPERBALL DAEMON FUNCTIONALITY ============
// Toggle daemon on/off
function toggleDaemon() {
if (daemonRunning) {
stopDaemon();
} else {
startDaemon();
}
}
// Start the Superball daemon
async function startDaemon() {
if (!userPubkey || currentRelays.length === 0) {
addLogEntry('error', 'Cannot start daemon: Profile not loaded or no relays configured');
showStatus('relay-status', 'Please configure profile and relays before starting daemon', 'error');
return;
}
try {
addLogEntry('info', 'Starting Superball daemon...');
daemonRunning = true;
updateDaemonUI();
// Get relay URLs for monitoring
const monitoringRelays = currentRelays.map(r => r.url);
addLogEntry('info', `Connecting to ${monitoringRelays.length} relays via WebSocket for ALL kind 22222 events`);
// Generate unique subscription ID
subscriptionId = 'superball_' + Date.now();
// Subscribe to kind 22222 events with p tag matching this node's pubkey and created_at > now
const now = Math.floor(Date.now() / 1000);
const subscriptionFilter = {
kinds: [22222],
'#p': [userPubkey],
since: now
};
addLogEntry('info', `Subscription ID: ${subscriptionId}`);
addLogEntry('info', `Subscription filter: ${JSON.stringify(subscriptionFilter)}`);
// Connect to each relay with direct WebSocket
monitoringRelays.forEach((relayUrl, index) => {
connectToRelay(relayUrl, subscriptionFilter);
});
// Update monitoring count
document.getElementById('monitoring-relays').textContent = monitoringRelays.length;
// Start auto-publishing Thrower Info Document
startThrowerInfoAutoPublish();
} catch (error) {
addLogEntry('error', `Failed to start daemon: ${error.message}`);
daemonRunning = false;
updateDaemonUI();
}
}
// Stop the Superball daemon
function stopDaemon() {
addLogEntry('info', 'Stopping Superball daemon...');
daemonRunning = false;
// Close all WebSocket connections
monitoringWebSockets.forEach((ws, index) => {
if (ws.readyState === WebSocket.OPEN) {
// Send CLOSE message for subscription
const closeMsg = JSON.stringify(['CLOSE', subscriptionId]);
ws.send(closeMsg);
addLogEntry('info', `Sent CLOSE to relay ${index + 1}`);
// Close WebSocket
ws.close();
}
});
monitoringWebSockets = [];
subscriptionId = null;
// Clear event queue
eventQueue = [];
// Stop auto-publishing Thrower Info Document
stopThrowerInfoAutoPublish();
updateDaemonUI();
updateEventQueue();
addLogEntry('success', 'Daemon stopped successfully');
}
// Connect to relay via direct WebSocket
function connectToRelay(relayUrl, subscriptionFilter) {
addLogEntry('info', `Connecting to relay: ${relayUrl}`);
const ws = new WebSocket(relayUrl);
ws.onopen = () => {
addLogEntry('success', `Connected to relay: ${relayUrl}`);
// Send subscription request
const reqMessage = JSON.stringify([
'REQ',
subscriptionId,
subscriptionFilter
]);
addLogEntry('info', `Sending REQ to ${relayUrl}: ${reqMessage}`);
ws.send(reqMessage);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
addLogEntry('info', `Received from ${relayUrl}: ${JSON.stringify(message).substring(0, 200)}...`);
// Handle different message types
if (message[0] === 'EVENT' && message[1] === subscriptionId) {
const nostrEvent = message[2];
addLogEntry('success', `Received EVENT from ${relayUrl}: ${nostrEvent.id.substring(0, 16)}...`);
// Log full event without truncation
addLogEntry('info', `Full received event:\n${JSON.stringify(nostrEvent, null, 2)}`);
handleIncomingEvent(nostrEvent);
} else if (message[0] === 'EOSE' && message[1] === subscriptionId) {
addLogEntry('info', `End of stored events from ${relayUrl}`);
} else if (message[0] === 'NOTICE') {
addLogEntry('info', `Notice from ${relayUrl}: ${message[1]}`);
} else if (message[0] === 'OK') {
addLogEntry('info', `OK response from ${relayUrl}: ${JSON.stringify(message)}`);
}
} catch (error) {
addLogEntry('error', `Error parsing message from ${relayUrl}: ${error.message}`);
}
};
ws.onerror = (error) => {
addLogEntry('error', `WebSocket error with ${relayUrl}: ${error}`);
};
ws.onclose = (event) => {
addLogEntry('info', `Connection closed to ${relayUrl} - Code: ${event.code}, Reason: ${event.reason}`);
};
monitoringWebSockets.push(ws);
}
// Handle incoming routing events
async function handleIncomingEvent(event) {
addLogEntry('info', `Received routing event: ${event.id.substring(0,16)}...`);
try {
// Decrypt the event payload
let decryptedPayload = await decryptRoutingEvent(event);
if (!decryptedPayload) {
addLogEntry('error', `Failed to decrypt event ${event.id.substring(0,16)}...`);
return;
}
addLogEntry('success', `First decryption successful for event ${event.id.substring(0,16)}...`);
// Check payload type according to corrected DAEMON.md protocol
if (decryptedPayload.padding !== undefined) {
addLogEntry('info', `Detected Type 2 (Padding Payload) - discarding padding and performing second decryption`);
// This is a padding layer from previous daemon - discard padding and decrypt again
const innerEvent = decryptedPayload.event;
addLogEntry('info', `Discarding padding: "${decryptedPayload.padding}"`);
// DEBUG: Log the inner event BEFORE attempting second decryption
addLogEntry('info', `INNER EVENT BEFORE SECOND DECRYPTION:`);
addLogEntry('info', ` Inner event ID: ${innerEvent.id}`);
addLogEntry('info', ` Inner event pubkey: ${innerEvent.pubkey}`);
addLogEntry('info', ` Inner event content length: ${innerEvent.content.length}`);
addLogEntry('info', ` Inner event full JSON:\n${JSON.stringify(innerEvent, null, 2)}`);
// Second decryption to get the actual routing instructions that were encrypted for me
// Use the inner event's pubkey, not the outer event's pubkey
decryptedPayload = await decryptRoutingEvent(innerEvent);
if (!decryptedPayload) {
addLogEntry('error', `Failed to decrypt inner event ${innerEvent.id.substring(0,16)}...`);
return;
}
addLogEntry('success', `Second decryption successful - found my original routing instructions from builder`);
} else {
addLogEntry('info', `Detected Type 1 (Routing Payload) - processing routing instructions directly`);
}
// Log the complete decrypted payload without truncation
addLogEntry('info', `Final routing payload:\n${JSON.stringify(decryptedPayload, null, 2)}`);
// Parse routing instructions (these are from the builder, specific to this daemon)
const { event: wrappedEvent, routing } = decryptedPayload;
if (!routing) {
addLogEntry('error', `No routing instructions found in final payload for ${event.id.substring(0,16)}...`);
return;
}
// DEBUG: Log routing decision
addLogEntry('info', `DEBUG routing.p = "${routing.p}" (type: ${typeof routing.p})`);
if (routing.add_padding_bytes) {
addLogEntry('info', `DEBUG add_padding_bytes = ${routing.add_padding_bytes}`);
}
addLogEntry('info', `DEBUG Will ${routing.p ? 'FORWARD to next hop' : 'POST FINAL EVENT'}`);
if (!validateRoutingInstructions(routing)) {
addLogEntry('error', `Invalid routing instructions in event ${event.id.substring(0,16)}...`);
return;
}
// Create queue item
const queueItem = {
id: event.id,
wrappedEvent,
routing,
receivedAt: Date.now(),
processAt: Date.now() + (routing.delay * 1000),
status: 'queued'
};
eventQueue.push(queueItem);
updateEventQueue();
addLogEntry('info', `Event queued for processing in ${routing.delay}s: ${event.id.substring(0,16)}...`);
// Schedule processing
setTimeout(() => processQueuedEvent(queueItem), routing.delay * 1000);
} catch (error) {
addLogEntry('error', `Error processing event ${event.id.substring(0,16)}...: ${error.message}`);
}
}
// Decrypt routing event using NIP-44 via NIP-07 interface
async function decryptRoutingEvent(event) {
try {
// DEBUG: Log full decryption attempt details
addLogEntry('info', `DEBUG DECRYPTION ATTEMPT:`);
addLogEntry('info', ` Event ID: ${event.id}`);
addLogEntry('info', ` Event pubkey (who signed): ${event.pubkey}`);
addLogEntry('info', ` My daemon pubkey: ${userPubkey}`);
addLogEntry('info', ` Content length: ${event.content.length}`);
addLogEntry('info', ` Full event JSON:\n${JSON.stringify(event, null, 2)}`);
// DEBUG: Log exact parameters being passed to NIP-44 decrypt
addLogEntry('info', ` CALLING window.nostr.nip44.decrypt() with:`);
addLogEntry('info', ` pubkey parameter: "${event.pubkey}"`);
addLogEntry('info', ` content parameter: "${event.content}"`);
addLogEntry('info', ` content parameter length: ${event.content.length}`);
addLogEntry('info', ` content parameter type: ${typeof event.content}`);
// Use window.nostr.nip44.decrypt() which handles the private key internally
const decrypted = await window.nostr.nip44.decrypt(event.pubkey, event.content);
addLogEntry('success', ` Decryption successful! Decrypted length: ${decrypted.length}`);
addLogEntry('info', ` Decrypted preview: ${decrypted.substring(0, 100)}...`);
return JSON.parse(decrypted);
} catch (error) {
addLogEntry('error', ` Decryption failed: ${error.message}`);
addLogEntry('error', ` Error details: ${JSON.stringify(error, null, 2)}`);
console.error('Decryption error:', error);
return null;
}
}
// Validate routing instructions
function validateRoutingInstructions(routing) {
if (!routing || typeof routing !== 'object') return false;
if (!Array.isArray(routing.relays) || routing.relays.length === 0) return false;
if (typeof routing.delay !== 'number' || routing.delay < 0) return false;
if (!routing.audit || typeof routing.audit !== 'string') return false;
return true;
}
// Process a queued event according to corrected DAEMON.md protocol
async function processQueuedEvent(queueItem) {
if (!daemonRunning) return;
addLogEntry('info', `Processing event ${queueItem.id.substring(0,16)}...`);
queueItem.status = 'processing';
updateEventQueue();
try {
const { wrappedEvent, routing } = queueItem;
// Check if this is final posting or continued routing
addLogEntry('info', `DEBUG Decision point - routing.p = "${routing.p}" (${typeof routing.p})`);
if (routing.add_padding_bytes) {
addLogEntry('info', `DEBUG add_padding_bytes = ${routing.add_padding_bytes} (will add padding when forwarding)`);
}
addLogEntry('info', `DEBUG Will ${routing.p ? 'FORWARD with padding wrapper' : 'POST FINAL EVENT'}`);
if (routing.p) {
// Continue routing to next Superball with padding-only wrapper
await forwardToNextSuperball(wrappedEvent, routing);
} else {
// Final posting - post the wrapped event directly
await postFinalEvent(wrappedEvent, routing.relays);
}
processedEvents++;
document.getElementById('events-processed').textContent = processedEvents;
// Remove from queue
const index = eventQueue.findIndex(item => item.id === queueItem.id);
if (index !== -1) {
eventQueue.splice(index, 1);
updateEventQueue();
}
addLogEntry('success', `Successfully processed event ${queueItem.id.substring(0,16)}...`);
} catch (error) {
addLogEntry('error', `Failed to process event ${queueItem.id.substring(0,16)}...: ${error.message}`);
// Mark as failed
queueItem.status = 'failed';
updateEventQueue();
}
}
// Legacy padding function - no longer used in corrected protocol
// Padding is now handled during forwarding with add_padding_bytes
function applyPadding(event, paddingInstruction) {
// This function is deprecated in the corrected protocol
// Padding is now generated during forwarding based on routing.add_padding_bytes
addLogEntry('info', 'Legacy padding function called - no action taken (using new protocol)');
return event;
}
// Forward event to next Superball with padding-only wrapper (DAEMON.md corrected protocol)
async function forwardToNextSuperball(event, routing) {
addLogEntry('info', `Forwarding to next Superball: ${routing.p.substring(0,16)}...`);
// Create new ephemeral keypair
const ephemeralKey = window.NostrTools.generateSecretKey();
const ephemeralPubkey = window.NostrTools.getPublicKey(ephemeralKey);
// Generate padding based on add_padding_bytes instruction
let paddingData = '';
if (routing.add_padding_bytes && routing.add_padding_bytes > 0) {
// Create random padding of specified length
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (let i = 0; i < routing.add_padding_bytes; i++) {
paddingData += chars.charAt(Math.floor(Math.random() * chars.length));
}
addLogEntry('info', `Generated ${paddingData.length} bytes of padding`);
}
// Create padding-only payload (NEVER create routing instructions - only builder does that)
const paddingPayload = {
event: event, // This is the still-encrypted inner event
padding: paddingData // Padding to discard
};
addLogEntry('info', `DEBUG Creating padding payload with ${paddingData.length} bytes of padding`);
// Log the complete padding payload before encryption
addLogEntry('info', `Padding payload to encrypt:\n${JSON.stringify(paddingPayload, null, 2)}`);
// Encrypt padding payload to next Superball
let ephemeralKeyHex;
if (window.NostrTools.utils && window.NostrTools.utils.bytesToHex) {
ephemeralKeyHex = window.NostrTools.utils.bytesToHex(ephemeralKey);
} else if (window.NostrTools.bytesToHex) {
ephemeralKeyHex = window.NostrTools.bytesToHex(ephemeralKey);
} else {
// Fallback: convert Uint8Array to hex manually
ephemeralKeyHex = Array.from(ephemeralKey).map(b => b.toString(16).padStart(2, '0')).join('');
}
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
ephemeralKeyHex,
routing.p
);
const encryptedContent = window.NostrTools.nip44.v2.encrypt(
JSON.stringify(paddingPayload),
conversationKey
);
// Create new routing event (forwarding to next hop)
const routingEvent = {
kind: 22222,
content: encryptedContent,
tags: [
['p', routing.p], // Next Superball
['p', routing.audit] // Audit tag (camouflage)
],
created_at: Math.floor(Date.now() / 1000)
};
// Sign with ephemeral key
const signedEvent = window.NostrTools.finalizeEvent(routingEvent, ephemeralKey);
// Log the complete rewrapped event before publishing
addLogEntry('info', `Padding-wrapped event to publish:\n${JSON.stringify(signedEvent, null, 2)}`);
// Publish to specified relays
await publishToRelays(signedEvent, routing.relays);
addLogEntry('success', `Forwarded with padding layer to ${routing.p.substring(0,16)}... (audit: ${routing.audit.substring(0,16)}...)`);
}
// Post final event directly to relays
async function postFinalEvent(event, relays) {
addLogEntry('info', `Posting final event to ${relays.length} relays`);
await publishToRelays(event, relays);
addLogEntry('success', `Final event posted to relays: ${event.id.substring(0,16)}...`);
}
// Publish event to relays
async function publishToRelays(event, relays) {
const pool = new window.NostrTools.SimplePool();
try {
await Promise.any(pool.publish(relays, event));
addLogEntry('success', `Published to relays: ${relays.join(', ')}`);
// Log full published event
addLogEntry('info', `Full published event:\n${JSON.stringify(event, null, 2)}`);
} catch (aggregateError) {
const errorMessages = aggregateError.errors.map((err, index) =>
`${relays[index]}: ${err.message}`
).join(', ');
throw new Error('Failed to publish to any relay: ' + errorMessages);
} finally {
pool.close(relays);
}
}
// Update daemon UI
function updateDaemonUI() {
const button = document.getElementById('daemon-toggle');
const buttonText = document.getElementById('daemon-button-text');
const statusText = document.getElementById('daemon-status-text');
if (daemonRunning) {
button.classList.add('running');
buttonText.textContent = 'Stop Daemon';
statusText.textContent = 'Running';
statusText.style.color = '#28a745';
} else {
button.classList.remove('running');
buttonText.textContent = 'Start Daemon';
statusText.textContent = 'Stopped';
statusText.style.color = '#dc3545';
// Reset counters when stopped
document.getElementById('monitoring-relays').textContent = '0';
document.getElementById('events-queued').textContent = '0';
}
}
// Update event queue display
function updateEventQueue() {
const container = document.getElementById('event-queue');
if (eventQueue.length === 0) {
container.innerHTML = '<div class="info status-message">No events in queue</div>';
document.getElementById('events-queued').textContent = '0';
return;
}
container.innerHTML = '';
document.getElementById('events-queued').textContent = eventQueue.length;
eventQueue.forEach(item => {
const div = document.createElement('div');
div.className = `event-queue-item ${item.status}`;
const timeLeft = Math.max(0, Math.ceil((item.processAt - Date.now()) / 1000));
const statusText = item.status === 'queued' ?
`Queued - Processing in ${timeLeft}s` :
item.status.charAt(0).toUpperCase() + item.status.slice(1);
div.innerHTML = `
<div><strong>Event:</strong> ${item.id.substring(0,32)}...</div>
<div><strong>Status:</strong> ${statusText}</div>
<div><strong>Target Relays:</strong> ${item.routing.relays.length}</div>
<div><strong>Delay:</strong> ${item.routing.delay}s</div>
${item.routing.padding ? `<div><strong>Padding:</strong> ${item.routing.padding}</div>` : ''}
${item.routing.p ? `<div><strong>Next Hop:</strong> ${item.routing.p.substring(0,16)}...</div>` : '<div><strong>Final Posting</strong></div>'}
`;
container.appendChild(div);
});
}
// Add log entry
function addLogEntry(type, message) {
const timestamp = new Date().toLocaleTimeString();
const entry = {
timestamp,
type,
message
};
logEntries.push(entry);
// Keep only last 100 entries
if (logEntries.length > 100) {
logEntries.shift();
}
updateProcessingLog();
}
// Update processing log display
function updateProcessingLog() {
const container = document.getElementById('processing-log');
if (logEntries.length === 0) {
container.innerHTML = '<div class="info status-message">Daemon stopped - no activity</div>';
return;
}
container.innerHTML = '';
// Show most recent entries first
logEntries.slice().reverse().forEach(entry => {
const div = document.createElement('div');
div.className = `log-entry ${entry.type}`;
// Handle multiline messages (like JSON) with proper formatting
const messageContent = entry.message.includes('\n') ?
`<span class="log-timestamp">${entry.timestamp}</span><br><pre style="margin: 5px 0; font-family: monospace; font-size: 11px; white-space: pre-wrap;">${entry.message}</pre>` :
`<span class="log-timestamp">${entry.timestamp}</span> ${entry.message}`;
div.innerHTML = messageContent;
container.appendChild(div);
});
// Auto-scroll to top (most recent)
container.scrollTop = 0;
}
// Update queue timers every second
setInterval(() => {
if (daemonRunning && eventQueue.length > 0) {
updateEventQueue();
}
}, 1000);
document.addEventListener('DOMContentLoaded', () => {
setTimeout(initializeApp, 100);
});
</script>
</body>
</html>