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:
parent
a404410efd
commit
d16a525ffc
@@ -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
|
||||
|
||||
93
services/arbiter-3.0/migrations/139_seed_server_config.js
Normal file
93
services/arbiter-3.0/migrations/139_seed_server_config.js
Normal 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);
|
||||
});
|
||||
24
services/arbiter-3.0/migrations/139_server_config.sql
Normal file
24
services/arbiter-3.0/migrations/139_server_config.sql
Normal 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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
103
services/arbiter-3.0/src/services/pterodactyl.js
Normal file
103
services/arbiter-3.0/src/services/pterodactyl.js
Normal 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 };
|
||||
138
services/arbiter-3.0/src/services/uptimeKuma.js
Normal file
138
services/arbiter-3.0/src/services/uptimeKuma.js
Normal 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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user