feat: Task #109 — MCP Logging in Trinity Console (v2.3.0)

Arbiter changes:
- POST /api/internal/mcp/log endpoint in api.js
- MCP Logs admin route (/admin/mcp-logs) with filters, stats, pagination
- EJS view with expandable detail rows, server color badges
- Sidebar link under System group

Trinity Core v2.3.0:
- logToArbiter() function POSTs to Arbiter after every command
- Both MCP (Claude.ai) and REST (/exec) paths log with execution timing
- Async logging — doesn't block command response

Database:
- mcp_logs table created on Command Center (indexes on server, time, success)

Architecture:
  Trinity Core → command → response + async POST → Arbiter → PostgreSQL → Trinity Console

Chronicler #78 | firefrost-services
This commit is contained in:
Claude (Chronicler #78)
2026-04-11 11:55:22 +00:00
parent 0b61d38419
commit 03974d1f13
7 changed files with 318 additions and 4 deletions

View File

@@ -17,6 +17,7 @@ const systemRouter = require('./system');
const socialRouter = require('./social');
const infrastructureRouter = require('./infrastructure');
const aboutRouter = require('./about');
const mcpLogsRouter = require('./mcp-logs');
router.use(requireTrinityAccess);
@@ -121,5 +122,6 @@ router.use('/system', systemRouter);
router.use('/social', socialRouter);
router.use('/infrastructure', infrastructureRouter);
router.use('/about', aboutRouter);
router.use('/mcp-logs', mcpLogsRouter);
module.exports = router;

View File

@@ -0,0 +1,98 @@
const express = require('express');
const router = express.Router();
const db = require('../../database');
/**
* MCP Logs Module — Trinity Console
*
* View and filter command execution logs from Trinity Core.
*
* GET /admin/mcp-logs — Main logs page with filters
*
* Chronicler #78 | April 11, 2026
*/
const SERVERS = [
'command-center', 'tx1-dallas', 'nc1-charlotte',
'panel-vps', 'dev-panel', 'wiki-vps', 'services-vps', 'trinity-core'
];
router.get('/', async (req, res) => {
try {
const { server, success, limit = 50, offset = 0 } = req.query;
// Build filtered query
let where = 'WHERE 1=1';
const params = [];
let p = 0;
if (server) {
p++;
where += ` AND server = $${p}`;
params.push(server);
}
if (success !== undefined && success !== '') {
p++;
where += ` AND success = $${p}`;
params.push(success === 'true');
}
// Get logs
const logsResult = await db.query(
`SELECT * FROM mcp_logs ${where} ORDER BY executed_at DESC LIMIT $${p + 1} OFFSET $${p + 2}`,
[...params, parseInt(limit), parseInt(offset)]
);
// Get total count
const countResult = await db.query(
`SELECT COUNT(*) FROM mcp_logs ${where}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get stats
const statsResult = await db.query(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE success = true) as success_count,
COUNT(*) FILTER (WHERE success = false) as fail_count,
COALESCE(ROUND(AVG(execution_time_ms)), 0) as avg_time
FROM mcp_logs
`);
const stats = statsResult.rows[0];
res.render('admin/mcp-logs/index', {
title: 'MCP Logs',
currentPath: '/mcp-logs',
logs: logsResult.rows,
total,
limit: parseInt(limit),
offset: parseInt(offset),
query: req.query,
stats,
servers: SERVERS,
adminUser: req.user,
layout: 'layout'
});
} catch (err) {
console.error('[MCP Logs] Route error:', err);
res.render('admin/mcp-logs/index', {
title: 'MCP Logs',
currentPath: '/mcp-logs',
logs: [],
total: 0,
limit: 50,
offset: 0,
query: {},
stats: { total: 0, success_count: 0, fail_count: 0, avg_time: 0 },
servers: SERVERS,
error: err.message,
adminUser: req.user,
layout: 'layout'
});
}
});
module.exports = router;

View File

@@ -368,4 +368,31 @@ router.get('/social/digest', async (req, res) => {
}
});
// =============================================================================
// POST /api/internal/mcp/log
// Log a command execution from Trinity Core
// =============================================================================
router.post('/mcp/log', async (req, res) => {
try {
const { server, command, success, stdout, stderr, error, execution_time_ms } = req.body;
if (!server || !command || success === undefined) {
return res.status(400).json({ error: 'Missing required fields: server, command, success' });
}
const result = await db.query(
`INSERT INTO mcp_logs (server, command, success, stdout, stderr, error, execution_time_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[server, command, success, stdout || '', stderr || '', error || null, execution_time_ms || null]
);
res.json({ success: true, id: result.rows[0].id });
} catch (err) {
console.error('❌ [MCP Log] Error:', err);
res.status(500).json({ error: 'Failed to log command' });
}
});
module.exports = router;

View File

@@ -0,0 +1,161 @@
<!-- MCP Logs Module — Trinity Console -->
<!-- Chronicler #78 | April 11, 2026 -->
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 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">
<div class="text-sm text-gray-500 dark:text-gray-400">Total Commands</div>
<div class="text-2xl font-bold mt-1"><%= stats.total %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Successful</div>
<div class="text-2xl font-bold mt-1 text-green-500"><%= stats.success_count %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Failed</div>
<div class="text-2xl font-bold mt-1 text-red-500"><%= stats.fail_count %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Execution</div>
<div class="text-2xl font-bold mt-1 text-frost"><%= stats.avg_time %>ms</div>
</div>
</div>
<!-- Filters -->
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-6">
<form method="GET" class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Server</label>
<select name="server" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
<option value="">All Servers</option>
<% servers.forEach(s => { %>
<option value="<%= s %>" <%= query.server === s ? 'selected' : '' %>><%= s %></option>
<% }); %>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Status</label>
<select name="success" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
<option value="">All</option>
<option value="true" <%= query.success === 'true' ? 'selected' : '' %>>Success</option>
<option value="false" <%= query.success === 'false' ? 'selected' : '' %>>Failed</option>
</select>
</div>
<div class="flex gap-2">
<button type="submit" class="bg-frost text-white px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Filter</button>
<a href="/admin/mcp-logs" class="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Reset</a>
</div>
</form>
</div>
<!-- Logs Table -->
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="px-4 py-3">Time</th>
<th class="px-4 py-3">Server</th>
<th class="px-4 py-3">Command</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<% if (logs.length === 0) { %>
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<% if (stats.total == 0) { %>
No commands logged yet. Commands executed via Trinity Core will appear here.
<% } else { %>
No logs match your filters.
<% } %>
</td>
</tr>
<% } %>
<% logs.forEach(log => {
const time = new Date(log.executed_at);
const timeStr = time.toLocaleString('en-US', { month:'short', day:'numeric', hour:'numeric', minute:'2-digit', second:'2-digit', hour12:true });
const cmdShort = log.command.length > 60 ? log.command.substring(0, 60) + '...' : log.command;
%>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition cursor-pointer" onclick="toggleDetail('<%= log.id %>')">
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"><%= timeStr %></td>
<td class="px-4 py-3">
<span class="inline-block px-2 py-0.5 text-xs rounded-full
<% if (log.server === 'tx1-dallas') { %>bg-fire/20 text-fire
<% } else if (log.server === 'nc1-charlotte') { %>bg-frost/20 text-frost
<% } else if (log.server === 'command-center' || log.server === 'trinity-core') { %>bg-universal/20 text-universal
<% } else { %>bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300
<% } %>
"><%= log.server %></span>
</td>
<td class="px-4 py-3"><code class="text-xs text-gray-300"><%= cmdShort %></code></td>
<td class="px-4 py-3">
<% if (log.success) { %>
<span class="inline-block px-2 py-0.5 text-xs rounded-full bg-green-500/20 text-green-500">✓ OK</span>
<% } else { %>
<span class="inline-block px-2 py-0.5 text-xs rounded-full bg-red-500/20 text-red-500">✗ Fail</span>
<% } %>
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400"><%= log.execution_time_ms || '—' %>ms</td>
<td class="px-4 py-3 text-xs text-gray-400">▼</td>
</tr>
<!-- Expandable detail row -->
<tr id="detail-<%= log.id %>" style="display:none;" class="bg-gray-50 dark:bg-gray-800/30">
<td colspan="6" class="px-4 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1 font-semibold">Full Command</div>
<pre class="bg-gray-900 text-green-400 text-xs p-3 rounded-md overflow-x-auto max-h-32"><%= log.command %></pre>
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1 font-semibold">Output</div>
<pre class="bg-gray-900 text-gray-300 text-xs p-3 rounded-md overflow-x-auto max-h-32"><%= log.stdout || '(no output)' %></pre>
</div>
<% if (log.stderr) { %>
<div>
<div class="text-xs text-yellow-500 mb-1 font-semibold">STDERR</div>
<pre class="bg-yellow-900/20 text-yellow-300 text-xs p-3 rounded-md overflow-x-auto max-h-32"><%= log.stderr %></pre>
</div>
<% } %>
<% if (log.error) { %>
<div>
<div class="text-xs text-red-500 mb-1 font-semibold">Error</div>
<pre class="bg-red-900/20 text-red-300 text-xs p-3 rounded-md overflow-x-auto max-h-32"><%= log.error %></pre>
</div>
<% } %>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<!-- Pagination -->
<% if (total > limit) { %>
<div class="flex justify-center mt-6 gap-2">
<% const totalPages = Math.ceil(total / limit); %>
<% const currentPage = Math.floor(offset / limit) + 1; %>
<% for (let i = 1; i <= totalPages && i <= 10; i++) {
const pageOffset = (i - 1) * limit;
const params = new URLSearchParams({...query, offset: pageOffset});
params.delete('offset');
if (pageOffset > 0) params.set('offset', pageOffset);
%>
<a href="/admin/mcp-logs?<%= params.toString() %>"
class="px-3 py-1 rounded-md text-sm <%= currentPage === i ? 'bg-frost text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600' %> transition">
<%= i %>
</a>
<% } %>
</div>
<% } %>
<script>
function toggleDetail(id) {
const row = document.getElementById('detail-' + id);
if (row) {
row.style.display = row.style.display === 'none' ? '' : 'none';
}
}
</script>

View File

@@ -118,6 +118,9 @@
<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>
</nav>
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<!-- User Info -->

View File

@@ -10,6 +10,8 @@ const API_TOKEN = 'FFG-Trinity-2026-Core-Access';
const LOG_FILE = '/home/claude_executor/mcp-server/command.log';
const PORT = 3000;
const BASE_URL = 'https://mcp.firefrostgaming.com';
const ARBITER_LOG_URL = 'https://discord-bot.firefrostgaming.com/api/internal/mcp/log';
const ARBITER_TOKEN = '6fYF1akCRW6pM2F8n3S3RxeIod4YgRniUJNEQurvBP4=';
const SERVERS = {
'command-center': { host: '63.143.34.217', user: 'root' },
@@ -28,6 +30,21 @@ function log(msg) {
console.log(line.trim());
}
async function logToArbiter(data) {
try {
await fetch(ARBITER_LOG_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ARBITER_TOKEN}`
},
body: JSON.stringify(data)
});
} catch (err) {
log(`Arbiter log failed: ${err.message}`);
}
}
const app = express();
app.use(cors({ origin: '*', credentials: true }));
app.use(express.json());
@@ -119,9 +136,12 @@ function setupToolHandlers(mcpServer) {
const { server, command } = request.params.arguments;
if (!SERVERS[server]) return { content: [{ type: "text", text: `Unknown server ${server}` }], isError: true };
const target = SERVERS[server];
const startTime = Date.now();
const result = target.local
? await executeLocal(command)
: await executeSSH(server, command);
const executionTime = Date.now() - startTime;
logToArbiter({ server, command, success: result.success, stdout: result.stdout, stderr: result.stderr, error: result.error, execution_time_ms: executionTime });
let output = result.stdout || '(no output)';
if (result.stderr) output += `\nSTDERR: ${result.stderr}`;
return { content: [{ type: "text", text: output }], isError: !result.success };
@@ -147,10 +167,13 @@ app.post('/exec', auth, async (req, res) => {
return res.status(400).json({ error: `Unknown server: ${server}` });
}
const target = SERVERS[server];
const startTime = Date.now();
const result = target.local
? await executeLocal(command)
: await executeSSH(server, command);
log(`REST /exec [${server}] -> ${result.success ? 'OK' : 'FAIL'}`);
const executionTime = Date.now() - startTime;
log(`REST /exec [${server}] -> ${result.success ? 'OK' : 'FAIL'} (${executionTime}ms)`);
logToArbiter({ server, command, success: result.success, stdout: result.stdout, stderr: result.stderr, error: result.error, execution_time_ms: executionTime });
res.json({
success: result.success,
output: result.stdout || '',
@@ -164,7 +187,7 @@ app.post('/exec', auth, async (req, res) => {
app.get('/mcp', auth, async (req, res) => {
log(`SSE connection from ${req.ip}`);
const mcpServer = new Server({ name: "trinity-core", version: "2.2.0" }, { capabilities: { tools: {} } });
const mcpServer = new Server({ name: "trinity-core", version: "2.3.0" }, { capabilities: { tools: {} } });
setupToolHandlers(mcpServer);
mcpServer.onmessage = (msg) => log(`SERVER MSG: ${JSON.stringify(msg)}`);
@@ -201,4 +224,4 @@ app.post('/mcp/messages', auth, async (req, res) => {
}
});
app.listen(PORT, () => log(`Trinity Core MCP v2.2.0 started on port ${PORT}`));
app.listen(PORT, () => log(`Trinity Core MCP v2.3.0 started on port ${PORT}`));

View File

@@ -1,6 +1,6 @@
{
"name": "trinity-core",
"version": "2.2.0",
"version": "2.3.0",
"type": "module",
"main": "index.js",
"dependencies": {