- Add admin-ui/index.html: web-based dashboard for monitoring hub - Add admin-ui/README.md: setup instructions for OBSERVE_SECRET connection - Configure nginx to serve admin-ui/ statically at /admin-ui/ location - No separate server process needed, UI uses WebSocket to connect - OBSERVE_SECRET provides auth layer, no nginx auth required Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
788 lines
23 KiB
HTML
788 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>MCP Hub Admin</title>
|
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
background-color: #0d1117;
|
|
color: #e6edf3;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#root {
|
|
width: 100%;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.app-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
background-color: #0d1117;
|
|
}
|
|
|
|
.status-bar {
|
|
background-color: #161b22;
|
|
border-bottom: 1px solid #30363d;
|
|
padding: 12px 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
font-size: 13px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.status-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
.status-dot.connected {
|
|
background-color: #3fb950;
|
|
}
|
|
|
|
.status-dot.connecting {
|
|
background-color: #d29922;
|
|
}
|
|
|
|
.status-dot.disconnected {
|
|
background-color: #f85149;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.hub-url-input {
|
|
background-color: #0d1117;
|
|
border: 1px solid #30363d;
|
|
color: #e6edf3;
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
width: 300px;
|
|
}
|
|
|
|
.secret-input {
|
|
background-color: #0d1117;
|
|
border: 1px solid #30363d;
|
|
color: #e6edf3;
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
width: 200px;
|
|
}
|
|
|
|
.button {
|
|
background-color: #1f6feb;
|
|
color: white;
|
|
border: none;
|
|
padding: 6px 16px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.button:hover {
|
|
background-color: #388bfd;
|
|
}
|
|
|
|
.button.secondary {
|
|
background-color: #21262d;
|
|
color: #e6edf3;
|
|
border: 1px solid #30363d;
|
|
}
|
|
|
|
.button.secondary:hover {
|
|
background-color: #30363d;
|
|
}
|
|
|
|
.content-panels {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
background-color: #0d1117;
|
|
}
|
|
|
|
.panel {
|
|
background-color: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel-header {
|
|
background-color: #0d1117;
|
|
border-bottom: 1px solid #30363d;
|
|
padding: 12px 16px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel-body {
|
|
overflow: auto;
|
|
flex: 1;
|
|
padding: 12px;
|
|
}
|
|
|
|
.backends-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.backend-card {
|
|
background-color: #0d1117;
|
|
border: 1px solid #30363d;
|
|
border-radius: 6px;
|
|
padding: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.backend-card.disconnected {
|
|
opacity: 0.5;
|
|
border-color: #f85149;
|
|
}
|
|
|
|
.backend-card-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.backend-status-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.backend-status-dot.connected {
|
|
background-color: #3fb950;
|
|
}
|
|
|
|
.backend-status-dot.disconnected {
|
|
background-color: #f85149;
|
|
}
|
|
|
|
.backend-info {
|
|
font-size: 12px;
|
|
color: #8b949e;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.backend-counter {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 12px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.log-stream {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
height: 100%;
|
|
}
|
|
|
|
.log-controls {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.log-filter {
|
|
background-color: #0d1117;
|
|
border: 1px solid #30363d;
|
|
color: #e6edf3;
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
flex: 1;
|
|
}
|
|
|
|
.log-lines {
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.log-line {
|
|
padding: 4px 0;
|
|
border-bottom: 1px solid #21262d;
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
.log-timestamp {
|
|
color: #8b949e;
|
|
min-width: 120px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.log-level {
|
|
min-width: 60px;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
}
|
|
|
|
.log-level.info {
|
|
background-color: #0d47a1;
|
|
color: #79c0ff;
|
|
}
|
|
|
|
.log-level.warn {
|
|
background-color: #663c00;
|
|
color: #d29922;
|
|
}
|
|
|
|
.log-level.error {
|
|
background-color: #490202;
|
|
color: #f85149;
|
|
}
|
|
|
|
.log-message {
|
|
flex: 1;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.message-inspector {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
max-height: 300px;
|
|
}
|
|
|
|
.inspector-controls {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.inspector-table {
|
|
overflow-y: auto;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 11px;
|
|
flex: 1;
|
|
}
|
|
|
|
.inspector-row {
|
|
padding: 8px;
|
|
border-bottom: 1px solid #21262d;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
.inspector-row:hover {
|
|
background-color: #21262d;
|
|
}
|
|
|
|
.inspector-row.expanded {
|
|
background-color: #21262d;
|
|
}
|
|
|
|
.inspector-column {
|
|
flex: 0 0 auto;
|
|
color: #8b949e;
|
|
}
|
|
|
|
.inspector-direction {
|
|
color: #79c0ff;
|
|
}
|
|
|
|
.inspector-method {
|
|
flex: 1;
|
|
color: #e6edf3;
|
|
}
|
|
|
|
.inspector-payload {
|
|
margin-top: 8px;
|
|
padding: 12px;
|
|
background-color: #0d1117;
|
|
border: 1px solid #30363d;
|
|
border-radius: 6px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 11px;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.toggle-button {
|
|
background-color: #21262d;
|
|
color: #e6edf3;
|
|
border: 1px solid #30363d;
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.toggle-button:hover {
|
|
background-color: #30363d;
|
|
}
|
|
|
|
.collapse-toggle {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.checkbox-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
input[type="checkbox"] {
|
|
cursor: pointer;
|
|
}
|
|
|
|
select {
|
|
background-color: #0d1117;
|
|
border: 1px solid #30363d;
|
|
color: #e6edf3;
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useRef, useCallback } = React;
|
|
|
|
function McpHubAdmin() {
|
|
const [connectionStatus, setConnectionStatus] = useState('disconnected');
|
|
const [reconnectCount, setReconnectCount] = useState(0);
|
|
const [lastEventTime, setLastEventTime] = useState(null);
|
|
const [hubUrl, setHubUrl] = useState(() => localStorage.getItem('hubUrl') || 'wss://mcp.arik.work/ws/observe');
|
|
const [secret, setSecret] = useState(() => localStorage.getItem('observeSecret') || '');
|
|
const [showSettings, setShowSettings] = useState(!localStorage.getItem('observeSecret'));
|
|
|
|
const [backends, setBackends] = useState({});
|
|
const [logs, setLogs] = useState([]);
|
|
const [messages, setMessages] = useState([]);
|
|
const [logFilter, setLogFilter] = useState('');
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
const [inspectorCollapsed, setInspectorCollapsed] = useState(true);
|
|
const [selectedMessage, setSelectedMessage] = useState(null);
|
|
const [messageFilter, setMessageFilter] = useState('');
|
|
|
|
const ws = useRef(null);
|
|
const reconnectTimeout = useRef(null);
|
|
const backoffMs = useRef(1000);
|
|
const logsEndRef = useRef(null);
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
if (autoScroll && logsEndRef.current) {
|
|
setTimeout(() => {
|
|
logsEndRef.current?.scrollIntoView({ behavior: 'auto' });
|
|
}, 0);
|
|
}
|
|
}, [autoScroll]);
|
|
|
|
const addLog = useCallback((level, message, serviceId = 'system') => {
|
|
setLogs(prevLogs => {
|
|
const newLogs = [...prevLogs, {
|
|
id: Math.random(),
|
|
timestamp: new Date(),
|
|
level,
|
|
message,
|
|
serviceId
|
|
}];
|
|
return newLogs.slice(-500);
|
|
});
|
|
setLastEventTime(new Date());
|
|
}, []);
|
|
|
|
const addMessage = useCallback((timestamp, serviceId, direction, method, payload) => {
|
|
setMessages(prevMessages => {
|
|
const newMessages = [...prevMessages, {
|
|
id: Math.random(),
|
|
timestamp,
|
|
serviceId,
|
|
direction,
|
|
method,
|
|
payload
|
|
}];
|
|
return newMessages.slice(-100);
|
|
});
|
|
}, []);
|
|
|
|
const connect = useCallback(() => {
|
|
if (!secret.trim()) {
|
|
addLog('error', 'OBSERVE_SECRET not set');
|
|
return;
|
|
}
|
|
|
|
setConnectionStatus('connecting');
|
|
addLog('info', `Connecting to ${hubUrl}...`);
|
|
|
|
try {
|
|
ws.current = new WebSocket(hubUrl);
|
|
|
|
ws.current.onopen = () => {
|
|
setConnectionStatus('connected');
|
|
backoffMs.current = 1000;
|
|
setReconnectCount(0);
|
|
addLog('info', 'Connected to hub');
|
|
|
|
ws.current.send(JSON.stringify({
|
|
type: 'observe',
|
|
secret: secret.trim()
|
|
}));
|
|
addLog('info', 'Sent observe request');
|
|
};
|
|
|
|
ws.current.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
setLastEventTime(new Date());
|
|
|
|
if (data.type === 'snapshot') {
|
|
addLog('info', `Snapshot: ${Object.keys(data.backends || {}).length} backends`);
|
|
setBackends(data.backends || {});
|
|
Object.entries(data.backends || {}).forEach(([id, backend]) => {
|
|
addMessage(new Date(), id, '←', 'snapshot', backend);
|
|
});
|
|
} else if (data.type === 'backend:connected') {
|
|
const { id } = data;
|
|
addLog('info', `Backend connected: ${id}`);
|
|
setBackends(prev => ({
|
|
...prev,
|
|
[id]: {
|
|
id,
|
|
connected: true,
|
|
connectedSince: new Date(),
|
|
lastPing: new Date(),
|
|
messagesIn: 0,
|
|
messagesOut: 0
|
|
}
|
|
}));
|
|
addMessage(new Date(), id, '→', 'connected', data);
|
|
} else if (data.type === 'backend:disconnected') {
|
|
const { id } = data;
|
|
addLog('info', `Backend disconnected: ${id}`);
|
|
setBackends(prev => {
|
|
const updated = { ...prev };
|
|
if (updated[id]) {
|
|
updated[id] = { ...updated[id], connected: false };
|
|
setTimeout(() => {
|
|
setBackends(p => {
|
|
const { [id]: _, ...rest } = p;
|
|
return rest;
|
|
});
|
|
}, 10000);
|
|
}
|
|
return updated;
|
|
});
|
|
addMessage(new Date(), id, '→', 'disconnected', data);
|
|
} else if (data.type === 'message') {
|
|
const { serviceId, direction, method } = data;
|
|
addLog('info', `Message from ${serviceId}: ${method} (${direction})`);
|
|
setBackends(prev => {
|
|
const updated = { ...prev };
|
|
if (updated[serviceId]) {
|
|
updated[serviceId] = {
|
|
...updated[serviceId],
|
|
[direction === 'in' ? 'messagesIn' : 'messagesOut']:
|
|
(updated[serviceId][direction === 'in' ? 'messagesIn' : 'messagesOut'] || 0) + 1,
|
|
lastPing: new Date()
|
|
};
|
|
}
|
|
return updated;
|
|
});
|
|
addMessage(new Date(), serviceId, direction === 'in' ? '←' : '→', method, data.payload);
|
|
} else if (data.type === 'log') {
|
|
addLog(data.level || 'info', data.message, data.serviceId);
|
|
}
|
|
} catch (err) {
|
|
addLog('error', `Failed to parse message: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
ws.current.onerror = (error) => {
|
|
addLog('error', `WebSocket error: ${error.type}`);
|
|
setConnectionStatus('disconnected');
|
|
};
|
|
|
|
ws.current.onclose = () => {
|
|
setConnectionStatus('disconnected');
|
|
addLog('warn', `Disconnected. Reconnecting in ${backoffMs.current}ms...`);
|
|
reconnectTimeout.current = setTimeout(() => {
|
|
setReconnectCount(prev => prev + 1);
|
|
backoffMs.current = Math.min(backoffMs.current * 1.5, 30000);
|
|
connect();
|
|
}, backoffMs.current);
|
|
};
|
|
} catch (err) {
|
|
addLog('error', `Connection failed: ${err.message}`);
|
|
setConnectionStatus('disconnected');
|
|
}
|
|
}, [hubUrl, secret, addLog, addMessage]);
|
|
|
|
const handleSaveSettings = () => {
|
|
localStorage.setItem('hubUrl', hubUrl);
|
|
localStorage.setItem('observeSecret', secret);
|
|
setShowSettings(false);
|
|
connect();
|
|
};
|
|
|
|
const handleClearLogs = () => {
|
|
setLogs([]);
|
|
};
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [logs, scrollToBottom]);
|
|
|
|
useEffect(() => {
|
|
if (!showSettings && secret.trim()) {
|
|
connect();
|
|
}
|
|
return () => {
|
|
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
|
if (ws.current) ws.current.close();
|
|
};
|
|
}, []);
|
|
|
|
const filteredLogs = logs.filter(log => {
|
|
if (!logFilter) return true;
|
|
return log.message.toLowerCase().includes(logFilter.toLowerCase()) ||
|
|
log.serviceId.toLowerCase().includes(logFilter.toLowerCase());
|
|
});
|
|
|
|
const filteredMessages = messages.filter(msg => {
|
|
if (!messageFilter) return true;
|
|
return msg.serviceId === messageFilter;
|
|
});
|
|
|
|
const serviceIds = [...new Set(messages.map(m => m.serviceId))];
|
|
|
|
return (
|
|
<div className="app-container">
|
|
{/* Status Bar */}
|
|
<div className="status-bar">
|
|
<div className="status-indicator">
|
|
<div className={`status-dot ${connectionStatus}`}></div>
|
|
<span>{connectionStatus.toUpperCase()}</span>
|
|
</div>
|
|
<span>Reconnects: {reconnectCount}</span>
|
|
<span>Last Event: {lastEventTime ? lastEventTime.toLocaleTimeString() : 'Never'}</span>
|
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px' }}>
|
|
{showSettings && (
|
|
<>
|
|
<input
|
|
type="text"
|
|
className="hub-url-input"
|
|
placeholder="Hub WebSocket URL"
|
|
value={hubUrl}
|
|
onChange={(e) => setHubUrl(e.target.value)}
|
|
/>
|
|
<input
|
|
type="password"
|
|
className="secret-input"
|
|
placeholder="OBSERVE_SECRET"
|
|
value={secret}
|
|
onChange={(e) => setSecret(e.target.value)}
|
|
/>
|
|
<button className="button" onClick={handleSaveSettings}>Connect</button>
|
|
</>
|
|
)}
|
|
<button className="button secondary" onClick={() => setShowSettings(!showSettings)}>
|
|
{showSettings ? 'Cancel' : 'Settings'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Panels */}
|
|
<div className="content-panels">
|
|
{/* Backends Panel */}
|
|
<div className="panel" style={{ flex: '1 1 30%', minHeight: '150px' }}>
|
|
<div className="panel-header">
|
|
Connected Backends ({Object.keys(backends).length})
|
|
</div>
|
|
<div className="panel-body">
|
|
<div className="backends-grid">
|
|
{Object.entries(backends).map(([id, backend]) => (
|
|
<div key={id} className={`backend-card ${backend.connected ? '' : 'disconnected'}`}>
|
|
<div className="backend-card-title">
|
|
<div className={`backend-status-dot ${backend.connected ? 'connected' : 'disconnected'}`}></div>
|
|
<span>{id}</span>
|
|
</div>
|
|
<div className="backend-info">
|
|
<span>Connected Since:</span>
|
|
<span>
|
|
{backend.connectedSince ?
|
|
((new Date() - backend.connectedSince) / 1000).toFixed(0) + 's' : 'N/A'}
|
|
</span>
|
|
</div>
|
|
<div className="backend-info">
|
|
<span>Last Ping:</span>
|
|
<span>
|
|
{backend.lastPing ?
|
|
((new Date() - backend.lastPing) / 1000).toFixed(0) + 's ago' : 'N/A'}
|
|
</span>
|
|
</div>
|
|
<div className="backend-counter">
|
|
<span>↓ In: {backend.messagesIn || 0}</span>
|
|
<span>↑ Out: {backend.messagesOut || 0}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Log Stream Panel */}
|
|
<div className="panel" style={{ flex: '1 1 40%', minHeight: '200px' }}>
|
|
<div className="panel-header">
|
|
Log Stream ({filteredLogs.length})
|
|
</div>
|
|
<div className="panel-body">
|
|
<div className="log-stream">
|
|
<div className="log-controls">
|
|
<input
|
|
type="text"
|
|
className="log-filter"
|
|
placeholder="Filter logs..."
|
|
value={logFilter}
|
|
onChange={(e) => setLogFilter(e.target.value)}
|
|
/>
|
|
<div className="checkbox-group">
|
|
<input
|
|
type="checkbox"
|
|
id="autoScroll"
|
|
checked={autoScroll}
|
|
onChange={(e) => setAutoScroll(e.target.checked)}
|
|
/>
|
|
<label htmlFor="autoScroll">Auto-scroll</label>
|
|
</div>
|
|
<button className="button secondary" onClick={handleClearLogs}>Clear</button>
|
|
</div>
|
|
<div className="log-lines">
|
|
{filteredLogs.map(log => (
|
|
<div key={log.id} className="log-line">
|
|
<span className="log-timestamp">
|
|
{log.timestamp.toLocaleTimeString()}
|
|
</span>
|
|
<span className={`log-level ${log.level}`}>
|
|
{log.level.toUpperCase()}
|
|
</span>
|
|
<span className="log-message">{log.message}</span>
|
|
</div>
|
|
))}
|
|
<div ref={logsEndRef} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message Inspector Panel */}
|
|
<div className="panel" style={{ flex: inspectorCollapsed ? '0 0 auto' : '1 1 30%', minHeight: inspectorCollapsed ? '40px' : '150px' }}>
|
|
<div className="panel-header" onClick={() => setInspectorCollapsed(!inspectorCollapsed)}>
|
|
<span className="collapse-toggle">
|
|
{inspectorCollapsed ? '▶' : '▼'} Message Inspector ({filteredMessages.length})
|
|
</span>
|
|
</div>
|
|
{!inspectorCollapsed && (
|
|
<div className="panel-body">
|
|
<div className="message-inspector">
|
|
<div className="inspector-controls">
|
|
<select
|
|
value={messageFilter}
|
|
onChange={(e) => setMessageFilter(e.target.value)}
|
|
>
|
|
<option value="">All Services</option>
|
|
{serviceIds.map(id => (
|
|
<option key={id} value={id}>{id}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="inspector-table">
|
|
{filteredMessages.map(msg => (
|
|
<div key={msg.id}>
|
|
<div
|
|
className={`inspector-row ${selectedMessage === msg.id ? 'expanded' : ''}`}
|
|
onClick={() => setSelectedMessage(selectedMessage === msg.id ? null : msg.id)}
|
|
>
|
|
<span className="inspector-column">
|
|
{msg.timestamp.toLocaleTimeString()}
|
|
</span>
|
|
<span className="inspector-column">{msg.serviceId}</span>
|
|
<span className="inspector-direction">{msg.direction}</span>
|
|
<span className="inspector-method">{msg.method}</span>
|
|
</div>
|
|
{selectedMessage === msg.id && (
|
|
<div className="inspector-payload">
|
|
{JSON.stringify(msg.payload, null, 2)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<McpHubAdmin />);
|
|
</script>
|
|
</body>
|
|
</html>
|