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:
committed by
Claude
parent
04bc2e734f
commit
b96ab1fb24
@@ -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
|
||||||
|
|||||||
467
services/arbiter-3.0/src/views/admin/discord/index.ejs
Normal file
467
services/arbiter-3.0/src/views/admin/discord/index.ejs
Normal 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">×</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>
|
||||||
|
|
||||||
|
<% } %>
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user