dtmrs-server 0.6.0

Distributed transaction coordinator: SAGA / TCC / two-phase messaging / XA / workflow, over HTTP and gRPC, embeddable as a library
Documentation
<!-- dtmrs 管理台。刻意做成单文件内嵌:
     没有构建步骤、没有 CDN、没有外部字体 —— 内网和离线环境都能用。 -->
<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 &lt;token&gt;</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 =>
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

// 点 gid 展开分支明细
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>