From a364418df19f5539277fe4b923685c281a5355d1 Mon Sep 17 00:00:00 2001 From: david kiania Date: Fri, 19 Jun 2026 11:49:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(tickets):=20Ticket=20explorer=20=E2=80=94?= =?UTF-8?q?=20query=20open/closed=20tickets=20by=20id/engineer/cluster/tim?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/index.html | 203 ++++++++++++++++++++++++++++--------------------- 1 file changed, 116 insertions(+), 87 deletions(-) diff --git a/src/index.html b/src/index.html index 7ef40c6..411c677 100644 --- a/src/index.html +++ b/src/index.html @@ -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 @@ -
-

Closures — daily

-
-
- -
-

Closures by engineer

-
Loading…
-
- -
-

By status

-
Loading…
-
- -
-

Clusters — Nairobi

-
Loading…
-
- -
-

Clusters — Mombasa / Voi

-
Loading…
+
+

Ticket explorer

+
+
+
+
+
+
+
+
+
+
+ +
+
Search by ticket id, engineer, cluster, state and time.
@@ -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: '
No data.
' }; - const body = rows.map(([k, v]) => `${escapeHtml(k)}${intg(v)}`).join(''); - return { n: rows.length, html: `${body}
NameCount
` }; +// ── 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 = '
No closures in window.
'; 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 ` - ${escapeHtml(o.owner)}${intg(o.closed)} - ${intg(o.breached || 0)}${mttrH}`; - }).join(''); - wrap.innerHTML = `${body}
EngineerClosedBreachMTTR h
`; - 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 - })); -} - -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); +let tkSearchRows = []; +async function loadIncSearch() { + const wrap = $('tk-x-wrap'); + wrap.innerHTML = '
Searching…
'; + 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 = '
No tickets match.
'; 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 ? `${escapeHtml(r.sla_status)}` : '—'; + const locatable = (r.lat != null && r.lng != null); + return ` + ${escapeHtml(r.ticket_id || '—')} + ${escapeHtml(r.normalized_status || '—')} + ${escapeHtml(r.cluster || '—')}${r.location_name ? ' · ' + escapeHtml(r.location_name) : ''} + ${escapeHtml(r.owner || '—')} + ${when} + ${sla} + ${mttrFmt(r.mttr)}`; + }).join(''); + wrap.innerHTML = `${body}
TicketStatusCluster · locationEngineerWhenSLAMTTR
`; + 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 = `
Search failed: ${escapeHtml(e.message || 'API unreachable')}
`; + } } // 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) {