feat: Add Infrastructure module to Trinity Console

New module: /admin/infrastructure
- Interactive topology map showing all 8 servers + external services
- Live data from Trinity Core MCP API (cached 60s)
- Bezier curve connections color-coded by type (external/internal/SSH/MCP)
- Hover highlights all connections to/from a node
- Click to drill into server detail (CPU, RAM, disk, load, services)
- Game server list for TX1/NC1 with RAM allocation totals
- External service detail (Cloudflare, Stripe, Discord, Claude.ai)
- Fleet summary bar (8 servers, 22 games, ~42 containers)
- Refresh button forces cache clear + re-audit
- Mobile responsive grid layout
- JetBrains Mono typography, Fire/Frost/Arcane color scheme

Files:
- src/routes/admin/infrastructure.js (route + Trinity Core API client)
- src/views/admin/infrastructure/index.ejs (topology + detail views)
- Modified: src/routes/admin/index.js (registered infrastructure router)
- Modified: src/views/layout.ejs (added nav link)

Chronicler #78 | firefrost-services
This commit is contained in:
Claude (Chronicler #78)
2026-04-11 10:24:05 +00:00
parent 5c980ea681
commit a83766efb4
4 changed files with 966 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ const schedulerRouter = require('./scheduler');
const discordAuditRouter = require('./discord-audit');
const systemRouter = require('./system');
const socialRouter = require('./social');
const infrastructureRouter = require('./infrastructure');
router.use(requireTrinityAccess);
@@ -117,5 +118,6 @@ router.use('/scheduler', schedulerRouter);
router.use('/discord', discordAuditRouter);
router.use('/system', systemRouter);
router.use('/social', socialRouter);
router.use('/infrastructure', infrastructureRouter);
module.exports = router;

View File

@@ -0,0 +1,258 @@
const express = require('express');
const router = express.Router();
/**
* Infrastructure Module — Trinity Console
*
* Live infrastructure topology and server health monitoring.
* Data sourced from Trinity Core MCP gateway via HTTP API.
*
* GET /admin/infrastructure — Main topology view
* GET /admin/infrastructure/refresh — Force cache refresh (HTMX)
* GET /admin/infrastructure/server/:id — Server detail partial (HTMX)
*
* Chronicler #78 | April 11, 2026
*/
const TRINITY_CORE_URL = 'https://mcp.firefrostgaming.com';
const TRINITY_CORE_TOKEN = 'FFG-Trinity-2026-Core-Access';
const CACHE_TTL = 60000; // 60 seconds
// Server definitions (matches Trinity Core's SERVERS config)
const SERVERS = {
'command-center': { label: 'Command Center', role: 'Management Hub', color: '#A855F7' },
'tx1-dallas': { label: 'TX1 Dallas', role: 'Game Node (Primary)', color: '#FF6B35' },
'nc1-charlotte': { label: 'NC1 Charlotte', role: 'Game Node (Secondary)', color: '#4ECDC4' },
'panel-vps': { label: 'Panel VPS', role: 'Pterodactyl Panel', color: '#3b82f6' },
'dev-panel': { label: 'Dev Panel', role: 'Development & Testing', color: '#6b7280' },
'wiki-vps': { label: 'Wiki VPS', role: 'Knowledge Base', color: '#06b6d4' },
'services-vps': { label: 'Services VPS', role: 'Email & Services', color: '#f59e0b' },
'trinity-core': { label: 'Trinity Core', role: 'MCP Gateway (Pi)', color: '#A855F7' }
};
// In-memory cache
let auditCache = { data: null, lastFetch: 0 };
/**
* Execute a command on a server via Trinity Core
*/
async function trinityExec(server, command) {
try {
const res = await fetch(`${TRINITY_CORE_URL}/exec`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TRINITY_CORE_TOKEN}`
},
body: JSON.stringify({ server, command })
});
if (!res.ok) throw new Error(`Trinity Core error: ${res.status}`);
const data = await res.json();
return data.output || data.stdout || '';
} catch (err) {
console.error(`[INFRA] trinityExec failed for ${server}:`, err.message);
return null;
}
}
/**
* Parse the standard audit command output
*/
function parseAuditOutput(output) {
if (!output) return null;
const lines = output.split('\n');
const get = (marker) => {
const idx = lines.findIndex(l => l.includes(marker));
return idx >= 0 && idx + 1 < lines.length ? lines[idx + 1]?.trim() : '';
};
// Parse RAM line: "Mem: 3.8Gi 1.4Gi 803Mi ..."
const memLine = get('=== RAM ===');
const memParts = memLine.split(/\s+/);
const totalRam = memParts[1] || '?';
const usedRam = memParts[2] || '?';
// Parse disk line: "/dev/xxx 38G 17G 21G 45% /"
const diskLine = get('=== DISK ===');
const diskParts = diskLine.split(/\s+/);
const totalDisk = diskParts[1] || '?';
const usedDisk = diskParts[2] || '?';
const diskPct = parseInt(diskParts[4]) || 0;
// Parse uptime: "05:03:05 up 61 days, ..."
const uptimeLine = get('=== UPTIME ===');
const uptimeMatch = uptimeLine.match(/up\s+(.+?),\s+\d+\s+user/);
const uptime = uptimeMatch ? uptimeMatch[1].trim() : uptimeLine;
// Parse load averages
const loadMatch = uptimeLine.match(/load average:\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)/);
const load = loadMatch ? [parseFloat(loadMatch[1]), parseFloat(loadMatch[2]), parseFloat(loadMatch[3])] : [0, 0, 0];
// Parse restart required
const restartLine = get('=== RESTART ===');
const restart = restartLine.includes('restart required');
// Parse hostname, OS, kernel, CPU
const hostname = get('=== HOSTNAME ===');
const osLines = output.split('\n').filter(l => l.startsWith('NAME=') || l.startsWith('VERSION='));
const osName = osLines.map(l => l.split('=')[1]?.replace(/"/g, '')).join(' ');
const kernel = get('=== KERNEL ===');
const cpuModel = lines.find(l => l.includes('Model name'))?.split(':')[1]?.trim() || '?';
const cpuCores = lines.find(l => l.match(/^CPU\(s\)/))?.split(':')[1]?.trim() || '?';
// Parse RAM percentage
const totalGi = parseFloat(totalRam);
const usedGi = parseFloat(usedRam);
const ramPct = totalGi > 0 ? Math.round((usedGi / totalGi) * 100) : 0;
return {
hostname,
os: osName,
kernel,
cpu: `${cpuModel} (${cpuCores} cores)`,
ram: { total: totalRam, used: usedRam, pct: ramPct },
disk: { total: totalDisk, used: usedDisk, pct: diskPct },
uptime,
load,
restart,
diskWarning: diskPct >= 70,
status: diskPct >= 85 ? 'critical' : diskPct >= 70 ? 'warning' : restart ? 'warning' : 'healthy'
};
}
/**
* Fetch audit data from all servers in parallel
*/
async function fetchFleetAudit() {
const now = Date.now();
if (auditCache.data && (now - auditCache.lastFetch < CACHE_TTL)) {
return auditCache.data;
}
console.log('[INFRA] Fetching fleet audit from Trinity Core...');
const auditCommand = [
'echo "=== HOSTNAME ===" && hostname',
'echo "=== OS ===" && cat /etc/os-release | grep -E "^(NAME|VERSION)="',
'echo "=== CPU ===" && lscpu | grep -E "^(Architecture|CPU\\(s\\)|Model name|Thread)"',
'echo "=== RAM ===" && free -h | grep Mem',
'echo "=== DISK ===" && df -h / | tail -1',
'echo "=== KERNEL ===" && uname -r',
'echo "=== RESTART ===" && [ -f /var/run/reboot-required ] && cat /var/run/reboot-required || echo "No restart required"',
'echo "=== UPTIME ===" && uptime'
].join(' && ');
// Run all server audits in parallel
const serverIds = Object.keys(SERVERS);
const results = await Promise.allSettled(
serverIds.map(id => trinityExec(id, auditCommand))
);
const audit = {};
serverIds.forEach((id, i) => {
const output = results[i].status === 'fulfilled' ? results[i].value : null;
const parsed = parseAuditOutput(output);
audit[id] = {
...SERVERS[id],
...(parsed || { status: 'offline', error: 'Could not reach server' }),
online: !!parsed
};
});
// Fetch game servers from Pterodactyl
let gameServers = [];
try {
const panelRes = await fetch(`${process.env.PANEL_URL}/api/application/servers?per_page=50`, {
headers: {
'Authorization': `Bearer ${process.env.PANEL_APPLICATION_KEY}`,
'Accept': 'application/json'
}
});
if (panelRes.ok) {
const panelData = await panelRes.json();
const nodeMap = { 2: 'NC1', 3: 'TX1' };
gameServers = panelData.data.map(s => ({
name: s.attributes.name,
uuid: s.attributes.uuid,
node: nodeMap[s.attributes.node] || `Node ${s.attributes.node}`,
nodeId: s.attributes.node,
ram: s.attributes.limits.memory,
disk: s.attributes.limits.disk,
suspended: s.attributes.suspended
}));
}
} catch (err) {
console.error('[INFRA] Pterodactyl API error:', err.message);
}
const fleetData = {
servers: audit,
gameServers,
fetchedAt: new Date().toISOString(),
summary: {
totalServers: serverIds.length,
online: Object.values(audit).filter(s => s.online).length,
needRestart: Object.values(audit).filter(s => s.restart).length,
totalGameServers: gameServers.length,
tx1Games: gameServers.filter(g => g.node === 'TX1').length,
nc1Games: gameServers.filter(g => g.node === 'NC1').length
}
};
auditCache = { data: fleetData, lastFetch: now };
console.log(`[INFRA] Fleet audit complete: ${fleetData.summary.online}/${fleetData.summary.totalServers} online`);
return fleetData;
}
// GET /admin/infrastructure — Main page
router.get('/', async (req, res) => {
try {
const fleet = await fetchFleetAudit();
res.render('admin/infrastructure/index', {
title: 'Infrastructure',
currentPath: '/infrastructure',
fleet,
adminUser: req.user,
layout: 'layout'
});
} catch (err) {
console.error('[INFRA] Route error:', err);
res.render('admin/infrastructure/index', {
title: 'Infrastructure',
currentPath: '/infrastructure',
fleet: null,
error: err.message,
adminUser: req.user,
layout: 'layout'
});
}
});
// GET /admin/infrastructure/refresh — Force refresh (HTMX)
router.get('/refresh', async (req, res) => {
auditCache = { data: null, lastFetch: 0 };
try {
const fleet = await fetchFleetAudit();
res.json({ success: true, fleet, fetchedAt: fleet.fetchedAt });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
// GET /admin/infrastructure/server/:id — Server detail (JSON for client)
router.get('/server/:id', async (req, res) => {
const fleet = await fetchFleetAudit();
const server = fleet.servers[req.params.id];
if (!server) return res.status(404).json({ error: 'Server not found' });
const games = fleet.gameServers.filter(g => {
if (req.params.id === 'tx1-dallas') return g.node === 'TX1';
if (req.params.id === 'nc1-charlotte') return g.node === 'NC1';
return false;
});
res.json({ server, games });
});
module.exports = router;

View File

@@ -0,0 +1,703 @@
<!-- 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; }
}
</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-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 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 wrap = document.getElementById('topo-wrap');
if (!wrap) return {};
const w = wrap.offsetWidth;
const h = wrap.offsetHeight;
const positions = {};
document.querySelectorAll('.topo-node, .topo-ext').forEach(el => {
const id = el.dataset.node;
if (!id) return;
const rect = el.getBoundingClientRect();
const wrapRect = wrap.getBoundingClientRect();
positions[id] = {
x: (rect.left - wrapRect.left) + rect.width / 2,
y: (rect.top - wrapRect.top) + rect.height / 2
};
});
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 (!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) {
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);
}
}
// Draw connections on load and resize
window.addEventListener('load', () => setTimeout(drawConnections, 100));
window.addEventListener('resize', drawConnections);
</script>

View File

@@ -101,6 +101,9 @@
<a href="/admin/social" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/social') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📊 Social
</a>
<a href="/admin/infrastructure" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/infrastructure') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🌐 Infrastructure
</a>
</nav>
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
<!-- Deploy Button -->