<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) }
</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>
<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);
};
load();
</script>