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:
parent
2d7cc98d4b
commit
a364418df1
1 changed files with 116 additions and 87 deletions
203
src/index.html
203
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 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card span3">
|
||||
<h2>Closures — daily</h2>
|
||||
<div class="chart-wrap"><canvas id="tk-closureChart"></canvas></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 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="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>`;
|
||||
}).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
|
||||
}));
|
||||
}
|
||||
|
||||
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 = '<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>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>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue