feat(arbiter): Add Discord dashboard to Trinity Console

- New sidebar entry for Discord
- Full server structure visualization
- Channel tree with expandable categories
- Role hierarchy with color badges
- Health checks (orphan channels, empty roles, bot roles)
- Search/filter across channels and roles
- Click channel to see permission overwrites
- Click role to see explicit channel access
- Responsive design with modal details view

Chronicler: #70
This commit is contained in:
Claude Chronicler-70
2026-04-08 15:30:22 +00:00
committed by Claude
parent 04bc2e734f
commit b96ab1fb24
3 changed files with 588 additions and 0 deletions

View File

@@ -9,6 +9,124 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
/**
* GET /admin/discord
* Main Discord audit dashboard
*/
router.get('/', async (req, res) => {
try {
const client = req.app.locals.client;
const guildId = process.env.GUILD_ID;
if (!client || !client.isReady()) {
return res.render('admin/discord/index', {
title: 'Discord',
error: 'Discord client not ready',
data: null
});
}
const guild = client.guilds.cache.get(guildId);
if (!guild) {
return res.render('admin/discord/index', {
title: 'Discord',
error: 'Guild not found',
data: null
});
}
// Fetch fresh data
await guild.channels.fetch();
await guild.roles.fetch();
// Build channel structure
const channels = guild.channels.cache.map(ch => ({
id: ch.id,
name: ch.name,
type: ch.type,
typeName: getChannelTypeName(ch.type),
parentId: ch.parentId,
position: ch.position,
nsfw: ch.nsfw || false,
topic: ch.topic || null,
permissionOverwrites: ch.permissionOverwrites?.cache.map(p => ({
id: p.id,
type: p.type,
allow: p.allow.bitfield.toString(),
deny: p.deny.bitfield.toString()
})) || []
})).sort((a, b) => a.position - b.position);
// Build role structure with permission overwrites lookup
const roles = guild.roles.cache.map(r => ({
id: r.id,
name: r.name,
color: r.hexColor,
position: r.position,
permissions: r.permissions.bitfield.toString(),
mentionable: r.mentionable,
managed: r.managed,
memberCount: r.members.size
})).sort((a, b) => b.position - a.position);
// Categories with their children
const categories = channels
.filter(ch => ch.type === 4)
.map(cat => ({
...cat,
children: channels.filter(ch => ch.parentId === cat.id)
}));
// Orphan channels
const orphanChannels = channels.filter(ch => !ch.parentId && ch.type !== 4);
// Server info
const serverInfo = {
id: guild.id,
name: guild.name,
memberCount: guild.memberCount,
ownerId: guild.ownerId,
createdAt: guild.createdAt,
icon: guild.iconURL(),
features: guild.features
};
// Health checks
const healthChecks = {
orphanChannels: orphanChannels.length,
emptyRoles: roles.filter(r => r.memberCount === 0 && !r.managed && r.name !== '@everyone').length,
botRoles: roles.filter(r => r.managed).length
};
res.render('admin/discord/index', {
title: 'Discord',
error: null,
data: {
server: serverInfo,
categories,
orphanChannels,
allChannels: channels,
roles,
healthChecks,
summary: {
totalChannels: channels.length,
totalRoles: roles.length,
categoryCount: categories.length,
orphanCount: orphanChannels.length
}
}
});
} catch (error) {
console.error('Discord dashboard error:', error);
res.render('admin/discord/index', {
title: 'Discord',
error: error.message,
data: null
});
}
});
/** /**
* GET /admin/discord/audit * GET /admin/discord/audit
* Full Discord server audit - channels, roles, members * Full Discord server audit - channels, roles, members

View File

@@ -0,0 +1,467 @@
<% if (error) { %>
<div class="bg-red-500/10 border border-red-500/50 rounded-lg p-6 text-center">
<div class="text-4xl mb-2">⚠️</div>
<div class="text-red-400 font-medium"><%= error %></div>
</div>
<% } else if (data) { %>
<!-- Search & Filter Bar -->
<div class="mb-6">
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-64">
<input type="text" id="search-input" placeholder="Search channels or roles..."
class="w-full px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-frost">
</div>
<div class="flex gap-2">
<button onclick="setView('channels')" id="btn-channels" class="px-4 py-2 rounded-lg bg-frost text-white font-medium transition">
💬 Channels
</button>
<button onclick="setView('roles')" id="btn-roles" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition">
🎭 Roles
</button>
<button onclick="setView('health')" id="btn-health" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition">
🩺 Health
</button>
</div>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
<div class="text-2xl font-bold text-frost"><%= data.summary.totalChannels %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Channels</div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
<div class="text-2xl font-bold text-fire"><%= data.summary.totalRoles %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Roles</div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
<div class="text-2xl font-bold text-universal"><%= data.summary.categoryCount %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Categories</div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
<div class="text-2xl font-bold"><%= data.server.memberCount %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Members</div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
<% if (data.healthChecks.orphanChannels === 0) { %>
<div class="text-2xl font-bold text-green-500">✓</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Healthy</div>
<% } else { %>
<div class="text-2xl font-bold text-yellow-500"><%= data.healthChecks.orphanChannels %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Orphans</div>
<% } %>
</div>
</div>
<!-- Main Content Area -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Channels Panel (left 2/3) -->
<div id="panel-channels" class="lg:col-span-2">
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
<img src="<%= data.server.icon %>" class="w-8 h-8 rounded-full" alt="">
<h3 class="font-semibold"><%= data.server.name %></h3>
</div>
<div class="p-4 max-h-[600px] overflow-y-auto" id="channel-tree">
<% data.categories.forEach((cat, catIndex) => { %>
<div class="channel-item category-item mb-2" data-name="<%= cat.name.toLowerCase() %>">
<div class="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" onclick="toggleCategory('<%= cat.id %>')">
<span class="text-gray-400 transition-transform" id="arrow-<%= cat.id %>">▶</span>
<span class="text-gray-400">📁</span>
<span class="font-medium text-sm uppercase text-gray-600 dark:text-gray-300"><%= cat.name %></span>
<span class="text-xs text-gray-400 ml-auto"><%= cat.children.length %></span>
</div>
<div id="cat-<%= cat.id %>" class="hidden ml-6 mt-1 space-y-1">
<% cat.children.forEach(ch => { %>
<div class="channel-item flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
data-name="<%= ch.name.toLowerCase() %>"
onclick="showChannelDetails('<%= ch.id %>')">
<% if (ch.type === 0) { %>
<span class="text-gray-400">#</span>
<% } else if (ch.type === 2) { %>
<span class="text-gray-400">🔊</span>
<% } else if (ch.type === 5) { %>
<span class="text-gray-400">📢</span>
<% } else if (ch.type === 13) { %>
<span class="text-gray-400">🎭</span>
<% } else if (ch.type === 15) { %>
<span class="text-gray-400">💬</span>
<% } else { %>
<span class="text-gray-400">📄</span>
<% } %>
<span class="text-sm"><%= ch.name %></span>
<% if (ch.nsfw) { %>
<span class="text-xs bg-red-500/20 text-red-400 px-1 rounded">NSFW</span>
<% } %>
<% if (ch.permissionOverwrites.length > 0) { %>
<span class="text-xs text-gray-400 ml-auto">🔒 <%= ch.permissionOverwrites.length %></span>
<% } %>
</div>
<% }); %>
</div>
</div>
<% }); %>
<% if (data.orphanChannels.length > 0) { %>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-yellow-500 font-medium mb-2">⚠️ Orphan Channels (no category)</div>
<% data.orphanChannels.forEach(ch => { %>
<div class="channel-item flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
data-name="<%= ch.name.toLowerCase() %>"
onclick="showChannelDetails('<%= ch.id %>')">
<span class="text-yellow-500">#</span>
<span class="text-sm"><%= ch.name %></span>
</div>
<% }); %>
</div>
<% } %>
</div>
</div>
</div>
<!-- Roles Panel (right 1/3) -->
<div id="panel-roles" class="hidden lg:block lg:col-span-1">
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="font-semibold">🎭 Role Hierarchy</h3>
</div>
<div class="p-4 max-h-[600px] overflow-y-auto space-y-1" id="role-list">
<% data.roles.forEach(role => { %>
<div class="role-item flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
data-name="<%= role.name.toLowerCase() %>"
data-role-id="<%= role.id %>"
onclick="showRoleDetails('<%= role.id %>')">
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= role.color === '#000000' ? '#6b7280' : role.color %>"></span>
<span class="text-sm truncate flex-1"><%= role.name %></span>
<% if (role.managed) { %>
<span class="text-xs bg-blue-500/20 text-blue-400 px-1 rounded">BOT</span>
<% } %>
<span class="text-xs text-gray-400"><%= role.memberCount %></span>
</div>
<% }); %>
</div>
</div>
</div>
<!-- Health Panel (hidden by default) -->
<div id="panel-health" class="hidden lg:col-span-3">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Orphan Channels -->
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<h4 class="font-medium mb-3 flex items-center gap-2">
<% if (data.healthChecks.orphanChannels === 0) { %>
<span class="text-green-500">✓</span>
<% } else { %>
<span class="text-yellow-500">⚠️</span>
<% } %>
Orphan Channels
</h4>
<div class="text-3xl font-bold mb-2 <%= data.healthChecks.orphanChannels === 0 ? 'text-green-500' : 'text-yellow-500' %>">
<%= data.healthChecks.orphanChannels %>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Channels without a parent category
</p>
</div>
<!-- Empty Roles -->
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<h4 class="font-medium mb-3 flex items-center gap-2">
<% if (data.healthChecks.emptyRoles <= 5) { %>
<span class="text-green-500">✓</span>
<% } else { %>
<span class="text-yellow-500">⚠️</span>
<% } %>
Empty Roles
</h4>
<div class="text-3xl font-bold mb-2 <%= data.healthChecks.emptyRoles <= 5 ? 'text-green-500' : 'text-yellow-500' %>">
<%= data.healthChecks.emptyRoles %>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Non-bot roles with no members
</p>
</div>
<!-- Bot Roles -->
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<h4 class="font-medium mb-3 flex items-center gap-2">
<span class="text-blue-500">🤖</span>
Bot Roles
</h4>
<div class="text-3xl font-bold mb-2 text-blue-500">
<%= data.healthChecks.botRoles %>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Managed by Discord integrations
</p>
</div>
</div>
<!-- Empty Roles List -->
<% const emptyRoles = data.roles.filter(r => r.memberCount === 0 && !r.managed && r.name !== '@everyone'); %>
<% if (emptyRoles.length > 0) { %>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mt-6">
<h4 class="font-medium mb-3">Empty Roles (candidates for cleanup)</h4>
<div class="flex flex-wrap gap-2">
<% emptyRoles.forEach(role => { %>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs"
style="background-color: <%= role.color === '#000000' ? '#374151' : role.color %>20; border: 1px solid <%= role.color === '#000000' ? '#374151' : role.color %>">
<span class="w-2 h-2 rounded-full" style="background-color: <%= role.color === '#000000' ? '#6b7280' : role.color %>"></span>
<%= role.name %>
</span>
<% }); %>
</div>
</div>
<% } %>
</div>
</div>
<!-- Details Modal -->
<div id="details-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50" onclick="closeModal(event)">
<div class="bg-white dark:bg-darkcard rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[80vh] overflow-hidden" onclick="event.stopPropagation()">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="font-semibold" id="modal-title">Details</h3>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl">&times;</button>
</div>
<div class="p-4 overflow-y-auto max-h-[60vh]" id="modal-content">
<!-- Populated by JS -->
</div>
</div>
</div>
<script>
// Store data for JS access
const discordData = <%- JSON.stringify(data) %>;
// Role lookup by ID
const rolesById = {};
discordData.roles.forEach(r => rolesById[r.id] = r);
// Channel lookup by ID
const channelsById = {};
discordData.allChannels.forEach(ch => channelsById[ch.id] = ch);
// View switching
function setView(view) {
// Update buttons
document.getElementById('btn-channels').className = view === 'channels'
? 'px-4 py-2 rounded-lg bg-frost text-white font-medium transition'
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
document.getElementById('btn-roles').className = view === 'roles'
? 'px-4 py-2 rounded-lg bg-fire text-white font-medium transition'
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
document.getElementById('btn-health').className = view === 'health'
? 'px-4 py-2 rounded-lg bg-universal text-white font-medium transition'
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
// Show/hide panels
document.getElementById('panel-channels').className = view === 'channels' ? 'lg:col-span-2' : 'hidden';
document.getElementById('panel-roles').className = view === 'roles' || view === 'channels' ? 'lg:col-span-1' : 'hidden';
document.getElementById('panel-health').className = view === 'health' ? 'lg:col-span-3' : 'hidden';
// Adjust for roles-only view
if (view === 'roles') {
document.getElementById('panel-roles').className = 'lg:col-span-3';
}
}
// Category toggle
function toggleCategory(catId) {
const content = document.getElementById('cat-' + catId);
const arrow = document.getElementById('arrow-' + catId);
if (content.classList.contains('hidden')) {
content.classList.remove('hidden');
arrow.style.transform = 'rotate(90deg)';
} else {
content.classList.add('hidden');
arrow.style.transform = 'rotate(0deg)';
}
}
// Show channel details
function showChannelDetails(channelId) {
const channel = channelsById[channelId];
if (!channel) return;
let html = `
<div class="space-y-4">
<div>
<div class="text-sm text-gray-500 dark:text-gray-400">Channel</div>
<div class="font-medium"># ${channel.name}</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-gray-500 dark:text-gray-400">Type</div>
<div>${channel.typeName}</div>
</div>
<div>
<div class="text-sm text-gray-500 dark:text-gray-400">ID</div>
<div class="text-xs font-mono">${channel.id}</div>
</div>
</div>
${channel.topic ? `<div>
<div class="text-sm text-gray-500 dark:text-gray-400">Topic</div>
<div class="text-sm">${channel.topic}</div>
</div>` : ''}
`;
if (channel.permissionOverwrites.length > 0) {
html += `<div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-2">Permission Overwrites</div>
<div class="space-y-1">`;
channel.permissionOverwrites.forEach(p => {
const role = rolesById[p.id];
const name = role ? role.name : `User ${p.id.slice(-4)}`;
const color = role ? (role.color === '#000000' ? '#6b7280' : role.color) : '#6b7280';
const allow = p.allow !== '0' ? '✓ Allow' : '';
const deny = p.deny !== '0' ? '✗ Deny' : '';
html += `<div class="flex items-center gap-2 text-sm px-2 py-1 rounded bg-gray-100 dark:bg-gray-800">
<span class="w-2 h-2 rounded-full" style="background-color: ${color}"></span>
<span class="flex-1">${name}</span>
${allow ? `<span class="text-green-500 text-xs">${allow}</span>` : ''}
${deny ? `<span class="text-red-500 text-xs">${deny}</span>` : ''}
</div>`;
});
html += `</div></div>`;
}
html += '</div>';
document.getElementById('modal-title').textContent = '# ' + channel.name;
document.getElementById('modal-content').innerHTML = html;
document.getElementById('details-modal').classList.remove('hidden');
document.getElementById('details-modal').classList.add('flex');
}
// Show role details
function showRoleDetails(roleId) {
const role = rolesById[roleId];
if (!role) return;
// Find channels this role can access
const accessibleChannels = discordData.allChannels.filter(ch => {
// Check if role has explicit permission
const overwrite = ch.permissionOverwrites.find(p => p.id === roleId);
if (overwrite) {
return overwrite.allow !== '0' || overwrite.deny === '0';
}
// Check if @everyone is denied (and role doesn't have explicit access)
const everyoneOverwrite = ch.permissionOverwrites.find(p => p.id === discordData.server.id);
if (everyoneOverwrite && everyoneOverwrite.deny !== '0') {
return false;
}
return true;
});
const color = role.color === '#000000' ? '#6b7280' : role.color;
let html = `
<div class="space-y-4">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full" style="background-color: ${color}"></span>
<div>
<div class="font-medium">${role.name}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Position: ${role.position}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-gray-500 dark:text-gray-400">Members</div>
<div class="text-xl font-bold">${role.memberCount}</div>
</div>
<div>
<div class="text-sm text-gray-500 dark:text-gray-400">Type</div>
<div>${role.managed ? '🤖 Bot Managed' : '👥 Regular'}</div>
</div>
</div>
<div>
<div class="text-sm text-gray-500 dark:text-gray-400">ID</div>
<div class="text-xs font-mono">${role.id}</div>
</div>
<div>
<div class="text-sm text-gray-500 dark:text-gray-400 mb-2">Explicit Channel Access (${accessibleChannels.filter(ch => ch.permissionOverwrites.some(p => p.id === roleId)).length})</div>
<div class="flex flex-wrap gap-1">
`;
// Show channels with explicit overwrites for this role
const explicitChannels = discordData.allChannels.filter(ch =>
ch.permissionOverwrites.some(p => p.id === roleId)
);
if (explicitChannels.length === 0) {
html += '<span class="text-gray-500 text-sm">No explicit overwrites</span>';
} else {
explicitChannels.forEach(ch => {
const overwrite = ch.permissionOverwrites.find(p => p.id === roleId);
const isAllowed = overwrite && overwrite.allow !== '0';
const isDenied = overwrite && overwrite.deny !== '0';
html += `<span class="text-xs px-2 py-1 rounded ${isAllowed ? 'bg-green-500/20 text-green-400' : isDenied ? 'bg-red-500/20 text-red-400' : 'bg-gray-500/20 text-gray-400'}">
# ${ch.name}
</span>`;
});
}
html += `</div></div></div>`;
document.getElementById('modal-title').innerHTML = `<span class="inline-block w-3 h-3 rounded-full mr-2" style="background-color: ${color}"></span>${role.name}`;
document.getElementById('modal-content').innerHTML = html;
document.getElementById('details-modal').classList.remove('hidden');
document.getElementById('details-modal').classList.add('flex');
}
// Close modal
function closeModal(event) {
if (!event || event.target === document.getElementById('details-modal')) {
document.getElementById('details-modal').classList.add('hidden');
document.getElementById('details-modal').classList.remove('flex');
}
}
// Search filter
document.getElementById('search-input').addEventListener('input', function(e) {
const query = e.target.value.toLowerCase();
// Filter channels
document.querySelectorAll('.channel-item').forEach(el => {
const name = el.dataset.name || '';
el.style.display = name.includes(query) ? '' : 'none';
});
// Filter roles
document.querySelectorAll('.role-item').forEach(el => {
const name = el.dataset.name || '';
el.style.display = name.includes(query) ? '' : 'none';
});
// If searching, expand all categories
if (query) {
document.querySelectorAll('[id^="cat-"]').forEach(el => {
el.classList.remove('hidden');
});
document.querySelectorAll('[id^="arrow-"]').forEach(el => {
el.style.transform = 'rotate(90deg)';
});
}
});
// Keyboard shortcut to close modal
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
// Expand all categories on load for better UX
document.addEventListener('DOMContentLoaded', function() {
// Keep categories collapsed by default - user can expand as needed
});
</script>
<% } %>

View File

@@ -95,6 +95,9 @@
<a href="/admin/scheduler" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/scheduler') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>"> <a href="/admin/scheduler" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/scheduler') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
⏰ Restart Scheduler ⏰ Restart Scheduler
</a> </a>
<a href="/admin/discord" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/discord') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
💬 Discord
</a>
</nav> </nav>
<div class="p-4 border-t border-gray-200 dark:border-gray-700"> <div class="p-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">