mcsm custom card.md - yumi-furry/AstralGame-cli GitHub Wiki
本文介绍如何把 AstralGame 服务器的运行状态与当前房间成员列表(名字、延迟)以一张实时 WebSocket 卡片嵌入 MCSM(MCSManager)管理面板。卡片通过 /api/ws/service 连接 CLI,实时推送服务器状态与房间事件,并支持卡片内可视化配置连接。
MCSManager 面板支持添加"自定义 HTML"类型的应用卡片,可内嵌或 iframe 加载一段独立网页。把本文提供的 HTML 页面作为该卡片的页面内容即可。
页面加载后:
- 卡片顶部展示配置区:填入 WS 地址、密钥、服务名称、服务类型后点击「连接」;
- 通过
ws://<地址>/api/ws/service?key=...&name=...&type=...建立 WebSocket 连接(系统随机密钥鉴权); - 连接成功收到
hello后,发送只读指令get_room_info获取服务器状态与成员列表(名字、延迟); - 之后持续接收
snapshot/room.created/room.joined/room.left/server.started事件实时更新房间,并定时重发get_room_info刷新成员延迟; - 点击「退出」按钮可随时断开连接,配置可重新编辑。
-
astral-server已运行,Web 管理端可访问(默认http://<服务器IP>:8080); - 需要知道第三方服务连接密钥
ws_key(可在面板「设置 → 第三方服务」查看与复制;也可在config.toml的[astral_server]中手动指定); -
get_room_info为只读指令,不受service_control_enabled开关限制,查询成员/状态无需开启控制权限。
重要:WS 地址必须填公网可达的地址。 MCSM 的 HTML 卡片本质上是一个独立的小浏览器,它在访问面板的那台机器(客户端浏览器所在环境)上发起 WebSocket 请求,而不是在 astral-server 所在服务器上发起。因此:
- 不能填
127.0.0.1或localhost——那指的是客户端本机,不是 astral-server 所在主机;- 必须填写从访问 MCSM 面板的机器能够解析并连通的地址,如公网 IP 或域名 + 端口;
- 若 astral-server 只绑定了内网 IP 或本地端口,需要为其配置公网映射 / 反向代理,确保卡片所在环境能访问该
/api/ws/service端点。
连接区可配置以下字段(自动保存到浏览器本地存储):
| 配置项 | 说明 | 示例 |
|---|---|---|
| WS 地址 |
/api/ws/service 端点的完整地址 |
ws://192.168.1.10:8080/api/ws/service |
| 服务密钥 | 面板「设置 → 第三方服务」中的 ws_key
|
a1b2c3... |
| 服务名称 | 上报的服务名称(显示在在线服务列表) | MyCard |
| 服务类型 | 上报的服务类型 | MCSM |
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AstralGame 实时卡片</title>
<style>
:root {
--bg: #ffffff;
--bg-muted: #f6f8fa;
--border: #d0d7de;
--fg: #1f2328;
--fg-muted: #656d76;
--success: #1a7f37;
--danger: #cf222e;
--warn: #9a6700;
--radius: 6px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg-muted);
padding: 12px;
color: var(--fg);
font-size: 13px;
line-height: 1.5;
}
.ag-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
max-width: 460px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(31,35,40,0.04);
}
.ag-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg-muted);
border-bottom: 1px solid var(--border);
}
.ag-card__title { font-size: 13px; font-weight: 600; letter-spacing: 0.2px; }
.ag-card__title small { font-weight: 400; color: var(--fg-muted); margin-left: 6px; }
.ag-status {
display: inline-flex; align-items: center;
font-size: 12px; font-weight: 500;
padding: 2px 8px; border-radius: 999px; border: 1px solid transparent;
}
.ag-status__dot { width: 6px; height: 6px; border-radius: 50%; margin-right: 5px; }
.ag-status--connected { color: var(--success); background: #dafbe1; border-color: #aceebb; }
.ag-status--connected .ag-status__dot { background: var(--success); }
.ag-status--disconnected { color: var(--fg-muted); background: #eef1f4; border-color: #d0d7de; }
.ag-status--disconnected .ag-status__dot { background: var(--fg-muted); }
.ag-status--error { color: var(--danger); background: #ffebe9; border-color: #ffcecb; }
.ag-status--error .ag-status__dot { background: var(--danger); }
.ag-status--loading { color: var(--warn); background: #fff8c5; border-color: #f5e08a; }
.ag-status--loading .ag-status__dot { background: var(--warn); animation: ag-pulse 1.2s infinite; }
@keyframes ag-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
.ag-config { padding: 12px 14px; border-bottom: 1px solid var(--border); display: none; }
.ag-config--open { display: block; }
.ag-config label {
display: block; font-size: 11px; color: var(--fg-muted);
margin: 8px 0 3px;
}
.ag-config input {
width: 100%; padding: 5px 8px; font-size: 12px; font-family: inherit;
border: 1px solid var(--border); border-radius: 4px; background: #fff; color: var(--fg);
}
.ag-config input:focus { outline: none; border-color: #0969da; }
.ag-card__body { padding: 14px; }
.ag-row {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 0; border-bottom: 1px dashed #eaeef2; font-size: 12.5px;
}
.ag-row:last-child { border-bottom: none; }
.ag-row__label { color: var(--fg-muted); flex-shrink: 0; margin-right: 12px; }
.ag-row__value { font-weight: 500; text-align: right; word-break: break-all; max-width: 62%; }
.ag-row__value code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
background: var(--bg-muted); padding: 1px 6px; border-radius: 4px; font-size: 11.5px;
}
.ag-empty { text-align: center; color: var(--fg-muted); padding: 16px 8px; font-size: 12.5px; }
.ag-section { font-size: 11px; color: var(--fg-muted); margin: 14px 0 6px; font-weight: 600; }
.ag-members { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.ag-member {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 10px; border-bottom: 1px solid #eaeef2; font-size: 12.5px;
}
.ag-member:last-child { border-bottom: none; }
.ag-member__name { font-weight: 500; }
.ag-member__name .ag-tag {
font-size: 10px; font-weight: 500; margin-left: 6px; padding: 1px 5px;
border-radius: 999px; background: #ddf4ff; color: #0969da;
}
.ag-member__meta { text-align: right; color: var(--fg-muted); font-size: 11.5px; }
.ag-member__latency { font-weight: 600; color: var(--fg); }
.ag-latency--good { color: var(--success); }
.ag-latency--mid { color: var(--warn); }
.ag-latency--bad { color: var(--danger); }
.ag-card__footer {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 14px; background: var(--bg-muted);
border-top: 1px solid var(--border); font-size: 11.5px; color: var(--fg-muted);
}
.ag-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
padding: 3px 10px; font-size: 12px; color: var(--fg); cursor: pointer; font-family: inherit;
}
.ag-btn:hover { background: #eef1f4; }
.ag-btn--primary { background: #0969da; border-color: #0969da; color: #fff; }
.ag-btn--primary:hover { background: #0a5cc0; }
.ag-btn--danger { background: none; border-color: #ffcecb; color: var(--danger); }
.ag-btn--danger:hover { background: #ffebe9; }
</style>
</head>
<body>
<div class="ag-card">
<div class="ag-card__header">
<div class="ag-card__title">
AstralGame <small id="agVersion">—</small>
</div>
<span class="ag-status ag-status--disconnected" id="agStatus">
<span class="ag-status__dot"></span>
<span id="agStatusText">未连接</span>
</span>
</div>
<!-- 连接配置区 -->
<div class="ag-config" id="agConfig">
<label for="inUrl">WS 地址</label>
<input id="inUrl" placeholder="ws://127.0.0.1:8080/api/ws/service">
<label for="inKey">服务密钥(ws_key)</label>
<input id="inKey" placeholder="面板「设置 → 第三方服务」中的密钥">
<label for="inName">服务名称</label>
<input id="inName" placeholder="MyCard">
<label for="inType">服务类型</label>
<input id="inType" placeholder="MCSM">
<div style="margin-top:10px;text-align:right;">
<button class="ag-btn ag-btn--primary" id="agConnectBtn">连接</button>
</div>
</div>
<div class="ag-card__body" id="agBody">
<div class="ag-empty">请点击下方「配置」填写 WS 连接信息后连接</div>
</div>
<div class="ag-card__footer">
<span id="agUpdated">—</span>
<span>
<button class="ag-btn" id="agConfigBtn">配置</button>
<button class="ag-btn ag-btn--danger" id="agExitBtn" disabled>退出</button>
</span>
</div>
</div>
<script>
(function () {
'use strict';
var STORE_KEY = 'agCardConfig';
var REFRESH_MS = 5000; // 成员列表刷新间隔
var RECONNECT_MS = 5000; // 断线自动重连间隔(0 = 关闭)
var $ = function (id) { return document.getElementById(id); };
var ws = null;
var refreshTimer = null;
var reconnectTimer = null;
var lastRoom = null; // 最近一次事件中的房间对象
var lastShareUrl = null;
var lastNode = null;
var lastRunning = false;
// ---------- 配置持久化 ----------
function loadConfig() {
try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; }
catch (e) { return {}; }
}
function saveConfig(cfg) {
try { localStorage.setItem(STORE_KEY, JSON.stringify(cfg)); } catch (e) {}
}
// ---------- 状态展示 ----------
function setStatus(kind, text) {
$('agStatus').className = 'ag-status ag-status--' + kind;
$('agStatusText').textContent = text;
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
function row(label, value) {
return '<div class="ag-row"><span class="ag-row__label">' + esc(label) +
'</span><span class="ag-row__value">' + value + '</span></div>';
}
function setUpdated() {
$('agUpdated').textContent = '更新于 ' + new Date().toLocaleTimeString('zh-CN', { hour12: false });
}
function latencyClass(ms) {
if (ms == null || ms < 0) return '';
if (ms < 80) return 'ag-latency--good';
if (ms < 200) return 'ag-latency--mid';
return 'ag-latency--bad';
}
// ---------- 渲染 ----------
function render() {
var html = '';
if (!lastRoom) {
html += '<div class="ag-empty">当前未加入任何房间</div>';
} else {
var r = lastRoom;
html += row('当前房间', esc(r.game_name || r.game_id || '—'));
if (r.short_code) html += row('短码', '<code>' + esc(r.short_code) + '</code>');
if (r.display_name) html += row('显示名', esc(r.display_name));
html += row('房主', r.is_host ? '是' : '否');
if (lastShareUrl) html += row('分享链接', '<code>' + esc(lastShareUrl) + '</code>');
}
if (lastNode) {
html += row('运行状态', lastRunning ? '运行中' : '已停止');
if (lastNode.my_ipv4) html += row('虚拟 IP', '<code>' + esc(lastNode.my_ipv4) + '</code>');
if (lastNode.total_nodes != null) html += row('节点数', esc(lastNode.total_nodes));
}
$('agBody').innerHTML = html || '<div class="ag-empty">暂无状态数据</div>';
}
function renderMembers(members) {
var list = (members || []).filter(function (m) {
// 过滤中继节点(cost>1),仅展示本机与直连真实用户
return m.cost == null || m.cost <= 1;
});
if (!list.length) return;
var html = '<div class="ag-section">在线成员(' + list.length + ')</div><div class="ag-members">';
list.forEach(function (m) {
var name = esc(m.hostname || 'unknown');
var tag = '';
if (m.peer_id === 0 || m.conn_type === 'Local') tag = '<span class="ag-tag">本机</span>';
if (m.cost > 1) tag = '<span class="ag-tag">中继</span>';
var lat = (m.latency_ms == null || m.latency_ms < 0) ? '—' : Math.round(m.latency_ms) + ' ms';
var latCls = latencyClass(m.latency_ms);
html += '<div class="ag-member">' +
'<span class="ag-member__name">' + name + tag + '</span>' +
'<span class="ag-member__meta">' +
(m.ipv4 ? '<code>' + esc(m.ipv4) + '</code> · ' : '') +
'<span class="ag-member__latency ' + latCls + '">' + lat + '</span>' +
'</span></div>';
});
html += '</div>';
// 成员区追加在房间/状态信息之后
$('agBody').insertAdjacentHTML('beforeend', html);
}
// ---------- WebSocket 数据流 ----------
function handleMessage(raw) {
var msg;
try { msg = JSON.parse(raw); } catch (e) { return; }
if (msg.event === 'hello') {
setStatus('connected', '已连接');
if (msg.id) $('agVersion').textContent = 'ID ' + String(msg.id).slice(0, 8);
requestRoomInfo(); // 连接后立即拉取成员
return;
}
if (msg.event === 'result') {
if (msg.ok && msg.data) {
lastRunning = !!msg.data.running;
if (msg.data.node) lastNode = msg.data.node;
render();
renderMembers(msg.data.members);
if (msg.data.uptime_secs != null) {
$('agBody').insertAdjacentHTML('beforeend',
row('运行时长', Math.floor(msg.data.uptime_secs / 60) + ' 分钟'));
}
setUpdated();
}
return;
}
// 快照 / 房间事件
if (msg.event === 'snapshot' || msg.event === 'server.started' ||
msg.event === 'room.created' || msg.event === 'room.joined' ||
msg.event === 'room.left') {
lastRoom = msg.room || null;
lastShareUrl = msg.share_url || null;
lastNode = msg.node || null;
lastRunning = !!msg.running;
if (msg.node && msg.node.server_version) $('agVersion').textContent = msg.node.server_version;
render();
if (msg.room) requestRoomInfo(); // 有房间则拉取成员列表
setUpdated();
}
}
function requestRoomInfo() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'get_room_info', id: 'card-' + Date.now() }));
}
}
// ---------- 连接管理 ----------
function connect() {
var cfg = loadConfig();
var base = (cfg.url || '').trim();
if (!base) { setStatus('error', '请先填写 WS 地址'); return; }
var qs = 'key=' + encodeURIComponent(cfg.key || '') +
'&name=' + encodeURIComponent(cfg.name || '') +
'&type=' + encodeURIComponent(cfg.type || '');
var url;
try {
// 只取 origin(协议+主机+端口)作为底座,一律拼 /api/ws/service,
// 避免在用户误输入的 path 上再追加一节路径。
var u = new URL(base);
var proto = u.protocol === 'https:' ? 'wss' : (u.protocol === 'http:' ? 'ws' : u.protocol);
url = proto + '//' + u.host + '/api/ws/service?' + qs;
} catch (e) { setStatus('error', '地址无效,应形如 ws://主机:端口'); return; }
setStatus('loading', '连接中…');
try {
ws = new WebSocket(url);
} catch (e) { setStatus('error', '地址无效'); return; }
ws.onopen = function () { setStatus('loading', '等待认证…'); };
ws.onmessage = function (e) { handleMessage(String(e.data)); };
ws.onerror = function () {
setStatus('error', '连接失败');
$('agBody').innerHTML =
'<div class="ag-empty">WebSocket 连接失败<br><small>请检查:端口是否与 web_port 一致、密钥是否正确、是否被混合内容/同源策略拦截</small></div>';
};
ws.onclose = function (e) {
var reason = (e && e.reason) ? String(e.reason) : '';
var code = (e && e.code != null) ? String(e.code) : '';
// code 1006 = 无正常关闭帧,通常握手阶段被拒绝(如 401/端口不通);reason 含服务端原因时优先展示。
if (reason && reason.indexOf('401') >= 0) {
setStatus('error', '密钥错误');
$('agBody').innerHTML = '<div class="ag-empty">服务端拒绝鉴权(401)<br><small>ws_key 不正确,请在面板「设置 → 第三方服务」核对密钥</small></div>';
} else if (code === '1006') {
setStatus('error', '握手失败');
$('agBody').innerHTML = '<div class="ag-empty">握手失败<br><small>端口可能未监听 / web_port 不匹配,或页面为 HTTPS 而使用了 ws://(混合内容被浏览器拦截)</small></div>';
} else {
setStatus('disconnected', '已断开');
$('agBody').innerHTML = '<div class="ag-empty">连接已关闭' +
(reason ? '<br><small>' + esc(reason) + '</small>' : '') + '</div>';
}
stopRefresh();
scheduleReconnect();
};
startRefresh();
}
function disconnect() {
clearTimeout(reconnectTimer);
reconnectTimer = null;
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
setStatus('disconnected', '已断开');
stopRefresh();
}
function startRefresh() {
stopRefresh();
refreshTimer = setInterval(requestRoomInfo, REFRESH_MS);
}
function stopRefresh() {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
}
function scheduleReconnect() {
if (RECONNECT_MS > 0) {
reconnectTimer = setTimeout(connect, RECONNECT_MS);
}
}
// ---------- 配置区交互 ----------
function openConfig() {
var cfg = loadConfig();
$('inUrl').value = cfg.url || '';
$('inKey').value = cfg.key || '';
$('inName').value = cfg.name || '';
$('inType').value = cfg.type || '';
$('agConfig').classList.add('ag-config--open');
}
$('agConfigBtn').addEventListener('click', function () {
if ($('agConfig').classList.contains('ag-config--open')) {
$('agConfig').classList.remove('ag-config--open');
} else { openConfig(); }
});
$('agConnectBtn').addEventListener('click', function () {
saveConfig({
url: $('inUrl').value.trim(),
key: $('inKey').value.trim(),
name: $('inName').value.trim(),
type: $('inType').value.trim()
});
$('agConfig').classList.remove('ag-config--open');
if (ws && ws.readyState === WebSocket.OPEN) disconnect();
connect();
});
$('agExitBtn').addEventListener('click', function () {
disconnect();
$('agExitBtn').disabled = true;
$('agConfigBtn').disabled = false;
});
// ---------- 初始化 ----------
(function init() {
var cfg = loadConfig();
$('agVersion').textContent = 'v1.4.2_4';
openConfig();
if (cfg.url) {
// 已保存过配置:自动连接
$('agConfig').classList.remove('ag-config--open');
connect();
}
})();
})();
</script>
</body>
</html>服务端推送事件(连接后被动接收):
| 事件 | 触发时机 |
|---|---|
hello |
第三方服务连接认证成功后,首帧推送(含连接 ID、名称、类型) |
snapshot |
连接建立后立即推送当前快照 |
server.started |
服务启动完成 |
room.created / room.joined / room.left
|
房间创建 / 加入 / 离开 |
卡片主动发送的指令:
{"id":"card-xxx","action":"get_room_info"}应答 event: "result",data 中包含:
| 字段 | 说明 |
|---|---|
running |
实例是否运行中 |
uptime_secs |
运行时长(秒) |
room |
当前房间对象或 null
|
share_url |
分享链接 |
node |
my_ipv4、total_nodes、error 等 |
members |
成员节点列表(含 hostname、latency_ms、ipv4、conn_type、cost 等) |
get_room_info为只读指令,不受service_control_enabled开关限制。 成员列表的cost:0= 本机,1= 直连,>1= 经中继。卡片默认过滤cost > 1的中继节点,仅展示本机与真实用户。