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:
@@ -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;
|
||||
|
||||
258
services/arbiter-3.0/src/routes/admin/infrastructure.js
Normal file
258
services/arbiter-3.0/src/routes/admin/infrastructure.js
Normal 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;
|
||||
703
services/arbiter-3.0/src/views/admin/infrastructure/index.ejs
Normal file
703
services/arbiter-3.0/src/views/admin/infrastructure/index.ejs
Normal 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>
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user