<meta charset="utf-8">
<title>dtmrs 管理台</title>
<style>
:root {
--bg: #fff; --fg: #1a1a1a; --dim: #666; --line: #e3e3e3;
--ok: #0a7c3f; --fail: #c0362c; --run: #1a63c7; --warn: #a86400;
--card: #fafafa;
}
@media (prefers-color-scheme: dark) {
:root { --bg:#16181c; --fg:#e6e6e6; --dim:#9aa0a6; --line:#2c2f36;
--ok:#4ec97e; --fail:#ff6b5e; --run:#6aa9ff; --warn:#e0a838; --card:#1d2025; }
}
* { box-sizing: border-box }
body { margin:0; padding:1.2rem; background:var(--bg); color:var(--fg);
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,
"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif; }
h1 { font-size:1.1rem; margin:0 0 .2rem }
.sub { color:var(--dim); font-size:.85rem; margin-bottom:1rem }
.bar { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; margin-bottom:.8rem }
input, select, button {
font:inherit; padding:.35rem .6rem; border:1px solid var(--line);
border-radius:6px; background:var(--bg); color:var(--fg);
}
button { cursor:pointer }
button:hover { background:var(--card) }
button.act { border-color:var(--run); color:var(--run) }
button.danger { border-color:var(--fail); color:var(--fail) }
.wrap { overflow-x:auto; border:1px solid var(--line); border-radius:8px }
table { border-collapse:collapse; width:100%; min-width:720px }
th, td { text-align:left; padding:.5rem .7rem; border-bottom:1px solid var(--line); white-space:nowrap }
th { background:var(--card); font-weight:600; font-size:.82rem; color:var(--dim) }
tr:last-child td { border-bottom:0 }
tr.row:hover { background:var(--card) }
.gid { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; cursor:pointer }
.gid:hover { text-decoration:underline }
.tag { display:inline-block; padding:.1rem .45rem; border-radius:4px; font-size:.78rem;
border:1px solid currentColor }
.succeed { color:var(--ok) } .failed { color:var(--fail) }
.submitted, .aborting { color:var(--run) } .prepared { color:var(--warn) }
.detail td { background:var(--card); white-space:normal }
.branches { width:100%; min-width:0; margin:.3rem 0 }
.branches th, .branches td { padding:.3rem .5rem; font-size:.85rem }
.reason { color:var(--fail); font-size:.85rem; margin:.3rem 0 }
.empty { padding:2rem; text-align:center; color:var(--dim) }
.msg { padding:.4rem .6rem; border-radius:6px; margin-bottom:.6rem; display:none }
.msg.show { display:block }
.msg.good { background:rgba(10,124,63,.12); color:var(--ok) }
.msg.bad { background:rgba(192,54,44,.12); color:var(--fail) }
.tk-h { margin:34px 0 10px; font-size:17px; font-weight:600 }
.tk-hint { font-size:12px; font-weight:400; color:var(--dim) }
.tk-new { margin:10px 0; padding:12px 14px; border-radius:8px;
background:rgba(56,189,248,.10); border:1px solid rgba(56,189,248,.35) }
.tk-new-t { font-size:12px; margin-bottom:7px; color:var(--dim) }
.tk-new code { font-size:13px; word-break:break-all; user-select:all }
.tk-dead { color:var(--dim); text-decoration:line-through }
</style>
<h1>dtmrs 管理台</h1>
<div class="sub" id="sub">加载中…</div>
<div class="msg" id="msg"></div>
<div class="bar">
<input id="q" placeholder="按 gid 过滤" size="22">
<select id="st">
<option value="">全部状态</option>
<option>submitted</option><option>aborting</option>
<option>prepared</option><option>succeed</option><option>failed</option>
</select>
<button onclick="load()">刷新</button>
<label><input type="checkbox" id="auto"> 自动刷新(3 秒)</label>
</div>
<div class="wrap">
<table>
<thead><tr>
<th>gid</th><th>类型</th><th>状态</th><th>创建</th><th>耗时</th><th>操作</th>
</tr></thead>
<tbody id="rows"><tr><td colspan="6" class="empty">加载中…</td></tr></tbody>
</table>
</div>
<h2 class="tk-h">访问令牌 <span class="tk-hint">业务端调 TC 用:<code>Authorization: Bearer <token></code></span></h2>
<div class="bar">
<input id="tkname" placeholder="给这个令牌起个名(比如 order-service)" size="34">
<button id="tknew">生成</button>
<span class="tk-hint">作废后最长 10 秒生效(各实例各自刷新缓存)</span>
</div>
<div id="tknew-box" class="tk-new" style="display:none">
<div class="tk-new-t">令牌明文(配了 DTMRS_TOKEN_KEY 的话,以后也能在列表里点「复制」取回)</div>
<code id="tkval"></code>
<button id="tkcopy">复制</button>
</div>
<div class="wrap">
<table>
<thead><tr>
<th>名字</th><th>ID</th><th>创建</th><th>最后使用</th><th>调用次数</th><th>最近来源</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody id="tkrows"><tr><td colspan="8" class="empty">加载中…</td></tr></tbody>
</table>
</div>
<script>
const $ = s => document.querySelector(s);
let timer = null;
function fmtTime(t) {
if (!t) return '-';
return new Date(t * 1000).toLocaleString('zh-CN', { hour12: false });
}
function fmtDur(g) {
const end = g.finish_time || Math.floor(Date.now() / 1000);
const d = end - g.create_time;
if (d < 60) return d + ' 秒';
if (d < 3600) return Math.floor(d / 60) + ' 分';
return Math.floor(d / 3600) + ' 小时';
}
function toast(text, good) {
const m = $('#msg');
m.textContent = text;
m.className = 'msg show ' + (good ? 'good' : 'bad');
setTimeout(() => m.className = 'msg', 3000);
}
async function load() {
let all;
try {
all = await (await fetch('api/dtmsvr/all')).json();
} catch (e) {
$('#sub').textContent = '连不上 TC:' + e.message;
return;
}
const kw = $('#q').value.trim(), st = $('#st').value;
const list = all.filter(g =>
(!kw || g.gid.includes(kw)) && (!st || g.status === st));
const n = s => all.filter(g => g.status === s).length;
$('#sub').textContent =
`共 ${all.length} 笔(最近 100)· 推进中 ${n('submitted') + n('aborting')}` +
` · 成功 ${n('succeed')} · 失败 ${n('failed')}`;
const tb = $('#rows');
if (!list.length) {
tb.innerHTML = '<tr><td colspan="6" class="empty">没有匹配的事务</td></tr>';
return;
}
tb.innerHTML = list.map(g => `
<tr class="row">
<td class="gid" onclick="detail('${g.gid}', this)">${esc(g.gid)}</td>
<td>${esc(g.trans_type)}</td>
<td><span class="tag ${g.status}">${g.status}</span></td>
<td>${fmtTime(g.create_time)}</td>
<td>${fmtDur(g)}</td>
<td>
${isFinal(g) ? '' :
`<button class="act" onclick="act('retry','${g.gid}')">立刻重试</button>
<button class="danger" onclick="act('abort','${g.gid}')">中止</button>`}
</td>
</tr>`).join('');
}
const isFinal = g => g.status === 'succeed' || g.status === 'failed';
const esc = s => String(s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
async function detail(gid, cell) {
const tr = cell.parentElement;
if (tr.nextElementSibling?.classList.contains('detail')) {
tr.nextElementSibling.remove();
return;
}
const g = await (await fetch('api/dtmsvr/query?gid=' + encodeURIComponent(gid))).json();
const rows = (g.branches || []).map(b => `
<tr><td>${esc(b.branch_id)}</td><td>${esc(b.op)}</td>
<td><span class="tag ${b.status}">${b.status}</span></td>
<td>${esc(b.url)}</td></tr>`).join('');
const el = document.createElement('tr');
el.className = 'detail';
el.innerHTML = `<td colspan="6">
${g.rollback_reason ? `<div class="reason">回滚原因:${esc(g.rollback_reason)}</div>` : ''}
<table class="branches">
<thead><tr><th>分支</th><th>操作</th><th>状态</th><th>地址</th></tr></thead>
<tbody>${rows || '<tr><td colspan="4">还没有分支</td></tr>'}</tbody>
</table></td>`;
tr.after(el);
}
async function act(what, gid) {
if (what === 'abort' && !confirm(`确定要中止 ${gid} 吗?会触发逆序补偿。`)) return;
const path = what === 'retry' ? 'api/dtmsvr/retry' : 'api/dtmsvr/abort';
try {
const r = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ gid })
});
const j = await r.json();
toast(j.dtm_result === 'SUCCESS'
? (what === 'retry' ? '已排到队首,推进器马上会捞它' : '已标记中止')
: ('操作失败:' + (j.message || j.dtm_result)),
j.dtm_result === 'SUCCESS');
} catch (e) {
toast('请求失败:' + e.message, false);
}
load();
}
$('#q').oninput = load;
$('#st').onchange = load;
$('#auto').onchange = e => {
clearInterval(timer);
if (e.target.checked) timer = setInterval(load, 3000);
};
const tkTime = t => t ? new Date(t * 1000).toLocaleString() : '—';
async function loadTokens() {
try {
const r = await fetch('api/admin/tokens');
if (!r.ok) {
$('#tkrows').innerHTML = '<tr><td colspan="8" class="empty">' +
(r.status === 403 ? '令牌管理只能在管理台里操作' : '读取失败') + '</td></tr>';
return;
}
const list = await r.json();
if (!list.length) {
$('#tkrows').innerHTML = '<tr><td colspan="8" class="empty">还没有令牌</td></tr>';
return;
}
$('#tkrows').innerHTML = list.map(t => {
const dead = t.revoked !== 0;
return '<tr' + (dead ? ' class="tk-dead"' : '') + '>' +
'<td>' + esc(t.name) + '</td>' +
'<td><code>' + esc(t.id) + '</code></td>' +
'<td>' + tkTime(t.create_time) + '</td>' +
'<td>' + tkTime(t.last_used) + '</td>' +
'<td>' + t.use_count + '</td>' +
'<td>' + esc(t.last_ip || '—') + '</td>' +
'<td>' + (dead ? '已作废 ' + tkTime(t.revoked) : '有效') + '</td>' +
'<td>' +
(t.revealable ? '<button onclick="copyToken(\'' + t.id + '\')">复制</button> ' : '') +
(dead ? '' : '<button onclick="revokeToken(\'' + t.id + '\')">作废</button>') +
'</td>' +
'</tr>';
}).join('');
} catch (e) {
$('#tkrows').innerHTML = '<tr><td colspan="8" class="empty">' + esc(e.message) + '</td></tr>';
}
}
$('#tknew').onclick = async () => {
const name = $('#tkname').value.trim();
const r = await fetch('api/admin/tokens/create', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name })
});
if (!r.ok) { toast('生成失败:' + await r.text(), false); return; }
const j = await r.json();
$('#tkval').textContent = j.token;
$('#tknew-box').style.display = '';
$('#tkname').value = '';
toast('已生成', true);
loadTokens();
};
$('#tkcopy').onclick = () => {
navigator.clipboard.writeText($('#tkval').textContent)
.then(() => toast('已复制', true), () => toast('复制失败,请手动选中', false));
};
async function copyToken(id) {
const r = await fetch('api/admin/tokens/reveal', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id })
});
if (!r.ok) { toast(await r.text(), false); return; }
const j = await r.json();
navigator.clipboard.writeText(j.token)
.then(() => toast('已复制到剪贴板', true),
() => { $('#tkval').textContent = j.token; $('#tknew-box').style.display = ''; });
}
async function revokeToken(id) {
if (!confirm('作废之后用这个令牌的服务会立刻开始收到 401,确定?')) return;
const r = await fetch('api/admin/tokens/revoke', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id })
});
toast(r.ok ? '已作废' : ('失败:' + await r.text()), r.ok);
loadTokens();
}
load();
loadTokens();
</script>