Contains: - 1m-brag - tem - VaultMesh_Catalog_v1 - VAULTMESH-ETERNAL-PATTERN 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1356 lines
54 KiB
HTML
1356 lines
54 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>IoTek.nexus — Sovereign Console</title>
|
||
<meta name="description" content="Live control surface for VaultMesh sovereign infrastructure.">
|
||
|
||
<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=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||
|
||
<style>
|
||
:root {
|
||
--onyx: #0a0a0f;
|
||
--onyx-deep: #050508;
|
||
--void: #000000;
|
||
--neon-green: #00ff88;
|
||
--neon-emerald: #00ff66;
|
||
--neon-ruby: #ff0044;
|
||
--neon-purple: #6600ff;
|
||
--neon-cyan: #00ffff;
|
||
--neon-gold: #ffaa00;
|
||
--platinum: #c0c0c0;
|
||
--silver: #888899;
|
||
--steel: #1a1a2e;
|
||
--grid: #0d0d15;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
background: var(--onyx);
|
||
color: var(--platinum);
|
||
min-height: 100vh;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.code-rain {
|
||
position: fixed;
|
||
top: 0; left: 0;
|
||
width: 100%; height: 100%;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
overflow: hidden;
|
||
opacity: 0.06;
|
||
}
|
||
|
||
.rain-column {
|
||
position: absolute;
|
||
top: -100%;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
color: var(--neon-green);
|
||
text-shadow: 0 0 5px var(--neon-green);
|
||
writing-mode: vertical-rl;
|
||
animation: rain-fall linear infinite;
|
||
}
|
||
|
||
@keyframes rain-fall {
|
||
0% { transform: translateY(-100%); }
|
||
100% { transform: translateY(200vh); }
|
||
}
|
||
|
||
.app-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
HEADER BAR
|
||
═══════════════════════════════════════════════════════════ */
|
||
.header-bar {
|
||
height: 40px;
|
||
background: linear-gradient(to bottom, var(--steel) 0%, var(--onyx) 100%);
|
||
border-bottom: 1px solid var(--steel);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 1rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.logo {
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
color: var(--neon-green);
|
||
text-shadow: 0 0 10px var(--neon-green);
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.logo span { color: var(--platinum); text-shadow: none; }
|
||
|
||
.status-pills {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.status-pill {
|
||
font-size: 0.6rem;
|
||
padding: 0.2rem 0.5rem;
|
||
border-radius: 2px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
letter-spacing: 0.05em;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.status-pill.mesh {
|
||
background: rgba(0, 255, 136, 0.15);
|
||
color: var(--neon-green);
|
||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||
}
|
||
|
||
.status-pill.shield {
|
||
background: rgba(255, 0, 68, 0.15);
|
||
color: var(--neon-ruby);
|
||
border: 1px solid rgba(255, 0, 68, 0.3);
|
||
}
|
||
|
||
.status-pill.ws {
|
||
background: rgba(102, 0, 255, 0.15);
|
||
color: var(--neon-purple);
|
||
border: 1px solid rgba(102, 0, 255, 0.3);
|
||
}
|
||
|
||
.status-pill.ws.connected {
|
||
background: rgba(0, 255, 136, 0.15);
|
||
color: var(--neon-green);
|
||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||
}
|
||
|
||
.status-dot {
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.header-time {
|
||
font-size: 0.65rem;
|
||
color: var(--silver);
|
||
}
|
||
|
||
.header-btn {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.header-btn.minimize { background: var(--neon-gold); }
|
||
.header-btn.maximize { background: var(--neon-green); }
|
||
.header-btn.close { background: var(--neon-ruby); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
MAIN CONTENT
|
||
═══════════════════════════════════════════════════════════ */
|
||
.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
SIDEBAR
|
||
═══════════════════════════════════════════════════════════ */
|
||
.sidebar {
|
||
width: 220px;
|
||
background: var(--onyx-deep);
|
||
border-right: 1px solid var(--steel);
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-section {
|
||
padding: 1rem;
|
||
border-bottom: 1px solid var(--steel);
|
||
}
|
||
|
||
.sidebar-label {
|
||
font-size: 0.6rem;
|
||
color: var(--silver);
|
||
letter-spacing: 0.15em;
|
||
text-transform: uppercase;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.sidebar-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.5rem 0.75rem;
|
||
margin: 0.25rem 0;
|
||
font-size: 0.75rem;
|
||
color: var(--platinum);
|
||
cursor: pointer;
|
||
border-radius: 3px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.sidebar-item:hover {
|
||
background: rgba(0, 255, 136, 0.1);
|
||
color: var(--neon-green);
|
||
}
|
||
|
||
.sidebar-item.active {
|
||
background: rgba(0, 255, 136, 0.15);
|
||
color: var(--neon-green);
|
||
border-left: 2px solid var(--neon-green);
|
||
margin-left: -2px;
|
||
}
|
||
|
||
.sidebar-item-icon { font-size: 0.9rem; }
|
||
|
||
.agent-list { flex: 1; overflow-y: auto; }
|
||
|
||
.agent-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.4rem 0.75rem;
|
||
font-size: 0.7rem;
|
||
color: var(--silver);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.agent-item:hover {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
color: var(--platinum);
|
||
}
|
||
|
||
.agent-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.agent-dot.sentinel { background: #f472b6; }
|
||
.agent-dot.orchestrator { background: #a78bfa; }
|
||
.agent-dot.analyst { background: #60a5fa; }
|
||
.agent-dot.executor { background: #34d399; }
|
||
|
||
.agent-status {
|
||
margin-left: auto;
|
||
font-size: 0.6rem;
|
||
padding: 0.1rem 0.3rem;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.agent-status.active {
|
||
background: rgba(0, 255, 136, 0.2);
|
||
color: var(--neon-green);
|
||
}
|
||
|
||
.agent-status.idle {
|
||
background: rgba(136, 136, 153, 0.2);
|
||
color: var(--silver);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
TERMINAL
|
||
═══════════════════════════════════════════════════════════ */
|
||
.terminal-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--onyx);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.terminal-tabs {
|
||
display: flex;
|
||
background: var(--onyx-deep);
|
||
border-bottom: 1px solid var(--steel);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.terminal-tab {
|
||
padding: 0.6rem 1rem;
|
||
font-size: 0.7rem;
|
||
color: var(--silver);
|
||
cursor: pointer;
|
||
border-right: 1px solid var(--steel);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.terminal-tab:hover {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
}
|
||
|
||
.terminal-tab.active {
|
||
background: var(--onyx);
|
||
color: var(--neon-green);
|
||
border-bottom: 1px solid var(--onyx);
|
||
margin-bottom: -1px;
|
||
}
|
||
|
||
.terminal-tab-close {
|
||
font-size: 0.8rem;
|
||
opacity: 0.5;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.terminal-tab-close:hover {
|
||
opacity: 1;
|
||
color: var(--neon-ruby);
|
||
}
|
||
|
||
.terminal-output {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 1rem;
|
||
font-size: 0.8rem;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.terminal-output::-webkit-scrollbar { width: 8px; }
|
||
.terminal-output::-webkit-scrollbar-track { background: var(--onyx-deep); }
|
||
.terminal-output::-webkit-scrollbar-thumb { background: var(--steel); border-radius: 4px; }
|
||
|
||
.output-line {
|
||
margin-bottom: 0.25rem;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.output-line.command { color: var(--platinum); }
|
||
.output-line.command .prompt { color: var(--neon-green); }
|
||
.output-line.command .path { color: var(--neon-cyan); }
|
||
.output-line.response { color: var(--silver); }
|
||
.output-line.success { color: var(--neon-green); }
|
||
.output-line.error { color: var(--neon-ruby); }
|
||
.output-line.warning { color: var(--neon-gold); }
|
||
.output-line.info { color: var(--neon-cyan); }
|
||
.output-line.highlight {
|
||
color: var(--platinum);
|
||
background: rgba(0, 255, 136, 0.1);
|
||
padding: 0.5rem;
|
||
margin: 0.5rem 0;
|
||
border-left: 2px solid var(--neon-green);
|
||
}
|
||
.output-line.ascii {
|
||
color: var(--neon-green);
|
||
text-shadow: 0 0 5px var(--neon-green);
|
||
}
|
||
.output-line.dimmed { color: var(--steel); }
|
||
|
||
.output-table {
|
||
margin: 0.5rem 0;
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.output-table th {
|
||
text-align: left;
|
||
color: var(--silver);
|
||
padding: 0.3rem 1rem 0.3rem 0;
|
||
border-bottom: 1px solid var(--steel);
|
||
}
|
||
|
||
.output-table td {
|
||
padding: 0.3rem 1rem 0.3rem 0;
|
||
color: var(--platinum);
|
||
}
|
||
|
||
.output-table .status-ok { color: var(--neon-green); }
|
||
.output-table .status-warn { color: var(--neon-gold); }
|
||
.output-table .status-error { color: var(--neon-ruby); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
INPUT
|
||
═══════════════════════════════════════════════════════════ */
|
||
.input-area {
|
||
border-top: 1px solid var(--steel);
|
||
padding: 0.75rem 1rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
background: var(--onyx-deep);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.input-prompt {
|
||
color: var(--neon-green);
|
||
font-size: 0.8rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.input-path {
|
||
color: var(--neon-cyan);
|
||
font-size: 0.8rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.input-field {
|
||
flex: 1;
|
||
background: transparent;
|
||
border: none;
|
||
outline: none;
|
||
color: var(--platinum);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 0.8rem;
|
||
caret-color: var(--neon-green);
|
||
}
|
||
|
||
.input-field::placeholder { color: var(--steel); }
|
||
.input-field:disabled { opacity: 0.5; }
|
||
|
||
.input-hint {
|
||
font-size: 0.65rem;
|
||
color: var(--steel);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.input-spinner {
|
||
width: 14px;
|
||
height: 14px;
|
||
border: 2px solid var(--steel);
|
||
border-top-color: var(--neon-green);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
display: none;
|
||
}
|
||
|
||
.input-spinner.active { display: block; }
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
STATUS BAR
|
||
═══════════════════════════════════════════════════════════ */
|
||
.status-bar {
|
||
height: 24px;
|
||
background: var(--steel);
|
||
border-top: 1px solid var(--onyx);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 1rem;
|
||
font-size: 0.6rem;
|
||
color: var(--silver);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.status-bar-left,
|
||
.status-bar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.status-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
}
|
||
|
||
.status-indicator .dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.status-indicator .dot.green { background: var(--neon-green); }
|
||
.status-indicator .dot.yellow { background: var(--neon-gold); }
|
||
.status-indicator .dot.red { background: var(--neon-ruby); }
|
||
|
||
@media (max-width: 768px) {
|
||
.sidebar { display: none; }
|
||
.header-left .status-pills { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="code-rain" id="codeRain"></div>
|
||
|
||
<div class="app-container">
|
||
<div class="header-bar">
|
||
<div class="header-left">
|
||
<span class="logo">IoTek<span>.nexus</span></span>
|
||
<div class="status-pills">
|
||
<span class="status-pill mesh" id="meshPill">
|
||
<span class="status-dot"></span>
|
||
<span id="meshStatus">MESH INIT</span>
|
||
</span>
|
||
<span class="status-pill shield" id="shieldPill">
|
||
<span class="status-dot"></span>
|
||
<span id="shieldStatus">SHIELD INIT</span>
|
||
</span>
|
||
<span class="status-pill ws" id="wsPill">
|
||
<span class="status-dot"></span>
|
||
<span id="wsStatus">WS CONNECTING</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<span class="header-time" id="headerTime">00:00:00</span>
|
||
<button class="header-btn minimize"></button>
|
||
<button class="header-btn maximize"></button>
|
||
<button class="header-btn close"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main-content">
|
||
<div class="sidebar">
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-label">Navigation</div>
|
||
<div class="sidebar-item active" data-cmd="status">
|
||
<span class="sidebar-item-icon">◉</span>
|
||
Dashboard
|
||
</div>
|
||
<div class="sidebar-item" data-cmd="shield status">
|
||
<span class="sidebar-item-icon">🛡</span>
|
||
Shield
|
||
</div>
|
||
<div class="sidebar-item" data-cmd="proof latest">
|
||
<span class="sidebar-item-icon">📜</span>
|
||
Proof
|
||
</div>
|
||
<div class="sidebar-item" data-cmd="mesh status">
|
||
<span class="sidebar-item-icon">🕸</span>
|
||
Mesh
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar-section agent-list">
|
||
<div class="sidebar-label">Agents</div>
|
||
<div class="agent-item" data-cmd="agents list">
|
||
<span class="agent-dot sentinel"></span>
|
||
Sentinel
|
||
<span class="agent-status" id="agentSentinel">—</span>
|
||
</div>
|
||
<div class="agent-item" data-cmd="agents list">
|
||
<span class="agent-dot orchestrator"></span>
|
||
Orchestrator
|
||
<span class="agent-status" id="agentOrchestrator">—</span>
|
||
</div>
|
||
<div class="agent-item" data-cmd="agents list">
|
||
<span class="agent-dot analyst"></span>
|
||
Analyst
|
||
<span class="agent-status" id="agentAnalyst">—</span>
|
||
</div>
|
||
<div class="agent-item" data-cmd="agents list">
|
||
<span class="agent-dot executor"></span>
|
||
Executor
|
||
<span class="agent-status" id="agentExecutor">—</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-label">Quick Commands</div>
|
||
<div class="sidebar-item" data-cmd="help">
|
||
<span class="sidebar-item-icon">?</span>
|
||
Help
|
||
</div>
|
||
<div class="sidebar-item" data-cmd="clear">
|
||
<span class="sidebar-item-icon">✕</span>
|
||
Clear
|
||
</div>
|
||
<div class="sidebar-item" data-cmd="history">
|
||
<span class="sidebar-item-icon">↺</span>
|
||
History
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="terminal-area">
|
||
<div class="terminal-tabs">
|
||
<div class="terminal-tab active">
|
||
<span>◉ <span id="tabUser">sovereign</span>@nexus</span>
|
||
<span class="terminal-tab-close">×</span>
|
||
</div>
|
||
<div class="terminal-tab" style="opacity: 0.5;">
|
||
<span>+ New Tab</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="terminal-output" id="terminalOutput"></div>
|
||
|
||
<div class="input-area">
|
||
<span class="input-prompt" id="promptUser">sovereign</span>
|
||
<span class="input-prompt">@nexus</span>
|
||
<span class="input-path">~/vaultmesh</span>
|
||
<span class="input-prompt">$</span>
|
||
<input type="text" class="input-field" id="commandInput" placeholder="Enter command..." autocomplete="off" spellcheck="false">
|
||
<div class="input-spinner" id="inputSpinner"></div>
|
||
<span class="input-hint">Tab to autocomplete</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-bar">
|
||
<div class="status-bar-left">
|
||
<span class="status-indicator">
|
||
<span class="dot" id="connDot"></span>
|
||
<span id="connStatus">Connecting...</span>
|
||
</span>
|
||
<span id="tailnetInfo">Tailnet: —</span>
|
||
<span id="nodeInfo">Node: —</span>
|
||
</div>
|
||
<div class="status-bar-right">
|
||
<span id="proofsInfo">Proofs: —</span>
|
||
<span id="uptimeInfo">Uptime: —</span>
|
||
<span>v1.0.0-sovereign</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// CONFIGURATION
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const CONFIG = {
|
||
// Backend endpoints - adjust for your deployment
|
||
MCP_ENDPOINT: '/mcp/command', // HTTP endpoint for commands
|
||
WS_ENDPOINT: null, // WebSocket endpoint (null = derive from location)
|
||
|
||
// Session
|
||
SESSION_ID: `vaultmesh-${Date.now().toString(36)}`,
|
||
|
||
// Fallback to mock mode if backend unavailable
|
||
MOCK_FALLBACK: true,
|
||
|
||
// Retry settings
|
||
WS_RETRY_DELAY: 3000,
|
||
HTTP_TIMEOUT: 10000
|
||
};
|
||
|
||
// Derive WebSocket URL from current location
|
||
if (!CONFIG.WS_ENDPOINT) {
|
||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
CONFIG.WS_ENDPOINT = `${wsProtocol}//${window.location.host}/ws`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// STATE
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const state = {
|
||
user: 'sovereign',
|
||
commandHistory: [],
|
||
historyIndex: -1,
|
||
wsConnected: false,
|
||
backendAvailable: false,
|
||
proofCount: 0,
|
||
uptime: '—',
|
||
nodes: 0,
|
||
shieldArmed: false,
|
||
pendingCommand: false
|
||
};
|
||
|
||
let socket = null;
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// LOCAL COMMANDS (client-side only)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const localCommands = {
|
||
help: () => ({
|
||
type: 'multi',
|
||
lines: [
|
||
{ type: 'info', text: ' IoTek.nexus — Sovereign Console\n' },
|
||
{ type: 'table', headers: ['Command', 'Description'],
|
||
rows: [
|
||
['help', 'Show this help'],
|
||
['clear', 'Clear terminal'],
|
||
['history', 'Show command history'],
|
||
['status', 'System status dashboard'],
|
||
['mesh status', 'Mesh network status'],
|
||
['mesh nodes', 'List mesh nodes'],
|
||
['shield status', 'Shield defense status'],
|
||
['shield arm', 'Arm the shield'],
|
||
['shield disarm', 'Disarm the shield'],
|
||
['proof latest', 'Latest proof receipts'],
|
||
['proof generate', 'Generate new proof'],
|
||
['proof verify <id>', 'Verify a proof'],
|
||
['agents list', 'List all agents'],
|
||
['agent task <desc>', 'Dispatch agent task'],
|
||
['oracle reason <q>', 'Oracle reasoning'],
|
||
['whoami', 'Current identity'],
|
||
['neofetch', 'System info display'],
|
||
]
|
||
},
|
||
{ type: 'dimmed', text: '\n Commands without local handlers are sent to MCP backend.' }
|
||
]
|
||
}),
|
||
|
||
clear: () => ({ type: 'clear' }),
|
||
|
||
history: () => ({
|
||
type: 'multi',
|
||
lines: state.commandHistory.length === 0
|
||
? [{ type: 'dimmed', text: ' No command history.' }]
|
||
: state.commandHistory.slice(0, 20).map((cmd, i) => ({
|
||
type: 'response',
|
||
text: ` ${String(i + 1).padStart(3)} ${cmd}`
|
||
}))
|
||
}),
|
||
|
||
whoami: () => ({
|
||
type: 'multi',
|
||
lines: [
|
||
{ type: 'success', text: state.user },
|
||
{ type: 'response', text: `Session: ${CONFIG.SESSION_ID}` },
|
||
{ type: 'response', text: `Backend: ${state.backendAvailable ? 'connected' : 'offline (mock mode)'}` },
|
||
{ type: 'response', text: `WebSocket: ${state.wsConnected ? 'connected' : 'disconnected'}` }
|
||
]
|
||
}),
|
||
|
||
neofetch: () => ({
|
||
type: 'multi',
|
||
lines: [
|
||
{ type: 'ascii', text: `
|
||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ${state.user}@nexus
|
||
██████████████████ ──────────────────
|
||
████ ████████ ████ OS: VaultMesh 1.0
|
||
████ ██████ ████ Kernel: sovereign-mesh
|
||
████ ████ ████ Shell: nexus-cli
|
||
████████████████████████ Terminal: IoTek.nexus
|
||
████████████████████████
|
||
████ ██████████ ██████ Mesh: ${state.nodes} nodes
|
||
████ ██████████ ██████ Proofs: ${state.proofCount}
|
||
████████████████████████ Uptime: ${state.uptime}
|
||
██████████████████████
|
||
████████████████████ Shield: ${state.shieldArmed ? 'ARMED' : 'STANDBY'}
|
||
██████████████████ Backend: ${state.backendAvailable ? 'LIVE' : 'MOCK'}
|
||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Epoch: Citrinitas` }
|
||
]
|
||
})
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// MOCK COMMANDS (when backend unavailable)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const mockCommands = {
|
||
status: () => ({
|
||
type: 'multi',
|
||
lines: [
|
||
{ type: 'warning', text: ' ⚠ Running in MOCK MODE (backend unavailable)\n' },
|
||
{ type: 'ascii', text: `
|
||
╦ ╦╔═╗╦ ╦╦ ╔╦╗╔╦╗╔═╗╔═╗╦ ╦
|
||
╚╗╔╝╠═╣║ ║║ ║ ║║║║╣ ╚═╗╠═╣
|
||
╚╝ ╩ ╩╚═╝╩═╝╩ ╩ ╩╚═╝╚═╝╩ ╩` },
|
||
{ type: 'info', text: '\n Sovereign Infrastructure Status (simulated)\n' },
|
||
{ type: 'table', headers: ['System', 'Status', 'Details'],
|
||
rows: [
|
||
['Shield', '● MOCK', 'Simulated status'],
|
||
['Proof', '● MOCK', 'No real receipts'],
|
||
['Mesh', '● MOCK', 'No real nodes'],
|
||
['Agents', '● MOCK', 'Simulated agents'],
|
||
]
|
||
},
|
||
{ type: 'dimmed', text: '\n Start offsec-mcp backend for live data.' }
|
||
]
|
||
}),
|
||
|
||
'mesh status': () => ({
|
||
type: 'multi',
|
||
lines: [
|
||
{ type: 'warning', text: ' ⚠ MOCK MODE\n' },
|
||
{ type: 'response', text: ' Would query real Tailnet status via MCP backend.' },
|
||
{ type: 'dimmed', text: ' Start: uvicorn offsec_mcp:app --reload' }
|
||
]
|
||
}),
|
||
|
||
'shield status': () => ({
|
||
type: 'multi',
|
||
lines: [
|
||
{ type: 'warning', text: ' ⚠ MOCK MODE\n' },
|
||
{ type: 'response', text: ' Would query real Shield vectors via MCP backend.' }
|
||
]
|
||
}),
|
||
|
||
'proof latest': () => ({
|
||
type: 'multi',
|
||
lines: [
|
||
{ type: 'warning', text: ' ⚠ MOCK MODE\n' },
|
||
{ type: 'response', text: ' Would fetch real proof receipts via MCP backend.' }
|
||
]
|
||
}),
|
||
|
||
'agents list': () => ({
|
||
type: 'multi',
|
||
lines: [
|
||
{ type: 'warning', text: ' ⚠ MOCK MODE\n' },
|
||
{ type: 'response', text: ' Would list real agent status via MCP backend.' }
|
||
]
|
||
})
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// RENDERING
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const outputEl = document.getElementById('terminalOutput');
|
||
|
||
function renderOutput(result) {
|
||
if (result.type === 'clear') {
|
||
outputEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
if (result.type === 'multi') {
|
||
result.lines.forEach(line => renderOutput(line));
|
||
return;
|
||
}
|
||
|
||
if (result.type === 'table') {
|
||
const table = document.createElement('table');
|
||
table.className = 'output-table';
|
||
|
||
const thead = document.createElement('thead');
|
||
const headerRow = document.createElement('tr');
|
||
result.headers.forEach(h => {
|
||
const th = document.createElement('th');
|
||
th.textContent = h;
|
||
headerRow.appendChild(th);
|
||
});
|
||
thead.appendChild(headerRow);
|
||
table.appendChild(thead);
|
||
|
||
const tbody = document.createElement('tbody');
|
||
result.rows.forEach(row => {
|
||
const tr = document.createElement('tr');
|
||
row.forEach(cell => {
|
||
const td = document.createElement('td');
|
||
td.innerHTML = cell
|
||
.replace(/●/g, '<span class="status-ok">●</span>')
|
||
.replace(/○/g, '<span class="status-warn">○</span>');
|
||
tr.appendChild(td);
|
||
});
|
||
tbody.appendChild(tr);
|
||
});
|
||
table.appendChild(tbody);
|
||
outputEl.appendChild(table);
|
||
return;
|
||
}
|
||
|
||
const line = document.createElement('div');
|
||
line.className = `output-line ${result.type || 'response'}`;
|
||
line.textContent = result.text;
|
||
outputEl.appendChild(line);
|
||
}
|
||
|
||
function printLine(text, type = 'response') {
|
||
renderOutput({ type, text });
|
||
outputEl.scrollTop = outputEl.scrollHeight;
|
||
}
|
||
|
||
function printCommand(input) {
|
||
const cmdLine = document.createElement('div');
|
||
cmdLine.className = 'output-line command';
|
||
cmdLine.innerHTML = `<span class="prompt">${state.user}@nexus</span> <span class="path">~/vaultmesh</span> $ ${escapeHtml(input)}`;
|
||
outputEl.appendChild(cmdLine);
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// MCP BACKEND COMMUNICATION
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function sendToMCP(command, args = []) {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), CONFIG.HTTP_TIMEOUT);
|
||
|
||
try {
|
||
const response = await fetch(CONFIG.MCP_ENDPOINT, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
session_id: CONFIG.SESSION_ID,
|
||
user: state.user,
|
||
command: command,
|
||
args: args,
|
||
cwd: '/vaultmesh',
|
||
meta: {
|
||
client: 'iotek-nexus-cli',
|
||
version: '1.0.0'
|
||
}
|
||
}),
|
||
signal: controller.signal
|
||
});
|
||
|
||
clearTimeout(timeout);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
state.backendAvailable = true;
|
||
updateConnectionStatus();
|
||
|
||
return data;
|
||
|
||
} catch (err) {
|
||
clearTimeout(timeout);
|
||
|
||
if (err.name === 'AbortError') {
|
||
throw new Error('Request timeout');
|
||
}
|
||
|
||
state.backendAvailable = false;
|
||
updateConnectionStatus();
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// WEBSOCKET
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function connectWebSocket() {
|
||
try {
|
||
socket = new WebSocket(CONFIG.WS_ENDPOINT);
|
||
|
||
socket.onopen = () => {
|
||
state.wsConnected = true;
|
||
updateWsStatus();
|
||
|
||
// Send handshake
|
||
socket.send(JSON.stringify({
|
||
type: 'handshake',
|
||
session_id: CONFIG.SESSION_ID,
|
||
user: state.user
|
||
}));
|
||
};
|
||
|
||
socket.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
handleWsMessage(msg);
|
||
} catch (e) {
|
||
console.error('WS parse error:', e);
|
||
}
|
||
};
|
||
|
||
socket.onclose = () => {
|
||
state.wsConnected = false;
|
||
updateWsStatus();
|
||
|
||
// Retry connection
|
||
setTimeout(connectWebSocket, CONFIG.WS_RETRY_DELAY);
|
||
};
|
||
|
||
socket.onerror = (err) => {
|
||
console.error('WS error:', err);
|
||
state.wsConnected = false;
|
||
updateWsStatus();
|
||
};
|
||
|
||
} catch (err) {
|
||
console.error('WS connect failed:', err);
|
||
state.wsConnected = false;
|
||
updateWsStatus();
|
||
|
||
setTimeout(connectWebSocket, CONFIG.WS_RETRY_DELAY);
|
||
}
|
||
}
|
||
|
||
function handleWsMessage(msg) {
|
||
switch (msg.type) {
|
||
case 'console.line':
|
||
printLine(msg.line, msg.lineType || 'response');
|
||
break;
|
||
|
||
case 'status.update':
|
||
if (msg.payload) {
|
||
updateStatusFromPayload(msg.payload);
|
||
}
|
||
break;
|
||
|
||
case 'agent.update':
|
||
updateAgentStatus(msg.agent, msg.status);
|
||
break;
|
||
|
||
case 'proof.new':
|
||
state.proofCount++;
|
||
document.getElementById('proofsInfo').textContent = `Proofs: ${state.proofCount}`;
|
||
printLine(`✓ New proof anchored: ${msg.proof_id}`, 'success');
|
||
break;
|
||
|
||
case 'shield.event':
|
||
printLine(`🛡 Shield: ${msg.event}`, msg.severity === 'high' ? 'warning' : 'info');
|
||
break;
|
||
|
||
case 'error':
|
||
printLine(`! ${msg.message}`, 'error');
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// COMMAND EXECUTION
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function executeCommand(input) {
|
||
const trimmed = input.trim();
|
||
if (!trimmed) return;
|
||
|
||
// Add to history
|
||
state.commandHistory.unshift(trimmed);
|
||
state.historyIndex = -1;
|
||
|
||
// Echo command
|
||
printCommand(trimmed);
|
||
|
||
// Check local commands first
|
||
const localKey = trimmed.toLowerCase();
|
||
if (localCommands[localKey]) {
|
||
const result = localCommands[localKey]();
|
||
renderOutput(result);
|
||
outputEl.scrollTop = outputEl.scrollHeight;
|
||
return;
|
||
}
|
||
|
||
// Try MCP backend
|
||
if (state.backendAvailable || !CONFIG.MOCK_FALLBACK) {
|
||
setLoading(true);
|
||
|
||
try {
|
||
const data = await sendToMCP(trimmed);
|
||
|
||
// Render response lines
|
||
if (data.lines && Array.isArray(data.lines)) {
|
||
data.lines.forEach(line => printLine(line));
|
||
}
|
||
|
||
// Apply effects to UI state
|
||
if (data.effects) {
|
||
updateStatusFromPayload(data.effects);
|
||
}
|
||
|
||
} catch (err) {
|
||
printLine(`! MCP error: ${err.message}`, 'error');
|
||
|
||
// Fallback to mock if enabled
|
||
if (CONFIG.MOCK_FALLBACK) {
|
||
printLine(' Falling back to mock mode...', 'dimmed');
|
||
executeMock(localKey);
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
|
||
} else {
|
||
// Mock mode
|
||
executeMock(localKey);
|
||
}
|
||
|
||
// Add spacer
|
||
const spacer = document.createElement('div');
|
||
spacer.innerHTML = ' ';
|
||
outputEl.appendChild(spacer);
|
||
outputEl.scrollTop = outputEl.scrollHeight;
|
||
}
|
||
|
||
function executeMock(key) {
|
||
// Find matching mock command
|
||
const mockKey = Object.keys(mockCommands).find(k => key.startsWith(k));
|
||
|
||
if (mockKey && mockCommands[mockKey]) {
|
||
renderOutput(mockCommands[mockKey]());
|
||
} else {
|
||
printLine(` Command "${key}" requires MCP backend.`, 'warning');
|
||
printLine(' Start: uvicorn offsec_mcp:app --reload', 'dimmed');
|
||
}
|
||
}
|
||
|
||
function setLoading(loading) {
|
||
state.pendingCommand = loading;
|
||
document.getElementById('inputSpinner').classList.toggle('active', loading);
|
||
document.getElementById('commandInput').disabled = loading;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// UI UPDATES
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function updateConnectionStatus() {
|
||
const dot = document.getElementById('connDot');
|
||
const status = document.getElementById('connStatus');
|
||
|
||
if (state.backendAvailable) {
|
||
dot.className = 'dot green';
|
||
status.textContent = 'Connected';
|
||
} else {
|
||
dot.className = 'dot yellow';
|
||
status.textContent = 'Mock Mode';
|
||
}
|
||
}
|
||
|
||
function updateWsStatus() {
|
||
const pill = document.getElementById('wsPill');
|
||
const status = document.getElementById('wsStatus');
|
||
|
||
if (state.wsConnected) {
|
||
pill.classList.add('connected');
|
||
status.textContent = 'WS LIVE';
|
||
} else {
|
||
pill.classList.remove('connected');
|
||
status.textContent = 'WS OFFLINE';
|
||
}
|
||
}
|
||
|
||
function updateStatusFromPayload(payload) {
|
||
if (payload.nodes !== undefined) {
|
||
state.nodes = payload.nodes;
|
||
document.getElementById('meshStatus').textContent = `MESH ${payload.nodes}`;
|
||
}
|
||
|
||
if (payload.shield !== undefined) {
|
||
state.shieldArmed = payload.shield.armed;
|
||
document.getElementById('shieldStatus').textContent =
|
||
payload.shield.armed ? 'SHIELD ARMED' : 'SHIELD STANDBY';
|
||
}
|
||
|
||
if (payload.proofs !== undefined) {
|
||
state.proofCount = payload.proofs;
|
||
document.getElementById('proofsInfo').textContent = `Proofs: ${payload.proofs}`;
|
||
}
|
||
|
||
if (payload.uptime !== undefined) {
|
||
state.uptime = payload.uptime;
|
||
document.getElementById('uptimeInfo').textContent = `Uptime: ${payload.uptime}`;
|
||
}
|
||
|
||
if (payload.tailnet !== undefined) {
|
||
document.getElementById('tailnetInfo').textContent = `Tailnet: ${payload.tailnet}`;
|
||
}
|
||
|
||
if (payload.node !== undefined) {
|
||
document.getElementById('nodeInfo').textContent = `Node: ${payload.node}`;
|
||
}
|
||
}
|
||
|
||
function updateAgentStatus(agent, status) {
|
||
const el = document.getElementById(`agent${agent.charAt(0).toUpperCase() + agent.slice(1)}`);
|
||
if (el) {
|
||
el.textContent = status.toUpperCase();
|
||
el.className = `agent-status ${status === 'active' ? 'active' : 'idle'}`;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// BOOT SEQUENCE
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
async function bootSequence() {
|
||
const bootLines = [
|
||
{ delay: 0, type: 'dimmed', text: '> Initializing IoTek.nexus...' },
|
||
{ delay: 150, type: 'dimmed', text: '> Loading sovereign-mesh kernel...' },
|
||
];
|
||
|
||
for (const line of bootLines) {
|
||
await sleep(line.delay);
|
||
printLine(line.text, line.type);
|
||
}
|
||
|
||
// Try to connect to backend
|
||
printLine('> Connecting to MCP backend...', 'dimmed');
|
||
|
||
try {
|
||
const data = await sendToMCP('ping');
|
||
printLine('> ✓ MCP backend connected', 'success');
|
||
|
||
if (data.effects) {
|
||
updateStatusFromPayload(data.effects);
|
||
}
|
||
|
||
// Fetch initial status
|
||
printLine('> Fetching system status...', 'dimmed');
|
||
const statusData = await sendToMCP('status');
|
||
if (statusData.effects) {
|
||
updateStatusFromPayload(statusData.effects);
|
||
}
|
||
printLine('> ✓ Status synchronized', 'success');
|
||
|
||
} catch (err) {
|
||
printLine('> ⚠ MCP backend unavailable', 'warning');
|
||
printLine('> Running in MOCK MODE', 'warning');
|
||
printLine('> Start backend: uvicorn offsec_mcp:app --reload', 'dimmed');
|
||
}
|
||
|
||
// Connect WebSocket
|
||
printLine('> Connecting WebSocket...', 'dimmed');
|
||
connectWebSocket();
|
||
|
||
await sleep(500);
|
||
if (state.wsConnected) {
|
||
printLine('> ✓ WebSocket connected', 'success');
|
||
} else {
|
||
printLine('> ○ WebSocket connecting...', 'dimmed');
|
||
}
|
||
|
||
printLine('\n Welcome to IoTek.nexus — Sovereign Console v1.0', 'info');
|
||
printLine(' Type "help" for commands.\n', 'dimmed');
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// EVENT LISTENERS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
document.getElementById('commandInput').addEventListener('keydown', (e) => {
|
||
const input = e.target;
|
||
|
||
if (e.key === 'Enter' && !state.pendingCommand) {
|
||
executeCommand(input.value);
|
||
input.value = '';
|
||
}
|
||
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
if (state.historyIndex < state.commandHistory.length - 1) {
|
||
state.historyIndex++;
|
||
input.value = state.commandHistory[state.historyIndex];
|
||
}
|
||
}
|
||
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
if (state.historyIndex > 0) {
|
||
state.historyIndex--;
|
||
input.value = state.commandHistory[state.historyIndex];
|
||
} else {
|
||
state.historyIndex = -1;
|
||
input.value = '';
|
||
}
|
||
}
|
||
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
const partial = input.value.toLowerCase();
|
||
const allCommands = [
|
||
...Object.keys(localCommands),
|
||
...Object.keys(mockCommands),
|
||
'status', 'mesh status', 'mesh nodes', 'shield status', 'shield arm',
|
||
'shield disarm', 'proof latest', 'proof generate', 'agents list',
|
||
'oracle reason'
|
||
];
|
||
const matches = [...new Set(allCommands)].filter(c => c.startsWith(partial));
|
||
if (matches.length === 1) {
|
||
input.value = matches[0];
|
||
}
|
||
}
|
||
|
||
if (e.key === 'c' && e.ctrlKey) {
|
||
if (state.pendingCommand) {
|
||
printLine('^C', 'dimmed');
|
||
setLoading(false);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Sidebar clicks
|
||
document.querySelectorAll('.sidebar-item, .agent-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const cmd = item.dataset.cmd;
|
||
if (cmd && !state.pendingCommand) {
|
||
document.getElementById('commandInput').value = cmd;
|
||
executeCommand(cmd);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Focus input
|
||
document.querySelector('.terminal-area').addEventListener('click', () => {
|
||
document.getElementById('commandInput').focus();
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// CODE RAIN
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function createCodeRain() {
|
||
const container = document.getElementById('codeRain');
|
||
const chars = '01アイウエオカキクケコサシスセソ><{}[]|;:.,/*-+';
|
||
const columnCount = Math.floor(window.innerWidth / 25);
|
||
|
||
for (let i = 0; i < columnCount; i++) {
|
||
const column = document.createElement('div');
|
||
column.className = 'rain-column';
|
||
column.style.left = (i * 25) + 'px';
|
||
column.style.animationDuration = (10 + Math.random() * 15) + 's';
|
||
column.style.animationDelay = (Math.random() * 10) + 's';
|
||
column.style.opacity = 0.3 + Math.random() * 0.4;
|
||
|
||
let str = '';
|
||
const length = 15 + Math.floor(Math.random() * 20);
|
||
for (let j = 0; j < length; j++) {
|
||
str += chars[Math.floor(Math.random() * chars.length)];
|
||
}
|
||
column.textContent = str;
|
||
container.appendChild(column);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// CLOCK
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
function updateClock() {
|
||
document.getElementById('headerTime').textContent =
|
||
new Date().toTimeString().split(' ')[0];
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// INIT
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
createCodeRain();
|
||
updateClock();
|
||
setInterval(updateClock, 1000);
|
||
updateConnectionStatus();
|
||
updateWsStatus();
|
||
|
||
bootSequence().then(() => {
|
||
document.getElementById('commandInput').focus();
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|