← back to Ticket System

server.js

118 lines

// Ticket board viewer — kanban over the shared ticket store. :9794, basic-auth admin/DW2024!, open /healthz.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
const { tickets, STATUSES, messages } = require('./lib.js');

// ── live "running" signal: online pm2 processes + live claude CLI sessions ──
// Cached 5s so N polling browsers don't each spawn a `pm2 jlist` on a busy box.
let runCache = { ts: 0, data: { pm2: [], sessions: 0, at: null } };
function getRunning(cb) {
  if (Date.now() - runCache.ts < 5000) return cb(runCache.data);
  exec('pm2 jlist', { maxBuffer: 16 * 1024 * 1024, timeout: 8000 }, (e, out) => {
    let pm2 = [];
    if (!e) { try {
      pm2 = JSON.parse(out).filter(p => p.pm2_env && p.pm2_env.status === 'online')
        .map(p => ({ name: p.name, cpu: (p.monit && p.monit.cpu) || 0,
          mem: Math.round(((p.monit && p.monit.memory) || 0) / 1048576),
          up: p.pm2_env.pm_uptime || 0, restarts: p.pm2_env.restart_time || 0 }))
        .sort((a, b) => a.name < b.name ? -1 : 1);
    } catch (_) {} }
    // count live `claude` CLI sessions (exclude skills-dir helpers), best-effort
    exec("ps -Ao command | grep '[c]laude' | grep -v 'skills/' | wc -l", { timeout: 4000 }, (e2, out2) => {
      const sessions = e2 ? 0 : (parseInt(String(out2).trim(), 10) || 0);
      runCache = { ts: Date.now(), data: { pm2, sessions, at: new Date().toISOString() } };
      cb(runCache.data);
    });
  });
}

const OFFICE_HTML = path.join(__dirname, 'office.html');

const PORT = process.env.PORT || 9794;
const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toString('base64');

const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

function page() {
  const cols = { open: [], doing: [], blocked: [], done: [] };
  for (const t of tickets().values()) cols[t.status].push(t);
  for (const k of STATUSES) cols[k].sort((a, b) => a.updated_at < b.updated_at ? 1 : -1);
  cols.done = cols.done.slice(0, 40);
  const card = t => `<div class="card" onclick="this.classList.toggle('x')">
    <div class="cid">${t.id}${t.project ? `<span class="proj">${esc(t.project)}</span>` : ''}</div>
    <div class="ttl">${esc(t.title)}</div>
    <div class="meta"><span class="who">${esc(t.assignee || 'unassigned')}</span>
      <span class="when" title="${esc(t.created_at)}">🕓 ${new Date(t.created_at).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span></div>
    <div class="thread">${t.comments.map(c => `<div class="c k-${c.kind}"><b>${esc(c.agent)}</b> <i>${esc(c.kind)}</i> ${esc(c.text)}<span class="cts">${new Date(c.ts).toLocaleString()}</span></div>`).join('')}
      ${t.actions.map(a => `<div class="c k-action"><b>${esc(a.agent)}</b> <i>action</i> ${esc(a.text)}<span class="cts">${new Date(a.ts).toLocaleString()}</span></div>`).join('') || ''}
      ${!t.comments.length && !t.actions.length ? '<div class="c none">no comments yet</div>' : ''}</div></div>`;
  // ── Direct-message conversations, grouped into threads ──
  const mm = messages();
  const rootOf = mid => { let id = mid, c = mm.get(mid); const seen = new Set([id]); while (c && c.re && mm.get(c.re)) { if (seen.has(c.re)) break; seen.add(c.re); id = c.re; c = mm.get(id); } return id; };
  const threads = new Map();
  for (const m of mm.values()) { const r = rootOf(m.mid); (threads.get(r) || threads.set(r, []).get(r)).push(m); }
  const convos = [...threads.values()].map(ms => ms.sort((a, b) => a.ts < b.ts ? -1 : 1))
    .sort((a, b) => a[a.length - 1].ts < b[b.length - 1].ts ? 1 : -1).slice(0, 40);
  const dmLine = m => `<div class="dm"><b>${esc(m.from)}</b> <span class="arw">→</span> <b>${esc(m.to)}</b>${m.ticket ? `<span class="dtk">${esc(m.ticket.replace(/^(TK-\d+).*/, '$1'))}</span>` : ''}
    <span class="dts">${new Date(m.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
    <div class="dtx">${esc(m.text)}</div></div>`;
  const convoCard = ms => `<div class="convo"><div class="chd">${esc([...new Set(ms.flatMap(m => [m.from, m.to]))].filter(Boolean).join(' ⇄ '))}<span class="cnt">${ms.length} msg${ms.length > 1 ? 's' : ''}</span></div>${ms.map(dmLine).join('')}</div>`;
  const dmPanel = `<details class="dms" open><summary>DIRECT MESSAGES <small>${mm.size} total · ${convos.length} conversations · agents talk via <code>tk dm / reply / @mention</code></small></summary>
    <div class="convos">${convos.length ? convos.map(convoCard).join('') : '<div class="c none">no direct messages yet</div>'}</div></details>`;
  return `<!doctype html><meta charset="utf-8"><title>Fleet Tickets</title><meta http-equiv="refresh" content="30">
<style>
  body{margin:0;font:14px -apple-system,sans-serif;background:#0f1115;color:#e6e6e6}
  h1{font-size:16px;margin:0;padding:14px 18px;border-bottom:1px solid #262a33;letter-spacing:.06em}
  h1 small{color:#8a93a5;font-weight:400;margin-left:10px}
  .board{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:14px;align-items:start}
  .col h2{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#8a93a5;margin:4px 2px 8px}
  .card{background:#171b22;border:1px solid #262a33;border-radius:8px;padding:10px 12px;margin-bottom:8px;cursor:pointer}
  .cid{font-weight:600;color:#6db3f2;font-size:12px}.proj{float:right;color:#8a93a5;font-weight:400}
  .ttl{margin:4px 0 6px}
  .meta{display:flex;justify-content:space-between;font-size:11px;color:#8a93a5}
  .when{white-space:nowrap}
  .thread{display:none;margin-top:8px;border-top:1px dashed #2c3140;padding-top:6px}
  .card.x .thread{display:block}
  .c{font-size:12px;margin:4px 0;color:#c6ccd8}.c i{color:#8a93a5;font-style:normal;font-size:10px;margin:0 4px}
  .c.k-note{color:#e8d48b}.c.k-action{color:#8fd49a}.c.none{color:#5b6270}
  .c.k-win{color:#34d399}.c.k-challenge{color:#e06c75}.c.k-cody{color:#f59e0b}
  .cts{display:block;font-size:10px;color:#5b6270}
  .col-doing .card{border-left:3px solid #6db3f2}.col-blocked .card{border-left:3px solid #e06c75}.col-done .card{opacity:.55}
  .dms{margin:0 14px 6px;background:#141821;border:1px solid #262a33;border-radius:8px}
  .dms>summary{cursor:pointer;padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#c9a4f2}
  .dms>summary small{text-transform:none;letter-spacing:0;color:#8a93a5;margin-left:8px;font-size:11px}
  .dms code{color:#c9a4f2;background:#1c2130;padding:1px 4px;border-radius:4px}
  .convos{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px;padding:4px 14px 14px}
  .convo{background:#171b22;border:1px solid #262a33;border-left:3px solid #a06ef2;border-radius:8px;padding:8px 10px}
  .chd{font-size:11px;color:#c9a4f2;font-weight:600;margin-bottom:6px}.chd .cnt{float:right;color:#5b6270;font-weight:400}
  .dm{font-size:12px;margin:5px 0;padding-top:5px;border-top:1px dashed #2c3140}.dm:first-of-type{border-top:0}
  .dm .arw{color:#8a93a5;margin:0 3px}.dm b{color:#cdd4e0}
  .dm .dtk{color:#6db3f2;font-size:10px;margin-left:6px}.dm .dts{float:right;color:#5b6270;font-size:10px}
  .dm .dtx{color:#c6ccd8;margin-top:2px}
</style>
<h1>FLEET TICKETS<small>every agent action rides a ticket — tk new / comment / note / log / dm / inbox / reply / @mention / take / done</small><a href="/office" style="float:right;color:#c9a4f2;text-decoration:none;font-size:13px;border:1px solid #2c3140;padding:4px 10px;border-radius:6px">🏢 3D Office →</a></h1>
${dmPanel}
<div class="board">${STATUSES.map(s => `<div class="col col-${s}"><h2>${s} (${cols[s].length})</h2>${cols[s].map(card).join('') || '<div class="c none">empty</div>'}</div>`).join('')}</div>`;
}

http.createServer((req, res) => {
  if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
  if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="tickets"' }); return res.end('auth'); }
  if (req.url === '/office' || req.url === '/office.html') {
    return fs.readFile(OFFICE_HTML, (e, buf) => {
      if (e) { res.writeHead(500); return res.end('office view missing'); }
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(buf);
    });
  }
  if (req.url === '/api/running') { return getRunning(d => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(d)); }); }
  if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...tickets().values()])); }
  if (req.url === '/api/messages') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...messages().values()])); }
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page());
}).listen(PORT, '127.0.0.1', () => {
  console.log('ticket board on :' + PORT);
  getRunning(() => {});                          // warm the pm2/sessions cache so first client fetch is instant
  setInterval(() => getRunning(() => {}), 4500); // keep it warm ahead of the 5s TTL
});