Files
firefrost-services/services/arbiter-3.0/src/views/admin/infrastructure/index.ejs
Claude (Chronicler #78) 0c7dad36ea feat: Add zoom/pan/pinch to Infrastructure topology
- 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
2026-04-11 10:35:03 +00:00

948 lines
34 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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>