dtmrs-server 0.5.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) }
</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 =>
  ({ '&': '&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);
};
load();
</script>