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 @@
-
-
Clusters — Mombasa / Voi
-
+
+
Ticket explorer
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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: `
` };
+// ── 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 = `
| Engineer | Closed | Breach | MTTR h |
${body}
`;
- 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 = `
| Ticket | Status | Cluster · location | Engineer | When | SLA | MTTR |
${body}
`;
+ 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) {