1526 lines
56 KiB
HTML
1526 lines
56 KiB
HTML
<!DOCTYPE html>
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html lang="en" dir="ltr">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>C-Relay-PG</title>
|
|
|
|
<link rel="stylesheet" href="./css/client.css" />
|
|
|
|
<script>
|
|
(function () {
|
|
const savedTheme = localStorage.getItem('theme');
|
|
if (savedTheme === 'dark') {
|
|
document.documentElement.classList.add('dark-mode');
|
|
if (document.body) {
|
|
document.body.classList.add('dark-mode');
|
|
}
|
|
}
|
|
})();
|
|
</script>
|
|
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
|
|
|
<script>
|
|
window.__CRELAY_BOOT_LOG__ = [];
|
|
function __crelayBoot(message, isError = false) {
|
|
const line = `[c-relay-pg boot] ${message}`;
|
|
window.__CRELAY_BOOT_LOG__.push(line);
|
|
if (isError) {
|
|
console.error(line);
|
|
} else {
|
|
console.log(line);
|
|
}
|
|
const sink = document.getElementById('bootLog');
|
|
if (sink) {
|
|
sink.textContent = `${line}\n` + sink.textContent;
|
|
}
|
|
}
|
|
|
|
window.addEventListener('error', (e) => {
|
|
__crelayBoot(`window error: ${e.message} @ ${e.filename}:${e.lineno}`, true);
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (e) => {
|
|
const reason = e?.reason?.message || String(e?.reason || 'unknown rejection');
|
|
__crelayBoot(`unhandled rejection: ${reason}`, true);
|
|
});
|
|
</script>
|
|
|
|
<script src="./js/vendor/svg.min.js"></script>
|
|
|
|
<style media="screen">
|
|
#divBody {
|
|
flex-direction: column !important;
|
|
flex-wrap: nowrap !important;
|
|
align-items: stretch !important;
|
|
justify-content: flex-start !important;
|
|
align-content: flex-start !important;
|
|
gap: 10px !important;
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
#divAdminNav {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
margin-bottom: 10px;
|
|
padding: 10px 0;
|
|
background: var(--secondary-color);
|
|
}
|
|
|
|
.adminNavItem {
|
|
border-bottom: 1px solid var(--muted-color);
|
|
padding: 6px 0;
|
|
cursor: pointer;
|
|
font-size: 70%;
|
|
font-family: var(--font-family);
|
|
user-select: none;
|
|
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
|
|
}
|
|
|
|
.adminNavItem:hover {
|
|
border-bottom-color: var(--accent-color);
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.adminNavItem.active {
|
|
color: var(--primary-color);
|
|
background: color-mix(in srgb, var(--muted-color) 65%, transparent);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.adminSection {
|
|
display: none;
|
|
border: 2px solid var(--primary-color);
|
|
padding: 10px;
|
|
background: var(--secondary-color);
|
|
}
|
|
|
|
.adminSection.active {
|
|
display: block;
|
|
}
|
|
|
|
.adminSectionHeader {
|
|
font-family: pageKanji;
|
|
font-size: 120%;
|
|
margin-bottom: 10px;
|
|
border-bottom: 2px solid var(--muted-color);
|
|
padding-bottom: 5px;
|
|
}
|
|
|
|
.adminGrid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
}
|
|
|
|
.adminCard {
|
|
border: 1px solid var(--muted-color);
|
|
padding: 10px;
|
|
}
|
|
|
|
.adminCardTitle {
|
|
font-weight: bold;
|
|
margin-bottom: 8px;
|
|
font-size: 90%;
|
|
}
|
|
|
|
.adminValue {
|
|
font-size: 80%;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.adminTable {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 80%;
|
|
}
|
|
|
|
.adminTable th,
|
|
.adminTable td {
|
|
border: 1px solid var(--muted-color);
|
|
padding: 6px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.adminTable th {
|
|
background: var(--primary-color);
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
.adminRow {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
.adminInput,
|
|
.adminTextArea,
|
|
.adminSelect {
|
|
border: 1px solid var(--primary-color);
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
padding: 8px;
|
|
font-family: var(--font-family);
|
|
font-size: 80%;
|
|
min-width: 220px;
|
|
}
|
|
|
|
.adminTextArea {
|
|
width: 100%;
|
|
min-height: 120px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.adminBtn {
|
|
border: 1px solid var(--primary-color);
|
|
background: transparent;
|
|
color: var(--primary-color);
|
|
padding: 8px 12px;
|
|
cursor: pointer;
|
|
font-family: var(--font-family);
|
|
font-size: 80%;
|
|
}
|
|
|
|
.adminBtn:hover {
|
|
background: var(--accent-color);
|
|
color: var(--secondary-color);
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
.adminLog {
|
|
border: 1px solid var(--muted-color);
|
|
min-height: 120px;
|
|
max-height: 320px;
|
|
overflow: auto;
|
|
font-size: 75%;
|
|
padding: 8px;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.mono {
|
|
font-family: monospace;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div id="divSvgHam" class="divHeaderButtons"></div>
|
|
|
|
<div id="divHeader">
|
|
<div id="divHeaderFlexLeft"></div>
|
|
<div id="divHeaderFlexCenter">
|
|
<div class="divHeaderText">C-Relay-PG Admin</div>
|
|
</div>
|
|
<div id="divHeaderFlexRight"></div>
|
|
</div>
|
|
|
|
<div id="divBody">
|
|
<div id="section-statistics" class="adminSection active">
|
|
<div class="adminSectionHeader">Database Statistics</div>
|
|
<div class="adminRow">
|
|
<button class="adminBtn" id="btnRefreshStats">Refresh Statistics</button>
|
|
</div>
|
|
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="statsOverviewTable">
|
|
<tbody>
|
|
<tr><th>Database Size</th><td id="db-size">-</td></tr>
|
|
<tr><th>Total Events</th><td id="total-events">-</td></tr>
|
|
<tr><th>Process ID</th><td id="process-id">-</td></tr>
|
|
<tr><th>WebSocket Connections</th><td id="websocket-connections">-</td></tr>
|
|
<tr><th>Active Subscriptions</th><td id="active-subscriptions">-</td></tr>
|
|
<tr><th>Memory Usage</th><td id="memory-usage">-</td></tr>
|
|
<tr><th>CPU Usage</th><td id="cpu-usage">-</td></tr>
|
|
<tr><th>CPU Core</th><td id="cpu-core">-</td></tr>
|
|
<tr><th>Oldest Event</th><td id="oldest-event">-</td></tr>
|
|
<tr><th>Newest Event</th><td id="newest-event">-</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="adminRow"><strong>Event Kind Distribution</strong></div>
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="statsKindsTable">
|
|
<thead>
|
|
<tr><th>Event Kind</th><th>Count</th><th>Percentage</th></tr>
|
|
</thead>
|
|
<tbody id="stats-kinds-table-body">
|
|
<tr><td colspan="3" style="text-align:center;font-style:italic;">No data loaded</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="adminRow"><strong>Time-based Statistics</strong></div>
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="statsTimeTable">
|
|
<thead>
|
|
<tr><th>Period</th><th>Events</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td>Last 24 Hours</td><td id="events-24h">-</td></tr>
|
|
<tr><td>Last 7 Days</td><td id="events-7d">-</td></tr>
|
|
<tr><td>Last 30 Days</td><td id="events-30d">-</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="adminRow"><strong>Top Pubkeys by Event Count</strong></div>
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="statsPubkeysTable">
|
|
<thead>
|
|
<tr><th>Rank</th><th>Pubkey</th><th>Event Count</th><th>Percentage</th></tr>
|
|
</thead>
|
|
<tbody id="stats-pubkeys-table-body">
|
|
<tr><td colspan="4" style="text-align:center;font-style:italic;">No data loaded</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="section-subscriptions" class="adminSection">
|
|
<div class="adminSectionHeader">Subscriptions</div>
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="subscription-details-table">
|
|
<thead>
|
|
<tr><th>WSI</th><th>Sub ID</th><th>Filters</th><th>Duration</th></tr>
|
|
</thead>
|
|
<tbody id="subscription-details-table-body">
|
|
<tr><td colspan="4" style="text-align:center;font-style:italic;">No subscriptions active</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="adminLog mono" id="subscriptionLog">No subscription details received yet.</div>
|
|
</div>
|
|
|
|
<div id="section-configuration" class="adminSection">
|
|
<div class="adminSectionHeader">Configuration</div>
|
|
<div class="adminRow">
|
|
<button class="adminBtn" id="btnConfigRefresh">Fetch Config</button>
|
|
</div>
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="config-table">
|
|
<thead>
|
|
<tr><th>Parameter</th><th>Value</th><th>Actions</th></tr>
|
|
</thead>
|
|
<tbody id="config-table-body">
|
|
<tr><td colspan="3" style="text-align:center;font-style:italic;">No config loaded</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="adminRow">
|
|
<input class="adminInput" id="cfgKey" placeholder="config key" />
|
|
<input class="adminInput" id="cfgValue" placeholder="config value" />
|
|
<button class="adminBtn" id="btnConfigSet">Set Config</button>
|
|
</div>
|
|
<div class="adminLog mono" id="configLog">Configuration responses appear here.</div>
|
|
</div>
|
|
|
|
<div id="section-authorization" class="adminSection">
|
|
<div class="adminSectionHeader">Authorization & Web of Trust</div>
|
|
<div class="adminRow">
|
|
<input class="adminInput mono" id="authPubkey" placeholder="pubkey hex" />
|
|
<button class="adminBtn" id="btnWhitelist">Add Whitelist</button>
|
|
<button class="adminBtn" id="btnBlacklist">Add Blacklist</button>
|
|
<button class="adminBtn" id="btnAuthQuery">Auth Query</button>
|
|
</div>
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="authRulesTable">
|
|
<thead>
|
|
<tr><th>Rule Type</th><th>Pattern Type</th><th>Pattern Value</th></tr>
|
|
</thead>
|
|
<tbody id="authRulesTableBody">
|
|
<tr><td colspan="3" style="text-align:center;font-style:italic;">No auth rules loaded</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="adminRow">
|
|
<strong>WoT:</strong>
|
|
<span id="wotKind3Indicator">Checking...</span>
|
|
<span id="wotWhitelistCount">—</span>
|
|
</div>
|
|
<div class="adminRow">
|
|
<button class="adminBtn" id="wotLevel0Btn">WoT OFF</button>
|
|
<button class="adminBtn" id="wotLevel1Btn">WoT WRITE</button>
|
|
<button class="adminBtn" id="wotLevel2Btn">WoT FULL</button>
|
|
<button class="adminBtn" id="wotRefreshBtn">WoT Status</button>
|
|
<button class="adminBtn" id="wotSyncBtn">WoT Sync</button>
|
|
</div>
|
|
<div class="adminLog mono" id="authLog">Authorization responses appear here.</div>
|
|
</div>
|
|
|
|
<div id="section-ip-bans" class="adminSection">
|
|
<div class="adminSectionHeader">IP Bans</div>
|
|
<div class="adminRow">
|
|
<input class="adminInput" id="banIp" placeholder="IP address" />
|
|
<input class="adminInput" id="banDuration" placeholder="duration sec (e.g. 86400)" value="86400" />
|
|
<button class="adminBtn" id="btnBanIp">Ban IP</button>
|
|
<button class="adminBtn" id="btnIpQuery">Query IP Bans</button>
|
|
</div>
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="ip-bans-stats-table">
|
|
<thead><tr><th>Total IPs</th><th>Banned</th><th>Total Bans</th></tr></thead>
|
|
<tbody><tr><td id="ip-bans-total">-</td><td id="ip-bans-active">-</td><td id="ip-bans-issued">-</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="ip-bans-table">
|
|
<thead>
|
|
<tr><th>IP</th><th>Status</th><th>Banned Until</th><th>Failures</th><th>Connections</th></tr>
|
|
</thead>
|
|
<tbody id="ip-bans-tbody">
|
|
<tr><td colspan="5" style="text-align:center;font-style:italic;">No IP ban data loaded</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="adminLog mono" id="ipBanLog">IP ban responses appear here.</div>
|
|
</div>
|
|
|
|
<div id="section-relay-events" class="adminSection">
|
|
<div class="adminSectionHeader">Relay Events</div>
|
|
<div class="adminGrid">
|
|
<div class="adminCard">
|
|
<div class="adminCardTitle">Kind 0 Metadata</div>
|
|
<div class="adminRow"><input class="adminInput" id="kind0Name" placeholder="name" /></div>
|
|
<div class="adminRow"><textarea class="adminTextArea" id="kind0About" placeholder="about"></textarea></div>
|
|
<div class="adminRow"><button class="adminBtn" id="btnPublishKind0">Publish Kind 0</button></div>
|
|
|
|
<div class="adminCardTitle" style="margin-top:12px;">Kind 10050 DM Relays</div>
|
|
<div class="adminRow"><textarea class="adminTextArea" id="kind10050Relays" placeholder="wss://relay1.com wss://relay2.com"></textarea></div>
|
|
<div class="adminRow"><button class="adminBtn" id="btnPublishKind10050">Publish Kind 10050</button></div>
|
|
|
|
<div class="adminCardTitle" style="margin-top:12px;">Kind 10002 Relay List</div>
|
|
<div class="adminRow"><textarea class="adminTextArea" id="kind10002Relays" placeholder="wss://relay1.com read write wss://relay2.com read"></textarea></div>
|
|
<div class="adminRow"><button class="adminBtn" id="btnPublishKind10002">Publish Kind 10002</button></div>
|
|
</div>
|
|
<div class="adminCard">
|
|
<div class="adminCardTitle">Live Feed (excluding kinds 23456/23457/24567)</div>
|
|
<div class="adminLog mono" id="liveEventLog">Waiting for relay events...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="section-dm" class="adminSection">
|
|
<div class="adminSectionHeader">DM</div>
|
|
<div class="adminRow">
|
|
<textarea class="adminTextArea" id="dmMessage" placeholder="Send admin command as DM payload text"></textarea>
|
|
</div>
|
|
<div class="adminRow">
|
|
<button class="adminBtn" id="btnSendDm">Send NIP-17 DM</button>
|
|
</div>
|
|
<div class="adminLog mono" id="dmLog">DM activity appears here.</div>
|
|
</div>
|
|
|
|
<div id="section-database" class="adminSection">
|
|
<div class="adminSectionHeader">Database Query</div>
|
|
<div class="adminRow">
|
|
<textarea class="adminTextArea" id="sqlQuery" placeholder="SELECT * FROM events LIMIT 10"></textarea>
|
|
</div>
|
|
<div class="adminRow">
|
|
<button class="adminBtn" id="btnRunSql">Run SQL Query</button>
|
|
</div>
|
|
<div class="adminRow">
|
|
<div id="query-info" class="adminValue">No query executed yet.</div>
|
|
</div>
|
|
<div class="adminRow">
|
|
<table class="adminTable" id="query-results-table">
|
|
<tbody id="query-results-body">
|
|
<tr><td style="text-align:center;font-style:italic;">No query results</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="adminLog mono" id="sqlLog">SQL responses appear here.</div>
|
|
</div>
|
|
|
|
<div class="adminSection active">
|
|
<div class="adminSectionHeader">System</div>
|
|
<div class="adminTableWrap">
|
|
<table class="adminTable">
|
|
<tbody>
|
|
<tr><th>Relay WS</th><td id="sysRelayWs" class="mono"></td></tr>
|
|
<tr><th>Relay HTTP</th><td id="sysRelayHttp" class="mono"></td></tr>
|
|
<tr><th>Relay Pubkey</th><td id="sysRelayPubkey" class="mono">Loading...</td></tr>
|
|
<tr><th>User Pubkey</th><td id="sysUserPubkey" class="mono">Loading...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="adminSection active">
|
|
<div class="adminSectionHeader">Boot Diagnostics</div>
|
|
<div id="bootLog" class="adminLog mono">Boot log initialized.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="divFooter">
|
|
<div id="divFooterLeft" class="divFooterBox"></div>
|
|
<div id="divFooterCenter" class="divFooterBox"></div>
|
|
<div id="divFooterRight" class="divFooterBox"></div>
|
|
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
|
</div>
|
|
|
|
<div id="divSideNav">
|
|
<div id="divSideNavHeader"></div>
|
|
|
|
<div id="divSideNavBody">
|
|
<div id="divAdminNav">
|
|
<div class="adminNavItem active" data-section="statistics">Statistics</div>
|
|
<div class="adminNavItem" data-section="subscriptions">Subscriptions</div>
|
|
<div class="adminNavItem" data-section="configuration">Configuration</div>
|
|
<div class="adminNavItem" data-section="authorization">Authorization</div>
|
|
<div class="adminNavItem" data-section="ip-bans">IP Bans</div>
|
|
<div class="adminNavItem" data-section="relay-events">Relay Events</div>
|
|
<div class="adminNavItem" data-section="dm">DM</div>
|
|
<div class="adminNavItem" data-section="database">Database Query</div>
|
|
</div>
|
|
<div id="divFiles"></div>
|
|
</div>
|
|
|
|
<div id="divAiSection" class="sidenavSection">
|
|
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
|
<div id="divAiList" class="sidenavSectionList">
|
|
<div id="divAiProvidersList">No saved providers yet.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="divRelaySection">
|
|
<div id="divRelaySectionTitle">リレー</div>
|
|
<div id="divRelayList">Loading relays...</div>
|
|
</div>
|
|
|
|
<div id="divBlossomSection">
|
|
<div id="divBlossomSectionTitle">ブロッサム</div>
|
|
<div id="divBlossomList">Loading blossom servers...</div>
|
|
</div>
|
|
|
|
<div id="divVersionBar">
|
|
<span id="versionDisplay">v0.0.1</span>
|
|
<div id="divVersionBarButtons">
|
|
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
|
<div id="themeToggleHamburgerContainer"></div>
|
|
</button>
|
|
<button id="logoutButton" title="Logout">
|
|
<div id="logoutHamburgerContainer"></div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="./nostr.bundle.js"></script>
|
|
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
|
|
|
<script type="module">
|
|
import {
|
|
initNDKPage,
|
|
getPubkey, injectHeaderAvatar,
|
|
subscribe,
|
|
publishEvent,
|
|
disconnect,
|
|
getVersion,
|
|
updateVersionDisplay,
|
|
getUserSettings,
|
|
patchUserSettings,
|
|
onUserSettings
|
|
} from './js/init-ndk.mjs';
|
|
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
|
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
|
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
|
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
|
|
|
const ADMIN_RELAY_WS_URL = 'wss://relay.laantungir.net';
|
|
const ADMIN_RELAY_HTTP_URL = 'https://relay.laantungir.net';
|
|
const ADMIN_RELAY_PUBKEY_FALLBACK = 'e9aa50decff01f2cec1ec2b2e0b34332cf9c92cafdac5a7cc0881a6d26b59854';
|
|
|
|
const versionInfo = await getVersion();
|
|
const VERSION = versionInfo.VERSION;
|
|
console.log(`[relay-admin.html ${VERSION}] Loading...`);
|
|
|
|
let updateIntervalId = null;
|
|
let currentPubkey = null;
|
|
|
|
let isAuthenticated = false;
|
|
let authMode = 'required';
|
|
let authedPageInitialized = false;
|
|
let relayActivityListenersBound = false;
|
|
|
|
let hamburgerInstance = null;
|
|
let isNavOpen = false;
|
|
|
|
let logoutHamburger = null;
|
|
let themeToggleHamburger = null;
|
|
let isDarkMode = false;
|
|
|
|
let pageSettings = {};
|
|
let unsubscribeUserSettings = null;
|
|
|
|
// Admin-specific state (additive to template)
|
|
let relayPubkey = '';
|
|
let adminInitialized = false;
|
|
let adminUiBound = false;
|
|
const adminSubscriptions = [];
|
|
|
|
const divBody = document.getElementById("divBody");
|
|
const divSideNav = document.getElementById("divSideNav");
|
|
const divSideNavBody = document.getElementById("divSideNavBody");
|
|
const divFooterCenter = document.getElementById("divFooterCenter");
|
|
const divFooterRight = document.getElementById("divFooterRight");
|
|
|
|
const MAX_LOG_LINES = 200;
|
|
|
|
function appendLog(id, msg) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
const ts = new Date().toISOString();
|
|
const next = `[${ts}] ${msg}`;
|
|
const lines = (el.textContent || '').split('\n').filter(Boolean);
|
|
lines.unshift(next);
|
|
if (lines.length > MAX_LOG_LINES) lines.length = MAX_LOG_LINES;
|
|
el.textContent = lines.join('\n');
|
|
}
|
|
|
|
function setSystemInfo() {
|
|
const wsEl = document.getElementById('sysRelayWs');
|
|
const httpEl = document.getElementById('sysRelayHttp');
|
|
const relayPkEl = document.getElementById('sysRelayPubkey');
|
|
const userPkEl = document.getElementById('sysUserPubkey');
|
|
if (wsEl) wsEl.textContent = ADMIN_RELAY_WS_URL;
|
|
if (httpEl) httpEl.textContent = ADMIN_RELAY_HTTP_URL;
|
|
if (relayPkEl) relayPkEl.textContent = relayPubkey || 'Unavailable';
|
|
if (userPkEl) userPkEl.textContent = currentPubkey || 'Unavailable';
|
|
}
|
|
|
|
function initHamburgerMenu() {
|
|
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
hamburgerInstance.animateTo('burger');
|
|
}
|
|
|
|
function openNav() {
|
|
divSideNav.style.zIndex = 3;
|
|
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
|
|
isNavOpen = true;
|
|
if (hamburgerInstance) {
|
|
hamburgerInstance.animateTo('arrow_left');
|
|
}
|
|
|
|
if (!logoutHamburger) {
|
|
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
|
size: 24,
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
logoutHamburger.animateTo('x');
|
|
}
|
|
|
|
if (!themeToggleHamburger) {
|
|
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
|
size: 24,
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
|
|
const savedTheme = localStorage.getItem('theme');
|
|
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
|
const initialShape = isDarkMode ? 'moon' : 'circle';
|
|
themeToggleHamburger.animateTo(initialShape);
|
|
}
|
|
}
|
|
|
|
function closeNav() {
|
|
divSideNav.style.width = "0vw";
|
|
divSideNav.style.zIndex = -1;
|
|
isNavOpen = false;
|
|
if (hamburgerInstance) {
|
|
hamburgerInstance.animateTo('burger');
|
|
}
|
|
}
|
|
|
|
function toggleNav() {
|
|
if (isNavOpen) {
|
|
closeNav();
|
|
} else {
|
|
openNav();
|
|
}
|
|
}
|
|
|
|
function hasTargetPubkeyInUrl() {
|
|
const params = new URLSearchParams(window.location.search || '');
|
|
const npub = String(params.get('npub') || '').trim();
|
|
const pubkey = String(params.get('pubkey') || '').trim();
|
|
return Boolean(npub || pubkey);
|
|
}
|
|
|
|
function resolveAuthModeFromUrl() {
|
|
const params = new URLSearchParams(window.location.search || '');
|
|
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
|
|
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
|
|
return explicitAuth;
|
|
}
|
|
|
|
if (hasTargetPubkeyInUrl()) {
|
|
return 'optional';
|
|
}
|
|
|
|
return 'required';
|
|
}
|
|
|
|
function isAuthRequiredError(error) {
|
|
const message = String(error?.message || error || '').toLowerCase();
|
|
return message.includes('authentication required');
|
|
}
|
|
|
|
async function initializeAuthentication(mode) {
|
|
if (mode === 'required') {
|
|
await initNDKPage();
|
|
currentPubkey = await getPubkey();
|
|
isAuthenticated = true;
|
|
return;
|
|
}
|
|
|
|
if (mode === 'none') {
|
|
isAuthenticated = false;
|
|
currentPubkey = null;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await initNDKPage();
|
|
currentPubkey = await getPubkey();
|
|
isAuthenticated = true;
|
|
} catch (error) {
|
|
if (isAuthRequiredError(error)) {
|
|
console.log('[relay-admin.html] Optional auth mode: continuing unauthenticated');
|
|
isAuthenticated = false;
|
|
currentPubkey = null;
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function fetchRelayInfo() {
|
|
try {
|
|
const resp = await fetch(ADMIN_RELAY_HTTP_URL, {
|
|
method: 'GET',
|
|
headers: { 'Accept': 'application/nostr+json' }
|
|
});
|
|
if (!resp.ok) throw new Error(`NIP-11 fetch failed: ${resp.status}`);
|
|
const info = await resp.json();
|
|
relayPubkey = String(info?.pubkey || '').trim();
|
|
if (!relayPubkey) {
|
|
relayPubkey = ADMIN_RELAY_PUBKEY_FALLBACK;
|
|
appendLog('bootLog', 'NIP-11 missing relay pubkey, using fallback relay pubkey');
|
|
}
|
|
setSystemInfo();
|
|
return info;
|
|
} catch (error) {
|
|
relayPubkey = ADMIN_RELAY_PUBKEY_FALLBACK;
|
|
appendLog('bootLog', `NIP-11 fetch failed (${error.message}), using fallback relay pubkey`);
|
|
setSystemInfo();
|
|
return { pubkey: relayPubkey };
|
|
}
|
|
}
|
|
|
|
async function encryptForRelay(content) {
|
|
if (!window.nostr?.nip44?.encrypt) {
|
|
throw new Error('window.nostr.nip44.encrypt not available');
|
|
}
|
|
return await window.nostr.nip44.encrypt(relayPubkey, content);
|
|
}
|
|
|
|
async function decryptFromRelay(content) {
|
|
if (!window.nostr?.nip44?.decrypt) {
|
|
throw new Error('window.nostr.nip44.decrypt not available');
|
|
}
|
|
return await window.nostr.nip44.decrypt(relayPubkey, content);
|
|
}
|
|
|
|
async function sendAdminCommand(commandArray, sink = 'configLog') {
|
|
if (!relayPubkey) throw new Error('relay pubkey unavailable');
|
|
const encrypted = await encryptForRelay(JSON.stringify(commandArray));
|
|
const event = {
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: 23456,
|
|
tags: [['p', relayPubkey]],
|
|
content: encrypted
|
|
};
|
|
appendLog(sink, `CMD -> ${JSON.stringify(commandArray)}`);
|
|
await publishEvent(event);
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
const n = Number(bytes || 0);
|
|
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let v = n;
|
|
let idx = 0;
|
|
while (v >= 1024 && idx < units.length - 1) {
|
|
v /= 1024;
|
|
idx++;
|
|
}
|
|
return `${v.toFixed(idx === 0 ? 0 : 2)} ${units[idx]}`;
|
|
}
|
|
|
|
function setCellText(id, value) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.textContent = value;
|
|
}
|
|
|
|
function populateStatsKindsFromMonitoring(kindsData, totalEvents) {
|
|
const tableBody = document.getElementById('stats-kinds-table-body');
|
|
if (!tableBody) return;
|
|
tableBody.innerHTML = '';
|
|
|
|
if (!Array.isArray(kindsData) || kindsData.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td colspan="3" style="text-align:center;font-style:italic;">No event data</td>';
|
|
tableBody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
kindsData.forEach((kind) => {
|
|
const row = document.createElement('tr');
|
|
const count = Number(kind?.count || 0);
|
|
const percentage = totalEvents > 0 ? ((count / totalEvents) * 100).toFixed(1) : '0.0';
|
|
row.innerHTML = `<td>${kind?.kind ?? '-'}</td><td>${count}</td><td>${percentage}%</td>`;
|
|
tableBody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function populateStatsPubkeysFromMonitoring(pubkeysData, totalEvents) {
|
|
const tableBody = document.getElementById('stats-pubkeys-table-body');
|
|
if (!tableBody) return;
|
|
tableBody.innerHTML = '';
|
|
|
|
if (!Array.isArray(pubkeysData) || pubkeysData.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td colspan="4" style="text-align:center;font-style:italic;">No pubkey data</td>';
|
|
tableBody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
pubkeysData.forEach((entry, index) => {
|
|
const row = document.createElement('tr');
|
|
const pubkey = String(entry?.pubkey || '-');
|
|
const eventCount = Number(entry?.event_count ?? entry?.count ?? 0);
|
|
const percentage = totalEvents > 0 ? ((eventCount / totalEvents) * 100).toFixed(1) : '0.0';
|
|
const shortPk = pubkey.length > 24 ? `${pubkey.slice(0, 12)}...${pubkey.slice(-12)}` : pubkey;
|
|
row.innerHTML = `<td>${index + 1}</td><td class="mono">${shortPk}</td><td>${eventCount}</td><td>${percentage}%</td>`;
|
|
tableBody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function handleMonitoringEvent(event) {
|
|
const dTag = (event.tags || []).find((t) => t && t[0] === 'd');
|
|
const d = dTag ? dTag[1] : 'unknown';
|
|
|
|
console.log('[relay-admin.html] MONITOR EVENT kind=24567 processed', {
|
|
dTag: d,
|
|
contentLength: typeof event.content === 'string' ? event.content.length : 0
|
|
});
|
|
|
|
let payload = null;
|
|
try {
|
|
payload = JSON.parse(event.content || '{}');
|
|
} catch {
|
|
payload = null;
|
|
}
|
|
|
|
if (d === 'event_kinds' && payload) {
|
|
const totalEvents = Number(payload.total_events || 0);
|
|
setCellText('total-events', totalEvents > 0 ? String(totalEvents) : '-');
|
|
if (payload.latest_event_at) {
|
|
setCellText('newest-event', new Date(Number(payload.latest_event_at) * 1000).toLocaleString());
|
|
}
|
|
if (payload.database_created_at) {
|
|
setCellText('oldest-event', new Date(Number(payload.database_created_at) * 1000).toLocaleString());
|
|
}
|
|
if (payload.database_size_bytes !== undefined) {
|
|
setCellText('db-size', formatFileSize(payload.database_size_bytes));
|
|
}
|
|
populateStatsKindsFromMonitoring(payload.kinds || payload.event_kinds || [], totalEvents);
|
|
} else if (d === 'time_stats' && payload) {
|
|
const periods = Array.isArray(payload.periods) ? payload.periods : [];
|
|
const map = { last_24h: '0', last_7d: '0', last_30d: '0' };
|
|
periods.forEach((p) => {
|
|
if (p?.period === 'last_24h') map.last_24h = String(p.count ?? 0);
|
|
if (p?.period === 'last_7d') map.last_7d = String(p.count ?? 0);
|
|
if (p?.period === 'last_30d') map.last_30d = String(p.count ?? 0);
|
|
});
|
|
setCellText('events-24h', map.last_24h);
|
|
setCellText('events-7d', map.last_7d);
|
|
setCellText('events-30d', map.last_30d);
|
|
} else if (d === 'top_pubkeys' && payload) {
|
|
const totalEvents = Number(payload.total_events || 0);
|
|
populateStatsPubkeysFromMonitoring(payload.pubkeys || payload.top_pubkeys || [], totalEvents);
|
|
} else if (d === 'cpu_metrics' && payload) {
|
|
if (payload.process_id !== undefined) setCellText('process-id', String(payload.process_id));
|
|
if (payload.active_connections !== undefined) setCellText('websocket-connections', String(payload.active_connections));
|
|
if (payload.memory_usage_mb !== undefined) setCellText('memory-usage', `${Number(payload.memory_usage_mb).toFixed(1)} MB`);
|
|
if (payload.current_cpu_core !== undefined) setCellText('cpu-core', `Core ${payload.current_cpu_core}`);
|
|
if (payload.process_cpu_time !== undefined && payload.system_cpu_time !== undefined) {
|
|
if (window._relayAdminPrevCpuSample) {
|
|
const procDelta = payload.process_cpu_time - window._relayAdminPrevCpuSample.proc;
|
|
const sysDelta = payload.system_cpu_time - window._relayAdminPrevCpuSample.sys;
|
|
if (sysDelta > 0) {
|
|
const cpuPct = Math.min(100, Math.max(0, (procDelta / sysDelta) * 100));
|
|
setCellText('cpu-usage', `${cpuPct.toFixed(1)}%`);
|
|
}
|
|
}
|
|
window._relayAdminPrevCpuSample = {
|
|
proc: payload.process_cpu_time,
|
|
sys: payload.system_cpu_time
|
|
};
|
|
}
|
|
} else if (d === 'subscription_details') {
|
|
appendLog('subscriptionLog', event.content);
|
|
if (payload?.data?.subscriptions && Array.isArray(payload.data.subscriptions)) {
|
|
setCellText('active-subscriptions', String(payload.data.subscriptions.length));
|
|
}
|
|
} else {
|
|
appendLog('configLog', `MONITOR ${d}: ${event.content}`);
|
|
}
|
|
}
|
|
|
|
async function runAdminAction(sink, fn) {
|
|
try {
|
|
await fn();
|
|
} catch (error) {
|
|
appendLog(sink, `Action failed: ${error.message || error}`);
|
|
console.error('[relay-admin.html] admin action failed', error);
|
|
}
|
|
}
|
|
|
|
function renderConfigTable(rows) {
|
|
const tbody = document.getElementById('config-table-body');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = '';
|
|
|
|
let list = [];
|
|
if (Array.isArray(rows)) {
|
|
list = rows;
|
|
} else if (rows && typeof rows === 'object') {
|
|
list = Object.entries(rows).map(([key, value]) => ({ key, value }));
|
|
}
|
|
|
|
if (!Array.isArray(list) || list.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;font-style:italic;">No config loaded</td></tr>';
|
|
return;
|
|
}
|
|
list.forEach((row) => {
|
|
const tr = document.createElement('tr');
|
|
const key = String(row?.key ?? row?.config_key ?? '-');
|
|
const value = String(row?.value ?? row?.config_value ?? '');
|
|
tr.innerHTML = `<td class="mono">${key}</td><td class="mono">${value}</td><td><button class="adminBtn" data-cfg-edit="${key}">Use</button></td>`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
tbody.querySelectorAll('button[data-cfg-edit]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
document.getElementById('cfgKey').value = btn.getAttribute('data-cfg-edit') || '';
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderAuthRulesTable(rules) {
|
|
const tbody = document.getElementById('authRulesTableBody');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = '';
|
|
const list = Array.isArray(rules) ? rules : Array.isArray(rules?.rules) ? rules.rules : [];
|
|
if (list.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;font-style:italic;">No auth rules loaded</td></tr>';
|
|
return;
|
|
}
|
|
list.forEach((r) => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `<td>${r?.rule_type || r?.type || '-'}</td><td>${r?.pattern_type || '-'}</td><td class="mono">${r?.pattern_value || r?.value || '-'}</td>`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function renderWotStatus(responseData) {
|
|
const indicator = document.getElementById('wotKind3Indicator');
|
|
const count = document.getElementById('wotWhitelistCount');
|
|
if (indicator) indicator.textContent = responseData?.admin_kind3_exists ? 'Kind3 Found ✓' : 'Kind3 Missing ✗';
|
|
if (count) count.textContent = `Whitelisted: ${responseData?.wot_whitelist_count ?? '—'}`;
|
|
}
|
|
|
|
function renderSqlResults(responseData) {
|
|
const info = document.getElementById('query-info');
|
|
const tbody = document.getElementById('query-results-body');
|
|
if (!info || !tbody) return;
|
|
|
|
const cols = Array.isArray(responseData?.columns) ? responseData.columns : [];
|
|
const rows = Array.isArray(responseData?.rows) ? responseData.rows : [];
|
|
info.textContent = `Rows: ${responseData?.row_count ?? rows.length ?? 0}, Time: ${responseData?.execution_time_ms ?? '-'}ms`;
|
|
|
|
if (cols.length === 0) {
|
|
tbody.innerHTML = '<tr><td style="text-align:center;font-style:italic;">No query results</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const table = document.getElementById('query-results-table');
|
|
const theadExisting = table.querySelector('thead');
|
|
if (theadExisting) theadExisting.remove();
|
|
const thead = document.createElement('thead');
|
|
thead.innerHTML = `<tr>${cols.map(c => `<th>${c}</th>`).join('')}</tr>`;
|
|
table.insertBefore(thead, tbody.parentElement ? tbody : table.firstChild);
|
|
|
|
tbody.innerHTML = '';
|
|
rows.forEach((r) => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = (Array.isArray(r) ? r : []).map((v) => `<td class="mono">${String(v ?? '')}</td>`).join('');
|
|
tbody.appendChild(tr);
|
|
});
|
|
if (rows.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="${cols.length}" style="text-align:center;font-style:italic;">No query results</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function renderIpBans(responseData) {
|
|
const tbody = document.getElementById('ip-bans-tbody');
|
|
if (!tbody) return;
|
|
|
|
const rows = Array.isArray(responseData?.rows) ? responseData.rows : [];
|
|
const columns = Array.isArray(responseData?.columns) ? responseData.columns : [];
|
|
const idx = Object.fromEntries(columns.map((c, i) => [c, i]));
|
|
|
|
const totalEl = document.getElementById('ip-bans-total');
|
|
const activeEl = document.getElementById('ip-bans-active');
|
|
const issuedEl = document.getElementById('ip-bans-issued');
|
|
if (totalEl) totalEl.textContent = String(rows.length);
|
|
|
|
let activeCount = 0;
|
|
let totalIssued = 0;
|
|
|
|
tbody.innerHTML = '';
|
|
rows.forEach((r) => {
|
|
const ip = r[idx.ip] ?? '-';
|
|
const until = Number(r[idx.banned_until] ?? 0);
|
|
const banCount = Number(r[idx.ban_count] ?? 0);
|
|
const failures = Number(r[idx.failure_count] ?? 0);
|
|
const conns = Number(r[idx.total_connections] ?? 0);
|
|
const active = until > Math.floor(Date.now() / 1000);
|
|
if (active) activeCount++;
|
|
totalIssued += banCount;
|
|
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `<td class="mono">${ip}</td><td>${active ? 'BANNED' : 'expired'}</td><td>${until ? new Date(until * 1000).toLocaleString() : '-'}</td><td>${failures}</td><td>${conns}</td>`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
if (activeEl) activeEl.textContent = String(activeCount);
|
|
if (issuedEl) issuedEl.textContent = String(totalIssued);
|
|
|
|
if (rows.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic;">No IP ban data loaded</td></tr>';
|
|
}
|
|
}
|
|
|
|
function renderSubscriptionDetails(subscriptions) {
|
|
const tbody = document.getElementById('subscription-details-table-body');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = '';
|
|
const list = Array.isArray(subscriptions) ? subscriptions : [];
|
|
if (list.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;font-style:italic;">No subscriptions active</td></tr>';
|
|
return;
|
|
}
|
|
list.forEach((s) => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `<td class="mono">${s?.wsi_pointer || '-'}</td><td class="mono">${s?.subscription_id || '-'}</td><td class="mono">${JSON.stringify(s?.filters || [])}</td><td>${s?.duration || '-'}</td>`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
async function handleAdminResponse(event) {
|
|
try {
|
|
const plain = await decryptFromRelay(event.content);
|
|
let parsed = null;
|
|
try { parsed = JSON.parse(plain); } catch (_) {}
|
|
|
|
const queryType = String(parsed?.query_type || '').toLowerCase();
|
|
const command = String(parsed?.command || '').toLowerCase();
|
|
|
|
if (queryType.includes('config')) {
|
|
renderConfigTable(parsed?.data);
|
|
}
|
|
|
|
if (queryType.includes('auth')) {
|
|
renderAuthRulesTable(parsed?.data);
|
|
}
|
|
|
|
if (queryType.includes('sql')) {
|
|
renderSqlResults(parsed || {});
|
|
|
|
const q = String(parsed?.query || '').toLowerCase();
|
|
if (q.includes(' from ip_bans')) {
|
|
renderIpBans(parsed || {});
|
|
}
|
|
}
|
|
|
|
if (command === 'wot_status' || command === 'wot_sync') {
|
|
renderWotStatus(parsed || {});
|
|
}
|
|
|
|
if (queryType.includes('stats_query')) {
|
|
setCellText('db-size', parsed?.database_size_bytes ? formatFileSize(parsed.database_size_bytes) : '-');
|
|
setCellText('total-events', String(parsed?.total_events ?? '-'));
|
|
if (parsed?.database_created_at) setCellText('oldest-event', new Date(parsed.database_created_at * 1000).toLocaleString());
|
|
if (parsed?.latest_event_at) setCellText('newest-event', new Date(parsed.latest_event_at * 1000).toLocaleString());
|
|
if (Array.isArray(parsed?.event_kinds)) {
|
|
populateStatsKindsFromMonitoring(parsed.event_kinds, Number(parsed?.total_events || 0));
|
|
}
|
|
if (parsed?.time_stats) {
|
|
setCellText('events-24h', String(parsed.time_stats.last_24h ?? 0));
|
|
setCellText('events-7d', String(parsed.time_stats.last_7d ?? 0));
|
|
setCellText('events-30d', String(parsed.time_stats.last_30d ?? 0));
|
|
}
|
|
if (Array.isArray(parsed?.top_pubkeys)) {
|
|
populateStatsPubkeysFromMonitoring(parsed.top_pubkeys, Number(parsed?.total_events || 0));
|
|
}
|
|
}
|
|
|
|
if (queryType.includes('subscription') && Array.isArray(parsed?.data?.subscriptions)) {
|
|
renderSubscriptionDetails(parsed.data.subscriptions);
|
|
}
|
|
|
|
if (queryType.includes('auth')) appendLog('authLog', `RESP <- ${plain}`);
|
|
else if (queryType.includes('sql')) appendLog('sqlLog', `RESP <- ${plain}`);
|
|
else if (queryType.includes('ip')) appendLog('ipBanLog', `RESP <- ${plain}`);
|
|
else if (queryType.includes('dm')) appendLog('dmLog', `RESP <- ${plain}`);
|
|
else appendLog('configLog', `RESP <- ${plain}`);
|
|
} catch (err) {
|
|
appendLog('configLog', `Failed to decrypt 23457: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
let liveEventCount = 0;
|
|
function handleLiveEvent(event) {
|
|
if ([23456, 23457, 24567].includes(event.kind)) return;
|
|
liveEventCount++;
|
|
// sample to keep UI responsive on busy relays
|
|
if (liveEventCount % 10 !== 0) return;
|
|
const shortId = String(event.id || '').slice(0, 12);
|
|
const shortPk = String(event.pubkey || '').slice(0, 12);
|
|
appendLog('liveEventLog', `#${liveEventCount} kind=${event.kind} id=${shortId} pubkey=${shortPk}`);
|
|
}
|
|
|
|
function activateSection(sectionKey) {
|
|
document.querySelectorAll('.adminNavItem').forEach((item) => {
|
|
item.classList.toggle('active', item.dataset.section === sectionKey);
|
|
});
|
|
document.querySelectorAll('.adminSection').forEach((el) => {
|
|
el.classList.remove('active');
|
|
});
|
|
const target = document.getElementById(`section-${sectionKey}`);
|
|
if (target) target.classList.add('active');
|
|
}
|
|
|
|
function bindAdminUi() {
|
|
if (adminUiBound) return;
|
|
adminUiBound = true;
|
|
|
|
document.querySelectorAll('.adminNavItem').forEach((item) => {
|
|
item.addEventListener('click', () => activateSection(item.dataset.section));
|
|
});
|
|
|
|
document.getElementById('btnRefreshStats').addEventListener('click', async () => {
|
|
await runAdminAction('configLog', async () => {
|
|
await sendAdminCommand(['stats_query'], 'configLog');
|
|
});
|
|
});
|
|
|
|
document.getElementById('btnConfigRefresh').addEventListener('click', async () => {
|
|
await runAdminAction('configLog', async () => {
|
|
await sendAdminCommand(['config_query', 'all'], 'configLog');
|
|
});
|
|
});
|
|
|
|
document.getElementById('btnConfigSet').addEventListener('click', async () => {
|
|
const key = document.getElementById('cfgKey').value.trim();
|
|
const value = document.getElementById('cfgValue').value.trim();
|
|
if (!key) return appendLog('configLog', 'Missing config key');
|
|
await sendAdminCommand([key, value], 'configLog');
|
|
});
|
|
|
|
document.getElementById('btnWhitelist').addEventListener('click', async () => {
|
|
const pk = document.getElementById('authPubkey').value.trim();
|
|
if (!pk) return appendLog('authLog', 'Missing pubkey');
|
|
await sendAdminCommand(['whitelist', 'pubkey', pk], 'authLog');
|
|
});
|
|
|
|
document.getElementById('btnBlacklist').addEventListener('click', async () => {
|
|
const pk = document.getElementById('authPubkey').value.trim();
|
|
if (!pk) return appendLog('authLog', 'Missing pubkey');
|
|
await sendAdminCommand(['blacklist', 'pubkey', pk], 'authLog');
|
|
});
|
|
|
|
document.getElementById('btnAuthQuery').addEventListener('click', async () => {
|
|
await sendAdminCommand(['auth_query', 'all'], 'authLog');
|
|
});
|
|
|
|
document.getElementById('wotRefreshBtn').addEventListener('click', async () => {
|
|
await sendAdminCommand(['system_command', 'wot_status'], 'authLog');
|
|
});
|
|
|
|
document.getElementById('wotSyncBtn').addEventListener('click', async () => {
|
|
await sendAdminCommand(['system_command', 'wot_sync'], 'authLog');
|
|
});
|
|
|
|
document.getElementById('wotLevel0Btn').addEventListener('click', async () => {
|
|
await sendAdminCommand(['config_set', 'wot_enabled', '0'], 'authLog');
|
|
});
|
|
|
|
document.getElementById('wotLevel1Btn').addEventListener('click', async () => {
|
|
await sendAdminCommand(['config_set', 'wot_enabled', '1'], 'authLog');
|
|
});
|
|
|
|
document.getElementById('wotLevel2Btn').addEventListener('click', async () => {
|
|
await sendAdminCommand(['config_set', 'wot_enabled', '2'], 'authLog');
|
|
});
|
|
|
|
document.getElementById('btnBanIp').addEventListener('click', async () => {
|
|
const ip = document.getElementById('banIp').value.trim();
|
|
const duration = document.getElementById('banDuration').value.trim() || '86400';
|
|
if (!ip) return appendLog('ipBanLog', 'Missing IP');
|
|
await sendAdminCommand(['ip_ban', 'add', ip, duration], 'ipBanLog');
|
|
});
|
|
|
|
document.getElementById('btnIpQuery').addEventListener('click', async () => {
|
|
await sendAdminCommand(['ip_ban', 'query', 'all'], 'ipBanLog');
|
|
await sendAdminCommand(['sql_query', 'SELECT ip, failure_count, ban_count, banned_until, total_connections FROM ip_bans ORDER BY banned_until DESC, total_failures DESC LIMIT 200'], 'ipBanLog');
|
|
});
|
|
|
|
document.getElementById('btnPublishKind0').addEventListener('click', async () => {
|
|
const name = document.getElementById('kind0Name').value.trim();
|
|
const about = document.getElementById('kind0About').value.trim();
|
|
const event = {
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: 0,
|
|
tags: [],
|
|
content: JSON.stringify({ name, about })
|
|
};
|
|
await publishEvent(event);
|
|
appendLog('liveEventLog', 'Published kind 0 metadata');
|
|
});
|
|
|
|
document.getElementById('btnPublishKind10050').addEventListener('click', async () => {
|
|
const raw = document.getElementById('kind10050Relays').value.trim();
|
|
const relays = raw.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
const tags = relays.map((r) => ['relay', r]);
|
|
await publishEvent({
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: 10050,
|
|
tags,
|
|
content: ''
|
|
});
|
|
appendLog('liveEventLog', 'Published kind 10050 relay list');
|
|
});
|
|
|
|
document.getElementById('btnPublishKind10002').addEventListener('click', async () => {
|
|
const raw = document.getElementById('kind10002Relays').value.trim();
|
|
const lines = raw.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
const tags = lines.map((line) => {
|
|
const parts = line.split(/\s+/);
|
|
const url = parts[0];
|
|
const read = parts.includes('read');
|
|
const write = parts.includes('write');
|
|
return ['r', url, read ? 'read' : '', write ? 'write' : ''];
|
|
});
|
|
await publishEvent({
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: 10002,
|
|
tags,
|
|
content: ''
|
|
});
|
|
appendLog('liveEventLog', 'Published kind 10002 relay list');
|
|
});
|
|
|
|
document.getElementById('btnSendDm').addEventListener('click', async () => {
|
|
const message = document.getElementById('dmMessage').value.trim();
|
|
if (!message) return appendLog('dmLog', 'Missing message');
|
|
await sendAdminCommand(['dm', message], 'dmLog');
|
|
});
|
|
|
|
document.getElementById('btnRunSql').addEventListener('click', async () => {
|
|
const q = document.getElementById('sqlQuery').value.trim();
|
|
if (!q) return appendLog('sqlLog', 'Missing SQL query');
|
|
await sendAdminCommand(['sql_query', q], 'sqlLog');
|
|
});
|
|
|
|
}
|
|
|
|
async function setupAdminSubscriptions() {
|
|
const nowMinus60 = Math.floor(Date.now() / 1000) - 60;
|
|
const monitoringFilter = relayPubkey
|
|
? { kinds: [24567], authors: [relayPubkey], since: nowMinus60 }
|
|
: { kinds: [24567], since: nowMinus60 };
|
|
const monitoringOptions = { closeOnEose: false, cacheUsage: 'ONLY_RELAY' };
|
|
|
|
console.log('[relay-admin.html] SUBSCRIBE ATTEMPT kind=24567', {
|
|
relayPubkey,
|
|
filter: monitoringFilter,
|
|
options: monitoringOptions
|
|
});
|
|
|
|
const monitoringSub = subscribe(monitoringFilter, monitoringOptions);
|
|
adminSubscriptions.push(monitoringSub);
|
|
|
|
const responseFilter = currentPubkey
|
|
? { kinds: [23457], '#p': [currentPubkey], since: nowMinus60 }
|
|
: { kinds: [23457], since: nowMinus60 };
|
|
const responseSub = subscribe(responseFilter, { closeOnEose: false, cacheUsage: 'ONLY_RELAY' });
|
|
adminSubscriptions.push(responseSub);
|
|
|
|
const liveFeedSub = subscribe({ kinds: [0, 1, 3, 4, 6, 7, 16, 1059], since: nowMinus60 }, { closeOnEose: false, cacheUsage: 'ONLY_RELAY' });
|
|
adminSubscriptions.push(liveFeedSub);
|
|
|
|
window.addEventListener('ndkEvent', async (evt) => {
|
|
const event = evt.detail;
|
|
if (!event) return;
|
|
|
|
if (event.kind === 24567) {
|
|
console.log('[relay-admin.html] SUBSCRIBE EVENT kind=24567', {
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
created_at: event.created_at,
|
|
tagsCount: Array.isArray(event.tags) ? event.tags.length : 0,
|
|
contentPreview: typeof event.content === 'string' ? event.content.slice(0, 180) : event.content
|
|
});
|
|
return handleMonitoringEvent(event);
|
|
}
|
|
|
|
if (event.kind === 23457) {
|
|
return handleAdminResponse(event);
|
|
}
|
|
|
|
return handleLiveEvent(event);
|
|
});
|
|
}
|
|
|
|
async function initializeAdminFeatures() {
|
|
if (!isAuthenticated || adminInitialized) return;
|
|
|
|
bindAdminUi();
|
|
await fetchRelayInfo();
|
|
await setupAdminSubscriptions();
|
|
|
|
await runAdminAction('configLog', async () => {
|
|
await sendAdminCommand(['config_query', 'all'], 'configLog');
|
|
});
|
|
await runAdminAction('authLog', async () => {
|
|
await sendAdminCommand(['auth_query', 'all'], 'authLog');
|
|
});
|
|
await runAdminAction('configLog', async () => {
|
|
await sendAdminCommand(['stats_query'], 'configLog');
|
|
});
|
|
|
|
console.log('[relay-admin.html] admin features fully enabled');
|
|
adminInitialized = true;
|
|
__crelayBoot('admin features initialized');
|
|
}
|
|
|
|
async function initializeAuthenticatedPageFeatures() {
|
|
if (!isAuthenticated || authedPageInitialized) return;
|
|
|
|
|
|
await injectHeaderAvatar(currentPubkey);
|
|
console.log('[relay-admin.html] Authenticated as:', currentPubkey);
|
|
|
|
try {
|
|
pageSettings = await getUserSettings();
|
|
} catch (error) {
|
|
console.warn('[relay-admin.html] getUserSettings failed:', error);
|
|
pageSettings = {};
|
|
}
|
|
|
|
if (!unsubscribeUserSettings) {
|
|
unsubscribeUserSettings = onUserSettings((settings) => {
|
|
pageSettings = settings || {};
|
|
});
|
|
}
|
|
|
|
initFooterRelayStatus();
|
|
initSidenavRelaySection();
|
|
await initBlossomSection();
|
|
initAiSectionWithLocalConfig();
|
|
await UpdateFooter();
|
|
|
|
if (!updateIntervalId) {
|
|
updateIntervalId = setInterval(UpdateFooter, 1000);
|
|
}
|
|
|
|
if (!relayActivityListenersBound) {
|
|
window.addEventListener('ndkRelayActivity', (event) => {
|
|
const { relayUrl, activity, stats } = event.detail;
|
|
// console.log(`[relay-admin.html] Relay activity: ${relayUrl} - ${activity}`, stats);
|
|
setRelayActivityState(relayUrl, activity);
|
|
});
|
|
|
|
window.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'relayActivity') {
|
|
const { relayUrl, activity } = event.data;
|
|
// console.log(`[relay-admin.html] Relay activity: ${relayUrl} - ${activity}`);
|
|
setRelayActivityState(relayUrl, activity);
|
|
}
|
|
});
|
|
|
|
relayActivityListenersBound = true;
|
|
}
|
|
|
|
authedPageInitialized = true;
|
|
}
|
|
|
|
async function promptLoginIfNeeded() {
|
|
if (isAuthenticated) return true;
|
|
await initNDKPage();
|
|
currentPubkey = await getPubkey();
|
|
isAuthenticated = true;
|
|
await initializeAuthenticatedPageFeatures();
|
|
await initializeAdminFeatures();
|
|
return true;
|
|
}
|
|
|
|
const UpdateFooter = async () => {
|
|
try {
|
|
await updateFooterRelayStatus();
|
|
await updateSidenavRelaySection();
|
|
await updateBlossomSection();
|
|
divFooterCenter.innerHTML = '';
|
|
divFooterRight.innerHTML = '';
|
|
} catch (error) {
|
|
console.error('[relay-admin.html] Error updating footer:', error);
|
|
}
|
|
};
|
|
|
|
const Logout = async () => {
|
|
console.log("[relay-admin.html] Starting logout process...");
|
|
|
|
if (updateIntervalId) {
|
|
clearInterval(updateIntervalId);
|
|
updateIntervalId = null;
|
|
}
|
|
|
|
adminSubscriptions.forEach((s) => {
|
|
try { s.close(); } catch (_) {}
|
|
});
|
|
|
|
disconnect();
|
|
|
|
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
|
|
await window.NOSTR_LOGIN_LITE.logout();
|
|
}
|
|
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
|
|
if (window.indexedDB) {
|
|
const databases = await window.indexedDB.databases();
|
|
for (const db of databases) {
|
|
if (db.name) {
|
|
window.indexedDB.deleteDatabase(db.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log("[relay-admin.html] Logged out, reloading page");
|
|
location.reload(true);
|
|
};
|
|
|
|
(async function main() {
|
|
__crelayBoot('main() start');
|
|
try {
|
|
initHamburgerMenu();
|
|
__crelayBoot('ui initialized');
|
|
|
|
const divSvgHam = document.getElementById('divSvgHam');
|
|
if (divSvgHam) {
|
|
divSvgHam.addEventListener('click', toggleNav);
|
|
}
|
|
|
|
const themeToggleButton = document.getElementById('themeToggleButton');
|
|
const logoutButton = document.getElementById('logoutButton');
|
|
|
|
if (themeToggleButton) {
|
|
themeToggleButton.addEventListener('click', () => {
|
|
isDarkMode = !isDarkMode;
|
|
if (isDarkMode) {
|
|
localStorage.setItem('theme', 'dark');
|
|
} else {
|
|
localStorage.setItem('theme', 'light');
|
|
}
|
|
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
if (logoutButton) {
|
|
logoutButton.addEventListener('click', async () => {
|
|
try {
|
|
if (!isAuthenticated) {
|
|
await promptLoginIfNeeded();
|
|
return;
|
|
}
|
|
await Logout();
|
|
} catch (error) {
|
|
console.error('Logout/login action failed:', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
authMode = resolveAuthModeFromUrl();
|
|
__crelayBoot(`auth mode: ${authMode}`);
|
|
await initializeAuthentication(authMode);
|
|
__crelayBoot('initNDK/auth complete');
|
|
|
|
await initializeAuthenticatedPageFeatures();
|
|
__crelayBoot('template authenticated feature init complete');
|
|
|
|
await initializeAdminFeatures();
|
|
|
|
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
|
|
if (sidenavWasOpen === 'true') {
|
|
localStorage.removeItem('sidenavWasOpen');
|
|
openNav();
|
|
}
|
|
|
|
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
|
|
divFooterCenter.textContent = 'Public mode';
|
|
divFooterRight.textContent = 'Sign in from side menu for private features';
|
|
}
|
|
|
|
await updateVersionDisplay();
|
|
|
|
setSystemInfo();
|
|
|
|
__crelayBoot('initialization complete');
|
|
console.log('[relay-admin.html] Initialization complete');
|
|
} catch (error) {
|
|
__crelayBoot(`fatal initialization failure: ${error.message}`, true);
|
|
console.error('[relay-admin.html] Initialization failed:', error);
|
|
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
|
|
<div style="font-size: 24px; margin-bottom: 20px; color: red;">Initialization Error</div>
|
|
<div style="font-size: 16px; color: #666;">${error.message}</div>
|
|
<div style="margin-top: 20px;">
|
|
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|