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:
@@ -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;
|
||||
|
||||
98
services/arbiter-3.0/src/routes/admin/mcp-logs.js
Normal file
98
services/arbiter-3.0/src/routes/admin/mcp-logs.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
161
services/arbiter-3.0/src/views/admin/mcp-logs/index.ejs
Normal file
161
services/arbiter-3.0/src/views/admin/mcp-logs/index.ejs
Normal 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>
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trinity-core",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user