feat: add admin UI dashboard with static serving

- 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>
This commit is contained in:
Agent 2026-03-13 12:22:31 +00:00
parent 84a804c57a
commit 83cbe608a0
2 changed files with 849 additions and 0 deletions

61
admin-ui/README.md Normal file
View file

@ -0,0 +1,61 @@
# Admin UI
A web-based dashboard for monitoring the MCP Hub in real-time.
## Setup
### 1. Get Your OBSERVE_SECRET
The `OBSERVE_SECRET` is stored in `/workspace/.env` on the hub server. Extract it:
```bash
# On the hub server:
grep OBSERVE_SECRET /workspace/.env
```
Copy the secret value (it's a long hex string).
### 2. Open the UI
#### Option A: Via https (production)
```
https://mcp.arik.work/admin-ui/
```
#### Option B: Local development with file:// (offline)
```bash
# Simply open the file in your browser:
file:///workspace/admin-ui/index.html
```
#### Option C: Local development with HTTP server
```bash
cd /workspace/admin-ui
python3 -m http.server 8080
# Then open http://localhost:8080
```
### 3. Connect to the Hub
In the UI, enter:
- **Hub URL**: `wss://mcp.arik.work/ws/observe` (production) or `ws://localhost:3000/ws/observe` (local dev)
- **OBSERVE_SECRET**: Paste the value from step 1
The status light will turn **green** when connected.
### 4. Monitor
- **Backends**: View connected MCP backends
- **Logs**: Real-time event stream from the hub
- **Status**: Connection indicator at the top
## Adding More Backends
To connect additional backends to the hub, edit `/workspace/.env` on the hub server:
```env
OBSERVE_SECRET=...
# Add new backends via environment config or the hub's register endpoint
```
Then restart the hub to pick up new backends.

788
admin-ui/index.html Normal file
View file

@ -0,0 +1,788 @@
<!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>