feat(tickets): Ticket explorer — query open/closed tickets by id/engineer/cluster/time

Replace the five static bottom panels (closures-daily, closures-by-engineer,
by-status, cluster tables) with a queryable Ticket explorer: filter bar (Ticket
ID, Engineer, Cluster, State [Closed/Open/All], Time [today/week/month/all/
custom]) + a results table (ticket, status, cluster·location, engineer, when,
SLA pill, MTTR). Clicking a row flies the map to that ticket and opens its popup;
rows with no geom are flagged non-locatable. Backed by GET /webhook/inc-search
(fn_inc_search). EAT preset ranges resolved client-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
david kiania 2026-06-19 11:49:00 +03:00
parent 2d7cc98d4b
commit a364418df1

View file

@ -127,6 +127,7 @@
.btn.ghost:hover { color: var(--text); border-color: var(--muted); }
.ff.custom { display: none; }
.ff.custom.show { display: flex; }
.x-filters { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 14px; }
/* ── Content grid ────────────────────────────────────────────────────── */
main { padding: 16px 18px 40px; display: grid; gap: 16px; grid-template-columns: repeat(12, 1fr); }
@ -434,29 +435,31 @@
</div>
</div>
<div class="card span3">
<h2>Closures — daily</h2>
<div class="chart-wrap"><canvas id="tk-closureChart"></canvas></div>
<div class="card span12">
<h2>Ticket explorer <span class="count" id="tk-x-count"></span></h2>
<div class="x-filters">
<div class="ff"><label for="tk-x-id">Ticket ID</label><input type="text" id="tk-x-id" placeholder="WOT…" autocomplete="off"></div>
<div class="ff"><label for="tk-x-eng">Engineer</label><input type="text" id="tk-x-eng" placeholder="name…" autocomplete="off"></div>
<div class="ff"><label for="tk-x-cluster">Cluster</label><select id="tk-x-cluster"><option value="">All clusters</option></select></div>
<div class="ff"><label for="tk-x-state">State</label>
<select id="tk-x-state">
<option value="closed" selected>Closed</option>
<option value="open">Open</option>
<option value="all">All</option>
</select></div>
<div class="ff"><label for="tk-x-window">Time</label>
<select id="tk-x-window">
<option value="today">Today</option>
<option value="week">This week</option>
<option value="month" selected>This month</option>
<option value="all">All time</option>
<option value="custom">Custom range</option>
</select></div>
<div class="ff custom" id="tk-x-ff-start"><label for="tk-x-start">Start</label><input type="date" id="tk-x-start"></div>
<div class="ff custom" id="tk-x-ff-end"><label for="tk-x-end">End</label><input type="date" id="tk-x-end"></div>
<button class="btn" id="tk-x-search" type="button">Search</button>
</div>
<div class="card span3">
<h2>Closures by engineer <span class="count" id="tk-owner-count"></span></h2>
<div class="tbl-scroll" id="tk-owner-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span3">
<h2>By status <span class="count" id="tk-status-count"></span></h2>
<div class="tbl-scroll" id="tk-status-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span3">
<h2>Clusters — Nairobi <span class="count" id="tk-nbo-count"></span></h2>
<div class="tbl-scroll" id="tk-nbo-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span3">
<h2>Clusters — Mombasa / Voi <span class="count" id="tk-coast-count"></span></h2>
<div class="tbl-scroll" id="tk-coast-wrap"><div class="empty">Loading…</div></div>
<div class="tbl-scroll" id="tk-x-wrap"><div class="empty">Search by ticket id, engineer, cluster, state and time.</div></div>
</div>
</main>
</section>
@ -1084,6 +1087,16 @@ function initIncMap() {
$('tk-refresh').addEventListener('click', loadInc);
$('tk-layers-toggle').addEventListener('click', () => $('tk-layers').classList.toggle('collapsed'));
// Ticket explorer controls
$('tk-x-window').addEventListener('change', () => {
const c = $('tk-x-window').value === 'custom';
$('tk-x-ff-start').classList.toggle('show', c);
$('tk-x-ff-end').classList.toggle('show', c);
});
$('tk-x-search').addEventListener('click', loadIncSearch);
['tk-x-id', 'tk-x-eng'].forEach((id) =>
$(id).addEventListener('keydown', (e) => { if (e.key === 'Enter') loadIncSearch(); }));
tkMap = new maplibregl.Map({
container: 'tk-map', style: BASEMAP, center: [37.5, -1.1], zoom: 6,
attributionControl: { compact: true },
@ -1164,71 +1177,88 @@ function renderIncMetrics(m, freshness) {
$('tk-fresh').textContent = fr ? `updated ${eatShort(fr.ingested_at)} · ${intg(fr.records_ingested)} records` : '';
}
function incTable(obj) {
const rows = Object.entries(obj || {}).filter(([k]) => k !== '').sort((a, b) => b[1] - a[1]);
if (!rows.length) return { n: 0, html: '<div class="empty">No data.</div>' };
const body = rows.map(([k, v]) => `<tr><td class="plate">${escapeHtml(k)}</td><td>${intg(v)}</td></tr>`).join('');
return { n: rows.length, html: `<table><thead><tr><th>Name</th><th>Count</th></tr></thead><tbody>${body}</tbody></table>` };
// ── Ticket explorer (search) — GET /webhook/inc-search ──────────────────────
// Ad-hoc lookup of tickets by id / engineer / cluster / state / time (historical +
// current). Renders a results table; clicking a row flies the map to that ticket.
const _tkPill = (s) => s === 'Compliant' ? 'good' : (s === 'Breached' ? 'bad' : '');
// EAT calendar bounds (ISO strings with +03:00) for the preset windows.
function eatISO(y, m, d) { // m = 1-based; Date.UTC handles overflow
const dt = new Date(Date.UTC(y, m - 1, d));
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}T00:00:00+03:00`;
}
function renderIncTables(m) {
m = m || {};
const s = incTable(m.by_status);
$('tk-status-wrap').innerHTML = s.html; $('tk-status-count').textContent = s.n ? `(${s.n})` : '';
// Split the cluster breakdown into Nairobi vs Mombasa/Voi (Coast) by cluster name.
const nbo = {}, coast = {};
for (const [name, n] of Object.entries(m.by_cluster || {})) (clusterIsCoast(name) ? coast : nbo)[name] = n;
const cn = incTable(nbo), cc = incTable(coast);
$('tk-nbo-wrap').innerHTML = cn.html; $('tk-nbo-count').textContent = cn.n ? `(${cn.n})` : '';
$('tk-coast-wrap').innerHTML = cc.html; $('tk-coast-count').textContent = cc.n ? `(${cc.n})` : '';
renderIncOwners(m.by_owner);
function eatTodayParts() {
const p = {}; const wmap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
for (const x of new Intl.DateTimeFormat('en-CA', { timeZone: 'Africa/Nairobi', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' }).formatToParts(new Date()))
p[x.type] = x.value;
return { y: +p.year, m: +p.month, d: +p.day, dow: wmap[p.weekday] };
}
function presetRange(w) {
const { y, m, d, dow } = eatTodayParts();
if (w === 'today') return [eatISO(y, m, d), eatISO(y, m, d + 1)];
if (w === 'week') { const mon = d - ((dow + 6) % 7); return [eatISO(y, m, mon), eatISO(y, m, mon + 7)]; }
if (w === 'month') return [eatISO(y, m, 1), eatISO(y, m + 1, 1)];
return [null, null]; // all time
}
function incSearchQs() {
const p = new URLSearchParams();
const id = $('tk-x-id').value.trim(); if (id) p.set('ticket_id', id);
const eng = $('tk-x-eng').value.trim(); if (eng) p.set('owner', eng);
if ($('tk-x-cluster').value) p.set('cluster', $('tk-x-cluster').value);
p.set('state', $('tk-x-state').value || 'closed');
const w = $('tk-x-window').value;
if (w === 'custom') {
if ($('tk-x-start').value) p.set('from', $('tk-x-start').value + 'T00:00:00+03:00');
if ($('tk-x-end').value) p.set('to', addDay($('tk-x-end').value) + 'T00:00:00+03:00');
} else if (w !== 'all') {
const [f, t] = presetRange(w);
if (f) p.set('from', f); if (t) p.set('to', t);
}
return p.toString();
}
// Closures-by-engineer leaderboard (metrics.by_owner — owner case-normalized in SQL).
// Clicking a row toggles a drill-down that filters the closed map pins to that engineer.
function renderIncOwners(arr) {
arr = arr || [];
$('tk-owner-count').textContent = arr.length ? `(${arr.length})` : '';
const wrap = $('tk-owner-wrap');
if (!arr.length) { wrap.innerHTML = '<div class="empty">No closures in window.</div>'; return; }
const body = arr.map((o) => {
const hl = (o.owner === tkOwnerFilter) ? ' style="background:rgba(232,149,74,.14)"' : '';
const mttrH = (o.avg_mttr_min != null) ? num(o.avg_mttr_min / 60, 1) : '—';
return `<tr data-owner="${escapeHtml(o.owner)}"${hl} style="cursor:pointer">
<td class="plate">${escapeHtml(o.owner)}</td><td>${intg(o.closed)}</td>
<td>${intg(o.breached || 0)}</td><td>${mttrH}</td></tr>`;
let tkSearchRows = [];
async function loadIncSearch() {
const wrap = $('tk-x-wrap');
wrap.innerHTML = '<div class="empty">Searching…</div>';
try {
const j = await api(`/webhook/inc-search?${incSearchQs()}`);
tkSearchRows = (j && j.rows) || [];
const count = (j && j.count) || 0;
$('tk-x-count').textContent = j ? (j.truncated ? `(first ${tkSearchRows.length} of ${intg(count)})` : `(${intg(count)})`) : '';
if (!tkSearchRows.length) { wrap.innerHTML = '<div class="empty">No tickets match.</div>'; return; }
const body = tkSearchRows.map((r, i) => {
const when = r.is_actionable ? eatShort(r.created_at_service) : eatShort(r.closed_at);
const sla = r.sla_status ? `<span class="pill ${_tkPill(r.sla_status)}">${escapeHtml(r.sla_status)}</span>` : '—';
const locatable = (r.lat != null && r.lng != null);
return `<tr data-i="${i}" style="cursor:${locatable ? 'pointer' : 'default'}" title="${locatable ? 'Locate on map' : 'No map location'}">
<td class="plate">${escapeHtml(r.ticket_id || '—')}</td>
<td>${escapeHtml(r.normalized_status || '—')}</td>
<td class="dim">${escapeHtml(r.cluster || '—')}${r.location_name ? ' · ' + escapeHtml(r.location_name) : ''}</td>
<td>${escapeHtml(r.owner || '—')}</td>
<td class="dim">${when}</td>
<td>${sla}</td>
<td>${mttrFmt(r.mttr)}</td></tr>`;
}).join('');
wrap.innerHTML = `<table><thead><tr><th>Engineer</th><th>Closed</th><th>Breach</th><th>MTTR h</th></tr></thead><tbody>${body}</tbody></table>`;
wrap.querySelectorAll('tr[data-owner]').forEach((tr) => tr.addEventListener('click', () => {
const o = tr.getAttribute('data-owner');
tkOwnerFilter = (tkOwnerFilter === o) ? null : o; // toggle off if re-clicked
if (tkOwnerFilter && tkMap && !tkLayerState.closed) { // make sure closures are visible
tkLayerState.closed = true; tkMap.setLayoutProperty('inc-closed', 'visibility', 'visible'); buildIncLayers();
}
renderIncOwners(arr); // re-highlight selection
renderIncMap(); // re-filter the closed pins
wrap.innerHTML = `<table><thead><tr><th>Ticket</th><th>Status</th><th>Cluster · location</th><th>Engineer</th><th>When</th><th>SLA</th><th>MTTR</th></tr></thead><tbody>${body}</tbody></table>`;
wrap.querySelectorAll('tr[data-i]').forEach((tr) => tr.addEventListener('click', () => {
const r = tkSearchRows[+tr.getAttribute('data-i')];
if (!r || r.lat == null || r.lng == null || !tkMap) return;
tkMap.flyTo({ center: [r.lng, r.lat], zoom: 15 });
showIncPopup({
properties: {
ticket_id: r.ticket_id, normalized_status: r.normalized_status, cluster: r.cluster,
region: r.region, location_name: r.location_name, owner: r.owner, assigned_team: r.assigned_team,
sla_status: r.sla_status, closed_at: r.closed_at, mttr: r.mttr,
__lat: r.lat, __lng: r.lng,
},
geometry: { type: 'Point', coordinates: [r.lng, r.lat] },
}, !r.is_actionable);
}));
} catch (e) {
console.error(e);
wrap.innerHTML = `<div class="empty">Search failed: ${escapeHtml(e.message || 'API unreachable')}</div>`;
}
function renderClosureChart(cr) {
const series = (cr && cr.series) || [];
const labels = series.map(d => d.day), data = series.map(d => Number(d.count || 0));
const css = getComputedStyle(document.documentElement);
const accent = css.getPropertyValue('--accent').trim();
const muted = css.getPropertyValue('--muted').trim();
const border = css.getPropertyValue('--border').trim();
const cfg = {
data: { labels, datasets: [{ type: 'bar', label: 'Closures', data, backgroundColor: accent + 'cc', borderRadius: 3 }] },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: border }, border: { color: border }, ticks: { color: muted, maxRotation: 0, autoSkip: true } },
y: { grid: { color: border }, border: { color: border }, ticks: { color: muted, precision: 0 }, beginAtZero: true },
},
},
};
if (tkClosureChart) { tkClosureChart.data = cfg.data; tkClosureChart.options = cfg.options; tkClosureChart.update(); }
else tkClosureChart = new Chart($('tk-closureChart'), cfg);
}
// Populate the Cluster / Status filters from the first unfiltered response.
@ -1238,6 +1268,7 @@ function initIncDropdowns(m) {
Object.keys(obj || {}).filter(k => k !== '').sort().forEach(k => el.add(new Option(k, k)));
};
fill('tk-cluster', m.by_cluster);
fill('tk-x-cluster', m.by_cluster);
fill('tk-status', m.by_status);
}
@ -1249,8 +1280,6 @@ async function loadInc() {
tkLoadedDay = eatDay();
if (!incDropdownsInit && j.metrics) { initIncDropdowns(j.metrics); incDropdownsInit = true; }
renderIncMetrics(j.metrics, j.freshness);
renderIncTables(j.metrics);
renderClosureChart(j.metrics && j.metrics.closure_rate);
renderIncMap(); // fan out co-located pins so open (vivid) + closed (faded) both show
buildIncLayers();
} catch (e) {