The Forge module + collapsible sidebar nav

New Module: The Forge (/admin/forge)
- AI knowledge assistant powered by Gemma 4 via Dify RAG
- Streaming SSE chat interface with markdown rendering
- Think-tag filtering for Gemma 4's reasoning tokens
- Conversation continuity via Dify conversation IDs
- Source citations from knowledge base documents
- Fire/Frost/Arcane gradient branding
- Welcome screen with suggestion buttons
- Env vars: DIFY_API_URL, DIFY_APP_KEY

Sidebar Navigation Overhaul (layout.ejs)
- The Forge featured prominently at top with gradient border
- Collapsible category groups: Core, Revenue, Community, Operations
- localStorage persistence for collapsed/expanded state
- CSS transitions for smooth collapse animation

Chronicler #82 | April 12, 2026
This commit is contained in:
Claude (Chronicler #82)
2026-04-12 02:14:12 -05:00
parent 1ca6ef4dfa
commit 240a4776f6
4 changed files with 474 additions and 54 deletions

View File

@@ -0,0 +1,82 @@
const express = require('express');
const router = express.Router();
/**
* The Forge — AI Knowledge Assistant
* Trinity Console module for querying Firefrost documentation
* via Dify RAG + Gemma 4 on TX1
*
* Chronicler #82 | April 12, 2026
*/
const DIFY_API_URL = process.env.DIFY_API_URL || 'http://38.68.14.26:5001';
const DIFY_APP_KEY = process.env.DIFY_APP_KEY || 'app-forge-trinity-console-key';
// GET /admin/forge — The Forge chat interface
router.get('/', (req, res) => {
res.render('admin/forge/index', {
title: 'The Forge',
currentPath: '/forge',
adminUser: req.user
});
});
// POST /admin/forge/chat — Proxy to Dify streaming API
router.post('/chat', async (req, res) => {
const { query, conversation_id } = req.body;
if (!query || !query.trim()) {
return res.status(400).json({ error: 'Query is required' });
}
const userId = `trinity-${req.user?.id || 'unknown'}`;
try {
// Set up SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const response = await fetch(`${DIFY_API_URL}/v1/chat-messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${DIFY_APP_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
inputs: {},
query: query.trim(),
response_mode: 'streaming',
conversation_id: conversation_id || '',
user: userId
})
});
if (!response.ok) {
const errText = await response.text();
console.error('[Forge] Dify API error:', response.status, errText);
res.write(`data: ${JSON.stringify({ event: 'error', message: 'The Forge is temporarily unavailable.' })}\n\n`);
return res.end();
}
// Pipe the SSE stream from Dify to the client
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
res.write(chunk);
}
res.end();
} catch (err) {
console.error('[Forge] Chat error:', err);
res.write(`data: ${JSON.stringify({ event: 'error', message: 'Connection to The Forge failed.' })}\n\n`);
res.end();
}
});
module.exports = router;

View File

@@ -22,6 +22,7 @@ const infrastructureRouter = require('./infrastructure');
const aboutRouter = require('./about');
const mcpLogsRouter = require('./mcp-logs');
const tasksRouter = require('./tasks');
const forgeRouter = require('./forge');
router.use(requireTrinityAccess);
@@ -131,5 +132,6 @@ router.use('/infrastructure', infrastructureRouter);
router.use('/about', aboutRouter);
router.use('/mcp-logs', mcpLogsRouter);
router.use('/tasks', tasksRouter);
router.use('/forge', forgeRouter);
module.exports = router;

View File

@@ -0,0 +1,285 @@
<style>
#forge-container {
display: flex;
flex-direction: column;
height: calc(100vh - 8rem);
max-width: 900px;
margin: 0 auto;
}
#forge-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
scroll-behavior: smooth;
}
.forge-msg {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
max-width: 85%;
line-height: 1.6;
font-size: 0.95rem;
}
.forge-msg.user {
background: linear-gradient(135deg, rgba(255,107,53,0.15), rgba(78,205,196,0.15));
border: 1px solid rgba(168,85,247,0.3);
margin-left: auto;
text-align: right;
}
.forge-msg.assistant {
background: rgba(45,45,45,0.8);
border: 1px solid rgba(78,205,196,0.2);
margin-right: auto;
}
.forge-msg.assistant pre {
background: rgba(0,0,0,0.4);
padding: 0.75rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.5rem 0;
font-size: 0.85rem;
}
.forge-msg.assistant code {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
}
.forge-citations {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(78,205,196,0.2);
font-size: 0.8rem;
color: #888;
}
#forge-input-area {
padding: 1rem;
border-top: 1px solid rgba(78,205,196,0.2);
}
#forge-input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
border: 1px solid rgba(168,85,247,0.3);
background: rgba(45,45,45,0.6);
color: #e8f4f8;
font-size: 1rem;
resize: none;
outline: none;
transition: border-color 0.2s;
}
#forge-input:focus {
border-color: rgba(168,85,247,0.6);
}
#forge-send {
margin-top: 0.5rem;
padding: 0.5rem 1.5rem;
background: linear-gradient(135deg, #FF6B35, #A855F7, #4ECDC4);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
float: right;
transition: opacity 0.2s;
}
#forge-send:disabled { opacity: 0.4; cursor: not-allowed; }
#forge-send:hover:not(:disabled) { opacity: 0.9; }
.forge-welcome {
text-align: center;
padding: 3rem 1rem;
color: #888;
}
.forge-welcome h2 {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, #FF6B35, #A855F7, #4ECDC4);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.forge-welcome p {
max-width: 500px;
margin: 0.5rem auto;
line-height: 1.6;
}
.forge-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
margin-top: 1.5rem;
}
.forge-suggestion {
padding: 0.5rem 1rem;
border: 1px solid rgba(168,85,247,0.3);
border-radius: 2rem;
font-size: 0.85rem;
color: #a8dadc;
cursor: pointer;
transition: all 0.2s;
background: transparent;
}
.forge-suggestion:hover {
border-color: rgba(168,85,247,0.6);
background: rgba(168,85,247,0.1);
}
</style>
<div id="forge-container">
<div id="forge-messages">
<div class="forge-welcome" id="forge-welcome">
<h2>🔥 The Forge</h2>
<p>Ask me anything about Firefrost Gaming — infrastructure, procedures, server configs, policies, or history.</p>
<p style="font-size: 0.8rem; color: #666;">Powered by Gemma 4 on TX1 • Responses may take 30-60 seconds</p>
<div class="forge-suggestions">
<button class="forge-suggestion" onclick="askForge('How do I restart Arbiter?')">How do I restart Arbiter?</button>
<button class="forge-suggestion" onclick="askForge('What servers are on TX1?')">What servers are on TX1?</button>
<button class="forge-suggestion" onclick="askForge('What is the Sovereign tier?')">What is the Sovereign tier?</button>
<button class="forge-suggestion" onclick="askForge('What is the cancellation policy?')">Cancellation policy?</button>
</div>
</div>
</div>
<div id="forge-input-area">
<textarea id="forge-input" rows="2" placeholder="Ask The Forge..." onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();sendForge();}"></textarea>
<button id="forge-send" onclick="sendForge()">Ask The Forge</button>
<div style="clear:both;"></div>
</div>
</div>
<script>
let conversationId = '';
let isStreaming = false;
function askForge(text) {
document.getElementById('forge-input').value = text;
sendForge();
}
async function sendForge() {
const input = document.getElementById('forge-input');
const query = input.value.trim();
if (!query || isStreaming) return;
const welcome = document.getElementById('forge-welcome');
if (welcome) welcome.style.display = 'none';
const messages = document.getElementById('forge-messages');
const userDiv = document.createElement('div');
userDiv.className = 'forge-msg user';
userDiv.textContent = query;
messages.appendChild(userDiv);
const msgId = 'msg-' + Date.now();
const assistDiv = document.createElement('div');
assistDiv.className = 'forge-msg assistant';
assistDiv.id = msgId;
assistDiv.innerHTML = '<span style="color:#888;">🔥 The Forge is thinking...</span>';
messages.appendChild(assistDiv);
messages.scrollTop = messages.scrollHeight;
input.value = '';
isStreaming = true;
document.getElementById('forge-send').disabled = true;
try {
const response = await fetch('/admin/forge/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': '<%= csrfToken %>'
},
body: JSON.stringify({ query, conversation_id: conversationId })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let rawAnswer = '';
let buffer = '';
const msgEl = document.getElementById(msgId);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const jsonStr = line.substring(6).trim();
if (!jsonStr) continue;
try {
const data = JSON.parse(jsonStr);
if (data.event === 'message' && data.answer) {
rawAnswer += data.answer;
let display = rawAnswer;
display = display.replace(/<think>[\s\S]*?<\/think>/g, '');
display = display.replace(/<think>[\s\S]*/g, '');
display = display.trim();
if (display) {
msgEl.innerHTML = renderMarkdown(display);
}
messages.scrollTop = messages.scrollHeight;
if (data.conversation_id) {
conversationId = data.conversation_id;
}
}
if (data.event === 'message_end') {
if (data.metadata && data.metadata.retriever_resources) {
const sources = data.metadata.retriever_resources.map(function(r) { return r.document_name; });
const unique = [...new Set(sources)];
if (unique.length > 0) {
msgEl.innerHTML += '<div class="forge-citations">📄 Sources: ' + unique.join(', ') + '</div>';
}
}
}
if (data.event === 'error') {
msgEl.innerHTML = '<span style="color:#ff6b35;">⚠️ ' + escapeHtml(data.message || 'Something went wrong.') + '</span>';
}
} catch (e) {}
}
}
let finalDisplay = rawAnswer.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
if (finalDisplay) {
msgEl.innerHTML = renderMarkdown(finalDisplay);
} else if (!rawAnswer) {
msgEl.innerHTML = '<span style="color:#888;">No response received. The model may be loading.</span>';
}
} catch (err) {
document.getElementById(msgId).innerHTML = '<span style="color:#ff6b35;">⚠️ Connection failed: ' + escapeHtml(err.message) + '</span>';
}
isStreaming = false;
document.getElementById('forge-send').disabled = false;
input.focus();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderMarkdown(text) {
let html = escapeHtml(text);
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
html = html.replace(/`([^`]+)`/g, '<code style="background:rgba(0,0,0,0.3);padding:0.1rem 0.3rem;border-radius:3px;">$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/^### (.+)$/gm, '<h4 style="color:#4ECDC4;margin:0.5rem 0;">$1</h4>');
html = html.replace(/^## (.+)$/gm, '<h3 style="color:#4ECDC4;margin:0.5rem 0;">$1</h3>');
html = html.replace(/^- (.+)$/gm, '• $1');
html = html.replace(/\n/g, '<br>');
return html;
}
</script>

View File

@@ -54,6 +54,8 @@
display: block;
}
}
.nav-group { transition: all 0.2s ease; overflow: hidden; }
.nav-collapsed { max-height: 0; opacity: 0; margin: 0; padding: 0; }
</style>
</head>
<body class="bg-gray-100 dark:bg-darkbg text-gray-900 dark:text-gray-100 font-sans antialiased transition-colors duration-200">
@@ -71,65 +73,87 @@
<button onclick="document.getElementById('sidebar').classList.remove('open'); document.getElementById('sidebar-overlay').classList.remove('open');" class="md:hidden text-2xl">✕</button>
</div>
<nav class="flex-1 px-4 space-y-1 overflow-y-auto">
<!-- Operations -->
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Operations</div>
<a href="/admin/dashboard" class="block px-4 py-2 rounded-md <%= currentPath === '/dashboard' ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📊 Dashboard
</a>
<a href="/admin/tasks" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/tasks') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📋 Tasks
</a>
<a href="/admin/servers" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/servers') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🖥️ Servers
</a>
<a href="/admin/players" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/players') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
👥 Players
<!-- The Forge — proud and loud at the top -->
<a href="/admin/forge" class="block px-4 py-3 rounded-lg mb-2 bg-gradient-to-r from-fire/10 via-universal/10 to-frost/10 border border-universal/30 <%= currentPath.startsWith('/forge') ? 'ring-2 ring-universal' : 'hover:border-universal/60' %> transition">
<span class="text-base font-bold bg-gradient-to-r from-fire via-universal to-frost text-transparent bg-clip-text">🔥 The Forge</span>
</a>
<!-- Business -->
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Business</div>
<a href="/admin/financials" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/financials') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
💰 Financials
</a>
<a href="/admin/grace" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/grace') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
⏳ Grace Period
</a>
<a href="/admin/appeals" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/appeals') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
⚖️ Trinity Appeals
</a>
<!-- Core -->
<button onclick="toggleNav('nav-core')" class="w-full flex items-center justify-between text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400 font-semibold px-4 pt-3 pb-1 hover:text-gray-700 dark:hover:text-gray-300 transition">
<span>Core</span>
<span id="nav-core-arrow" class="text-xs transition-transform">▼</span>
</button>
<div id="nav-core" class="nav-group">
<a href="/admin/dashboard" class="block px-4 py-2 rounded-md <%= currentPath === '/dashboard' ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📊 Dashboard
</a>
<a href="/admin/tasks" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/tasks') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📋 Tasks
</a>
<a href="/admin/servers" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/servers') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🖥️ Servers
</a>
<a href="/admin/players" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/players') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
👥 Players
</a>
</div>
<!-- Revenue -->
<button onclick="toggleNav('nav-revenue')" class="w-full flex items-center justify-between text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400 font-semibold px-4 pt-3 pb-1 hover:text-gray-700 dark:hover:text-gray-300 transition">
<span>Revenue</span>
<span id="nav-revenue-arrow" class="text-xs transition-transform">▼</span>
</button>
<div id="nav-revenue" class="nav-group">
<a href="/admin/financials" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/financials') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
💰 Financials
</a>
<a href="/admin/grace" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/grace') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
⏳ Grace Period
</a>
<a href="/admin/appeals" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/appeals') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
⚖️ Appeals
</a>
</div>
<!-- Community -->
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Community</div>
<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>
<a href="/admin/social" class="block px-4 py-2 rounded-md <%= (currentPath === '/social' || (currentPath.startsWith('/social') && !currentPath.startsWith('/social-calendar'))) ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📈 Social
</a>
<a href="/admin/social-calendar" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/social-calendar') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📅 Social Calendar
</a>
<button onclick="toggleNav('nav-community')" class="w-full flex items-center justify-between text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400 font-semibold px-4 pt-3 pb-1 hover:text-gray-700 dark:hover:text-gray-300 transition">
<span>Community</span>
<span id="nav-community-arrow" class="text-xs transition-transform">▼</span>
</button>
<div id="nav-community" class="nav-group">
<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>
<a href="/admin/social" class="block px-4 py-2 rounded-md <%= (currentPath === '/social' || (currentPath.startsWith('/social') && !currentPath.startsWith('/social-calendar'))) ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📈 Social Analytics
</a>
<a href="/admin/social-calendar" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/social-calendar') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📅 Social Calendar
</a>
</div>
<!-- Infrastructure -->
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">Infrastructure</div>
<a href="/admin/infrastructure" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/infrastructure') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🌐 Infrastructure
</a>
<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
</a>
<!-- System -->
<div class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-600 font-semibold px-4 pt-3 pb-1">System</div>
<a href="/admin/audit" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/audit') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📋 Audit Log
</a>
<a href="/admin/roles" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/roles') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🔍 Role Audit
</a>
<a href="/admin/mcp-logs" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/mcp-logs') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🖥️ MCP Logs
</a>
<!-- Operations -->
<button onclick="toggleNav('nav-ops')" class="w-full flex items-center justify-between text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400 font-semibold px-4 pt-3 pb-1 hover:text-gray-700 dark:hover:text-gray-300 transition">
<span>Operations</span>
<span id="nav-ops-arrow" class="text-xs transition-transform">▼</span>
</button>
<div id="nav-ops" class="nav-group">
<a href="/admin/infrastructure" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/infrastructure') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🌐 Infrastructure
</a>
<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' %>">
⏰ Scheduler
</a>
<a href="/admin/audit" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/audit') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
📋 Audit Log
</a>
<a href="/admin/roles" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/roles') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🔍 Role Audit
</a>
<a href="/admin/mcp-logs" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/mcp-logs') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🖥️ MCP Logs
</a>
</div>
</nav>
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<!-- User Info -->
@@ -168,5 +192,32 @@
</div>
</main>
</div>
<script>
function toggleNav(id) {
const el = document.getElementById(id);
const arrow = document.getElementById(id + '-arrow');
const isHidden = el.classList.contains('nav-collapsed');
if (isHidden) {
el.classList.remove('nav-collapsed');
arrow.style.transform = 'rotate(0deg)';
localStorage.setItem(id, 'open');
} else {
el.classList.add('nav-collapsed');
arrow.style.transform = 'rotate(-90deg)';
localStorage.setItem(id, 'closed');
}
}
document.addEventListener('DOMContentLoaded', function() {
['nav-core', 'nav-revenue', 'nav-community', 'nav-ops'].forEach(function(id) {
var state = localStorage.getItem(id);
if (state === 'closed') {
var el = document.getElementById(id);
var arrow = document.getElementById(id + '-arrow');
if (el) { el.classList.add('nav-collapsed'); }
if (arrow) { arrow.style.transform = 'rotate(-90deg)'; }
}
});
});
</script>
</body>
</html>