- Mouse wheel zoom (centered on cursor position) - Click-drag to pan - Touch pinch-zoom for mobile - Touch drag to pan on mobile - Zoom controls: +, −, percentage display, reset (⌂) - Zoom range: 50% to 300% - Drag guard prevents accidental clicks after panning - Canvas connections redraw correctly at all zoom levels - Smooth CSS transitions on zoom, disabled during drag Chronicler #78 | firefrost-services
948 lines
34 KiB
Plaintext
948 lines
34 KiB
Plaintext
<!-- Infrastructure Module — Trinity Console -->
|
||
<!-- Chronicler #78 | April 11, 2026 -->
|
||
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||
|
||
#infra-module { font-family: 'JetBrains Mono', 'SF Mono', monospace; }
|
||
#infra-module * { box-sizing: border-box; }
|
||
|
||
.infra-card {
|
||
background: #2d2d2d;
|
||
border: 1px solid #404040;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.infra-metric-card {
|
||
background: #2d2d2d;
|
||
border: 1px solid #404040;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
text-align: center;
|
||
}
|
||
.infra-metric-card .value {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
}
|
||
.infra-metric-card .label {
|
||
font-size: 9px;
|
||
color: #888;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.topo-canvas-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 480px;
|
||
background: radial-gradient(ellipse at 50% 0%, #2d2d2d 0%, #1a1a1a 70%);
|
||
border-radius: 12px;
|
||
border: 1px solid #404040;
|
||
overflow: hidden;
|
||
}
|
||
.topo-grid {
|
||
position: absolute;
|
||
inset: 0;
|
||
background-image:
|
||
linear-gradient(#40404022 1px, transparent 1px),
|
||
linear-gradient(90deg, #40404022 1px, transparent 1px);
|
||
background-size: 40px 40px;
|
||
pointer-events: none;
|
||
}
|
||
.topo-canvas {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.topo-node {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease;
|
||
z-index: 1;
|
||
}
|
||
.topo-node:hover, .topo-node.selected {
|
||
transform: scale(1.08);
|
||
z-index: 10;
|
||
}
|
||
.topo-node-inner {
|
||
background: #2d2d2dee;
|
||
border: 1.5px solid #404040;
|
||
border-radius: 8px;
|
||
padding: 8px 6px;
|
||
text-align: center;
|
||
box-shadow: 0 2px 8px #00000044;
|
||
transition: all 0.15s ease;
|
||
}
|
||
.topo-node:hover .topo-node-inner,
|
||
.topo-node.selected .topo-node-inner {
|
||
box-shadow: 0 0 20px var(--node-color-glow);
|
||
border-color: var(--node-color);
|
||
}
|
||
.topo-node.selected .topo-node-inner {
|
||
background: var(--node-color-bg);
|
||
}
|
||
.topo-node-name { font-size: 11px; font-weight: 700; color: #e5e5e5; }
|
||
.topo-node-ip { font-size: 9px; color: #888; margin-top: 2px; }
|
||
.topo-node-role { font-size: 8px; font-weight: 600; margin-top: 2px; }
|
||
.topo-node-games { font-size: 8px; color: #aaa; margin-top: 3px; }
|
||
|
||
.topo-ext {
|
||
position: absolute;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease;
|
||
width: 80px;
|
||
}
|
||
.topo-ext:hover { transform: scale(1.1); }
|
||
.topo-ext-icon { font-size: 20px; }
|
||
.topo-ext-label { font-size: 9px; font-weight: 600; margin-top: 2px; white-space: nowrap; }
|
||
|
||
.topo-legend {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
right: 12px;
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 10px;
|
||
color: #888;
|
||
background: #1a1a1acc;
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.status-dot {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
margin-right: 4px;
|
||
vertical-align: middle;
|
||
}
|
||
.status-dot.healthy { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
|
||
.status-dot.warning { background: #eab308; box-shadow: 0 0 6px #eab308; }
|
||
.status-dot.critical { background: #ef4444; box-shadow: 0 0 6px #ef4444; }
|
||
.status-dot.offline { background: #666; box-shadow: 0 0 6px #666; }
|
||
|
||
.progress-bar {
|
||
width: 100%;
|
||
height: 6px;
|
||
background: #1a1a1a;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
margin-top: 6px;
|
||
}
|
||
.progress-bar-fill {
|
||
height: 100%;
|
||
border-radius: 3px;
|
||
transition: width 0.5s ease;
|
||
}
|
||
|
||
.detail-view { animation: fadeSlideIn 0.2s ease; }
|
||
@keyframes fadeSlideIn {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.back-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #4ECDC4;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
padding: 0;
|
||
margin-bottom: 12px;
|
||
font-family: inherit;
|
||
}
|
||
.back-btn:hover { text-decoration: underline; }
|
||
|
||
.svc-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid #40404044;
|
||
}
|
||
.svc-row:last-child { border-bottom: none; }
|
||
.svc-icon { font-size: 16px; }
|
||
.svc-name { font-size: 12px; font-weight: 500; color: #ddd; }
|
||
.svc-domain { font-size: 10px; color: #4ECDC4; }
|
||
.svc-note { font-size: 10px; color: #888; }
|
||
.svc-port {
|
||
font-size: 9px;
|
||
color: #666;
|
||
background: #1a1a1a;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.badge {
|
||
font-size: 10px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
margin-left: 8px;
|
||
}
|
||
.badge-warning { background: #eab30822; color: #eab308; }
|
||
.badge-critical { background: #ef444422; color: #ef4444; }
|
||
|
||
.refresh-btn {
|
||
background: #4ECDC415;
|
||
border: 1px solid #4ECDC444;
|
||
color: #4ECDC4;
|
||
padding: 6px 14px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
font-family: inherit;
|
||
transition: all 0.15s;
|
||
}
|
||
.refresh-btn:hover { background: #4ECDC425; border-color: #4ECDC4; }
|
||
.refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
.conn-dot {
|
||
display: inline-block;
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
margin-right: 6px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.topo-canvas-wrap { height: 380px; }
|
||
.fleet-grid { grid-template-columns: repeat(3, 1fr) !important; }
|
||
.detail-metrics { grid-template-columns: repeat(2, 1fr) !important; }
|
||
.detail-panels { grid-template-columns: 1fr !important; }
|
||
}
|
||
|
||
/* Zoom controls */
|
||
.zoom-controls {
|
||
position: absolute;
|
||
bottom: 12px;
|
||
left: 12px;
|
||
display: flex;
|
||
gap: 4px;
|
||
z-index: 20;
|
||
}
|
||
.zoom-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
background: #1a1a1acc;
|
||
border: 1px solid #404040;
|
||
border-radius: 6px;
|
||
color: #ccc;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-family: inherit;
|
||
transition: all 0.15s;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
.zoom-btn:hover { background: #2d2d2d; border-color: #4ECDC4; color: #4ECDC4; }
|
||
.zoom-btn:active { transform: scale(0.92); }
|
||
.zoom-level {
|
||
height: 32px;
|
||
padding: 0 8px;
|
||
background: #1a1a1acc;
|
||
border: 1px solid #404040;
|
||
border-radius: 6px;
|
||
color: #888;
|
||
font-size: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
font-family: inherit;
|
||
min-width: 44px;
|
||
justify-content: center;
|
||
}
|
||
.topo-zoomable {
|
||
position: absolute;
|
||
inset: 0;
|
||
transform-origin: 0 0;
|
||
transition: transform 0.15s ease;
|
||
will-change: transform;
|
||
}
|
||
.topo-zoomable.dragging {
|
||
transition: none;
|
||
cursor: grabbing;
|
||
}
|
||
.topo-canvas-wrap { cursor: grab; }
|
||
.topo-canvas-wrap.dragging { cursor: grabbing; }
|
||
</style>
|
||
|
||
<div id="infra-module">
|
||
<!-- Title bar -->
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
|
||
<div style="display:flex; align-items:center; gap:10px;">
|
||
<span style="font-size:24px">🌐</span>
|
||
<div>
|
||
<h1 style="font-size:20px; font-weight:700; margin:0; background:linear-gradient(135deg, #FF6B35, #A855F7, #4ECDC4); -webkit-background-clip:text; -webkit-text-fill-color:transparent;">
|
||
Infrastructure
|
||
</h1>
|
||
<div style="font-size:10px; color:#666;">
|
||
<% if (fleet) { %>
|
||
Last audit: <%= new Date(fleet.fetchedAt).toLocaleString('en-US', { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %>
|
||
<% } %>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:8px; align-items:center;">
|
||
<button id="topo-btn" class="refresh-btn" onclick="showTopology()" style="display:none;">View Topology</button>
|
||
<button id="refresh-btn" class="refresh-btn" onclick="refreshAudit()">⟳ Refresh</button>
|
||
</div>
|
||
</div>
|
||
|
||
<% if (!fleet) { %>
|
||
<div class="infra-card" style="text-align:center; padding:40px;">
|
||
<div style="font-size:48px; margin-bottom:16px;">🔌</div>
|
||
<div style="font-size:16px; font-weight:600; color:#e5e5e5;">Unable to reach Trinity Core</div>
|
||
<div style="font-size:12px; color:#888; margin-top:8px;"><%= error || 'Connection failed' %></div>
|
||
<button class="refresh-btn" onclick="refreshAudit()" style="margin-top:16px;">Try Again</button>
|
||
</div>
|
||
<% } else { %>
|
||
|
||
<!-- Fleet Summary -->
|
||
<div class="fleet-grid" style="display:grid; grid-template-columns:repeat(6, 1fr); gap:10px; margin-bottom:16px;">
|
||
<div class="infra-metric-card">
|
||
<div class="value" style="color:#4ECDC4;"><%= fleet.summary.totalServers %></div>
|
||
<div class="label">Physical Servers</div>
|
||
</div>
|
||
<div class="infra-metric-card">
|
||
<div class="value" style="color:#FF6B35;"><%= fleet.summary.totalGameServers %></div>
|
||
<div class="label">Game Servers</div>
|
||
</div>
|
||
<div class="infra-metric-card">
|
||
<div class="value" style="color:#A855F7;">~42</div>
|
||
<div class="label">Docker Containers</div>
|
||
</div>
|
||
<div class="infra-metric-card">
|
||
<div class="value" style="color:#3b82f6;">~526 GB</div>
|
||
<div class="label">Fleet RAM</div>
|
||
</div>
|
||
<div class="infra-metric-card">
|
||
<div class="value" style="color:#06b6d4;">~2 TB</div>
|
||
<div class="label">Fleet Disk</div>
|
||
</div>
|
||
<div class="infra-metric-card">
|
||
<div class="value" style="color:<%= fleet.summary.needRestart > 0 ? '#eab308' : '#22c55e' %>;"><%= fleet.summary.needRestart %>/<%= fleet.summary.totalServers %></div>
|
||
<div class="label">Need Restart</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Topology View -->
|
||
<div id="topology-view">
|
||
<div class="topo-canvas-wrap" id="topo-wrap">
|
||
<div class="topo-zoomable" id="topo-zoomable">
|
||
<div class="topo-grid"></div>
|
||
<canvas id="topo-canvas" class="topo-canvas"></canvas>
|
||
|
||
<!-- External service nodes -->
|
||
<div class="topo-ext" data-node="cloudflare" style="left:calc(50% - 40px); top:14px;" onclick="showExternal('cloudflare')">
|
||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #f4812055);">☁️</div>
|
||
<div class="topo-ext-label" style="color:#f48120;">Cloudflare</div>
|
||
</div>
|
||
<div class="topo-ext" data-node="claude" style="left:calc(12.5% - 40px); top:14px;" onclick="showExternal('claude')">
|
||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #d4a57455);">🧠</div>
|
||
<div class="topo-ext-label" style="color:#d4a574;">Claude.ai</div>
|
||
</div>
|
||
<div class="topo-ext" data-node="website" style="left:calc(73% - 40px); top:14px;" onclick="showExternal('website')">
|
||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #4ECDC455);">🌐</div>
|
||
<div class="topo-ext-label" style="color:#4ECDC4;">firefrostgaming.com</div>
|
||
</div>
|
||
<div class="topo-ext" data-node="stripe" style="left:calc(31.25% - 40px); top:14px;" onclick="showExternal('stripe')">
|
||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #635bff55);">💳</div>
|
||
<div class="topo-ext-label" style="color:#635bff;">Stripe</div>
|
||
</div>
|
||
<div class="topo-ext" data-node="discord" style="left:calc(87.5% - 40px); top:14px;" onclick="showExternal('discord')">
|
||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #5865f255);">💬</div>
|
||
<div class="topo-ext-label" style="color:#5865f2;">Discord</div>
|
||
</div>
|
||
|
||
<!-- Server nodes -->
|
||
<% const positions = {
|
||
'trinity-core': { x: 12.5, y: 33 },
|
||
'command-center': { x: 43.75, y: 33 },
|
||
'panel-vps': { x: 73, y: 33 },
|
||
'tx1-dallas': { x: 60.4, y: 67 },
|
||
'nc1-charlotte': { x: 85.4, y: 67 },
|
||
'wiki-vps': { x: 27, y: 67 },
|
||
'services-vps': { x: 8.3, y: 67 },
|
||
'dev-panel': { x: 43.75, y: 88 }
|
||
}; %>
|
||
|
||
<% Object.entries(fleet.servers).forEach(([id, server]) => {
|
||
const pos = positions[id];
|
||
if (!pos) return;
|
||
const statusClass = server.status || (server.online ? 'healthy' : 'offline');
|
||
const nodeGames = id === 'tx1-dallas' ? fleet.summary.tx1Games : (id === 'nc1-charlotte' ? fleet.summary.nc1Games : 0);
|
||
%>
|
||
<div class="topo-node" data-node="<%= id %>"
|
||
style="left:calc(<%= pos.x %>% - 56px); top:calc(<%= pos.y %>% - 28px); width:112px; --node-color:<%= server.color %>; --node-color-glow:<%= server.color %>33; --node-color-bg:<%= server.color %>22;"
|
||
onclick="showServer('<%= id %>')"
|
||
onmouseenter="highlightNode('<%= id %>')"
|
||
onmouseleave="clearHighlight()">
|
||
<div class="topo-node-inner">
|
||
<div style="display:flex; align-items:center; justify-content:center; gap:4px; margin-bottom:4px;">
|
||
<span class="status-dot <%= statusClass %>"></span>
|
||
<span class="topo-node-name"><%= server.label %></span>
|
||
</div>
|
||
<div class="topo-node-ip"><%= id === 'trinity-core' ? 'Home (Tunnel)' : (server.online ? '' : 'OFFLINE') %></div>
|
||
<div class="topo-node-role" style="color:<%= server.color %>;"><%= server.role %></div>
|
||
<% if (nodeGames > 0) { %>
|
||
<div class="topo-node-games">🎮 <%= nodeGames %> servers</div>
|
||
<% } %>
|
||
</div>
|
||
</div>
|
||
<% }); %>
|
||
|
||
</div> <!-- end topo-zoomable -->
|
||
|
||
<!-- Zoom Controls -->
|
||
<div class="zoom-controls">
|
||
<button class="zoom-btn" onclick="zoomIn()" title="Zoom In">+</button>
|
||
<button class="zoom-btn" onclick="zoomOut()" title="Zoom Out">−</button>
|
||
<div class="zoom-level" id="zoom-level">100%</div>
|
||
<button class="zoom-btn" onclick="zoomReset()" title="Reset View" style="font-size:12px;">⌂</button>
|
||
</div>
|
||
|
||
<div class="topo-legend">
|
||
<span><span style="color:#f48120">━</span> External</span>
|
||
<span><span style="color:#4ECDC4">━</span> Internal</span>
|
||
<span style="color:#A855F7">╌ SSH</span>
|
||
<span style="color:#d4a574">━ MCP</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detail View (hidden initially) -->
|
||
<div id="detail-view" style="display:none;"></div>
|
||
|
||
<% } %>
|
||
</div>
|
||
|
||
<script>
|
||
// Fleet data from server
|
||
const fleet = <%- fleet ? JSON.stringify(fleet) : 'null' %>;
|
||
|
||
// Service definitions for detail views
|
||
const serviceMap = {
|
||
'command-center': [
|
||
{ name:'Gitea', domain:'git.firefrostgaming.com', port:3000, icon:'📦' },
|
||
{ name:'Arbiter 3.5', domain:'discord-bot.firefrostgaming.com', port:3500, icon:'🤖' },
|
||
{ name:'Trinity Console', domain:'discord-bot.firefrostgaming.com/admin', icon:'🎛️' },
|
||
{ name:'Uptime Kuma', domain:'status.firefrostgaming.com', port:3001, icon:'📡' },
|
||
{ name:'Vaultwarden', domain:'vault.firefrostgaming.com', icon:'🔐' },
|
||
{ name:'Code Server', domain:'code.firefrostgaming.com', icon:'💻' },
|
||
{ name:'PostgreSQL', port:5432, icon:'🗃️' },
|
||
{ name:'Nginx', port:443, icon:'🌐' }
|
||
],
|
||
'tx1-dallas': [
|
||
{ name:'Wings', icon:'🦅' },
|
||
{ name:'Nginx', icon:'🌐' },
|
||
{ name:'Fail2ban', icon:'🛡️' },
|
||
{ name:'Codex Stack', icon:'🧠', note:'Dify + Qdrant + n8n + Ollama' }
|
||
],
|
||
'nc1-charlotte': [
|
||
{ name:'Wings', icon:'🦅' },
|
||
{ name:'MariaDB', icon:'🗃️' }
|
||
],
|
||
'panel-vps': [
|
||
{ name:'Pterodactyl Panel', domain:'panel.firefrostgaming.com', icon:'🦕' },
|
||
{ name:'Nginx', icon:'🌐' },
|
||
{ name:'MariaDB', icon:'🗃️' },
|
||
{ name:'Redis', icon:'⚡' },
|
||
{ name:'PHP-FPM 8.3', icon:'🐘' }
|
||
],
|
||
'dev-panel': [
|
||
{ name:'Pterodactyl Panel', icon:'🦕' },
|
||
{ name:'Wings', icon:'🦅' },
|
||
{ name:'Blueprint Beta', icon:'📐' }
|
||
],
|
||
'wiki-vps': [
|
||
{ name:'Staff Wiki', domain:'staff.firefrostgaming.com', icon:'📚' },
|
||
{ name:'Subscriber Wiki', domain:'subscribers.firefrostgaming.com', icon:'📖' },
|
||
{ name:'Pokerole Wiki', domain:'pokerole.firefrostgaming.com', icon:'🎲' },
|
||
{ name:'MkDocs', domain:'docs.firefrostgaming.com', icon:'📄' },
|
||
{ name:'PostgreSQL 16', icon:'🗃️' }
|
||
],
|
||
'services-vps': [
|
||
{ name:'Mailcow', domain:'mail.firefrostgaming.com', icon:'📧', note:'18 Docker containers' }
|
||
],
|
||
'trinity-core': [
|
||
{ name:'MCP Server v2.1.0', domain:'mcp.firefrostgaming.com', icon:'🔮' },
|
||
{ name:'Cloudflare Tunnel', icon:'🚇' }
|
||
]
|
||
};
|
||
|
||
const externalInfo = {
|
||
cloudflare: { label:'Cloudflare', icon:'☁️', color:'#f48120', desc:'DNS, CDN, Pages, Tunnel, Workers', connections:['command-center','panel-vps','wiki-vps','services-vps','trinity-core','website'] },
|
||
stripe: { label:'Stripe', icon:'💳', color:'#635bff', desc:'Payment processing (LIVE)', connections:['command-center'] },
|
||
discord: { label:'Discord', icon:'💬', color:'#5865f2', desc:'Community + Bot API', connections:['command-center'] },
|
||
website: { label:'firefrostgaming.com', icon:'🌐', color:'#4ECDC4', desc:'11ty on Cloudflare Pages', connections:['cloudflare'] },
|
||
claude: { label:'Claude.ai', icon:'🧠', color:'#d4a574', desc:'MCP Connector → Trinity Core', connections:['trinity-core'] }
|
||
};
|
||
|
||
// Connection definitions for canvas drawing
|
||
const connections = [
|
||
{ from:'cloudflare', to:'command-center', type:'external' },
|
||
{ from:'cloudflare', to:'panel-vps', type:'external' },
|
||
{ from:'cloudflare', to:'wiki-vps', type:'external' },
|
||
{ from:'cloudflare', to:'services-vps', type:'external' },
|
||
{ from:'cloudflare', to:'trinity-core', type:'tunnel' },
|
||
{ from:'cloudflare', to:'website', type:'external' },
|
||
{ from:'panel-vps', to:'tx1-dallas', type:'internal' },
|
||
{ from:'panel-vps', to:'nc1-charlotte', type:'internal' },
|
||
{ from:'command-center', to:'stripe', type:'external' },
|
||
{ from:'command-center', to:'discord', type:'external' },
|
||
{ from:'command-center', to:'panel-vps', type:'internal' },
|
||
{ from:'trinity-core', to:'command-center', type:'ssh' },
|
||
{ from:'trinity-core', to:'tx1-dallas', type:'ssh' },
|
||
{ from:'trinity-core', to:'nc1-charlotte', type:'ssh' },
|
||
{ from:'trinity-core', to:'panel-vps', type:'ssh' },
|
||
{ from:'trinity-core', to:'dev-panel', type:'ssh' },
|
||
{ from:'trinity-core', to:'wiki-vps', type:'ssh' },
|
||
{ from:'trinity-core', to:'services-vps', type:'ssh' },
|
||
{ from:'claude', to:'trinity-core', type:'mcp' }
|
||
];
|
||
|
||
let hoveredNode = null;
|
||
|
||
function getNodePositions() {
|
||
const zoomable = document.getElementById('topo-zoomable');
|
||
if (!zoomable) return {};
|
||
const positions = {};
|
||
|
||
// Temporarily remove transform to get unscaled positions
|
||
const savedTransform = zoomable.style.transform;
|
||
const savedTransition = zoomable.style.transition;
|
||
zoomable.style.transition = 'none';
|
||
zoomable.style.transform = 'none';
|
||
|
||
const zoomRect = zoomable.getBoundingClientRect();
|
||
|
||
document.querySelectorAll('.topo-node, .topo-ext').forEach(el => {
|
||
const id = el.dataset.node;
|
||
if (!id) return;
|
||
const rect = el.getBoundingClientRect();
|
||
positions[id] = {
|
||
x: (rect.left - zoomRect.left) + rect.width / 2,
|
||
y: (rect.top - zoomRect.top) + rect.height / 2
|
||
};
|
||
});
|
||
|
||
// Restore transform
|
||
zoomable.style.transform = savedTransform;
|
||
zoomable.style.transition = savedTransition;
|
||
|
||
return positions;
|
||
}
|
||
|
||
function drawConnections() {
|
||
const canvas = document.getElementById('topo-canvas');
|
||
const wrap = document.getElementById('topo-wrap');
|
||
if (!canvas || !wrap) return;
|
||
|
||
canvas.width = wrap.offsetWidth;
|
||
canvas.height = wrap.offsetHeight;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
const positions = getNodePositions();
|
||
|
||
const colors = { external:'#f4812055', internal:'#4ECDC455', ssh:'#A855F744', tunnel:'#f4812077', mcp:'#d4a57455' };
|
||
const hiColors = { external:'#f48120cc', internal:'#4ECDC4cc', ssh:'#A855F7aa', tunnel:'#f48120ee', mcp:'#d4a574cc' };
|
||
|
||
connections.forEach(conn => {
|
||
const from = positions[conn.from];
|
||
const to = positions[conn.to];
|
||
if (!from || !to) return;
|
||
|
||
const isHi = hoveredNode && (conn.from === hoveredNode || conn.to === hoveredNode);
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(from.x, from.y);
|
||
const midY = (from.y + to.y) / 2;
|
||
ctx.bezierCurveTo(from.x, midY, to.x, midY, to.x, to.y);
|
||
|
||
ctx.strokeStyle = isHi ? (hiColors[conn.type] || '#fff8') : (colors[conn.type] || '#fff2');
|
||
ctx.lineWidth = isHi ? 2.5 : 1.2;
|
||
ctx.setLineDash(conn.type === 'ssh' ? [4, 4] : []);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
});
|
||
}
|
||
|
||
function highlightNode(id) {
|
||
hoveredNode = id;
|
||
drawConnections();
|
||
}
|
||
|
||
function clearHighlight() {
|
||
hoveredNode = null;
|
||
drawConnections();
|
||
}
|
||
|
||
function progressBarColor(pct) {
|
||
if (pct > 80) return '#ef4444';
|
||
if (pct > 60) return '#eab308';
|
||
return '#4ECDC4';
|
||
}
|
||
|
||
function showTopology() {
|
||
document.getElementById('topology-view').style.display = 'block';
|
||
document.getElementById('detail-view').style.display = 'none';
|
||
document.getElementById('topo-btn').style.display = 'none';
|
||
document.querySelectorAll('.topo-node').forEach(n => n.classList.remove('selected'));
|
||
setTimeout(drawConnections, 50);
|
||
}
|
||
|
||
function showServer(id) {
|
||
if (dragMoved) return;
|
||
if (!fleet) return;
|
||
const server = fleet.servers[id];
|
||
if (!server) return;
|
||
|
||
document.querySelectorAll('.topo-node').forEach(n => n.classList.remove('selected'));
|
||
document.querySelector(`.topo-node[data-node="${id}"]`)?.classList.add('selected');
|
||
|
||
const services = serviceMap[id] || [];
|
||
const games = fleet.gameServers.filter(g => {
|
||
if (id === 'tx1-dallas') return g.node === 'TX1';
|
||
if (id === 'nc1-charlotte') return g.node === 'NC1';
|
||
return false;
|
||
});
|
||
const totalGameRam = games.reduce((a, g) => a + g.ram, 0);
|
||
|
||
const statusClass = server.status || 'healthy';
|
||
const badges = [];
|
||
if (server.restart) badges.push('<span class="badge badge-warning">Restart Required</span>');
|
||
if (server.diskWarning) badges.push('<span class="badge badge-critical">Disk Warning</span>');
|
||
|
||
let html = `<div class="detail-view">
|
||
<button class="back-btn" onclick="showTopology()">← Back to Topology</button>
|
||
<div style="display:flex; align-items:center; gap:12px; margin-bottom:20px;">
|
||
<div style="width:48px; height:48px; border-radius:10px; background:${server.color}22; border:2px solid ${server.color}; display:flex; align-items:center; justify-content:center; font-size:22px;">
|
||
${id === 'trinity-core' ? '🥧' : '🖥️'}
|
||
</div>
|
||
<div>
|
||
<h2 style="font-size:22px; font-weight:700; color:#f5f5f5; margin:0; display:flex; align-items:center; gap:6px;">
|
||
${server.label}
|
||
<span class="status-dot ${statusClass}"></span>
|
||
${badges.join('')}
|
||
</h2>
|
||
<div style="font-size:12px; color:#888;">${server.online ? (id === 'trinity-core' ? 'Home (Cloudflare Tunnel)' : '') : 'OFFLINE'} · ${server.role}</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
if (server.online) {
|
||
html += `<div class="detail-metrics" style="display:grid; grid-template-columns:repeat(4, 1fr); gap:12px; margin-bottom:20px;">
|
||
<div class="infra-card">
|
||
<div style="font-size:10px; color:#888; margin-bottom:4px;">CPU</div>
|
||
<div style="font-size:13px; font-weight:600; color:#e5e5e5;">${server.cpu || '?'}</div>
|
||
<div style="font-size:10px; color:#666; margin-top:4px;">Load: ${(server.load || [0,0,0]).join(' / ')}</div>
|
||
</div>
|
||
<div class="infra-card">
|
||
<div style="font-size:10px; color:#888; margin-bottom:4px;">RAM</div>
|
||
<div style="font-size:13px; font-weight:600; color:#e5e5e5;">${server.ram?.used || '?'} / ${server.ram?.total || '?'}</div>
|
||
<div class="progress-bar"><div class="progress-bar-fill" style="width:${server.ram?.pct || 0}%; background:${progressBarColor(server.ram?.pct || 0)};"></div></div>
|
||
</div>
|
||
<div class="infra-card">
|
||
<div style="font-size:10px; color:#888; margin-bottom:4px;">Disk</div>
|
||
<div style="font-size:13px; font-weight:600; color:#e5e5e5;">${server.disk?.used || '?'} / ${server.disk?.total || '?'}</div>
|
||
<div class="progress-bar"><div class="progress-bar-fill" style="width:${server.disk?.pct || 0}%; background:${progressBarColor(server.disk?.pct || 0)};"></div></div>
|
||
</div>
|
||
<div class="infra-card">
|
||
<div style="font-size:10px; color:#888; margin-bottom:4px;">Uptime</div>
|
||
<div style="font-size:13px; font-weight:600; color:#e5e5e5;">${server.uptime || '?'}</div>
|
||
<div style="font-size:10px; color:#666; margin-top:4px;">${server.os || ''}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
html += `<div class="detail-panels" style="display:grid; grid-template-columns:${games.length > 0 ? '1fr 1fr' : '1fr'}; gap:16px;">`;
|
||
|
||
// Services panel
|
||
html += `<div class="infra-card">
|
||
<h3 style="font-size:14px; font-weight:600; color:#e5e5e5; margin:0 0 12px 0;">Services (${services.length})</h3>`;
|
||
services.forEach(svc => {
|
||
html += `<div class="svc-row">
|
||
<span class="svc-icon">${svc.icon}</span>
|
||
<div style="flex:1;">
|
||
<div class="svc-name">${svc.name}</div>
|
||
${svc.domain ? `<div class="svc-domain">${svc.domain}</div>` : ''}
|
||
${svc.note ? `<div class="svc-note">${svc.note}</div>` : ''}
|
||
</div>
|
||
${svc.port ? `<span class="svc-port">:${svc.port}</span>` : ''}
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
|
||
// Game servers panel (TX1/NC1 only)
|
||
if (games.length > 0) {
|
||
html += `<div class="infra-card">
|
||
<h3 style="font-size:14px; font-weight:600; color:#e5e5e5; margin:0 0 12px 0;">Game Servers (${games.length})</h3>
|
||
<div style="max-height:280px; overflow-y:auto;">`;
|
||
games.forEach(gs => {
|
||
html += `<div style="display:flex; align-items:center; justify-content:space-between; padding:5px 0; border-bottom:1px solid #40404044;">
|
||
<div style="display:flex; align-items:center; gap:6px;">
|
||
<span style="font-size:12px;">🎮</span>
|
||
<span style="font-size:11px; color:#ddd;">${gs.name}</span>
|
||
</div>
|
||
<span style="font-size:9px; color:#888;">${(gs.ram / 1024).toFixed(0)} GB</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>
|
||
<div style="margin-top:8px; font-size:10px; color:#888; border-top:1px solid #404040; padding-top:8px;">
|
||
Total RAM: ${(totalGameRam / 1024).toFixed(0)} GB allocated
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
html += `</div></div>`;
|
||
|
||
document.getElementById('topology-view').style.display = 'none';
|
||
document.getElementById('detail-view').innerHTML = html;
|
||
document.getElementById('detail-view').style.display = 'block';
|
||
document.getElementById('topo-btn').style.display = 'inline-block';
|
||
}
|
||
|
||
function showExternal(id) {
|
||
if (dragMoved) return;
|
||
const ext = externalInfo[id];
|
||
if (!ext) return;
|
||
|
||
const connTypeColors = { external:'#f48120', internal:'#4ECDC4', ssh:'#A855F7', tunnel:'#f48120', mcp:'#d4a574' };
|
||
|
||
let html = `<div class="detail-view">
|
||
<button class="back-btn" onclick="showTopology()">← Back to Topology</button>
|
||
<div style="display:flex; align-items:center; gap:12px; margin-bottom:20px;">
|
||
<div style="width:48px; height:48px; border-radius:10px; background:${ext.color}22; border:2px solid ${ext.color}; display:flex; align-items:center; justify-content:center; font-size:22px;">
|
||
${ext.icon}
|
||
</div>
|
||
<div>
|
||
<h2 style="font-size:22px; font-weight:700; color:#f5f5f5; margin:0;">${ext.label}</h2>
|
||
<div style="font-size:12px; color:#888;">${ext.desc}</div>
|
||
</div>
|
||
</div>
|
||
<div class="infra-card">
|
||
<h3 style="font-size:14px; font-weight:600; color:#e5e5e5; margin:0 0 12px 0;">Connected Infrastructure</h3>`;
|
||
|
||
ext.connections.forEach(targetId => {
|
||
const target = fleet?.servers[targetId] || externalInfo[targetId];
|
||
if (!target) return;
|
||
const conn = connections.find(c =>
|
||
(c.from === id && c.to === targetId) || (c.from === targetId && c.to === id)
|
||
);
|
||
const connType = conn?.type || 'external';
|
||
const color = connTypeColors[connType] || '#888';
|
||
|
||
html += `<div style="display:flex; align-items:center; gap:8px; padding:8px 0; border-bottom:1px solid #40404044; cursor:pointer;" onclick="${fleet?.servers[targetId] ? `showServer('${targetId}')` : ''}">
|
||
<span class="conn-dot" style="background:${target.color || color};"></span>
|
||
<span style="font-size:12px; color:#ddd; flex:1;">${target.label}</span>
|
||
<span style="font-size:10px; color:${color}; background:${color}15; padding:2px 8px; border-radius:10px;">${conn?.type || 'connected'}</span>
|
||
</div>`;
|
||
});
|
||
|
||
html += `</div></div>`;
|
||
|
||
document.getElementById('topology-view').style.display = 'none';
|
||
document.getElementById('detail-view').innerHTML = html;
|
||
document.getElementById('detail-view').style.display = 'block';
|
||
document.getElementById('topo-btn').style.display = 'inline-block';
|
||
}
|
||
|
||
async function refreshAudit() {
|
||
const btn = document.getElementById('refresh-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = '⟳ Refreshing...';
|
||
|
||
try {
|
||
const res = await fetch('/admin/infrastructure/refresh', {
|
||
headers: { 'CSRF-Token': '<%= typeof csrfToken !== "undefined" ? csrfToken : "" %>' }
|
||
});
|
||
if (res.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
btn.textContent = '⟳ Failed';
|
||
setTimeout(() => { btn.textContent = '⟳ Refresh'; btn.disabled = false; }, 2000);
|
||
}
|
||
} catch (err) {
|
||
btn.textContent = '⟳ Error';
|
||
setTimeout(() => { btn.textContent = '⟳ Refresh'; btn.disabled = false; }, 2000);
|
||
}
|
||
}
|
||
|
||
// ─── Zoom / Pan / Pinch System ───
|
||
|
||
let zoomScale = 1;
|
||
let panX = 0, panY = 0;
|
||
let isDragging = false;
|
||
let dragStartX = 0, dragStartY = 0;
|
||
let dragStartPanX = 0, dragStartPanY = 0;
|
||
let dragMoved = false;
|
||
|
||
const MIN_ZOOM = 0.5;
|
||
const MAX_ZOOM = 3.0;
|
||
const ZOOM_STEP = 0.15;
|
||
|
||
function applyTransform() {
|
||
const el = document.getElementById('topo-zoomable');
|
||
if (!el) return;
|
||
el.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomScale})`;
|
||
document.getElementById('zoom-level').textContent = Math.round(zoomScale * 100) + '%';
|
||
// Redraw canvas connections at new scale
|
||
setTimeout(drawConnections, 20);
|
||
}
|
||
|
||
function zoomIn() {
|
||
zoomScale = Math.min(MAX_ZOOM, zoomScale + ZOOM_STEP);
|
||
applyTransform();
|
||
}
|
||
|
||
function zoomOut() {
|
||
zoomScale = Math.max(MIN_ZOOM, zoomScale - ZOOM_STEP);
|
||
applyTransform();
|
||
}
|
||
|
||
function zoomReset() {
|
||
zoomScale = 1;
|
||
panX = 0;
|
||
panY = 0;
|
||
applyTransform();
|
||
}
|
||
|
||
function zoomAtPoint(delta, clientX, clientY) {
|
||
const wrap = document.getElementById('topo-wrap');
|
||
if (!wrap) return;
|
||
const rect = wrap.getBoundingClientRect();
|
||
|
||
// Mouse position relative to wrap
|
||
const mx = clientX - rect.left;
|
||
const my = clientY - rect.top;
|
||
|
||
// Point in content space before zoom
|
||
const contentX = (mx - panX) / zoomScale;
|
||
const contentY = (my - panY) / zoomScale;
|
||
|
||
// Apply zoom
|
||
const oldScale = zoomScale;
|
||
zoomScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomScale + delta));
|
||
|
||
// Adjust pan so the point under the mouse stays put
|
||
panX = mx - contentX * zoomScale;
|
||
panY = my - contentY * zoomScale;
|
||
|
||
applyTransform();
|
||
}
|
||
|
||
// Mouse wheel zoom
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const wrap = document.getElementById('topo-wrap');
|
||
if (!wrap) return;
|
||
|
||
wrap.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||
zoomAtPoint(delta, e.clientX, e.clientY);
|
||
}, { passive: false });
|
||
|
||
// Mouse drag to pan
|
||
wrap.addEventListener('mousedown', (e) => {
|
||
if (e.target.closest('.topo-node, .topo-ext, .zoom-btn, .zoom-level')) return;
|
||
isDragging = true;
|
||
dragMoved = false;
|
||
dragStartX = e.clientX;
|
||
dragStartY = e.clientY;
|
||
dragStartPanX = panX;
|
||
dragStartPanY = panY;
|
||
wrap.classList.add('dragging');
|
||
document.getElementById('topo-zoomable')?.classList.add('dragging');
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isDragging) return;
|
||
const dx = e.clientX - dragStartX;
|
||
const dy = e.clientY - dragStartY;
|
||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
|
||
panX = dragStartPanX + dx;
|
||
panY = dragStartPanY + dy;
|
||
applyTransform();
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (isDragging) {
|
||
isDragging = false;
|
||
wrap.classList.remove('dragging');
|
||
document.getElementById('topo-zoomable')?.classList.remove('dragging');
|
||
}
|
||
});
|
||
|
||
// Touch: pinch zoom + drag pan
|
||
let lastTouchDist = 0;
|
||
let lastTouchCenter = { x: 0, y: 0 };
|
||
|
||
wrap.addEventListener('touchstart', (e) => {
|
||
if (e.touches.length === 2) {
|
||
e.preventDefault();
|
||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
|
||
lastTouchCenter = {
|
||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
|
||
};
|
||
} else if (e.touches.length === 1) {
|
||
if (e.target.closest('.topo-node, .topo-ext, .zoom-btn')) return;
|
||
isDragging = true;
|
||
dragMoved = false;
|
||
dragStartX = e.touches[0].clientX;
|
||
dragStartY = e.touches[0].clientY;
|
||
dragStartPanX = panX;
|
||
dragStartPanY = panY;
|
||
}
|
||
}, { passive: false });
|
||
|
||
wrap.addEventListener('touchmove', (e) => {
|
||
if (e.touches.length === 2) {
|
||
e.preventDefault();
|
||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
const center = {
|
||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
|
||
};
|
||
|
||
if (lastTouchDist > 0) {
|
||
const scaleDelta = (dist - lastTouchDist) * 0.005;
|
||
zoomAtPoint(scaleDelta, center.x, center.y);
|
||
}
|
||
|
||
lastTouchDist = dist;
|
||
lastTouchCenter = center;
|
||
} else if (e.touches.length === 1 && isDragging) {
|
||
const dx = e.touches[0].clientX - dragStartX;
|
||
const dy = e.touches[0].clientY - dragStartY;
|
||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
|
||
panX = dragStartPanX + dx;
|
||
panY = dragStartPanY + dy;
|
||
applyTransform();
|
||
}
|
||
}, { passive: false });
|
||
|
||
wrap.addEventListener('touchend', () => {
|
||
isDragging = false;
|
||
lastTouchDist = 0;
|
||
});
|
||
});
|
||
|
||
// Draw connections on load and resize
|
||
window.addEventListener('load', () => setTimeout(drawConnections, 100));
|
||
window.addEventListener('resize', drawConnections);
|
||
</script>
|