Server Command Center: full build (REQ-2026-04-14)

New files:
- 139_server_config.sql — DB migration for short_name system
- 139_seed_server_config.js — auto-populates 17 servers from Pterodactyl
- src/services/uptimeKuma.js — Socket.IO direct (no npm wrapper)
- src/services/pterodactyl.js — power actions + console commands

Modified files:
- servers.js — 6 new POST routes (short-name, lock, createserver,
  delserver, power, console) + short_name-based channel detection
- _server_card.ejs — full rebuild with command center UI
- _matrix_body.ejs — refactored from 144 lines to 20 (uses partial)
- package.json — added socket.io-client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (Chronicler #83 - The Compiler)
2026-04-13 20:37:46 -05:00
parent a404410efd
commit d16a525ffc
9 changed files with 837 additions and 234 deletions

View File

@@ -2,7 +2,7 @@
**Last Updated:** 2026-04-13 18:10 CDT
## Current Focus
Rules mod config bug — iterating fixes for Otherworld (NC1, 1.20.1 Forge).
Server Command Center (REQ-2026-04-14) — Arbiter feature build.
## Session Summary (2026-04-13)
@@ -15,12 +15,18 @@ Rules mod config bug — iterating fixes for Otherworld (NC1, 1.20.1 Forge).
- CurseForge project page copy pending from Chronicler
### Rules Mod Config Bug — FIXED ✅
- v1.0.1: Added `ModConfigEvent.Loading` handler (symptom fix, not root cause)
- v1.0.2: Switched `ModConfig.Type.SERVER``COMMON` (config persists ✅)
- v1.0.3: Fixed 1.20.1 event bus registration, added section header warnings
- v1.0.4: Diagnostic build — revealed DIAG logs never fired
- v1.0.5: **Root cause found** — console `/rules` hit early return, never fetched from Discord. Fixed: console path now fetches async. DIAG logging kept for observability.
- **Status:** WORKING on Otherworld. All 6 builds at v1.0.5. Ready for CurseForge submission.
- v1.0.5: All 6 builds working. DIAG logging kept for observability.
- WORKING on Otherworld. Ready for CurseForge submission.
### Server Command Center — IN PROGRESS 🔧
- Migration: `139_server_config.sql` — server_config table with short_name system
- Seed: `139_seed_server_config.js` — auto-populates 17 servers from Pterodactyl API
- Services: `uptimeKuma.js` (Socket.IO direct), `pterodactyl.js` (power/commands)
- Routes: 6 new POST endpoints (set-short-name, lock, createserver, delserver, power, console)
- Views: `_server_card.ejs` rebuilt with full command center UI, `_matrix_body.ejs` refactored to use partial
- Discord detection: uses `short_name` from DB instead of broken slug derivation
- Partial channel creation: createserver only creates missing channels
- **Status:** Code complete, needs deploy testing. Chronicler needs to add `UPTIME_KUMA_USERNAME` and `UPTIME_KUMA_PASSWORD` to `.env`.
### Bridge Queue — CLEAR ✅
- All REQs have matching RES files

View File

@@ -0,0 +1,93 @@
/**
* Seed script for server_config table.
* Fetches server identifiers from Pterodactyl API and populates
* short_name mappings for all known servers.
*
* Usage: node migrations/139_seed_server_config.js
*/
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
const { Pool } = require('pg');
const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT || 5432
});
// Known server mappings — short_name values confirmed from Discord audit
const SERVER_MAP = [
{ name: 'All the Mods 10: To the Sky', short_name: 'atm10-tts', node: 'NC1' },
{ name: 'All the Mons', short_name: 'all-the-mons', node: 'NC1' },
{ name: 'Mythcraft 5', short_name: 'mythcraft-5', node: 'NC1' },
{ name: 'All of Create (Creative)', short_name: 'all-of-create', node: 'NC1' },
{ name: 'DeceasedCraft', short_name: 'deceasedcraft', node: 'NC1' },
{ name: "Sneak's Pirate Pack", short_name: 'sneaks-pirate-pack', node: 'NC1' },
{ name: 'Otherworld [Dungeons & Dragons]', short_name: 'otherworld', node: 'NC1' },
{ name: 'Farm Crossing 6', short_name: 'farm-crossing-6', node: 'NC1' },
{ name: 'Homestead - A Cozy Survival Experience', short_name: 'homestead', node: 'NC1' },
{ name: 'Stoneblock 4', short_name: 'stoneblock-4', node: 'TX1' },
{ name: 'Society: Sunlit Valley', short_name: 'society-sunlit-valley', node: 'TX1' },
{ name: 'Submerged 2', short_name: 'submerged-2', node: 'TX1' },
{ name: 'Beyond Depth', short_name: 'beyond-depth', node: 'TX1' },
{ name: 'Beyond Ascension', short_name: 'beyond-ascension', node: 'TX1' },
{ name: 'Cottage Witch', short_name: 'cottage-witch', node: 'TX1' },
{ name: 'All The Mons (Private) - TX', short_name: 'all-the-mons-private', node: 'TX1' },
{ name: "Wold's Vaults", short_name: 'wolds-vaults', node: 'TX1' }
];
async function seed() {
console.log('Fetching servers from Pterodactyl API...');
const endpoint = `${process.env.PANEL_URL}/api/application/servers`;
const res = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${process.env.PANEL_APPLICATION_KEY}`,
'Accept': 'application/json'
}
});
if (!res.ok) {
console.error(`Panel API error: ${res.status} ${res.statusText}`);
process.exit(1);
}
const data = await res.json();
const servers = data.data.map(s => s.attributes);
let matched = 0;
let skipped = 0;
for (const mapping of SERVER_MAP) {
const ptero = servers.find(s => s.name === mapping.name);
if (!ptero) {
console.warn(` SKIP: "${mapping.name}" — not found in Pterodactyl`);
skipped++;
continue;
}
await pool.query(`
INSERT INTO server_config (server_identifier, short_name, short_name_locked, display_name, node, pterodactyl_name)
VALUES ($1, $2, true, $3, $4, $5)
ON CONFLICT (server_identifier) DO UPDATE SET
short_name = EXCLUDED.short_name,
short_name_locked = true,
display_name = EXCLUDED.display_name,
node = EXCLUDED.node,
pterodactyl_name = EXCLUDED.pterodactyl_name,
updated_at = NOW()
`, [ptero.identifier, mapping.short_name, mapping.name, mapping.node, mapping.name]);
console.log(` OK: "${mapping.name}" → ${mapping.short_name} (${ptero.identifier})`);
matched++;
}
console.log(`\nDone. Matched: ${matched}, Skipped: ${skipped}`);
await pool.end();
}
seed().catch(err => {
console.error('Seed failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,24 @@
-- Migration 139: Server Command Center — server_config table
-- Creates the foundation for short_name-based Discord channel detection
-- and server management from Trinity Console.
CREATE TABLE IF NOT EXISTS server_config (
server_identifier VARCHAR(36) PRIMARY KEY,
short_name VARCHAR(64) UNIQUE,
short_name_locked BOOLEAN DEFAULT false,
display_name VARCHAR(128),
restart_enabled BOOLEAN DEFAULT true,
restart_offset_minutes INTEGER DEFAULT 0,
node VARCHAR(8),
pterodactyl_name VARCHAR(128),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Index for fast short_name lookups (channel detection)
CREATE INDEX IF NOT EXISTS idx_server_config_short_name
ON server_config (short_name);
-- Index for node-based queries (matrix grouping)
CREATE INDEX IF NOT EXISTS idx_server_config_node
ON server_config (node);

View File

@@ -25,6 +25,7 @@
"passport": "^0.7.0",
"passport-discord": "^0.1.4",
"pg": "^8.11.3",
"socket.io-client": "^4.7.5",
"stripe": "^14.14.0"
}
}

View File

@@ -4,7 +4,9 @@ const db = require('../../database');
const { getMinecraftServers } = require('../../panel/discovery');
const { readServerProperties, writeWhitelistFile } = require('../../panel/files');
const { reloadWhitelistCommand } = require('../../panel/commands');
const { ChannelType } = require('discord.js');
const pterodactyl = require('../../services/pterodactyl');
const uptimeKuma = require('../../services/uptimeKuma');
const { ChannelType, PermissionFlagsBits } = require('discord.js');
// In-memory cache for RV low-bandwidth operations
let serverCache = { data: null, lastFetch: 0 };
@@ -14,6 +16,19 @@ const CACHE_TTL = 60000; // 60 seconds
let discordChannelCache = { channels: null, lastFetch: 0 };
const DISCORD_CACHE_TTL = 300000; // 5 minutes
// Staff/admin role names for channel permissions
const ADMIN_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
// Forum tags for new server forums
const STANDARD_FORUM_TAGS = [
{ name: 'Builds', emoji: '🏗️' },
{ name: 'Help', emoji: '❓' },
{ name: 'Suggestion', emoji: '💡' },
{ name: 'Bug Report', emoji: '🐛' },
{ name: 'Achievement', emoji: '🎉' },
{ name: 'Guide', emoji: '📖' }
];
/**
* Get Discord channels from cache or fetch fresh
*/
@@ -38,33 +53,33 @@ async function getDiscordChannels(client) {
}
/**
* Check which Discord channels exist for a server
* Returns object with missing channels array
* Invalidate Discord channel cache (after create/delete)
*/
function checkServerChannels(serverName, allChannels) {
// Extract the base name (before any " - " subtitle or parenthetical)
// "Homestead - A Cozy Survival Experience" -> "homestead"
// "All The Mons (Private) - TX" -> "all-the-mons"
// "Stoneblock 4" -> "stoneblock-4"
let baseName = serverName
.split(' - ')[0] // Take part before " - " subtitle
.replace(/\s*\([^)]*\)\s*/g, '') // Remove parentheticals like (Private)
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '') // Remove special chars except spaces
.replace(/\s+/g, '-') // Spaces to hyphens
.replace(/-+/g, '-') // Multiple hyphens to single
.trim();
function invalidateDiscordCache() {
discordChannelCache = { channels: null, lastFetch: 0 };
}
// Also create a display name for voice channel matching
/**
* Check which Discord channels exist for a server.
* Uses short_name from server_config instead of deriving from full name.
* Returns { missing, found, complete, unconfigured }
*/
function checkServerChannels(shortName, serverName, allChannels) {
if (!shortName) {
return { missing: [], found: [], complete: false, unconfigured: true };
}
// Voice channel uses the display name (before " - " subtitle)
const voiceDisplayName = serverName
.split(' - ')[0]
.replace(/\s*\([^)]*\)\s*/g, '')
.trim();
const expectedChannels = [
{ name: `${baseName}-chat`, type: 'text', label: 'Chat' },
{ name: `${baseName}-in-game`, type: 'text', label: 'In-Game' },
{ name: `${baseName}-forum`, type: 'forum', label: 'Forum' },
{ name: `${shortName}-chat`, type: 'text', label: 'Chat' },
{ name: `${shortName}-in-game`, type: 'text', label: 'In-Game' },
{ name: `${shortName}-forum`, type: 'forum', label: 'Forum' },
{ name: `${shortName}-status`, type: 'text', label: 'Status' },
{ name: voiceDisplayName, type: 'voice', label: 'Voice' }
];
@@ -73,22 +88,20 @@ function checkServerChannels(serverName, allChannels) {
for (const expected of expectedChannels) {
let exists = false;
if (expected.type === 'voice') {
// Voice channels match by exact name (case-insensitive)
exists = allChannels.some(ch =>
ch.type === ChannelType.GuildVoice &&
exists = allChannels.some(ch =>
ch.type === ChannelType.GuildVoice &&
ch.name.toLowerCase() === expected.name.toLowerCase()
);
} else if (expected.type === 'forum') {
exists = allChannels.some(ch =>
ch.type === ChannelType.GuildForum &&
exists = allChannels.some(ch =>
ch.type === ChannelType.GuildForum &&
ch.name === expected.name
);
} else {
// Text channels
exists = allChannels.some(ch =>
ch.type === ChannelType.GuildText &&
exists = allChannels.some(ch =>
ch.type === ChannelType.GuildText &&
ch.name === expected.name
);
}
@@ -100,24 +113,34 @@ function checkServerChannels(serverName, allChannels) {
}
}
return { missing, found, complete: missing.length === 0 };
return { missing, found, complete: missing.length === 0, unconfigured: false };
}
/**
* Get server_config row from DB, or null
*/
async function getServerConfig(identifier) {
const { rows } = await db.query(
'SELECT * FROM server_config WHERE server_identifier = $1',
[identifier]
);
return rows[0] || null;
}
// ─── PAGE ROUTES ────────────────────────────────────────────
router.get('/', (req, res) => {
res.render('admin/servers/index', { title: 'Server Matrix' });
res.render('admin/servers/index', { title: 'Server Command Center' });
});
router.get('/matrix', async (req, res) => {
const now = Date.now();
let serversData = [];
// Use cache if valid, otherwise fetch fresh
if (serverCache.data && (now - serverCache.lastFetch < CACHE_TTL)) {
serversData = serverCache.data;
} else {
const discovered = await getMinecraftServers();
// Fetch properties for all discovered servers sequentially to avoid rate limits
for (const srv of discovered) {
const props = await readServerProperties(srv.identifier);
serversData.push({ ...srv, whitelistEnabled: props.whitelistEnabled, rawProps: props.raw });
@@ -125,45 +148,48 @@ router.get('/matrix', async (req, res) => {
serverCache = { data: serversData, lastFetch: now };
}
// Join with Database Sync Logs (Always fetch fresh logs, no cache)
// Join with sync logs
const { rows: logs } = await db.query('SELECT * FROM server_sync_log');
const logMap = logs.reduce((acc, log) => {
acc[log.server_identifier] = log;
return acc;
}, {});
const logMap = logs.reduce((acc, log) => { acc[log.server_identifier] = log; return acc; }, {});
// Join with server_config
const { rows: configs } = await db.query('SELECT * FROM server_config');
const configMap = configs.reduce((acc, cfg) => { acc[cfg.server_identifier] = cfg; return acc; }, {});
// Get Discord channels
const client = req.app.locals.client;
const discordChannels = await getDiscordChannels(client);
const enrichedServers = serversData.map(srv => {
const channelStatus = checkServerChannels(srv.name, discordChannels);
const config = configMap[srv.identifier] || null;
const shortName = config ? config.short_name : null;
const channelStatus = checkServerChannels(shortName, srv.name, discordChannels);
return {
...srv,
config,
log: logMap[srv.identifier] || { is_online: false, last_error: 'Never synced' },
discord: channelStatus
};
});
// Group by Node Location
const txServers = enrichedServers.filter(s => s.node === 'TX1');
const ncServers = enrichedServers.filter(s => s.node === 'NC1');
res.render('admin/servers/_matrix_body', { txServers, ncServers, layout: false });
});
// ─── EXISTING ROUTES ────────────────────────────────────────
router.post('/:identifier/sync', async (req, res) => {
const { identifier } = req.params;
try {
const { rows: players } = await db.query(
`SELECT minecraft_username as name, minecraft_uuid as uuid FROM users
JOIN subscriptions ON users.discord_id = subscriptions.discord_id
`SELECT minecraft_username as name, minecraft_uuid as uuid FROM users
JOIN subscriptions ON users.discord_id = subscriptions.discord_id
WHERE subscriptions.status IN ('active', 'grace_period', 'lifetime')`
);
await writeWhitelistFile(identifier, players);
await reloadWhitelistCommand(identifier);
await db.query(
"INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online, last_error) VALUES ($1, NOW(), true, NULL) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true, last_error = NULL",
[identifier]
@@ -180,58 +206,39 @@ router.post('/:identifier/sync', async (req, res) => {
router.post('/:identifier/toggle-whitelist', async (req, res) => {
const { identifier } = req.params;
// Clear cache so the UI updates on next poll
serverCache.lastFetch = 0;
serverCache.lastFetch = 0;
const props = await readServerProperties(identifier);
if (!props.exists) return res.send('File not found');
let newContent;
if (props.whitelistEnabled) {
newContent = props.raw.replace('white-list=true', 'white-list=false');
} else {
if (props.raw.includes('white-list=false')) {
newContent = props.raw.replace('white-list=false', 'white-list=true');
} else {
newContent = props.raw + '\nwhite-list=true';
}
newContent = props.raw.includes('white-list=false')
? props.raw.replace('white-list=false', 'white-list=true')
: props.raw + '\nwhite-list=true';
}
const endpoint = `${process.env.PANEL_URL}/api/client/servers/${identifier}/files/write?file=server.properties`;
await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PANEL_CLIENT_KEY}`,
'Content-Type': 'text/plain'
},
headers: { 'Authorization': `Bearer ${process.env.PANEL_CLIENT_KEY}`, 'Content-Type': 'text/plain' },
body: newContent
});
res.send(`<span class="text-yellow-500 font-bold text-sm">⚠️ Requires Restart</span>`);
});
// Sync all servers on a specific node
router.post('/sync-all/:node', async (req, res) => {
const { node } = req.params;
const nodeId = node === 'tx1' ? 3 : node === 'nc1' ? 2 : null;
if (!nodeId) {
return res.send(`<span class="text-red-500">Invalid node</span>`);
}
if (!nodeId) return res.send(`<span class="text-red-500">Invalid node</span>`);
try {
const discovered = await getMinecraftServers();
const nodeServers = discovered.filter(s => s.nodeId === nodeId);
const { rows: players } = await db.query(
`SELECT minecraft_username as name, minecraft_uuid as uuid FROM users
JOIN subscriptions ON users.discord_id = subscriptions.discord_id
`SELECT minecraft_username as name, minecraft_uuid as uuid FROM users
JOIN subscriptions ON users.discord_id = subscriptions.discord_id
WHERE subscriptions.status IN ('active', 'grace_period', 'lifetime')`
);
let synced = 0;
let errors = 0;
let synced = 0, errors = 0;
for (const srv of nodeServers) {
try {
await writeWhitelistFile(srv.identifier, players);
@@ -249,11 +256,279 @@ router.post('/sync-all/:node', async (req, res) => {
errors++;
}
}
res.send(`<span class="text-green-500 font-bold">✅ ${synced} synced</span>${errors > 0 ? ` <span class="text-red-500">(${errors} errors)</span>` : ''}`);
} catch (error) {
res.send(`<span class="text-red-500">❌ ${error.message}</span>`);
}
});
// ─── NEW: SHORT NAME MANAGEMENT ─────────────────────────────
router.post('/:identifier/set-short-name', async (req, res) => {
const { identifier } = req.params;
const { short_name } = req.body;
// Validate: lowercase, hyphens, numbers only
if (!short_name || !/^[a-z0-9-]+$/.test(short_name)) {
return res.send(`<span class="text-red-500 text-sm">❌ Invalid: lowercase, hyphens, numbers only</span>`);
}
// Check if locked
const config = await getServerConfig(identifier);
if (config && config.short_name_locked) {
return res.send(`<span class="text-red-500 text-sm">❌ Short name is locked</span>`);
}
// Check uniqueness
const { rows: existing } = await db.query(
'SELECT server_identifier FROM server_config WHERE short_name = $1 AND server_identifier != $2',
[short_name, identifier]
);
if (existing.length > 0) {
return res.send(`<span class="text-red-500 text-sm">❌ "${short_name}" already in use</span>`);
}
await db.query(`
INSERT INTO server_config (server_identifier, short_name, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (server_identifier) DO UPDATE SET
short_name = $2, updated_at = NOW()
`, [identifier, short_name]);
// Invalidate caches
serverCache.lastFetch = 0;
invalidateDiscordCache();
res.send(`<span class="text-green-500 text-sm">✅ Saved: ${short_name}</span>`);
});
router.post('/:identifier/lock-short-name', async (req, res) => {
const { identifier } = req.params;
const config = await getServerConfig(identifier);
if (!config || !config.short_name) {
return res.send(`<span class="text-red-500 text-sm">❌ Set a short name first</span>`);
}
if (config.short_name_locked) {
return res.send(`<span class="text-yellow-500 text-sm">Already locked</span>`);
}
await db.query(
'UPDATE server_config SET short_name_locked = true, updated_at = NOW() WHERE server_identifier = $1',
[identifier]
);
serverCache.lastFetch = 0;
res.send(`<span class="text-green-500 text-sm">🔒 Locked: ${config.short_name}</span>`);
});
// ─── NEW: CREATE/DELETE SERVER (DISCORD CHANNELS) ───────────
router.post('/:identifier/createserver', async (req, res) => {
const { identifier } = req.params;
const config = await getServerConfig(identifier);
if (!config || !config.short_name_locked) {
return res.send(`<span class="text-red-500 text-sm">❌ Lock short name first</span>`);
}
const client = req.app.locals.client;
const guild = client.guilds.cache.get(process.env.GUILD_ID);
if (!guild) return res.send(`<span class="text-red-500 text-sm">❌ Guild not found</span>`);
const shortName = config.short_name;
const serverName = config.pterodactyl_name || config.display_name || shortName;
try {
await guild.channels.fetch();
await guild.roles.fetch();
const allChannels = guild.channels.cache;
// Voice channel uses display name
const voiceDisplayName = serverName
.split(' - ')[0]
.replace(/\s*\([^)]*\)\s*/g, '')
.trim();
// Define all 5 expected channels
const expectedChannels = [
{ name: `${shortName}-chat`, type: ChannelType.GuildText, label: 'Chat' },
{ name: `${shortName}-in-game`, type: ChannelType.GuildText, label: 'In-Game' },
{ name: `${shortName}-forum`, type: ChannelType.GuildForum, label: 'Forum' },
{ name: `${shortName}-status`, type: ChannelType.GuildText, label: 'Status' },
{ name: voiceDisplayName, type: ChannelType.GuildVoice, label: 'Voice' }
];
// Check which already exist
const missing = expectedChannels.filter(exp => {
return !allChannels.some(ch => ch.type === exp.type && ch.name.toLowerCase() === exp.name.toLowerCase());
});
if (missing.length === 0) {
return res.send(`<span class="text-green-500 text-sm">✅ All channels exist</span>`);
}
// Find or create category
let category = allChannels.find(
ch => ch.type === ChannelType.GuildCategory &&
(ch.name === `🎮 ${serverName}` || ch.name === serverName)
);
if (!category) {
// Build permission overwrites
const everyoneRole = guild.roles.everyone;
const wandererRole = guild.roles.cache.find(r => r.name === 'Wanderer');
const serverRole = guild.roles.cache.find(r => r.name.toLowerCase() === serverName.toLowerCase());
const adminRoleIds = ADMIN_ROLES
.map(name => guild.roles.cache.find(r => r.name === name)?.id)
.filter(Boolean);
const permissionOverwrites = [
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] }
];
if (wandererRole) {
permissionOverwrites.push({ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] });
}
if (serverRole) {
permissionOverwrites.push({ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] });
}
adminRoleIds.forEach(roleId => {
permissionOverwrites.push({ id: roleId, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] });
});
category = await guild.channels.create({
name: `🎮 ${serverName}`,
type: ChannelType.GuildCategory,
permissionOverwrites,
reason: `Server Command Center: createserver ${serverName}`
});
}
// Create only missing channels
const created = [];
for (const ch of missing) {
const opts = {
name: ch.name,
type: ch.type,
parent: category.id,
reason: `Server Command Center: createserver ${serverName}`
};
if (ch.type === ChannelType.GuildText) {
opts.topic = ch.label === 'Status' ? `Uptime status for ${serverName}` : `${ch.label} for ${serverName}`;
}
if (ch.type === ChannelType.GuildForum) {
opts.topic = `Discussion forum for ${serverName}`;
opts.availableTags = STANDARD_FORUM_TAGS.map(tag => ({ name: tag.name, emoji: { name: tag.emoji } }));
}
await guild.channels.create(opts);
created.push(ch.label);
}
// Try creating Uptime Kuma monitor (non-blocking)
try {
const monitorName = `${serverName} - ${config.node || 'Unknown'}`;
await uptimeKuma.createMonitor(monitorName, 'localhost', 25565);
} catch (kumaErr) {
console.warn('[Kuma] Monitor creation skipped:', kumaErr.message);
}
invalidateDiscordCache();
serverCache.lastFetch = 0;
res.send(`<span class="text-green-500 text-sm">✅ Created: ${created.join(', ')}</span>`);
} catch (error) {
console.error('[createserver]', error);
res.send(`<span class="text-red-500 text-sm">❌ ${error.message}</span>`);
}
});
router.post('/:identifier/delserver', async (req, res) => {
const { identifier } = req.params;
const config = await getServerConfig(identifier);
if (!config) {
return res.send(`<span class="text-red-500 text-sm">❌ No config found</span>`);
}
const client = req.app.locals.client;
const guild = client.guilds.cache.get(process.env.GUILD_ID);
if (!guild) return res.send(`<span class="text-red-500 text-sm">❌ Guild not found</span>`);
const serverName = config.pterodactyl_name || config.display_name || config.short_name;
try {
await guild.channels.fetch();
// Find category
const category = guild.channels.cache.find(
ch => ch.type === ChannelType.GuildCategory &&
(ch.name === `🎮 ${serverName}` || ch.name === serverName)
);
let deletedCount = 0;
if (category) {
// Delete all channels in category
const children = guild.channels.cache.filter(ch => ch.parentId === category.id);
for (const [, channel] of children) {
try {
await channel.delete(`Server Command Center: delserver ${serverName}`);
deletedCount++;
} catch (err) {
console.error(`Failed to delete channel ${channel.name}:`, err.message);
}
}
// Delete category
await category.delete(`Server Command Center: delserver ${serverName}`);
deletedCount++;
}
// Try deleting Uptime Kuma monitor (non-blocking)
try {
const monitorName = `${serverName} - ${config.node || 'Unknown'}`;
await uptimeKuma.deleteMonitor(monitorName);
} catch (kumaErr) {
console.warn('[Kuma] Monitor deletion skipped:', kumaErr.message);
}
invalidateDiscordCache();
serverCache.lastFetch = 0;
res.send(`<span class="text-green-500 text-sm">🗑️ Deleted ${deletedCount} channels</span>`);
} catch (error) {
console.error('[delserver]', error);
res.send(`<span class="text-red-500 text-sm">❌ ${error.message}</span>`);
}
});
// ─── NEW: POWER CONTROLS ────────────────────────────────────
router.post('/:identifier/power', async (req, res) => {
const { identifier } = req.params;
const { signal } = req.body;
try {
await pterodactyl.powerAction(identifier, signal);
const labels = { start: '▶️ Starting', stop: '⏹ Stopping', restart: '🔄 Restarting', kill: '💀 Killing' };
res.send(`<span class="text-yellow-500 text-sm">${labels[signal] || signal}...</span>`);
} catch (error) {
res.send(`<span class="text-red-500 text-sm">❌ ${error.message}</span>`);
}
});
router.post('/:identifier/console', async (req, res) => {
const { identifier } = req.params;
const { command } = req.body;
if (!command) return res.send(`<span class="text-red-500 text-sm">❌ No command</span>`);
try {
await pterodactyl.sendCommand(identifier, command);
res.send(`<span class="text-green-500 text-sm">✅ Sent</span>`);
} catch (error) {
res.send(`<span class="text-red-500 text-sm">❌ ${error.message}</span>`);
}
});
module.exports = router;

View File

@@ -0,0 +1,103 @@
/**
* Pterodactyl Service — power actions and console commands
*
* Uses PANEL_CLIENT_KEY for client endpoints (power, commands).
* Uses PANEL_APPLICATION_KEY for application endpoints (server listing).
* Uses PANEL_ADMIN_KEY for admin-level operations.
*
* Required .env:
* PANEL_URL=https://panel.firefrostgaming.com
* PANEL_CLIENT_KEY=ptlc_...
* PANEL_APPLICATION_KEY=ptla_...
* PANEL_ADMIN_KEY=ptla_...
*/
const PANEL_URL = process.env.PANEL_URL;
const CLIENT_KEY = process.env.PANEL_CLIENT_KEY;
const ADMIN_KEY = process.env.PANEL_ADMIN_KEY || process.env.PANEL_APPLICATION_KEY;
/**
* Send a power action to a server.
* @param {string} identifier - Server identifier (short UUID)
* @param {string} signal - 'start' | 'stop' | 'restart' | 'kill'
*/
async function powerAction(identifier, signal) {
const valid = ['start', 'stop', 'restart', 'kill'];
if (!valid.includes(signal)) {
throw new Error(`Invalid power signal: ${signal}`);
}
const url = `${PANEL_URL}/api/client/servers/${identifier}/power`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CLIENT_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ signal })
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Power action failed (${res.status}): ${text}`);
}
console.log(`[Pterodactyl] ${signal} sent to ${identifier}`);
return true;
}
/**
* Send a console command to a running server.
* @param {string} identifier - Server identifier
* @param {string} command - Command string (e.g. "say Hello")
*/
async function sendCommand(identifier, command) {
const url = `${PANEL_URL}/api/client/servers/${identifier}/command`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CLIENT_KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ command })
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Command failed (${res.status}): ${text}`);
}
console.log(`[Pterodactyl] Command sent to ${identifier}: ${command}`);
return true;
}
/**
* Get resource usage for a server.
* Returns: { current_state, memory, cpu, disk }
* @param {string} identifier - Server identifier
*/
async function getServerResources(identifier) {
const url = `${PANEL_URL}/api/client/servers/${identifier}/resources`;
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${CLIENT_KEY}`,
'Accept': 'application/json'
}
});
if (!res.ok) {
throw new Error(`Resources fetch failed (${res.status})`);
}
const data = await res.json();
return {
state: data.attributes.current_state,
memory: data.attributes.resources.memory_bytes,
cpu: data.attributes.resources.cpu_absolute,
disk: data.attributes.resources.disk_bytes
};
}
module.exports = { powerAction, sendCommand, getServerResources };

View File

@@ -0,0 +1,138 @@
/**
* Uptime Kuma Service — Socket.IO direct connection
*
* Kuma 2.x uses Socket.IO for monitor CRUD (REST only exposes status pages).
* Auth via login event with username/password.
*
* Required .env:
* UPTIME_KUMA_URL=http://localhost:3001
* UPTIME_KUMA_USERNAME=admin
* UPTIME_KUMA_PASSWORD=your_password
*/
const { io } = require('socket.io-client');
const KUMA_URL = process.env.UPTIME_KUMA_URL || 'http://localhost:3001';
const KUMA_USER = process.env.UPTIME_KUMA_USERNAME || 'admin';
const KUMA_PASS = process.env.UPTIME_KUMA_PASSWORD || '';
/**
* Connect to Kuma, authenticate, run a callback, then disconnect.
* Keeps connections short-lived — no persistent socket.
*/
async function withKuma(callback) {
const socket = io(KUMA_URL, {
reconnection: false,
timeout: 10000
});
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
socket.disconnect();
reject(new Error('Uptime Kuma connection timed out'));
}, 15000);
socket.on('connect', () => {
socket.emit('login', {
username: KUMA_USER,
password: KUMA_PASS,
token: ''
}, async (res) => {
if (!res.ok) {
clearTimeout(timeout);
socket.disconnect();
reject(new Error(`Kuma auth failed: ${res.msg}`));
return;
}
try {
const result = await callback(socket);
clearTimeout(timeout);
socket.disconnect();
resolve(result);
} catch (err) {
clearTimeout(timeout);
socket.disconnect();
reject(err);
}
});
});
socket.on('connect_error', (err) => {
clearTimeout(timeout);
reject(new Error(`Kuma connect failed: ${err.message}`));
});
});
}
/**
* Get all monitors.
*/
async function getMonitors() {
return withKuma((socket) => {
return new Promise((resolve) => {
socket.emit('getMonitorList', (res) => {
resolve(Object.values(res));
});
});
});
}
/**
* Create a TCP port monitor for a Minecraft server.
* @param {string} name - Monitor name (e.g. "Stoneblock 4 - TX")
* @param {string} hostname - Server hostname/IP
* @param {number} port - Server port (usually 25565)
*/
async function createMonitor(name, hostname, port) {
return withKuma((socket) => {
return new Promise((resolve, reject) => {
socket.emit('add', {
type: 'port',
name: name,
hostname: hostname,
port: port,
interval: 60,
retryInterval: 60,
maxretries: 3,
active: true
}, (res) => {
if (res.ok) {
console.log(`[Kuma] Created monitor: ${name} (ID: ${res.monitorID})`);
resolve(res.monitorID);
} else {
reject(new Error(`Kuma create failed: ${res.msg}`));
}
});
});
});
}
/**
* Delete a monitor by name.
* Finds the monitor first, then deletes by ID.
* @param {string} name - Monitor name to delete
*/
async function deleteMonitor(name) {
return withKuma((socket) => {
return new Promise((resolve, reject) => {
socket.emit('getMonitorList', (monitors) => {
const list = Object.values(monitors);
const monitor = list.find(m => m.name === name);
if (!monitor) {
console.log(`[Kuma] Monitor not found: ${name}`);
resolve(null);
return;
}
socket.emit('deleteMonitor', monitor.id, (res) => {
if (res.ok) {
console.log(`[Kuma] Deleted monitor: ${name} (ID: ${monitor.id})`);
resolve(monitor.id);
} else {
reject(new Error(`Kuma delete failed: ${res.msg}`));
}
});
});
});
});
}
module.exports = { getMonitors, createMonitor, deleteMonitor };

View File

@@ -4,70 +4,10 @@
<span>🔥</span> Dallas Node (TX1)
</h2>
<div class="space-y-4">
<% txServers.forEach(server => {
const isOnline = server.log.is_online;
const hasError = !!server.log.last_error;
const discordComplete = server.discord?.complete;
let borderClass = 'border-gray-200 dark:border-gray-700';
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
if (hasError) borderClass = 'border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.2)]';
%>
<div class="bg-white dark:bg-darkcard rounded-lg border-l-4 <%= borderClass %> p-4">
<div class="flex justify-between items-start mb-2">
<div>
<h3 class="font-bold text-gray-900 dark:text-white"><%= server.name %></h3>
<p class="text-xs text-gray-500 font-mono"><%= server.identifier %></p>
</div>
<div class="text-right">
<span class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-full <%= isOnline ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' %>">
<span class="w-1.5 h-1.5 rounded-full <%= isOnline ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %>"></span>
<%= isOnline ? 'Online' : 'Offline' %>
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4 text-sm mt-4 mb-4">
<div>
<span class="text-gray-500 dark:text-gray-400 block text-xs">Whitelist</span>
<span class="font-medium dark:text-gray-200">
<%= server.whitelistEnabled ? '✅ Enabled' : '🔓 Disabled' %>
</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400 block text-xs">Last Sync</span>
<span class="font-medium dark:text-gray-200 text-xs">
<%= server.log.last_successful_sync ? new Date(server.log.last_successful_sync).toLocaleString() : 'Never' %>
</span>
</div>
</div>
<!-- Discord Channel Status -->
<div class="mb-4">
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
<% if (discordComplete) { %>
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 4 channels</span>
<% } else if (server.discord?.missing?.length > 0) { %>
<div class="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 p-2 rounded text-xs">
<strong>Missing:</strong> <%= server.discord.missing.join(', ') %>
</div>
<% } else { %>
<span class="text-gray-500 dark:text-gray-400 text-sm">Unable to check</span>
<% } %>
</div>
<% if (hasError) { %>
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
<strong>Error:</strong> <%= server.log.last_error %>
</div>
<% } %>
<div class="flex items-center gap-3 border-t border-gray-100 dark:border-gray-700 pt-3 mt-2">
<button hx-post="/admin/servers/<%= server.identifier %>/sync" hx-swap="innerHTML" class="text-sm font-medium bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-white px-3 py-1.5 rounded">
⚡ Sync Now
</button>
<button hx-post="/admin/servers/<%= server.identifier %>/toggle-whitelist" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline">
Toggle Whitelist
</button>
</div>
</div>
<% txServers.forEach(server => { %>
<%- include('./_server_card', { server }) %>
<% }) %>
<% if(txServers.length === 0) { %><p class="text-gray-500 text-sm">No servers found.</p><% } %>
<% if (txServers.length === 0) { %><p class="text-gray-500 text-sm">No servers found.</p><% } %>
</div>
</div>
<div>
@@ -75,70 +15,10 @@
<span>❄️</span> Charlotte Node (NC1)
</h2>
<div class="space-y-4">
<% ncServers.forEach(server => {
const isOnline = server.log.is_online;
const hasError = !!server.log.last_error;
const discordComplete = server.discord?.complete;
let borderClass = 'border-gray-200 dark:border-gray-700';
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
if (hasError) borderClass = 'border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.2)]';
%>
<div class="bg-white dark:bg-darkcard rounded-lg border-l-4 <%= borderClass %> p-4">
<div class="flex justify-between items-start mb-2">
<div>
<h3 class="font-bold text-gray-900 dark:text-white"><%= server.name %></h3>
<p class="text-xs text-gray-500 font-mono"><%= server.identifier %></p>
</div>
<div class="text-right">
<span class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-full <%= isOnline ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' %>">
<span class="w-1.5 h-1.5 rounded-full <%= isOnline ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %>"></span>
<%= isOnline ? 'Online' : 'Offline' %>
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4 text-sm mt-4 mb-4">
<div>
<span class="text-gray-500 dark:text-gray-400 block text-xs">Whitelist</span>
<span class="font-medium dark:text-gray-200">
<%= server.whitelistEnabled ? '✅ Enabled' : '🔓 Disabled' %>
</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400 block text-xs">Last Sync</span>
<span class="font-medium dark:text-gray-200 text-xs">
<%= server.log.last_successful_sync ? new Date(server.log.last_successful_sync).toLocaleString() : 'Never' %>
</span>
</div>
</div>
<!-- Discord Channel Status -->
<div class="mb-4">
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
<% if (discordComplete) { %>
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 4 channels</span>
<% } else if (server.discord?.missing?.length > 0) { %>
<div class="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 p-2 rounded text-xs">
<strong>Missing:</strong> <%= server.discord.missing.join(', ') %>
</div>
<% } else { %>
<span class="text-gray-500 dark:text-gray-400 text-sm">Unable to check</span>
<% } %>
</div>
<% if (hasError) { %>
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
<strong>Error:</strong> <%= server.log.last_error %>
</div>
<% } %>
<div class="flex items-center gap-3 border-t border-gray-100 dark:border-gray-700 pt-3 mt-2">
<button hx-post="/admin/servers/<%= server.identifier %>/sync" hx-swap="innerHTML" class="text-sm font-medium bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-white px-3 py-1.5 rounded">
⚡ Sync Now
</button>
<button hx-post="/admin/servers/<%= server.identifier %>/toggle-whitelist" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline">
Toggle Whitelist
</button>
</div>
</div>
<% ncServers.forEach(server => { %>
<%- include('./_server_card', { server }) %>
<% }) %>
<% if(ncServers.length === 0) { %><p class="text-gray-500 text-sm">No servers found.</p><% } %>
<% if (ncServers.length === 0) { %><p class="text-gray-500 text-sm">No servers found.</p><% } %>
</div>
</div>
</div>

View File

@@ -1,29 +1,65 @@
<%
<%
const isOnline = server.log.is_online;
const hasError = !!server.log.last_error;
const config = server.config;
const shortName = config ? config.short_name : null;
const locked = config ? config.short_name_locked : false;
const discordComplete = server.discord?.complete;
let borderClass = 'border-gray-200 dark:border-gray-700'; // Default / Offline
const unconfigured = server.discord?.unconfigured;
const nodeBadge = server.node === 'TX1' ? '🔥 TX1' : '❄️ NC1';
let borderClass = 'border-gray-200 dark:border-gray-700';
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
if (hasError) borderClass = 'border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.2)]';
%>
<div class="bg-white dark:bg-darkcard rounded-lg border-l-4 <%= borderClass %> p-4 relative overflow-hidden">
<!-- Header -->
<div class="flex justify-between items-start mb-2">
<div>
<h3 class="font-bold text-gray-900 dark:text-white"><%= server.name %></h3>
<p class="text-xs text-gray-500 font-mono"><%= server.identifier %></p>
</div>
<div class="text-right">
<span class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-full <%= isOnline ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' %>">
<span class="w-1.5 h-1.5 rounded-full <%= isOnline ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %>"></span>
<%= isOnline ? 'Online' : 'Offline' %>
</span>
<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"><%= nodeBadge %></span>
</div>
<span class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-full <%= isOnline ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' %>">
<span class="w-1.5 h-1.5 rounded-full <%= isOnline ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %>"></span>
<%= isOnline ? 'Online' : 'Offline' %>
</span>
</div>
<div class="grid grid-cols-2 gap-4 text-sm mt-4 mb-4">
<!-- Short Name Section -->
<div class="mb-3" id="shortname-<%= server.identifier %>">
<% if (locked) { %>
<span class="inline-flex items-center gap-1 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 px-2 py-1 rounded">
🔒 <%= shortName %>
</span>
<% } else { %>
<div class="flex items-center gap-2">
<input type="text" name="short_name" value="<%= shortName || '' %>"
placeholder="e.g. atm10-tts"
class="text-xs font-mono border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded px-2 py-1 w-36"
id="sn-input-<%= server.identifier %>">
<button hx-post="/admin/servers/<%= server.identifier %>/set-short-name"
hx-include="#sn-input-<%= server.identifier %>"
hx-target="#shortname-<%= server.identifier %>"
class="text-xs bg-gray-200 dark:bg-gray-700 dark:text-white px-2 py-1 rounded hover:bg-gray-300 dark:hover:bg-gray-600">
Save
</button>
<% if (shortName) { %>
<button hx-post="/admin/servers/<%= server.identifier %>/lock-short-name"
hx-confirm="⚠️ Once locked, this cannot be changed. Continue?"
hx-target="#shortname-<%= server.identifier %>"
class="text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 px-2 py-1 rounded hover:bg-red-200 dark:hover:bg-red-900/50">
Lock In
</button>
<% } %>
</div>
<p class="text-[10px] text-gray-400 mt-1">⚠️ Once locked, this cannot be changed</p>
<% } %>
</div>
<!-- Stats Row -->
<div class="grid grid-cols-2 gap-4 text-sm mb-3">
<div>
<span class="text-gray-500 dark:text-gray-400 block text-xs">Whitelist</span>
<span class="font-medium dark:text-gray-200">
@@ -39,40 +75,87 @@
</div>
<!-- Discord Channel Status -->
<div class="mb-4">
<div class="mb-3">
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
<% if (discordComplete) { %>
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 4 channels configured</span>
<% } else if (server.discord?.missing?.length > 0) { %>
<div class="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 p-2 rounded text-xs">
<strong>Missing:</strong> <%= server.discord.missing.join(', ') %>
<% if (unconfigured) { %>
<span class="text-gray-400 dark:text-gray-500 text-xs italic">Set short name to enable channel detection</span>
<% } else if (discordComplete) { %>
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 5 channels configured</span>
<% } else if (server.discord?.found?.length > 0 || server.discord?.missing?.length > 0) { %>
<div class="text-xs space-y-0.5">
<% if (server.discord.found.length > 0) { %>
<div class="text-green-600 dark:text-green-400">✅ <%= server.discord.found.join(', ') %></div>
<% } %>
<% if (server.discord.missing.length > 0) { %>
<div class="text-yellow-600 dark:text-yellow-400">❌ Missing: <%= server.discord.missing.join(', ') %></div>
<% } %>
</div>
<% } else { %>
<span class="text-gray-500 dark:text-gray-400 text-sm">Unable to check</span>
<% } %>
</div>
<% if (hasError) { %>
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-3 break-words">
<strong>Error:</strong> <%= server.log.last_error %>
</div>
<% } %>
<div class="flex items-center gap-3 border-t border-gray-100 dark:border-gray-700 pt-3 mt-2" id="controls-<%= server.identifier %>">
<!-- Action Buttons -->
<div class="flex flex-wrap items-center gap-2 border-t border-gray-100 dark:border-gray-700 pt-3 mt-2" id="controls-<%= server.identifier %>">
<button hx-post="/admin/servers/<%= server.identifier %>/sync"
hx-target="#controls-<%= server.identifier %>"
hx-swap="innerHTML"
<%= !isOnline ? 'disabled' : '' %>
class="text-sm font-medium bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-white px-3 py-1.5 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
⚡ Sync Now
class="text-xs font-medium bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-white px-2.5 py-1.5 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
⚡ Sync
</button>
<button hx-post="/admin/servers/<%= server.identifier %>/toggle-whitelist"
hx-confirm="Changing the whitelist requires a server restart to take effect. Continue?"
hx-confirm="Changing the whitelist requires a server restart. Continue?"
hx-target="#controls-<%= server.identifier %>"
hx-swap="beforeend"
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline">
Toggle Whitelist
class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline">
Toggle WL
</button>
<% if (locked && !discordComplete) { %>
<button hx-post="/admin/servers/<%= server.identifier %>/createserver"
hx-target="#controls-<%= server.identifier %>"
hx-swap="beforeend"
class="text-xs font-medium bg-green-100 hover:bg-green-200 dark:bg-green-900/30 dark:hover:bg-green-900/50 text-green-700 dark:text-green-400 px-2.5 py-1.5 rounded">
🚀 Create Server
</button>
<% } %>
<% if (locked && server.discord?.found?.length > 0) { %>
<button hx-post="/admin/servers/<%= server.identifier %>/delserver"
hx-confirm="⚠️ This will delete ALL Discord channels for this server. Are you sure?"
hx-target="#controls-<%= server.identifier %>"
hx-swap="beforeend"
class="text-xs font-medium bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-700 dark:text-red-400 px-2.5 py-1.5 rounded">
🗑️ Delete
</button>
<% } %>
<!-- Power Controls -->
<div class="flex items-center gap-1 ml-auto" id="power-<%= server.identifier %>">
<button hx-post="/admin/servers/<%= server.identifier %>/power"
hx-vals='{"signal":"start"}'
hx-target="#power-<%= server.identifier %>"
hx-swap="innerHTML"
class="text-xs px-2 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50"
title="Start">▶️</button>
<button hx-post="/admin/servers/<%= server.identifier %>/power"
hx-vals='{"signal":"stop"}'
hx-target="#power-<%= server.identifier %>"
hx-swap="innerHTML"
class="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
title="Stop">⏹</button>
<button hx-post="/admin/servers/<%= server.identifier %>/power"
hx-vals='{"signal":"restart"}'
hx-target="#power-<%= server.identifier %>"
hx-swap="innerHTML"
class="text-xs px-2 py-1 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-200 dark:hover:bg-yellow-900/50"
title="Restart">🔄</button>
</div>
</div>
</div>