mirror of
https://github.com/permissionlesstech/georelays.git
synced 2026-07-24 23:25:18 +00:00
1867 lines
62 KiB
HTML
1867 lines
62 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>BitChat GeoRelays Terminal</title>
|
|
|
|
<!-- Leaflet Map CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
|
|
|
<!-- Google Fonts for retro/monospace style -->
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;700&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
/* Themes Configuration */
|
|
:root {
|
|
--bg-color: #07090e;
|
|
--bg-darker: #030406;
|
|
--bg-lighter: #0c111a;
|
|
--text-primary: #00ff66;
|
|
--text-secondary: #008833;
|
|
--border-color: #00ff66;
|
|
--accent-color: #ffb000;
|
|
--error-color: #ff3333;
|
|
--border-glow: 0 0 8px rgba(0, 255, 102, 0.4);
|
|
--text-glow: 0 0 6px rgba(0, 255, 102, 0.5);
|
|
}
|
|
|
|
body.theme-amber {
|
|
--bg-color: #0c0803;
|
|
--bg-darker: #050301;
|
|
--bg-lighter: #150f06;
|
|
--text-primary: #ffb000;
|
|
--text-secondary: #aa7500;
|
|
--border-color: #ffb000;
|
|
--accent-color: #00ff66;
|
|
--error-color: #ff5555;
|
|
--border-glow: 0 0 8px rgba(255, 176, 0, 0.4);
|
|
--text-glow: 0 0 6px rgba(255, 176, 0, 0.5);
|
|
}
|
|
|
|
body.theme-cyan {
|
|
--bg-color: #030a0f;
|
|
--bg-darker: #010406;
|
|
--bg-lighter: #06121a;
|
|
--text-primary: #00e5ff;
|
|
--text-secondary: #0099aa;
|
|
--border-color: #00e5ff;
|
|
--accent-color: #ff007f;
|
|
--error-color: #ff3333;
|
|
--border-glow: 0 0 8px rgba(0, 229, 255, 0.4);
|
|
--text-glow: 0 0 6px rgba(0, 229, 255, 0.5);
|
|
}
|
|
|
|
/* Base Styling */
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
html, body {
|
|
height: 100%;
|
|
width: 100%;
|
|
overflow: hidden;
|
|
font-family: 'Fira Code', 'Courier New', Courier, Consolas, Monaco, monospace;
|
|
background-color: var(--bg-darker);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Scanlines Overlay */
|
|
.scanlines {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: linear-gradient(
|
|
rgba(18, 16, 16, 0) 50%,
|
|
rgba(0, 0, 0, 0.2) 50%
|
|
);
|
|
background-size: 100% 4px;
|
|
z-index: 9999;
|
|
pointer-events: none;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.flicker {
|
|
animation: crt-flicker 0.15s infinite;
|
|
}
|
|
|
|
@keyframes crt-flicker {
|
|
0% { opacity: 0.99; }
|
|
50% { opacity: 1; }
|
|
100% { opacity: 0.99; }
|
|
}
|
|
|
|
/* App Structure */
|
|
.app-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
border: 3px double var(--border-color);
|
|
box-shadow: var(--border-glow);
|
|
background-color: var(--bg-color);
|
|
}
|
|
|
|
/* Client Header */
|
|
.irc-header {
|
|
background-color: var(--bg-darker);
|
|
border-bottom: 2px solid var(--border-color);
|
|
padding: 6px 12px;
|
|
font-size: 0.85rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
text-shadow: var(--text-glow);
|
|
}
|
|
|
|
.irc-title {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.irc-status {
|
|
display: flex;
|
|
gap: 15px;
|
|
}
|
|
|
|
.status-item span {
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
/* Client Body (Sidebar + Content + Nicklist) */
|
|
.irc-body {
|
|
display: flex;
|
|
flex: 1;
|
|
min-height: 0; /* Important for scrollable containers inside */
|
|
}
|
|
|
|
/* Sidebar - Channels / Views */
|
|
.irc-sidebar {
|
|
width: 220px;
|
|
border-right: 2px solid var(--border-color);
|
|
background-color: var(--bg-darker);
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.sidebar-section {
|
|
padding: 10px;
|
|
border-bottom: 1px solid rgba(0, 255, 102, 0.15);
|
|
}
|
|
|
|
body.theme-amber .sidebar-section {
|
|
border-bottom: 1px solid rgba(255, 176, 0, 0.15);
|
|
}
|
|
|
|
body.theme-cyan .sidebar-section {
|
|
border-bottom: 1px solid rgba(0, 229, 255, 0.15);
|
|
}
|
|
|
|
.sidebar-title {
|
|
font-weight: bold;
|
|
color: var(--accent-color);
|
|
margin-bottom: 8px;
|
|
font-size: 0.8rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.channel-list {
|
|
list-style: none;
|
|
}
|
|
|
|
.channel-item {
|
|
padding: 6px 8px;
|
|
cursor: pointer;
|
|
border-radius: 3px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-bottom: 2px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.channel-item:hover {
|
|
background-color: var(--bg-lighter);
|
|
}
|
|
|
|
.channel-item.active {
|
|
background-color: var(--text-secondary);
|
|
color: var(--bg-darker);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.channel-item.active .channel-hash {
|
|
color: var(--bg-darker);
|
|
}
|
|
|
|
.channel-hash {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* Interactive Options Section */
|
|
.options-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.control-btn {
|
|
background: transparent;
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-primary);
|
|
padding: 4px 8px;
|
|
font-family: inherit;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.control-btn:hover {
|
|
background-color: var(--text-primary);
|
|
color: var(--bg-darker);
|
|
box-shadow: var(--border-glow);
|
|
}
|
|
|
|
/* Main Window */
|
|
.irc-main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background-color: var(--bg-color);
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Tab Title Bar */
|
|
.channel-topic-bar {
|
|
background-color: var(--bg-lighter);
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding: 8px 12px;
|
|
font-size: 0.85rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.topic-channel {
|
|
font-weight: bold;
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.topic-divider {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* Content Area */
|
|
.content-area {
|
|
flex: 1;
|
|
position: relative;
|
|
min-height: 0;
|
|
}
|
|
|
|
.tab-content {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: none;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: flex;
|
|
}
|
|
|
|
/* Map Tab Styling */
|
|
#map {
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: #07090e;
|
|
}
|
|
|
|
/* Custom styles for Leaflet elements to fit dark cyber theme */
|
|
.leaflet-bar {
|
|
border: 1px solid var(--border-color) !important;
|
|
background-color: var(--bg-darker) !important;
|
|
box-shadow: var(--border-glow) !important;
|
|
}
|
|
|
|
.leaflet-bar a {
|
|
background-color: var(--bg-darker) !important;
|
|
color: var(--text-primary) !important;
|
|
border-bottom: 1px solid var(--border-color) !important;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.leaflet-bar a:hover {
|
|
background-color: var(--text-primary) !important;
|
|
color: var(--bg-darker) !important;
|
|
}
|
|
|
|
.leaflet-popup-content-wrapper {
|
|
background-color: var(--bg-darker) !important;
|
|
color: var(--text-primary) !important;
|
|
border: 2px solid var(--border-color) !important;
|
|
border-radius: 4px !important;
|
|
box-shadow: var(--border-glow) !important;
|
|
font-family: 'Fira Code', monospace !important;
|
|
font-size: 0.8rem !important;
|
|
}
|
|
|
|
.leaflet-popup-tip {
|
|
background-color: var(--border-color) !important;
|
|
}
|
|
|
|
.leaflet-popup-content h3 {
|
|
color: var(--accent-color);
|
|
border-bottom: 1px dashed var(--border-color);
|
|
margin-bottom: 6px;
|
|
padding-bottom: 4px;
|
|
}
|
|
|
|
/* Custom glowing Leaflet marker style */
|
|
.custom-marker {
|
|
border-radius: 50%;
|
|
box-shadow: var(--border-glow);
|
|
border: 1px solid var(--bg-color);
|
|
}
|
|
|
|
/* Charts Tab Styling */
|
|
.charts-container {
|
|
padding: 15px;
|
|
overflow-y: auto;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
/* Custom Date Range selector bar inside Charts tab */
|
|
.charts-filter-bar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
align-items: center;
|
|
background-color: var(--bg-darker);
|
|
border: 1px solid var(--border-color);
|
|
padding: 10px 15px;
|
|
border-radius: 4px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.filter-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.filter-group label {
|
|
color: var(--accent-color);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.filter-input {
|
|
background: var(--bg-lighter);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-primary);
|
|
padding: 4px 8px;
|
|
font-family: inherit;
|
|
border-radius: 3px;
|
|
outline: none;
|
|
}
|
|
|
|
.filter-input:focus {
|
|
box-shadow: var(--border-glow);
|
|
}
|
|
|
|
.preset-btn {
|
|
background: var(--bg-lighter);
|
|
border: 1px solid var(--text-secondary);
|
|
color: var(--text-primary);
|
|
padding: 4px 8px;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
border-radius: 3px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.preset-btn:hover {
|
|
border-color: var(--border-color);
|
|
background-color: var(--bg-color);
|
|
}
|
|
|
|
.preset-btn.active {
|
|
background-color: var(--text-primary);
|
|
color: var(--bg-darker);
|
|
border-color: var(--border-color);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.charts-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 20px;
|
|
flex: 1;
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.charts-grid {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
|
|
.chart-box {
|
|
border: 1px solid var(--border-color);
|
|
background-color: var(--bg-darker);
|
|
padding: 15px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
min-height: 350px;
|
|
}
|
|
|
|
.chart-box-title {
|
|
font-weight: bold;
|
|
color: var(--accent-color);
|
|
font-size: 0.9rem;
|
|
border-bottom: 1px dashed rgba(0, 255, 102, 0.2);
|
|
padding-bottom: 5px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
body.theme-amber .chart-box-title {
|
|
border-bottom: 1px dashed rgba(255, 176, 0, 0.2);
|
|
}
|
|
|
|
body.theme-cyan .chart-box-title {
|
|
border-bottom: 1px dashed rgba(0, 229, 255, 0.2);
|
|
}
|
|
|
|
.chart-canvas-wrapper {
|
|
position: relative;
|
|
flex: 1;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
/* System Log / Console Tab */
|
|
.terminal-log {
|
|
flex: 1;
|
|
background-color: var(--bg-darker);
|
|
padding: 15px;
|
|
overflow-y: auto;
|
|
font-size: 0.85rem;
|
|
line-height: 1.4;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.log-line {
|
|
word-break: break-all;
|
|
}
|
|
|
|
.log-line.system {
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.log-line.whois {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.log-line.error {
|
|
color: var(--error-color);
|
|
}
|
|
|
|
.log-line.user-msg {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.log-line.user-msg .msg-nick {
|
|
color: var(--accent-color);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.log-time {
|
|
color: var(--text-secondary);
|
|
margin-right: 8px;
|
|
}
|
|
|
|
/* Nicklist Panel */
|
|
.irc-nicklist {
|
|
width: 250px;
|
|
border-left: 2px solid var(--border-color);
|
|
background-color: var(--bg-darker);
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.nicklist-header {
|
|
padding: 10px;
|
|
border-bottom: 1px solid rgba(0, 255, 102, 0.15);
|
|
background-color: var(--bg-lighter);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
body.theme-amber .nicklist-header {
|
|
border-bottom: 1px solid rgba(255, 176, 0, 0.15);
|
|
}
|
|
|
|
body.theme-cyan .nicklist-header {
|
|
border-bottom: 1px solid rgba(0, 229, 255, 0.15);
|
|
}
|
|
|
|
.nick-count {
|
|
font-weight: bold;
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.nick-search-input {
|
|
width: 100%;
|
|
background: var(--bg-darker);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-primary);
|
|
padding: 5px 8px;
|
|
font-family: inherit;
|
|
font-size: 0.8rem;
|
|
border-radius: 3px;
|
|
outline: none;
|
|
}
|
|
|
|
.nick-search-input:focus {
|
|
box-shadow: var(--border-glow);
|
|
}
|
|
|
|
.nicks-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
list-style: none;
|
|
}
|
|
|
|
.nick-item {
|
|
padding: 4px 6px;
|
|
cursor: pointer;
|
|
border-radius: 2px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
transition: all 0.15s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.nick-item:hover {
|
|
background-color: var(--bg-lighter);
|
|
text-shadow: var(--text-glow);
|
|
}
|
|
|
|
.nick-prefix {
|
|
color: var(--text-secondary);
|
|
font-weight: bold;
|
|
}
|
|
|
|
/* Scrollbars Custom Styling */
|
|
::-webkit-scrollbar {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: var(--bg-darker);
|
|
border-left: 1px solid rgba(0, 255, 102, 0.1);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--text-secondary);
|
|
border: 2px solid var(--bg-darker);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: var(--text-primary);
|
|
}
|
|
|
|
/* Command Input Line at Bottom */
|
|
.irc-input-area {
|
|
background-color: var(--bg-darker);
|
|
border-top: 2px solid var(--border-color);
|
|
padding: 8px 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.input-prompt {
|
|
font-weight: bold;
|
|
color: var(--accent-color);
|
|
white-space: nowrap;
|
|
text-shadow: var(--text-glow);
|
|
}
|
|
|
|
.input-prompt span {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.cli-input {
|
|
flex: 1;
|
|
background: transparent;
|
|
border: none;
|
|
outline: none;
|
|
color: var(--text-primary);
|
|
font-family: inherit;
|
|
font-size: inherit;
|
|
text-shadow: var(--text-glow);
|
|
}
|
|
|
|
/* Custom Popup Styles */
|
|
.whois-popup {
|
|
font-family: 'Fira Code', monospace;
|
|
color: var(--text-primary);
|
|
line-height: 1.4;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="flicker">
|
|
|
|
<!-- Scanline effect -->
|
|
<div class="scanlines" id="scanlines-overlay"></div>
|
|
|
|
<div class="app-container">
|
|
|
|
<!-- Top Client Header -->
|
|
<header class="irc-header">
|
|
<div class="irc-title">▓ BitChat Relay Geo-Network v1.0.0 ▓</div>
|
|
<div class="irc-status">
|
|
<div class="status-item">Server: <span>irc.bitchat.org</span></div>
|
|
<div class="status-item">User: <span>cypherpunk(+i)</span></div>
|
|
<div class="status-item">Date: <span id="current-date">--</span></div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="irc-body">
|
|
|
|
<!-- Left Sidebar (Channels & Controllers) -->
|
|
<aside class="irc-sidebar">
|
|
|
|
<!-- Channels Section -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-title">Active Panes</div>
|
|
<ul class="channel-list">
|
|
<li class="channel-item active" data-tab="map-tab">
|
|
<span class="channel-hash">#</span>bitchat-map
|
|
</li>
|
|
<li class="channel-item" data-tab="globe-tab">
|
|
<span class="channel-hash">#</span>bitchat-3d
|
|
</li>
|
|
<li class="channel-item" data-tab="charts-tab">
|
|
<span class="channel-hash">#</span>bitchat-charts
|
|
</li>
|
|
<li class="channel-item" data-tab="log-tab">
|
|
<span class="channel-hash">#</span>system-log
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- CLI Command Quick buttons -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-title">Quick Commands</div>
|
|
<div class="options-grid">
|
|
<button class="control-btn" onclick="executeCLICommand('/help')">/_ help</button>
|
|
<button class="control-btn" onclick="executeCLICommand('/scanlines')">/_ scanlines</button>
|
|
<button class="control-btn" onclick="executeCLICommand('/clear')">/_ clear logs</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Theme Changer Section -->
|
|
<div class="sidebar-section" style="border-bottom: none; margin-top: auto;">
|
|
<div class="sidebar-title">Terminal Theme</div>
|
|
<div class="options-grid" style="flex-direction: row; flex-wrap: wrap; gap: 4px;">
|
|
<button class="control-btn" style="flex: 1; text-align: center;" onclick="executeCLICommand('/theme green')">Green</button>
|
|
<button class="control-btn" style="flex: 1; text-align: center;" onclick="executeCLICommand('/theme amber')">Amber</button>
|
|
<button class="control-btn" style="flex: 1; text-align: center;" onclick="executeCLICommand('/theme cyan')">Cyan</button>
|
|
</div>
|
|
</div>
|
|
|
|
</aside>
|
|
|
|
<!-- Main Panel containing Tabs -->
|
|
<main class="irc-main">
|
|
|
|
<!-- Header status for active channel -->
|
|
<div class="channel-topic-bar">
|
|
<span class="topic-channel" id="active-tab-title">#bitchat-map</span>
|
|
<span class="topic-divider">[topic:</span>
|
|
<span id="active-tab-desc" style="color: var(--text-primary);">Interactive map tracking geolocated BitChat relays</span>
|
|
<span class="topic-divider">]</span>
|
|
</div>
|
|
|
|
<!-- Content Area -->
|
|
<div class="content-area">
|
|
|
|
<!-- Tab 1: Map View -->
|
|
<div class="tab-content active" id="map-tab">
|
|
<div id="map"></div>
|
|
</div>
|
|
|
|
<!-- Tab 1.5: 3D Globe View -->
|
|
<div class="tab-content" id="globe-tab">
|
|
<div id="globe-3d-container" style="width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; overflow: hidden; background-color: var(--bg-darker);"></div>
|
|
</div>
|
|
|
|
<!-- Tab 2: Charts View -->
|
|
<div class="tab-content" id="charts-tab">
|
|
<div class="charts-container">
|
|
|
|
<!-- Custom Interactive Filter Bar for aggregation -->
|
|
<div class="charts-filter-bar">
|
|
<div class="filter-group">
|
|
<label>Preset ranges:</label>
|
|
<button class="preset-btn" onclick="setChartRangePreset(7)" id="preset-7">7 Days</button>
|
|
<button class="preset-btn" onclick="setChartRangePreset(30)" id="preset-30">30 Days</button>
|
|
<button class="preset-btn active" onclick="setChartRangePreset(70)" id="preset-70">70 Days</button>
|
|
</div>
|
|
|
|
<div style="flex-grow: 1;"></div>
|
|
|
|
<div class="filter-group">
|
|
<label>Custom Start:</label>
|
|
<input type="date" class="filter-input" id="chart-start-date" onchange="onCustomDateChange()">
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>End:</label>
|
|
<input type="date" class="filter-input" id="chart-end-date" onchange="onCustomDateChange()">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Interactive Graphs Grid -->
|
|
<div class="charts-grid">
|
|
|
|
<!-- Chart 1 -->
|
|
<div class="chart-box">
|
|
<div class="chart-box-title">
|
|
<span>BitChat-Compatible Relay Count</span>
|
|
<span id="bitchat-stat-box" style="font-size: 0.8rem; color: var(--text-primary);">--</span>
|
|
</div>
|
|
<div class="chart-canvas-wrapper">
|
|
<canvas id="bitchat-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chart 2 -->
|
|
<div class="chart-box">
|
|
<div class="chart-box-title">
|
|
<span>Total Functioning Relays Discovered</span>
|
|
<span id="total-stat-box" style="font-size: 0.8rem; color: var(--text-primary);">--</span>
|
|
</div>
|
|
<div class="chart-canvas-wrapper">
|
|
<canvas id="total-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 3: System Log View -->
|
|
<div class="tab-content" id="log-tab">
|
|
<div class="terminal-log" id="terminal-log-container">
|
|
<!-- Log entries will be prepended/appended here -->
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<!-- Right Sidebar: Nicklist/Relaylist -->
|
|
<aside class="irc-nicklist">
|
|
<div class="nicklist-header">
|
|
<div>Relays (<span class="nick-count" id="active-nick-count">0</span>)</div>
|
|
<input type="text" class="nick-search-input" id="nick-search" placeholder="/filter [term] or type..." oninput="onNickSearchInput(this.value)" style="margin-bottom: 6px;">
|
|
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.75rem; cursor: pointer; user-select: none; color: var(--text-primary);">
|
|
<input type="checkbox" id="bitchat-filter-checkbox" checked onchange="toggleBitChatFilter()" style="accent-color: var(--text-primary); cursor: pointer;">
|
|
BitChat Only (+voice)
|
|
</label>
|
|
</div>
|
|
<ul class="nicks-container" id="nicks-list">
|
|
<!-- Filled dynamically -->
|
|
</ul>
|
|
</aside>
|
|
|
|
</div>
|
|
|
|
<!-- Bottom Input CLI Console Line -->
|
|
<footer class="irc-input-area">
|
|
<div class="input-prompt">[cypherpunk(+i)]<span> /_</span></div>
|
|
<input type="text" class="cli-input" id="cli-input-box" placeholder="Type a CLI command (e.g. /help, /theme, /whois [relay]) or search terms..." autofocus autocomplete="off">
|
|
</footer>
|
|
|
|
</div>
|
|
|
|
<!-- Leaflet Map JS -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
|
|
<!-- ChartJS JS -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
|
|
<!-- Globe.gl WebGL JS for the 3D globe -->
|
|
<script src="https://unpkg.com/globe.gl"></script>
|
|
|
|
<script>
|
|
// Global App State
|
|
const state = {
|
|
relays: [], // list of { url, lat, lon }
|
|
history: null, // { bitchat: [{date, count, commit}], total: [] }
|
|
map: null,
|
|
markers: [],
|
|
charts: {
|
|
bitchat: null,
|
|
total: null
|
|
},
|
|
currentTab: 'map-tab',
|
|
activeFilter: '',
|
|
activeRangePreset: 70 // default is 70 commits/days
|
|
};
|
|
|
|
// Fallback data if fetch fails (e.g., local CORS restrictions)
|
|
const fallbackData = {
|
|
relays: [
|
|
{ url: "nostr-relay.corb.net:443", lat: 38.8353, lon: -104.822, isBitChat: 1 },
|
|
{ url: "nittom.nostr1.com", lat: 40.7057, lon: -74.0136, isBitChat: 1 },
|
|
{ url: "relay.nostr.blockhenge.com", lat: 39.0438, lon: -77.4874, isBitChat: 1 },
|
|
{ url: "relay.artx.market:443", lat: 43.6548, lon: -79.3885, isBitChat: 0 },
|
|
{ url: "eu.nostr.pikachat.org:443", lat: 49.4543, lon: 11.0746, isBitChat: 1 },
|
|
{ url: "nostr.oxtr.dev", lat: 50.4754, lon: 12.3683, isBitChat: 0 },
|
|
{ url: "nostr.2b9t.xyz:443", lat: 34.0549, lon: -118.243, isBitChat: 1 },
|
|
{ url: "nostr.tagomago.me", lat: 42.3601, lon: -71.0589, isBitChat: 0 },
|
|
{ url: "relay.getsafebox.app:443", lat: 43.6532, lon: -79.3832, isBitChat: 1 },
|
|
{ url: "nostr.spaceshell.xyz", lat: 43.6532, lon: -79.3832, isBitChat: 0 },
|
|
{ url: "social.amanah.eblessing.co", lat: 48.1046, lon: 11.6002, isBitChat: 0 },
|
|
{ url: "relay.trustr.ing:443", lat: 43.6548, lon: -79.3885, isBitChat: 1 },
|
|
{ url: "rele.speyhard.fi", lat: 51.5072, lon: -0.127586, isBitChat: 1 }
|
|
],
|
|
history: {
|
|
bitchat: [
|
|
{ date: "2026-04-01", count: 313, commit: "mock1" },
|
|
{ date: "2026-04-10", count: 320, commit: "mock2" },
|
|
{ date: "2026-04-20", count: 335, commit: "mock3" },
|
|
{ date: "2026-04-30", count: 350, commit: "mock4" },
|
|
{ date: "2026-05-10", count: 375, commit: "mock5" },
|
|
{ date: "2026-05-20", count: 390, commit: "mock6" },
|
|
{ date: "2026-06-01", count: 411, commit: "mock7" }
|
|
],
|
|
total: [
|
|
{ date: "2026-04-01", count: 720, commit: "mock1" },
|
|
{ date: "2026-04-10", count: 750, commit: "mock2" },
|
|
{ date: "2026-04-20", count: 790, commit: "mock3" },
|
|
{ date: "2026-04-30", count: 830, commit: "mock4" },
|
|
{ date: "2026-05-10", count: 880, commit: "mock5" },
|
|
{ date: "2026-05-20", count: 910, commit: "mock6" },
|
|
{ date: "2026-06-01", count: 962, commit: "mock7" }
|
|
]
|
|
}
|
|
};
|
|
|
|
// Initialization on Page Load
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
// Set timestamp
|
|
document.getElementById('current-date').textContent = new Date().toISOString().split('T')[0];
|
|
|
|
// Setup Terminal view log
|
|
logSystemMessage("==========================================================================");
|
|
logSystemMessage(" _ _ _ _ _ ____ ___ _____ ____ _ _ _ _____ ____ _ _ ");
|
|
logSystemMessage("| |/ /| || || | | __ )_ _|_ _/ ___| | | | / \\|_ _| | _ \\| | | |");
|
|
logSystemMessage("| ' / | || || | | _ \\| | | || | | |_| | / _ \\ | | | |_) | | | |");
|
|
logSystemMessage("| . \\ | \\/ || |__| |_) | | | || |___| _ |/ ___ \\| | | _ <| |___| |");
|
|
logSystemMessage("|_|\\_\\ \\__/ |____|____/___| |_| \\____|_| |_/_/ \\_\\_| |_| \\_\\_____|_|");
|
|
logSystemMessage("==========================================================================");
|
|
logSystemMessage("Initializing connection to BitChat GeoRelays Terminal...");
|
|
logSystemMessage("Retrieving geolocated relay network data...");
|
|
|
|
// Init UI Elements
|
|
setupTabs();
|
|
setupCLIInput();
|
|
|
|
// Init Leaflet Map
|
|
initMap();
|
|
|
|
// Load Relay Data
|
|
loadRelayData();
|
|
|
|
// Load History Data
|
|
loadHistoryData();
|
|
});
|
|
|
|
// Logging Helpers
|
|
function logSystemMessage(text) {
|
|
const logContainer = document.getElementById('terminal-log-container');
|
|
const line = document.createElement('div');
|
|
line.className = 'log-line system';
|
|
|
|
const time = document.createElement('span');
|
|
time.className = 'log-time';
|
|
time.textContent = `[${new Date().toTimeString().split(' ')[0]}]`;
|
|
|
|
const content = document.createElement('span');
|
|
content.textContent = `*** ${text}`;
|
|
|
|
line.appendChild(time);
|
|
line.appendChild(content);
|
|
logContainer.appendChild(line);
|
|
logContainer.scrollTop = logContainer.scrollHeight;
|
|
}
|
|
|
|
function logErrorMessage(text) {
|
|
const logContainer = document.getElementById('terminal-log-container');
|
|
const line = document.createElement('div');
|
|
line.className = 'log-line error';
|
|
|
|
const time = document.createElement('span');
|
|
time.className = 'log-time';
|
|
time.textContent = `[${new Date().toTimeString().split(' ')[0]}]`;
|
|
|
|
const content = document.createElement('span');
|
|
content.textContent = `!!! ERROR: ${text}`;
|
|
|
|
line.appendChild(time);
|
|
line.appendChild(content);
|
|
logContainer.appendChild(line);
|
|
logContainer.scrollTop = logContainer.scrollHeight;
|
|
}
|
|
|
|
function logUserMessage(user, msg) {
|
|
const logContainer = document.getElementById('terminal-log-container');
|
|
const line = document.createElement('div');
|
|
line.className = 'log-line user-msg';
|
|
|
|
const time = document.createElement('span');
|
|
time.className = 'log-time';
|
|
time.textContent = `[${new Date().toTimeString().split(' ')[0]}]`;
|
|
|
|
const nick = document.createElement('span');
|
|
nick.className = 'msg-nick';
|
|
nick.textContent = `<${user}> `;
|
|
|
|
const content = document.createElement('span');
|
|
content.textContent = msg;
|
|
|
|
line.appendChild(time);
|
|
line.appendChild(nick);
|
|
line.appendChild(content);
|
|
logContainer.appendChild(line);
|
|
logContainer.scrollTop = logContainer.scrollHeight;
|
|
}
|
|
|
|
function logWhoisMessage(title, dataLines) {
|
|
const logContainer = document.getElementById('terminal-log-container');
|
|
const block = document.createElement('div');
|
|
block.className = 'log-line whois';
|
|
|
|
const timeStr = `[${new Date().toTimeString().split(' ')[0]}]`;
|
|
|
|
let html = `<div style="margin: 6px 0; border-left: 2px solid var(--accent-color); padding-left: 10px;">`;
|
|
html += `<span class="log-time">${timeStr}</span> <span style="color:var(--accent-color);font-weight:bold;">WHOIS ${title}</span><br>`;
|
|
dataLines.forEach(line => {
|
|
html += `<span class="log-time">${timeStr}</span> *** ${line}<br>`;
|
|
});
|
|
html += `</div>`;
|
|
|
|
block.innerHTML = html;
|
|
logContainer.appendChild(block);
|
|
logContainer.scrollTop = logContainer.scrollHeight;
|
|
}
|
|
|
|
// Tabs / Channels Navigation
|
|
function setupTabs() {
|
|
const tabs = document.querySelectorAll('.channel-item');
|
|
tabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
switchTab(tab.getAttribute('data-tab'));
|
|
});
|
|
});
|
|
}
|
|
|
|
function switchTab(tabId) {
|
|
// Toggle Tab Content classes
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
const activeContent = document.getElementById(tabId);
|
|
if (activeContent) activeContent.classList.add('active');
|
|
|
|
// Toggle Sidebar List Active
|
|
document.querySelectorAll('.channel-item').forEach(i => i.classList.remove('active'));
|
|
const activeItem = document.querySelector(`.channel-item[data-tab="${tabId}"]`);
|
|
if (activeItem) activeItem.classList.add('active');
|
|
|
|
// Update active channel labels
|
|
state.currentTab = tabId;
|
|
const titleEl = document.getElementById('active-tab-title');
|
|
const descEl = document.getElementById('active-tab-desc');
|
|
|
|
if (tabId === 'map-tab') {
|
|
titleEl.textContent = '#bitchat-map';
|
|
descEl.textContent = 'Interactive map tracking geolocated BitChat relays';
|
|
if (state.map) {
|
|
// Leaflet needs an invalidateSize call when container shifts visibility
|
|
setTimeout(() => state.map.invalidateSize(), 50);
|
|
}
|
|
} else if (tabId === 'globe-tab') {
|
|
titleEl.textContent = '#bitchat-3d';
|
|
descEl.textContent = '3D Cinematic holographic terminal tracking geolocated relays';
|
|
|
|
// Lazy initialize the 3D globe to optimize startup performance
|
|
if (!state.globe) {
|
|
logSystemMessage("Booting WebGL 3D Globe Engine... Loading targeting mesh.");
|
|
setTimeout(() => {
|
|
init3DGlobe();
|
|
const container = document.getElementById('globe-3d-container');
|
|
if (container) {
|
|
state.globe.width(container.clientWidth).height(container.clientHeight);
|
|
}
|
|
logSystemMessage("3D Holographic target grid initialized. Auto-rotate ENABLED.");
|
|
}, 100);
|
|
} else {
|
|
// Trigger a resize/refresh in case size of container shifted
|
|
const container = document.getElementById('globe-3d-container');
|
|
if (container) {
|
|
setTimeout(() => state.globe.width(container.clientWidth).height(container.clientHeight), 50);
|
|
}
|
|
}
|
|
} else if (tabId === 'charts-tab') {
|
|
titleEl.textContent = '#bitchat-charts';
|
|
descEl.textContent = 'Interactive trend analysis over custom date ranges';
|
|
} else if (tabId === 'log-tab') {
|
|
titleEl.textContent = '#system-log';
|
|
descEl.textContent = 'Live terminal log console outputs';
|
|
}
|
|
}
|
|
|
|
// Leaflet Map Setup
|
|
function initMap() {
|
|
// Coordinates centered on Europe/Atlantic for global balanced view
|
|
state.map = L.map('map', {
|
|
minZoom: 1.5,
|
|
maxZoom: 10,
|
|
zoomSnap: 0.5
|
|
}).setView([18, 0], 2);
|
|
|
|
// CartoDB Dark Matter tiles (Perfect cypherpunk / terminal aesthetics)
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
|
subdomains: 'abcd',
|
|
maxZoom: 20
|
|
}).addTo(state.map);
|
|
}
|
|
|
|
// WebGL 3D Globe Setup
|
|
function init3DGlobe() {
|
|
const container = document.getElementById('globe-3d-container');
|
|
if (!container || state.globe) return;
|
|
|
|
const styles = getComputedStyle(document.documentElement);
|
|
const primaryColor = styles.getPropertyValue('--text-primary').trim();
|
|
const accentColor = styles.getPropertyValue('--accent-color').trim();
|
|
|
|
// Instantiate Globe.gl
|
|
state.globe = Globe()(container)
|
|
.backgroundColor('rgba(0,0,0,0)') // Transparent context to let the CSS background bleed through
|
|
.showAtmosphere(true)
|
|
.atmosphereColor(primaryColor) // glowing atmosphere matching current terminal theme
|
|
.atmosphereAltitude(0.18)
|
|
.showGraticules(true) // latitude/longitude wireframe grid
|
|
|
|
// Load the beautiful dark Earth satellite map texture!
|
|
.globeImageUrl('https://unpkg.com/three-globe/example/img/earth-dark.jpg')
|
|
|
|
// Bind Geolocation coordinates
|
|
.pointsData(state.relays)
|
|
.pointLat(d => d.lat)
|
|
.pointLng(d => d.lon)
|
|
.pointColor(d => d.isBitChat === 1 ? primaryColor : accentColor)
|
|
.pointRadius(d => d.isBitChat === 1 ? 0.6 : 0.3)
|
|
.pointAltitude(d => d.isBitChat === 1 ? 0.08 : 0.03) // Taller towers for voice/BitChat relays!
|
|
.pointResolution(8)
|
|
|
|
// Styled popup tooltip
|
|
.pointLabel(d => `
|
|
<div style="background-color: var(--bg-darker); border: 1px solid ${d.isBitChat === 1 ? 'var(--text-primary)' : 'var(--accent-color)'}; color: var(--text-primary); font-family: 'Fira Code', monospace; font-size: 0.8rem; padding: 8px; border-radius: 4px; box-shadow: var(--border-glow);">
|
|
<strong style="color: var(--accent-color);">wss://${d.url}</strong><br>
|
|
Scope: ${d.isBitChat === 1 ? 'BitChat (Kind 20000)' : 'Standard Relay'}<br>
|
|
Coords: ${d.lat.toFixed(4)}, ${d.lon.toFixed(4)}
|
|
</div>
|
|
`)
|
|
|
|
// Clicking points triggers terminal WHOIS report and halts rotation
|
|
.onPointClick(d => {
|
|
stopGlobeRotation();
|
|
triggerWhois(d);
|
|
});
|
|
|
|
// Enable slowly spinning globe controls
|
|
state.globe.controls().autoRotate = true;
|
|
state.globe.controls().autoRotateSpeed = 0.6;
|
|
state.globe.controls().enableZoom = true;
|
|
|
|
// Adjust camera distance for global viewing
|
|
state.globe.pointOfView({ lat: 20, lng: 0, altitude: 2.2 });
|
|
|
|
// Container click listener to resume spinning when clicking on empty space
|
|
container.addEventListener('mousedown', () => {
|
|
if (state.globe && !state.globe.controls().autoRotate) {
|
|
state.globe.controls().autoRotate = true;
|
|
logSystemMessage("3D Globe auto-rotation RESUMED.");
|
|
}
|
|
});
|
|
}
|
|
|
|
function stopGlobeRotation() {
|
|
if (state.globe && state.globe.controls().autoRotate) {
|
|
state.globe.controls().autoRotate = false;
|
|
logSystemMessage("3D Globe auto-rotation PAUSED (Locking target). Click empty space to resume.");
|
|
}
|
|
}
|
|
|
|
function updateGlobeColors() {
|
|
if (!state.globe) return;
|
|
const styles = getComputedStyle(document.documentElement);
|
|
const primaryColor = styles.getPropertyValue('--text-primary').trim();
|
|
const accentColor = styles.getPropertyValue('--accent-color').trim();
|
|
|
|
state.globe
|
|
.atmosphereColor(primaryColor)
|
|
.pointColor(d => d.isBitChat === 1 ? primaryColor : accentColor);
|
|
}
|
|
|
|
// Window Resize Handler to update WebGL aspect ratios
|
|
window.addEventListener('resize', () => {
|
|
if (state.globe) {
|
|
const container = document.getElementById('globe-3d-container');
|
|
if (container) {
|
|
state.globe.width(container.clientWidth).height(container.clientHeight);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load and Parse Relay CSV
|
|
function loadRelayData() {
|
|
fetch('all_relays_geo.csv')
|
|
.then(response => {
|
|
if (!response.ok) throw new Error("Could not fetch CSV");
|
|
return response.text();
|
|
})
|
|
.then(csvText => {
|
|
parseAndPopulateRelays(csvText);
|
|
logSystemMessage(`Successfully mapped ${state.relays.length} active geolocated relays from all_relays_geo.csv.`);
|
|
})
|
|
.catch(err => {
|
|
logErrorMessage(`Failed to fetch 'all_relays_geo.csv' due to CORS or network rules.`);
|
|
logSystemMessage("Loading secure offline preview database fallback (CORS / standalone mode)...");
|
|
|
|
// Load offline fallback data
|
|
const csvMock = "Relay URL,Latitude,Longitude,IsBitChat\n" +
|
|
fallbackData.relays.map(r => `${r.url},${r.lat},${r.lon},${r.isBitChat}`).join('\n');
|
|
parseAndPopulateRelays(csvMock);
|
|
});
|
|
}
|
|
|
|
function parseAndPopulateRelays(csvText) {
|
|
const lines = csvText.trim().split('\n');
|
|
if (lines.length <= 1) return;
|
|
|
|
const results = [];
|
|
// Skip header
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (!line) continue;
|
|
|
|
const parts = line.split(',');
|
|
if (parts.length >= 3) {
|
|
const url = parts[0].trim();
|
|
const lat = parseFloat(parts[1]);
|
|
const lon = parseFloat(parts[2]);
|
|
const isBitChat = parts.length >= 4 ? parseInt(parts[3]) : 1; // Default to 1
|
|
if (!isNaN(lat) && !isNaN(lon)) {
|
|
results.push({ url, lat, lon, isBitChat });
|
|
}
|
|
}
|
|
}
|
|
|
|
state.relays = results;
|
|
document.getElementById('active-nick-count').textContent = results.length;
|
|
|
|
// Plot coordinates
|
|
plotRelayMarkers();
|
|
|
|
// Fill nicklist sidebar
|
|
populateNicklist();
|
|
}
|
|
|
|
function plotRelayMarkers() {
|
|
// Clear existing markers
|
|
state.markers.forEach(m => state.map.removeLayer(m));
|
|
state.markers = [];
|
|
|
|
const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim();
|
|
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim();
|
|
const glowColor = getComputedStyle(document.documentElement).getPropertyValue('--border-color').trim();
|
|
|
|
const showBitChatOnly = document.getElementById('bitchat-filter-checkbox') ? document.getElementById('bitchat-filter-checkbox').checked : true;
|
|
|
|
// Custom Circle Markers for blazing-fast canvas rendering and glowing node look
|
|
state.relays.forEach(r => {
|
|
// Skip non-BitChat if filter is checked
|
|
if (showBitChatOnly && r.isBitChat === 0) {
|
|
return;
|
|
}
|
|
|
|
const isBC = r.isBitChat === 1;
|
|
const markerColor = isBC ? primaryColor : accentColor;
|
|
const markerRadius = isBC ? 5.5 : 3.5;
|
|
const markerOpacity = isBC ? 0.95 : 0.65;
|
|
const markerFillOpacity = isBC ? 0.85 : 0.45;
|
|
|
|
// Create circle marker
|
|
const marker = L.circleMarker([r.lat, r.lon], {
|
|
radius: markerRadius,
|
|
fillColor: markerColor,
|
|
color: '#000',
|
|
weight: 0.8,
|
|
opacity: markerOpacity,
|
|
fillOpacity: markerFillOpacity,
|
|
className: isBC ? 'custom-marker' : ''
|
|
});
|
|
|
|
// Setup Popup HTML
|
|
const whoisMeta = getMockWhoisRegistrar(r.url);
|
|
const popupHTML = `
|
|
<div class="whois-popup">
|
|
<h3>WHOIS DETAILS</h3>
|
|
<strong>URL:</strong> wss://${r.url}<br>
|
|
<strong>Lat:</strong> ${r.lat.toFixed(4)}<br>
|
|
<strong>Lon:</strong> ${r.lon.toFixed(4)}<br>
|
|
<strong>Registrar:</strong> ${whoisMeta.registrar.split(' (')[0]}<br>
|
|
<strong>Network:</strong> ${whoisMeta.provider.split(' (')[0]}<br>
|
|
<strong>Scope:</strong> ${isBC ? 'BitChat (Kind 20000)' : 'Standard Nostr Relay'}<br>
|
|
<span style="font-size: 0.75rem; color:var(--text-secondary); margin-top:5px; display:block;">Click nick in sidebar for log WHOIS</span>
|
|
</div>
|
|
`;
|
|
marker.bindPopup(popupHTML);
|
|
|
|
// Map events
|
|
marker.on('click', () => {
|
|
triggerWhois(r);
|
|
});
|
|
|
|
marker.on('mouseover', function(e) {
|
|
this.setStyle({
|
|
radius: isBC ? 8 : 5,
|
|
fillColor: isBC ? 'var(--accent-color)' : '#999999',
|
|
fillOpacity: 1
|
|
});
|
|
});
|
|
|
|
marker.on('mouseout', function(e) {
|
|
this.setStyle({
|
|
radius: markerRadius,
|
|
fillColor: markerColor,
|
|
fillOpacity: markerFillOpacity
|
|
});
|
|
});
|
|
|
|
marker.addTo(state.map);
|
|
state.markers.push(marker);
|
|
});
|
|
}
|
|
|
|
// Populate Sidebar Nicklist
|
|
function populateNicklist() {
|
|
const nicksList = document.getElementById('nicks-list');
|
|
nicksList.innerHTML = '';
|
|
|
|
const showBitChatOnly = document.getElementById('bitchat-filter-checkbox') ? document.getElementById('bitchat-filter-checkbox').checked : true;
|
|
|
|
const filtered = state.relays.filter(r => {
|
|
const matchesSearch = r.url.toLowerCase().includes(state.activeFilter.toLowerCase());
|
|
const matchesBitChat = !showBitChatOnly || r.isBitChat === 1;
|
|
return matchesSearch && matchesBitChat;
|
|
});
|
|
|
|
// Update active nick count
|
|
document.getElementById('active-nick-count').textContent = filtered.length;
|
|
|
|
filtered.forEach(r => {
|
|
const li = document.createElement('li');
|
|
li.className = 'nick-item';
|
|
li.title = `wss://${r.url}`;
|
|
if (r.isBitChat === 0) {
|
|
li.style.color = '#777';
|
|
}
|
|
|
|
const prefix = document.createElement('span');
|
|
prefix.className = 'nick-prefix';
|
|
prefix.textContent = r.isBitChat === 1 ? '+' : ' ';
|
|
if (r.isBitChat === 0) {
|
|
prefix.style.color = '#444';
|
|
}
|
|
|
|
const name = document.createElement('span');
|
|
name.textContent = r.url;
|
|
|
|
li.appendChild(prefix);
|
|
li.appendChild(name);
|
|
|
|
li.addEventListener('click', () => {
|
|
if (state.currentTab === 'globe-tab' && state.globe) {
|
|
stopGlobeRotation();
|
|
// Smoothly glide 3D camera in space to target node (altitude 1.2, 1.2s transition)
|
|
state.globe.pointOfView({ lat: r.lat, lng: r.lon, altitude: 1.2 }, 1200);
|
|
} else {
|
|
// Zoom to location on 2D map
|
|
switchTab('map-tab');
|
|
state.map.setView([r.lat, r.lon], 7);
|
|
|
|
// Find marker and open popup
|
|
const markerIndex = state.relays.filter(el => !showBitChatOnly || el.isBitChat === 1).indexOf(r);
|
|
if (markerIndex !== -1 && state.markers[markerIndex]) {
|
|
state.markers[markerIndex].openPopup();
|
|
}
|
|
}
|
|
|
|
// Output WHOIS to system logs
|
|
triggerWhois(r);
|
|
});
|
|
|
|
nicksList.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function toggleBitChatFilter() {
|
|
populateNicklist();
|
|
plotRelayMarkers();
|
|
|
|
// Instantly update the 3D Globe data if it's active
|
|
if (state.globe) {
|
|
const showBitChatOnly = document.getElementById('bitchat-filter-checkbox') ? document.getElementById('bitchat-filter-checkbox').checked : true;
|
|
const filteredPoints = state.relays.filter(r => !showBitChatOnly || r.isBitChat === 1);
|
|
state.globe.pointsData(filteredPoints);
|
|
}
|
|
}
|
|
|
|
function onNickSearchInput(val) {
|
|
state.activeFilter = val;
|
|
populateNicklist();
|
|
}
|
|
|
|
function getMockWhoisRegistrar(hostname) {
|
|
// Extract domain name
|
|
const parts = hostname.split(':');
|
|
const cleanHost = parts[0]; // strip port
|
|
const hostParts = cleanHost.split('.');
|
|
let domain = cleanHost;
|
|
if (hostParts.length >= 2) {
|
|
domain = hostParts.slice(-2).join('.');
|
|
}
|
|
|
|
const registrars = [
|
|
"Cloudflare, Inc. (iana: 1462)",
|
|
"Namecheap, Inc. (iana: 1068)",
|
|
"GoDaddy.com, LLC (iana: 146)",
|
|
"Gandi SAS (iana: 81)",
|
|
"Porkbun LLC (iana: 1861)",
|
|
"Tucows Domains Inc. (iana: 69)"
|
|
];
|
|
|
|
const providers = [
|
|
"Hetzner Online GmbH (AS24875)",
|
|
"DigitalOcean, LLC (AS14061)",
|
|
"OVH SAS (AS16276)",
|
|
"Amazon Technologies, Inc. (AS16509)",
|
|
"Google LLC (AS15169)",
|
|
"Linode, LLC (AS63949)",
|
|
"Cloudflare, Inc. (AS13335)",
|
|
"Contabo GmbH (AS51167)"
|
|
];
|
|
|
|
// simple deterministic hash from string
|
|
let hash = 0;
|
|
for (let i = 0; i < domain.length; i++) {
|
|
hash = domain.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
hash = Math.abs(hash);
|
|
|
|
const registrar = registrars[hash % registrars.length];
|
|
const provider = providers[(hash + 3) % providers.length];
|
|
|
|
return { registrar, provider, cleanHost };
|
|
}
|
|
|
|
function triggerWhois(relay) {
|
|
const whoisMeta = getMockWhoisRegistrar(relay.url);
|
|
const lines = [
|
|
`Host Address : wss://${relay.url}`,
|
|
`Coordinates : Latitude ${relay.lat.toFixed(6)}, Longitude ${relay.lon.toFixed(6)}`,
|
|
`Registrar : ${whoisMeta.registrar}`,
|
|
`Net Provider : ${whoisMeta.provider}`,
|
|
`Capability : BitChat Client Channel (Nostr Kind 20000 Read/Write verified)`,
|
|
`DB-IP Status : IPv4 Host Range Verified Location POP`,
|
|
`End of WHOIS : Securing connection channel.`
|
|
];
|
|
logWhoisMessage(relay.url, lines);
|
|
}
|
|
|
|
// Load History Charts Data
|
|
function loadHistoryData() {
|
|
fetch('assets/relay_history.json')
|
|
.then(response => {
|
|
if (!response.ok) throw new Error("Could not fetch history JSON");
|
|
return response.json();
|
|
})
|
|
.then(historyData => {
|
|
state.history = historyData;
|
|
logSystemMessage("Loaded interactive historical database assets/relay_history.json.");
|
|
initCharts();
|
|
})
|
|
.catch(err => {
|
|
logErrorMessage("Failed to load historical charts data due to network restrictions.");
|
|
logSystemMessage("Using integrated fallback offline simulation database for charts preview.");
|
|
state.history = fallbackData.history;
|
|
initCharts();
|
|
});
|
|
}
|
|
|
|
// Initialize ChartJS Graphics
|
|
function initCharts() {
|
|
// Default limits for dates
|
|
const bitchatDates = state.history.bitchat.map(d => d.date);
|
|
|
|
const minDate = bitchatDates[0];
|
|
const maxDate = bitchatDates[bitchatDates.length - 1];
|
|
|
|
document.getElementById('chart-start-date').min = minDate;
|
|
document.getElementById('chart-start-date').max = maxDate;
|
|
document.getElementById('chart-end-date').min = minDate;
|
|
document.getElementById('chart-end-date').max = maxDate;
|
|
|
|
// Default presets to full range
|
|
setChartRangePreset(state.activeRangePreset);
|
|
}
|
|
|
|
function renderCharts(bitchatFiltered, totalFiltered) {
|
|
// Get current CSS computed colors for matching chart theme dynamically!
|
|
const styles = getComputedStyle(document.documentElement);
|
|
const textPrimary = styles.getPropertyValue('--text-primary').trim();
|
|
const textSecondary = styles.getPropertyValue('--text-secondary').trim();
|
|
const bgDarker = styles.getPropertyValue('--bg-darker').trim();
|
|
const accentColor = styles.getPropertyValue('--accent-color').trim();
|
|
|
|
// BitChat Chart Data Config
|
|
const bcLabels = bitchatFiltered.map(d => d.date);
|
|
const bcCounts = bitchatFiltered.map(d => d.count);
|
|
|
|
// Total Chart Data Config
|
|
const totLabels = totalFiltered.map(d => d.date);
|
|
const totCounts = totalFiltered.map(d => d.count);
|
|
|
|
// Render BitChat Chart
|
|
if (state.charts.bitchat) {
|
|
state.charts.bitchat.destroy();
|
|
}
|
|
|
|
const ctxBC = document.getElementById('bitchat-chart').getContext('2d');
|
|
state.charts.bitchat = new Chart(ctxBC, {
|
|
type: 'line',
|
|
data: {
|
|
labels: bcLabels,
|
|
datasets: [{
|
|
label: 'BitChat Relays',
|
|
data: bcCounts,
|
|
borderColor: textPrimary,
|
|
backgroundColor: 'rgba(0, 255, 102, 0.05)',
|
|
borderWidth: 2,
|
|
pointBackgroundColor: textPrimary,
|
|
pointBorderColor: bgDarker,
|
|
pointHoverBackgroundColor: bgDarker,
|
|
pointHoverBorderColor: textPrimary,
|
|
pointRadius: 4,
|
|
pointHoverRadius: 6,
|
|
tension: 0.25,
|
|
fill: true
|
|
}]
|
|
},
|
|
options: getChartOptions(textPrimary, textSecondary, accentColor, 'BitChat Relays')
|
|
});
|
|
|
|
// Render Total Chart
|
|
if (state.charts.total) {
|
|
state.charts.total.destroy();
|
|
}
|
|
|
|
const ctxTot = document.getElementById('total-chart').getContext('2d');
|
|
state.charts.total = new Chart(ctxTot, {
|
|
type: 'line',
|
|
data: {
|
|
labels: totLabels,
|
|
datasets: [{
|
|
label: 'Total Discovered Relays',
|
|
data: totCounts,
|
|
borderColor: accentColor,
|
|
backgroundColor: 'rgba(255, 176, 0, 0.05)',
|
|
borderWidth: 2,
|
|
pointBackgroundColor: accentColor,
|
|
pointBorderColor: bgDarker,
|
|
pointHoverBackgroundColor: bgDarker,
|
|
pointHoverBorderColor: accentColor,
|
|
pointRadius: 4,
|
|
pointHoverRadius: 6,
|
|
tension: 0.25,
|
|
fill: true
|
|
}]
|
|
},
|
|
options: getChartOptions(accentColor, textSecondary, textPrimary, 'Total Functioning')
|
|
});
|
|
|
|
// Compute statistics and output
|
|
updateSummaryBoxes(bitchatFiltered, totalFiltered);
|
|
}
|
|
|
|
function getChartOptions(primaryColor, gridColor, tooltipColor, yAxisLabel) {
|
|
return {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
backgroundColor: 'var(--bg-darker)',
|
|
titleColor: tooltipColor,
|
|
bodyColor: 'var(--text-primary)',
|
|
borderColor: primaryColor,
|
|
borderWidth: 1,
|
|
cornerRadius: 3,
|
|
padding: 8,
|
|
titleFont: { family: 'Fira Code', size: 12 },
|
|
bodyFont: { family: 'Fira Code', size: 12 },
|
|
displayColors: false
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text: 'Timeline (Date)',
|
|
color: primaryColor,
|
|
font: { family: 'Fira Code', size: 11, weight: 'bold' }
|
|
},
|
|
grid: {
|
|
color: gridColor + '22',
|
|
drawTicks: true
|
|
},
|
|
ticks: {
|
|
color: primaryColor,
|
|
font: { family: 'Fira Code', size: 10 },
|
|
maxTicksLimit: 8
|
|
}
|
|
},
|
|
y: {
|
|
title: {
|
|
display: true,
|
|
text: yAxisLabel,
|
|
color: primaryColor,
|
|
font: { family: 'Fira Code', size: 11, weight: 'bold' }
|
|
},
|
|
grid: {
|
|
color: gridColor + '22',
|
|
drawTicks: true
|
|
},
|
|
ticks: {
|
|
color: primaryColor,
|
|
font: { family: 'Fira Code', size: 10 }
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function updateSummaryBoxes(bitchat, total) {
|
|
if (bitchat.length === 0 || total.length === 0) return;
|
|
|
|
const bcLatest = bitchat[bitchat.length - 1].count;
|
|
const bcMin = Math.min(...bitchat.map(d => d.count));
|
|
const bcMax = Math.max(...bitchat.map(d => d.count));
|
|
const bcStart = bitchat[0].count;
|
|
const bcGrowth = (((bcLatest - bcStart) / bcStart) * 100).toFixed(1);
|
|
const bcGrowthSign = bcLatest >= bcStart ? '+' : '';
|
|
|
|
document.getElementById('bitchat-stat-box').innerHTML = `
|
|
Latest: <strong style="color:var(--text-primary);">${bcLatest}</strong> |
|
|
Min: ${bcMin} | Max: ${bcMax} |
|
|
Growth: <strong style="color:var(--text-primary);">${bcGrowthSign}${bcGrowth}%</strong>
|
|
`;
|
|
|
|
const totLatest = total[total.length - 1].count;
|
|
const totMin = Math.min(...total.map(d => d.count));
|
|
const totMax = Math.max(...total.map(d => d.count));
|
|
const totStart = total[0].count;
|
|
const totGrowth = (((totLatest - totStart) / totStart) * 100).toFixed(1);
|
|
const totGrowthSign = totLatest >= totStart ? '+' : '';
|
|
|
|
document.getElementById('total-stat-box').innerHTML = `
|
|
Latest: <strong style="color:var(--accent-color);">${totLatest}</strong> |
|
|
Min: ${totMin} | Max: ${totMax} |
|
|
Growth: <strong style="color:var(--accent-color);">${totGrowthSign}${totGrowth}%</strong>
|
|
`;
|
|
}
|
|
|
|
// Chart Filters logic
|
|
function setChartRangePreset(days) {
|
|
state.activeRangePreset = days;
|
|
|
|
// Update preset buttons classes
|
|
document.querySelectorAll('.preset-btn').forEach(btn => btn.classList.remove('active'));
|
|
const activeBtn = document.getElementById(`preset-${days}`);
|
|
if (activeBtn) activeBtn.classList.add('active');
|
|
|
|
const bitchatFull = state.history.bitchat;
|
|
const totalFull = state.history.total;
|
|
|
|
// Take the last N days/entries
|
|
const bitchatFiltered = bitchatFull.slice(-days);
|
|
const totalFiltered = totalFull.slice(-days);
|
|
|
|
// Update Custom Inputs
|
|
document.getElementById('chart-start-date').value = bitchatFiltered[0].date;
|
|
document.getElementById('chart-end-date').value = bitchatFiltered[bitchatFiltered.length - 1].date;
|
|
|
|
renderCharts(bitchatFiltered, totalFiltered);
|
|
|
|
const stats = `Time range aggregated: last ${days} periods (${bitchatFiltered[0].date} to ${bitchatFiltered[bitchatFiltered.length-1].date}).`;
|
|
logSystemMessage(stats);
|
|
}
|
|
|
|
function onCustomDateChange() {
|
|
// Disable preset buttons active states since we are in custom mode
|
|
document.querySelectorAll('.preset-btn').forEach(btn => btn.classList.remove('active'));
|
|
|
|
const startVal = document.getElementById('chart-start-date').value;
|
|
const endVal = document.getElementById('chart-end-date').value;
|
|
|
|
if (!startVal || !endVal) return;
|
|
|
|
const bitchatFull = state.history.bitchat;
|
|
const totalFull = state.history.total;
|
|
|
|
const bitchatFiltered = bitchatFull.filter(d => d.date >= startVal && d.date <= endVal);
|
|
const totalFiltered = totalFull.filter(d => d.date >= startVal && d.date <= endVal);
|
|
|
|
if (bitchatFiltered.length === 0 || totalFiltered.length === 0) {
|
|
logErrorMessage("Custom date range filter returned 0 data points. Adjust ranges.");
|
|
return;
|
|
}
|
|
|
|
renderCharts(bitchatFiltered, totalFiltered);
|
|
logSystemMessage(`Custom aggregation applied successfully: ${startVal} to ${endVal} (${bitchatFiltered.length} periods mapped).`);
|
|
}
|
|
|
|
// CLI Shell Console Interpreter
|
|
function setupCLIInput() {
|
|
const cliBox = document.getElementById('cli-input-box');
|
|
cliBox.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
const val = cliBox.value.trim();
|
|
cliBox.value = '';
|
|
if (val) {
|
|
handleCommand(val);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function executeCLICommand(cmd) {
|
|
handleCommand(cmd);
|
|
}
|
|
|
|
function handleCommand(rawInput) {
|
|
logUserMessage('cypherpunk', rawInput);
|
|
|
|
if (!rawInput.startsWith('/')) {
|
|
// If not a slash command, treat it as Nick Search filter
|
|
onNickSearchInput(rawInput);
|
|
document.getElementById('nick-search').value = rawInput;
|
|
logSystemMessage(`Filtering nick list and map markers for term: "${rawInput}"`);
|
|
return;
|
|
}
|
|
|
|
const parts = rawInput.split(' ');
|
|
const command = parts[0].toLowerCase();
|
|
const args = parts.slice(1);
|
|
|
|
switch (command) {
|
|
case '/help':
|
|
logSystemMessage("=================================================");
|
|
logSystemMessage(" SECURE CLI TERMINAL COMMANDS ");
|
|
logSystemMessage("=================================================");
|
|
logSystemMessage("/help - Displays this instruction panel");
|
|
logSystemMessage("/map - Toggle view to interactive geographic node map");
|
|
logSystemMessage("/globe /3d - Toggle view to interactive 3D WebGL globe");
|
|
logSystemMessage("/charts - Toggle view to metrics & historical graphs");
|
|
logSystemMessage("/log - Toggle view to system diagnostic console");
|
|
logSystemMessage("/whois <relay> - Query specific relay metadata & locate");
|
|
logSystemMessage("/filter <text> - Find specific relays by URL patterns");
|
|
logSystemMessage("/theme <green/amber/cyan> - Toggle mainframe CRT phosphor colors");
|
|
logSystemMessage("/scanlines - Toggle micro CRT horizontal grid raster");
|
|
logSystemMessage("/range <7/30/70> - Quick aggregate charts range by days");
|
|
logSystemMessage("/clear - Purge local terminal buffers");
|
|
break;
|
|
|
|
case '/map':
|
|
switchTab('map-tab');
|
|
logSystemMessage("Mainframe display routed to #bitchat-map.");
|
|
break;
|
|
|
|
case '/globe':
|
|
case '/3d':
|
|
switchTab('globe-tab');
|
|
logSystemMessage("Mainframe display routed to #bitchat-3d.");
|
|
break;
|
|
|
|
case '/charts':
|
|
switchTab('charts-tab');
|
|
logSystemMessage("Mainframe display routed to #bitchat-charts.");
|
|
break;
|
|
|
|
case '/log':
|
|
switchTab('log-tab');
|
|
logSystemMessage("Mainframe display routed to #system-log.");
|
|
break;
|
|
|
|
case '/clear':
|
|
document.getElementById('terminal-log-container').innerHTML = '';
|
|
logSystemMessage("Terminal buffers cleared.");
|
|
break;
|
|
|
|
case '/scanlines':
|
|
const overlay = document.getElementById('scanlines-overlay');
|
|
const body = document.body;
|
|
if (body.classList.contains('flicker')) {
|
|
body.classList.remove('flicker');
|
|
overlay.style.display = 'none';
|
|
logSystemMessage("CRT Scanlines and phosphor flicker DISABLED.");
|
|
} else {
|
|
body.classList.add('flicker');
|
|
overlay.style.display = 'block';
|
|
logSystemMessage("CRT Scanlines and phosphor flicker ENABLED.");
|
|
}
|
|
break;
|
|
|
|
case '/theme':
|
|
if (args.length === 0) {
|
|
logErrorMessage("Syntax: /theme [green | amber | cyan]");
|
|
break;
|
|
}
|
|
const theme = args[0].toLowerCase();
|
|
document.body.className = document.body.className.replace(/theme-\w+/g, '');
|
|
|
|
if (theme === 'green' || theme === 'matrix') {
|
|
document.body.classList.remove('theme-amber', 'theme-cyan');
|
|
logSystemMessage("Switching phosphor grid to Matrix Green [P1 Phosphor].");
|
|
} else if (theme === 'amber' || theme === 'retro') {
|
|
document.body.classList.add('theme-amber');
|
|
document.body.classList.remove('theme-cyan');
|
|
logSystemMessage("Switching phosphor grid to Amber Terminal [P3 Phosphor].");
|
|
} else if (theme === 'cyan' || theme === 'cyber') {
|
|
document.body.classList.add('theme-cyan');
|
|
document.body.classList.remove('theme-amber');
|
|
logSystemMessage("Switching phosphor grid to Cyber Blue [P4 Phosphor].");
|
|
} else {
|
|
logErrorMessage(`Theme "${theme}" not recognized. Select green, amber, or cyan.`);
|
|
break;
|
|
}
|
|
|
|
// Re-render markers and charts with new CSS variables
|
|
setTimeout(() => {
|
|
plotRelayMarkers();
|
|
updateGlobeColors();
|
|
if (state.history) {
|
|
const startVal = document.getElementById('chart-start-date').value;
|
|
const endVal = document.getElementById('chart-end-date').value;
|
|
if (startVal && endVal) {
|
|
onCustomDateChange();
|
|
} else {
|
|
setChartRangePreset(state.activeRangePreset);
|
|
}
|
|
}
|
|
}, 100);
|
|
break;
|
|
|
|
case '/filter':
|
|
const filterTerm = args.join(' ');
|
|
onNickSearchInput(filterTerm);
|
|
document.getElementById('nick-search').value = filterTerm;
|
|
logSystemMessage(`Real-time filter term set to: "${filterTerm}"`);
|
|
break;
|
|
|
|
case '/range':
|
|
if (args.length === 0) {
|
|
logErrorMessage("Syntax: /range [7 | 30 | 70]");
|
|
break;
|
|
}
|
|
const daysNum = parseInt(args[0]);
|
|
if (daysNum === 7 || daysNum === 30 || daysNum === 70) {
|
|
setChartRangePreset(daysNum);
|
|
} else {
|
|
logErrorMessage("Supported range presets: 7, 30, 70.");
|
|
}
|
|
break;
|
|
|
|
case '/whois':
|
|
if (args.length === 0) {
|
|
logErrorMessage("Syntax: /whois [relay_url_substring]");
|
|
break;
|
|
}
|
|
const sub = args[0].toLowerCase();
|
|
const match = state.relays.find(r => r.url.toLowerCase().includes(sub));
|
|
if (match) {
|
|
if (state.currentTab === 'globe-tab' && state.globe) {
|
|
state.globe.pointOfView({ lat: match.lat, lng: match.lon, altitude: 1.2 }, 1200);
|
|
} else {
|
|
switchTab('map-tab');
|
|
state.map.setView([match.lat, match.lon], 7);
|
|
const index = state.relays.indexOf(match);
|
|
if (index !== -1 && state.markers[index]) {
|
|
state.markers[index].openPopup();
|
|
}
|
|
}
|
|
triggerWhois(match);
|
|
} else {
|
|
logErrorMessage(`Relay matching substring "${sub}" not found in current geolocation dataset.`);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
logErrorMessage(`Command "${command}" is invalid. Type /help to query supported protocols.`);
|
|
break;
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |