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