diff --git a/services/arbiter-3.0/src/routes/admin.js b/services/arbiter-3.0/src/routes/admin.js
new file mode 100644
index 0000000..63c7f44
--- /dev/null
+++ b/services/arbiter-3.0/src/routes/admin.js
@@ -0,0 +1,439 @@
+const express = require('express');
+const router = express.Router();
+const { getRoleMappings, saveRoleMappings } = require('../utils/roleMappings');
+const { getFinancialMetrics } = require('../services/FinancialsService');
+
+const isAdmin = (req, res, next) => {
+ if (req.isAuthenticated()) {
+ const admins = process.env.ADMIN_USERS.split(',');
+ if (admins.includes(req.user.id)) return next();
+ }
+ res.status(403).send('Forbidden: Admin access only.');
+};
+
+// TODO: Replace with full beautiful UI from live bot.js
+router.get('/', isAdmin, async (req, res) => {
+ try {
+ const mappings = getRoleMappings();
+ res.render('admin/dashboard', {
+ title: 'Dashboard',
+ adminUser: req.user,
+ csrfToken: req.csrfToken(),
+ mappings: mappings,
+ currentPath: '/dashboard'
+ });
+ } catch (error) {
+ console.error('Admin dashboard error:', error);
+ res.status(500).send('Internal Server Error: ' + error.message);
+ }
+});
+
+// Server Matrix Module
+router.get('/servers', isAdmin, async (req, res) => {
+ try {
+ res.render('admin/servers/index', {
+ title: 'Server Matrix',
+ adminUser: req.user,
+ csrfToken: req.csrfToken(),
+ currentPath: '/servers'
+ });
+ } catch (error) {
+ console.error('Server matrix error:', error);
+ res.status(500).send('Internal Server Error: ' + error.message);
+ }
+});
+
+// Player Management Module
+router.get('/players', isAdmin, async (req, res) => {
+ try {
+ res.render('admin/players/index', {
+ title: 'Player Management',
+ adminUser: req.user,
+ csrfToken: req.csrfToken(),
+ currentPath: '/players'
+ });
+ } catch (error) {
+ console.error('Player management error:', error);
+ res.status(500).send('Internal Server Error: ' + error.message);
+ }
+});
+
+// Role Management Module
+router.get('/roles', isAdmin, async (req, res) => {
+ try {
+ const mappings = getRoleMappings();
+ res.render('admin/roles/index', {
+ title: 'Role Management',
+ adminUser: req.user,
+ csrfToken: req.csrfToken(),
+ mappings: mappings,
+ currentPath: '/roles'
+ });
+ } catch (error) {
+ console.error('Role management error:', error);
+ res.status(500).send('Internal Server Error: ' + error.message);
+ }
+});
+
+// Grace Period Management Module
+router.get('/grace', isAdmin, async (req, res) => {
+ try {
+ res.render('admin/grace/index', {
+ title: 'Grace Period Management',
+ adminUser: req.user,
+ csrfToken: req.csrfToken(),
+ currentPath: '/grace'
+ });
+ } catch (error) {
+ console.error('Grace period management error:', error);
+ res.status(500).send('Internal Server Error: ' + error.message);
+ }
+});
+
+// Audit Log Module
+router.get('/audit', isAdmin, async (req, res) => {
+ try {
+ res.render('admin/audit/index', {
+ title: 'Audit Log',
+ adminUser: req.user,
+ csrfToken: req.csrfToken(),
+ currentPath: '/audit'
+ });
+ } catch (error) {
+ console.error('Audit log error:', error);
+ res.status(500).send('Internal Server Error: ' + error.message);
+ }
+});
+
+// Financials Module
+router.get('/financials', isAdmin, async (req, res) => {
+ try {
+ // Fetch real financial data from PostgreSQL
+ const financialData = await getFinancialMetrics();
+
+ res.render('admin/financials/index', {
+ title: 'Financials',
+ adminUser: req.user,
+ csrfToken: req.csrfToken(),
+ currentPath: '/financials',
+ metrics: financialData.metrics,
+ paths: financialData.paths,
+ tierBreakdown: financialData.tierBreakdown
+ });
+ } catch (error) {
+ console.error('Financials error:', error);
+ res.status(500).send('Internal Server Error: ' + error.message);
+ }
+});
+
+router.post('/mappings', isAdmin, express.json(), (req, res) => {
+ const newMappings = req.body;
+ if (saveRoleMappings(newMappings)) {
+ res.status(200).send('Mappings updated');
+ } else {
+ res.status(500).send('Failed to save mappings');
+ }
+});
+
+module.exports = router;
+
+// ==========================================
+// HTMX API ENDPOINTS (Return HTML fragments)
+// ==========================================
+
+// Servers Matrix Endpoint
+router.get('/servers/matrix', isAdmin, async (req, res) => {
+ // Static server list from infrastructure
+ const servers = [
+ { name: 'Awakened Survival', machine: 'TX1', status: 'online', players: '0/20' },
+ { name: 'Fire PvP Arena', machine: 'TX1', status: 'online', players: '0/50' },
+ { name: 'Frost Creative', machine: 'TX1', status: 'online', players: '0/30' },
+ { name: 'Knight Hardcore', machine: 'NC1', status: 'online', players: '0/25' },
+ { name: 'Master Skyblock', machine: 'NC1', status: 'online', players: '0/40' },
+ { name: 'Legend Factions', machine: 'NC1', status: 'online', players: '0/60' },
+ { name: 'Sovereign Network Hub', machine: 'TX1', status: 'online', players: '0/100' }
+ ];
+
+ let html = '
';
+
+ servers.forEach(server => {
+ const statusColor = server.status === 'online' ? 'bg-green-500' : 'bg-red-500';
+ html += `
+
+
+
${server.name}
+
+
+ ${server.status}
+
+
+
+
Machine: ${server.machine}
+
Players: ${server.players}
+
+
+ `;
+ });
+
+ html += '
';
+ html += `
+
+
+ 💡 Note: This is static data. Real-time Pterodactyl API integration coming soon.
+
+
+ `;
+
+ res.send(html);
+});
+
+// Players Table Endpoint
+router.get('/players/table', isAdmin, async (req, res) => {
+ const { Pool } = require('pg');
+ const pool = new Pool({
+ host: '127.0.0.1',
+ user: 'arbiter',
+ password: 'FireFrost2026!Arbiter',
+ database: 'arbiter_db'
+ });
+
+ try {
+ const result = await pool.query(`
+ SELECT
+ s.id,
+ s.discord_id,
+ s.tier_level,
+ p.tier_name,
+ s.status,
+ s.created_at,
+ s.mrr_value
+ FROM subscriptions s
+ LEFT JOIN stripe_products p ON s.tier_level = p.tier_level
+ ORDER BY s.created_at DESC
+ LIMIT 100
+ `);
+
+ if (result.rows.length === 0) {
+ res.send(`
+
+
👥 No subscribers yet
+
Subscribers will appear here after first signup
+
+ `);
+ } else {
+ let html = `
+
+
+
+ | Discord ID |
+ Tier |
+ Status |
+ MRR |
+ Since |
+
+
+
+ `;
+
+ result.rows.forEach(row => {
+ const statusColor = row.status === 'active' ? 'text-green-600' :
+ row.status === 'lifetime' ? 'text-purple-600' :
+ row.status === 'grace_period' ? 'text-yellow-600' :
+ 'text-gray-600';
+ const date = new Date(row.created_at);
+ html += `
+
+ | ${row.discord_id || 'N/A'} |
+ ${row.tier_name || 'Tier ' + row.tier_level} |
+
+ ${row.status}
+ |
+ $${parseFloat(row.mrr_value || 0).toFixed(2)} |
+ ${date.toLocaleDateString()} |
+
+ `;
+ });
+
+ html += '
';
+ res.send(html);
+ }
+ } catch (error) {
+ res.send(`Error: ${error.message}
`);
+ }
+});
+
+// Grace Period List Endpoint
+router.get('/grace/list', isAdmin, async (req, res) => {
+ const { Pool } = require('pg');
+ const pool = new Pool({
+ host: '127.0.0.1',
+ user: 'arbiter',
+ password: 'FireFrost2026!Arbiter',
+ database: 'arbiter_db'
+ });
+
+ try {
+ const result = await pool.query(`
+ SELECT discord_id, tier_level, grace_period_started_at, grace_period_ends_at
+ FROM subscriptions
+ WHERE status = 'grace_period'
+ ORDER BY grace_period_ends_at ASC
+ `);
+
+ if (result.rows.length === 0) {
+ res.send(`
+
+
✅ No users in grace period!
+
All subscribers are current on payments
+
+ `);
+ } else {
+ let html = '';
+ result.rows.forEach(row => {
+ const endsAt = new Date(row.grace_period_ends_at);
+ const hoursLeft = Math.round((endsAt - new Date()) / (1000 * 60 * 60));
+ html += `
+
+
+
+
Discord ID: ${row.discord_id}
+
Tier ${row.tier_level}
+
+
+
${hoursLeft}h remaining
+
${endsAt.toLocaleString()}
+
+
+
+ `;
+ });
+ html += '
';
+ res.send(html);
+ }
+ } catch (error) {
+ res.send(`Error: ${error.message}
`);
+ }
+});
+
+// Audit Log Feed Endpoint
+router.get('/audit/feed', isAdmin, async (req, res) => {
+ const { Pool } = require('pg');
+ const pool = new Pool({
+ host: '127.0.0.1',
+ user: 'arbiter',
+ password: 'FireFrost2026!Arbiter',
+ database: 'arbiter_db'
+ });
+
+ try {
+ const result = await pool.query(`
+ SELECT *
+ FROM webhook_events_processed
+ ORDER BY processed_at DESC
+ LIMIT 50
+ `);
+
+ if (result.rows.length === 0) {
+ res.send(`
+
+
📋 No webhook events yet
+
Events will appear here as Stripe webhooks are processed
+
+ `);
+ } else {
+ let html = '';
+ result.rows.forEach(row => {
+ const timestamp = new Date(row.processed_at);
+ const eventType = row.event_type || 'unknown';
+ const eventId = row.stripe_event_id || row.id || 'unknown';
+ const eventColor = eventType.includes('succeeded') ? 'text-green-600' :
+ eventType.includes('failed') ? 'text-red-600' :
+ eventType.includes('dispute') ? 'text-red-600' :
+ 'text-blue-600';
+ html += `
+
+
+
+
${eventId}
+
${eventType}
+
+
+ ${timestamp.toLocaleString()}
+
+
+
+ `;
+ });
+ html += '
';
+ res.send(html);
+ }
+ } catch (error) {
+ res.send(`Error: ${error.message}
`);
+ }
+});
+
+// Role Mismatches Diagnostic Endpoint
+router.get('/roles/mismatches', isAdmin, async (req, res) => {
+ const { Pool } = require('pg');
+ const pool = new Pool({
+ host: '127.0.0.1',
+ user: 'arbiter',
+ password: 'FireFrost2026!Arbiter',
+ database: 'arbiter_db'
+ });
+
+ try {
+ // Get subscription counts by tier
+ const result = await pool.query(`
+ SELECT tier_level, COUNT(*) as count, status
+ FROM subscriptions
+ WHERE status IN ('active', 'lifetime')
+ GROUP BY tier_level, status
+ ORDER BY tier_level
+ `);
+
+ if (result.rows.length === 0) {
+ res.send(`
+
+
✅ No active subscriptions
+
Role diagnostics will run when users subscribe
+
+ `);
+ } else {
+ let html = `
+
+
+
📊 Subscription Summary
+
Active subscribers by tier (Discord role sync coming soon)
+
+
+ `;
+
+ result.rows.forEach(row => {
+ const statusColor = row.status === 'active' ? 'text-green-600' : 'text-purple-600';
+ html += `
+
+
Tier ${row.tier_level}
+
+ ${row.status}
+ ${row.count} subscriber${row.count > 1 ? 's' : ''}
+
+
+ `;
+ });
+
+ html += `
+
+
+
+ 💡 Coming Soon: Discord API integration to compare database tiers with actual Discord roles
+
+
+
+ `;
+ res.send(html);
+ }
+ } catch (error) {
+ res.send(`Error: ${error.message}
`);
+ }
+});
diff --git a/services/arbiter-3.0/src/routes/admin/audit.js b/services/arbiter-3.0/src/routes/admin/audit.js
index 772122f..4be3e72 100644
--- a/services/arbiter-3.0/src/routes/admin/audit.js
+++ b/services/arbiter-3.0/src/routes/admin/audit.js
@@ -28,7 +28,7 @@ router.get('/feed', async (req, res) => {
try {
const { rows: logs } = await db.query(query, params);
- res.render('admin/audit/_feed', { logs, page, filterType });
+ res.render('admin/audit/_feed', { logs, page, filterType, layout: false });
} catch (error) {
console.error("Audit Log Error:", error);
res.status(500).send("Error loading audit logs.
");
diff --git a/services/arbiter-3.0/src/routes/admin/constants.js b/services/arbiter-3.0/src/routes/admin/constants.js
index 92dc3e6..3a0d898 100644
--- a/services/arbiter-3.0/src/routes/admin/constants.js
+++ b/services/arbiter-3.0/src/routes/admin/constants.js
@@ -1,14 +1,14 @@
const TIER_INFO = {
- 1: { name: 'The Awakened', mrr: 1.00, path: 'universal' },
- 5: { name: 'Fire Elemental', mrr: 5.00, path: 'fire' },
- 10: { name: 'Fire Knight', mrr: 10.00, path: 'fire' },
- 15: { name: 'Fire Master', mrr: 15.00, path: 'fire' },
- 20: { name: 'Fire Legend', mrr: 20.00, path: 'fire' },
- 105: { name: 'Frost Elemental', mrr: 5.00, path: 'frost' },
- 110: { name: 'Frost Knight', mrr: 10.00, path: 'frost' },
- 115: { name: 'Frost Master', mrr: 15.00, path: 'frost' },
- 120: { name: 'Frost Legend', mrr: 20.00, path: 'frost' },
- 499: { name: 'The Sovereign', mrr: 0.00, path: 'universal', lifetime: true },
+ 1: { name: 'Awakened', mrr: 1.00, path: 'both', lifetime: true },
+ 2: { name: 'Elemental (Fire)', mrr: 5.00, path: 'fire' },
+ 3: { name: 'Elemental (Frost)', mrr: 5.00, path: 'frost' },
+ 4: { name: 'Knight (Fire)', mrr: 10.00, path: 'fire' },
+ 5: { name: 'Knight (Frost)', mrr: 10.00, path: 'frost' },
+ 6: { name: 'Master (Fire)', mrr: 15.00, path: 'fire' },
+ 7: { name: 'Master (Frost)', mrr: 15.00, path: 'frost' },
+ 8: { name: 'Legend (Fire)', mrr: 20.00, path: 'fire' },
+ 9: { name: 'Legend (Frost)', mrr: 20.00, path: 'frost' },
+ 10: { name: 'Sovereign', mrr: 499.00, path: 'both', lifetime: true },
1000: { name: 'Admin', mrr: 0.00, path: 'universal', lifetime: true }
};
diff --git a/services/arbiter-3.0/src/routes/admin/grace.js b/services/arbiter-3.0/src/routes/admin/grace.js
index fb0ae9b..e74e1b5 100644
--- a/services/arbiter-3.0/src/routes/admin/grace.js
+++ b/services/arbiter-3.0/src/routes/admin/grace.js
@@ -33,12 +33,10 @@ router.get('/list', async (req, res) => {
const atRiskCount = atRisk.length;
// 3. Render the Partial
- res.render('admin/grace/_list', {
- atRisk,
+ res.render('admin/grace/_list', { atRisk,
totalAtRiskMrr,
atRiskCount,
- TIER_INFO
- });
+ TIER_INFO, layout: false });
} catch (error) {
console.error("Grace Period List Error:", error);
res.status(500).send("| Error loading data. |
");
diff --git a/services/arbiter-3.0/src/routes/admin/players.js b/services/arbiter-3.0/src/routes/admin/players.js
index 6528f7c..eb7d1d2 100644
--- a/services/arbiter-3.0/src/routes/admin/players.js
+++ b/services/arbiter-3.0/src/routes/admin/players.js
@@ -17,11 +17,19 @@ router.get('/table', async (req, res) => {
// Basic search implementation
let query = `
- SELECT u.discord_id, u.minecraft_username, u.minecraft_uuid, u.is_staff,
- s.tier_level, s.status, s.updated_at
- FROM users u
- LEFT JOIN subscriptions s ON u.discord_id = s.discord_id
- WHERE u.minecraft_username ILIKE $1 OR u.discord_id ILIKE $1
+ SELECT
+ COALESCE(s.discord_id, 'N/A') as discord_id,
+ u.minecraft_username,
+ u.minecraft_uuid,
+ COALESCE(u.is_staff, false) as is_staff,
+ s.tier_level,
+ s.status,
+ s.created_at,
+ s.id as subscription_id
+ FROM subscriptions s
+ LEFT JOIN users u ON s.discord_id = u.discord_id
+ WHERE s.discord_id ILIKE $1 OR u.minecraft_username ILIKE $1
+ OR CAST(s.id AS TEXT) ILIKE $1
ORDER BY s.updated_at DESC
LIMIT $2 OFFSET $3
`;
@@ -29,7 +37,7 @@ router.get('/table', async (req, res) => {
const { rows: players } = await db.query(query, [`%${search}%`, limit, offset]);
// Render just the partial table rows
- res.render('admin/players/_table_body', { players, TIER_INFO, page, search });
+ res.render('admin/players/_table_body', { players, TIER_INFO, page, search, layout: false });
});
// POST endpoint for tier changes
diff --git a/services/arbiter-3.0/src/routes/admin/roles.js b/services/arbiter-3.0/src/routes/admin/roles.js
index d9990ea..88f730e 100644
--- a/services/arbiter-3.0/src/routes/admin/roles.js
+++ b/services/arbiter-3.0/src/routes/admin/roles.js
@@ -67,7 +67,7 @@ router.get('/mismatches', async (req, res) => {
}
}
- res.render('admin/roles/_mismatches', { mismatches });
+ res.render('admin/roles/_mismatches', { mismatches, layout: false });
} catch (error) {
console.error("Role Audit Error:", error);
res.status(500).send("Error communicating with Discord API.
");
diff --git a/services/arbiter-3.0/src/routes/admin/servers.js b/services/arbiter-3.0/src/routes/admin/servers.js
index 61c9d2f..ba88651 100644
--- a/services/arbiter-3.0/src/routes/admin/servers.js
+++ b/services/arbiter-3.0/src/routes/admin/servers.js
@@ -47,7 +47,7 @@ router.get('/matrix', async (req, res) => {
const txServers = enrichedServers.filter(s => s.node === 'TX1' || s.node === 'Node 3' || s.name.includes('TX'));
const ncServers = enrichedServers.filter(s => s.node === 'NC1' || s.node === 'Node 2' || s.name.includes('NC'));
- res.render('admin/servers/_matrix_body', { txServers, ncServers });
+ res.render('admin/servers/_matrix_body', { txServers, ncServers, layout: false });
});
router.post('/:identifier/sync', async (req, res) => {
diff --git a/services/arbiter-3.0/src/services/FinancialsService.js b/services/arbiter-3.0/src/services/FinancialsService.js
new file mode 100644
index 0000000..108e575
--- /dev/null
+++ b/services/arbiter-3.0/src/services/FinancialsService.js
@@ -0,0 +1,108 @@
+const { Pool } = require('pg');
+
+const pool = new Pool({
+ host: process.env.DB_HOST || '127.0.0.1',
+ user: process.env.DB_USER || 'arbiter',
+ password: process.env.DB_PASSWORD || 'FireFrost2026!Arbiter',
+ database: process.env.DB_NAME || 'arbiter_db',
+ port: process.env.DB_PORT || 5432
+});
+
+async function getFinancialMetrics() {
+ try {
+ // 1. Get Active Subscribers
+ const subsQuery = await pool.query(
+ `SELECT COUNT(*) as count FROM subscriptions WHERE status = 'active' OR status = 'lifetime'`
+ );
+ const activeSubs = parseInt(subsQuery.rows[0].count) || 0;
+
+ // 2. Calculate Recognized MRR
+ const mrrQuery = await pool.query(
+ `SELECT SUM(mrr_value) as total FROM subscriptions WHERE status = 'active'`
+ );
+ const recognizedMrr = parseFloat(mrrQuery.rows[0].total) || 0;
+
+ // 3. At Risk Subscribers (grace period)
+ const atRiskQuery = await pool.query(
+ `SELECT COUNT(*) as count, SUM(mrr_value) as mrr
+ FROM subscriptions WHERE status = 'grace_period'`
+ );
+ const atRiskSubs = parseInt(atRiskQuery.rows[0].count) || 0;
+ const atRiskMrr = parseFloat(atRiskQuery.rows[0].mrr) || 0;
+
+ // 4. Lifetime Subscribers (Awakened + Sovereign)
+ const lifetimeQuery = await pool.query(
+ `SELECT COUNT(*) as count, SUM(CASE WHEN tier_level = 10 THEN 499 WHEN tier_level = 1 THEN 1 ELSE 0 END) as revenue
+ FROM subscriptions WHERE is_lifetime = TRUE`
+ );
+ const lifetimeSubs = parseInt(lifetimeQuery.rows[0].count) || 0;
+ const lifetimeRevenue = parseFloat(lifetimeQuery.rows[0].revenue) || 0;
+
+ // 5. Fire vs Frost Paths
+ const pathQuery = await pool.query(
+ `SELECT p.fire_or_frost, COUNT(s.*) as subs, SUM(s.mrr_value) as mrr
+ FROM subscriptions s
+ JOIN stripe_products p ON s.tier_level = p.tier_level
+ WHERE s.status = 'active'
+ GROUP BY p.fire_or_frost`
+ );
+
+ const paths = {
+ fire: { subs: 0, mrr: 0 },
+ frost: { subs: 0, mrr: 0 }
+ };
+
+ pathQuery.rows.forEach(row => {
+ if (row.fire_or_frost === 'fire') {
+ paths.fire.subs = parseInt(row.subs) || 0;
+ paths.fire.mrr = parseFloat(row.mrr) || 0;
+ } else if (row.fire_or_frost === 'frost') {
+ paths.frost.subs = parseInt(row.subs) || 0;
+ paths.frost.mrr = parseFloat(row.mrr) || 0;
+ }
+ });
+
+ // 6. Tier Breakdown
+ const tierQuery = await pool.query(
+ `SELECT
+ s.tier_level,
+ p.tier_name as name,
+ p.fire_or_frost as path,
+ COUNT(CASE WHEN s.status = 'active' THEN 1 END) as active_count,
+ COUNT(CASE WHEN s.status = 'grace_period' THEN 1 END) as grace_count,
+ SUM(CASE WHEN s.status = 'active' THEN s.mrr_value ELSE 0 END) as total_mrr
+ FROM subscriptions s
+ JOIN stripe_products p ON s.tier_level = p.tier_level
+ GROUP BY s.tier_level, p.tier_name, p.fire_or_frost`
+ );
+
+ const tierBreakdown = {};
+ tierQuery.rows.forEach(row => {
+ tierBreakdown[row.tier_level] = {
+ name: row.name,
+ path: row.path || 'both',
+ activeCount: parseInt(row.active_count) || 0,
+ graceCount: parseInt(row.grace_count) || 0,
+ totalMrr: parseFloat(row.total_mrr) || 0
+ };
+ });
+
+ return {
+ metrics: {
+ activeSubs,
+ recognizedMrr,
+ atRiskSubs,
+ atRiskMrr,
+ lifetimeSubs,
+ lifetimeRevenue
+ },
+ paths,
+ tierBreakdown
+ };
+ } catch (error) {
+ console.error('FinancialsService error:', error);
+ throw error;
+ }
+}
+
+module.exports = { getFinancialMetrics };
diff --git a/services/arbiter-3.0/src/views/admin/servers/_matrix_body.ejs b/services/arbiter-3.0/src/views/admin/servers/_matrix_body.ejs
index af2dc65..ba697ca 100644
--- a/services/arbiter-3.0/src/views/admin/servers/_matrix_body.ejs
+++ b/services/arbiter-3.0/src/views/admin/servers/_matrix_body.ejs
@@ -1,27 +1,116 @@
-
🔥 Dallas Node (TX1)
- <% txServers.forEach(server => { %>
- <%- include('_server_card', { server }) %>
+ <% txServers.forEach(server => {
+ const isOnline = server.log.is_online;
+ const hasError = !!server.log.last_error;
+ 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)]';
+ %>
+
+
+
+
<%= server.name %>
+
<%= server.identifier %>
+
+
+
+
+ <%= isOnline ? 'Online' : 'Offline' %>
+
+
+
+
+
+ Whitelist
+
+ <%= server.whitelistEnabled ? '✅ Enabled' : '🔓 Disabled' %>
+
+
+
+ Last Sync
+
+ <%= server.log.last_successful_sync ? new Date(server.log.last_successful_sync).toLocaleString() : 'Never' %>
+
+
+
+ <% if (hasError) { %>
+
+ Error: <%= server.log.last_error %>
+
+ <% } %>
+
+
+
+
+
<% }) %>
- <% if(txServers.length === 0) { %>
No servers found on this node.
<% } %>
+ <% if(txServers.length === 0) { %>
No servers found.
<% } %>
-
❄️ Charlotte Node (NC1)
- <% ncServers.forEach(server => { %>
- <%- include('_server_card', { server }) %>
+ <% ncServers.forEach(server => {
+ const isOnline = server.log.is_online;
+ const hasError = !!server.log.last_error;
+ 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)]';
+ %>
+
+
+
+
<%= server.name %>
+
<%= server.identifier %>
+
+
+
+
+ <%= isOnline ? 'Online' : 'Offline' %>
+
+
+
+
+
+ Whitelist
+
+ <%= server.whitelistEnabled ? '✅ Enabled' : '🔓 Disabled' %>
+
+
+
+ Last Sync
+
+ <%= server.log.last_successful_sync ? new Date(server.log.last_successful_sync).toLocaleString() : 'Never' %>
+
+
+
+ <% if (hasError) { %>
+
+ Error: <%= server.log.last_error %>
+
+ <% } %>
+
+
+
+
+
<% }) %>
- <% if(ncServers.length === 0) { %>
No servers found on this node.
<% } %>
+ <% if(ncServers.length === 0) { %>
No servers found.
<% } %>
-