diff --git a/services/arbiter-3.0/src/routes/admin/index.js b/services/arbiter-3.0/src/routes/admin/index.js index 558bbbc..8efc445 100644 --- a/services/arbiter-3.0/src/routes/admin/index.js +++ b/services/arbiter-3.0/src/routes/admin/index.js @@ -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; diff --git a/services/arbiter-3.0/src/routes/admin/infrastructure.js b/services/arbiter-3.0/src/routes/admin/infrastructure.js new file mode 100644 index 0000000..fd74c1e --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/infrastructure.js @@ -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; diff --git a/services/arbiter-3.0/src/views/admin/infrastructure/index.ejs b/services/arbiter-3.0/src/views/admin/infrastructure/index.ejs new file mode 100644 index 0000000..1589b2d --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/infrastructure/index.ejs @@ -0,0 +1,703 @@ + + + + + +