const API_BASE = "https://backend.kbnet.si";
const EVENT_LIMIT = 1000;
const SAME_ORIGIN_BASE = window.location.origin;
const URL_API_BASE_OVERRIDE = new URLSearchParams(window.location.search).get("apiBase");
const RUNTIME_API_BASE = (typeof window.GUNSHOT_API_BASE === "string" && window.GUNSHOT_API_BASE.trim())
|| (typeof URL_API_BASE_OVERRIDE === "string" && URL_API_BASE_OVERRIDE.trim())
|| "";
const API_BASE_CANDIDATES = Array.from(
new Set([
RUNTIME_API_BASE,
API_BASE,
SAME_ORIGIN_BASE
].filter(Boolean))
);
let activeApiBase = API_BASE_CANDIDATES[0] || "";
let map;
let markersLayer;
let selectionLayer;
let alertFxLayer;
let selectedHalo;
let mapHeatLayer;
let selectedEventKey = null;
let hasAutoFitted = false;
let mapWindowMinutes = 60;
let allEvents = [];
let markerByKey = new Map();
let eventByKey = new Map();
let timelineChart;
let nodeChart;
let shotTimelineChart;
let hourlyHeatmapChart;
let showHourlyHeatmap = false;
let lastSeenEventTime = 0;
let lastSeenEventSignature = null;
let lastAlertEventKey = null;
let alertHideTimeout;
let showMapHeatmap = true;
let liveTickerEntries = [];
const timelineGranularitySelect = document.getElementById("timelineGranularity");
const webhookAlert = document.getElementById("webhookAlert");
const webhookAlertText = document.getElementById("webhookAlertText");
const alertSeeMapBtn = document.getElementById("alertSeeMapBtn");
const themeToggle = document.getElementById("themeToggle");
const toggleHourlyHeatmap = document.getElementById("toggleHourlyHeatmap");
const exportPdfBtn = document.getElementById("exportPdfBtn");
const exportCsvBtn = document.getElementById("exportCsvBtn");
const toggleMapHeatmapBtn = document.getElementById("toggleMapHeatmap");
const mapWindowButtons = Array.from(document.querySelectorAll(".map-window-btn"));
const liveTickerTrack = document.getElementById("liveTickerTrack");
const riskStateBadge = document.getElementById("riskStateBadge");
const riskStateText = document.getElementById("riskStateText");
const streak10mDisplay = document.getElementById("streak10m");
const hottestNodeDisplay = document.getElementById("hottestNode");
function normalizeBase(base) {
return String(base || "").replace(/\/+$/, "");
}
function buildApiUrl(base, path) {
const normalizedBase = normalizeBase(base);
const normalizedPath = String(path || "").startsWith("/") ? path : `/${path}`;
return `${normalizedBase}${normalizedPath}`;
}
function createDetectionClusterLayer() {
if (typeof L.markerClusterGroup !== "function") {
return L.layerGroup();
}
return L.markerClusterGroup({
chunkedLoading: true,
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
disableClusteringAtZoom: 17,
maxClusterRadius: 56,
iconCreateFunction(cluster) {
const count = cluster.getChildCount();
const toneClass = count < 3 ? "low" : count < 6 ? "mid" : count < 10 ? "high" : "critical";
return L.divIcon({
html: `<div class="cluster-badge"><span class="cluster-count">${count}</span></div>`,
className: `detection-cluster detection-cluster-${toneClass}`,
iconSize: L.point(48, 48)
});
}
});
}
async function fetchFromApi(path, options) {
const preferred = activeApiBase;
const candidates = preferred
? [preferred, ...API_BASE_CANDIDATES.filter((base) => base !== preferred)]
: [...API_BASE_CANDIDATES];
const failures = [];
for (const base of candidates) {
try {
const response = await fetch(buildApiUrl(base, path), options);
if (!response.ok) {
throw new Error(`${path} failed on ${base} (${response.status})`);
}
if (base !== activeApiBase) {
console.info(`Switched API base to ${base}`);
}
activeApiBase = base;
return response;
}
catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
}
}
throw new Error(`All API bases failed for ${path}: ${failures.join(" | ")}`);
}
const shotAxisFormatter = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
const chartThemePlugin = {
id: "chartThemePlugin",
beforeDraw(chart, _args, opts) {
const { ctx, chartArea } = chart;
if (!chartArea || !opts?.backgroundColor) {
return;
}
ctx.save();
ctx.fillStyle = opts.backgroundColor;
ctx.fillRect(
chartArea.left,
chartArea.top,
chartArea.right - chartArea.left,
chartArea.bottom - chartArea.top
);
ctx.restore();
}
};
if (typeof Chart !== "undefined") {
Chart.register(chartThemePlugin);
}
function getChartThemePalette() {
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
if (isDark) {
return {
text: "#e2e8f0",
gridStrong: "rgba(148, 163, 184, 0.2)",
gridSoft: "rgba(148, 163, 184, 0.12)",
chartBg: "rgba(15, 23, 42, 0.5)",
timelineLine: "#fb923c",
timelineFill: "rgba(251, 146, 60, 0.2)",
nodeFill: "rgba(45, 212, 191, 0.72)",
nodeBorder: "#2dd4bf",
shotPointFill: "rgba(251, 146, 60, 0.75)",
shotPointBorder: "#fb923c",
trendLine: "rgba(52, 211, 153, 0.95)",
heatmapFill: "rgba(251, 146, 60, 0.55)",
heatmapBorder: "#fb923c"
};
}
return {
text: "#425346",
gridStrong: "rgba(39, 59, 43, 0.1)",
gridSoft: "rgba(39, 59, 43, 0.06)",
chartBg: "rgba(255, 255, 255, 0.55)",
timelineLine: "#df5f2d",
timelineFill: "rgba(223, 95, 45, 0.2)",
nodeFill: "rgba(26, 109, 90, 0.8)",
nodeBorder: "#1a6d5a",
shotPointFill: "rgba(223, 95, 45, 0.75)",
shotPointBorder: "#df5f2d",
trendLine: "rgba(26, 109, 90, 0.95)",
heatmapFill: "rgba(223, 95, 45, 0.6)",
heatmapBorder: "#df5f2d"
};
}
function applyChartTheme() {
const palette = getChartThemePalette();
if (timelineChart) {
timelineChart.data.datasets[0].borderColor = palette.timelineLine;
timelineChart.data.datasets[0].backgroundColor = palette.timelineFill;
timelineChart.options.plugins.chartThemePlugin = { backgroundColor: palette.chartBg };
timelineChart.options.scales.y.grid.color = palette.gridStrong;
timelineChart.options.scales.x.grid.color = palette.gridSoft;
timelineChart.options.scales.y.ticks.color = palette.text;
timelineChart.options.scales.x.ticks.color = palette.text;
timelineChart.update("none");
}
if (nodeChart) {
nodeChart.data.datasets[0].backgroundColor = palette.nodeFill;
nodeChart.data.datasets[0].borderColor = palette.nodeBorder;
nodeChart.options.plugins.chartThemePlugin = { backgroundColor: palette.chartBg };
nodeChart.options.scales.x.grid.color = palette.gridStrong;
nodeChart.options.scales.x.ticks.color = palette.text;
nodeChart.options.scales.y.ticks.color = palette.text;
nodeChart.update("none");
}
if (shotTimelineChart) {
shotTimelineChart.data.datasets[0].pointBackgroundColor = palette.shotPointFill;
shotTimelineChart.data.datasets[0].pointBorderColor = palette.shotPointBorder;
shotTimelineChart.data.datasets[1].borderColor = palette.trendLine;
shotTimelineChart.data.datasets[1].backgroundColor = "rgba(26, 109, 90, 0.2)";
shotTimelineChart.options.plugins.chartThemePlugin = { backgroundColor: palette.chartBg };
shotTimelineChart.options.plugins.legend.labels.color = palette.text;
shotTimelineChart.options.scales.y.grid.color = palette.gridStrong;
shotTimelineChart.options.scales.x.grid.color = palette.gridSoft;
shotTimelineChart.options.scales.y.ticks.color = palette.text;
shotTimelineChart.options.scales.x.ticks.color = palette.text;
shotTimelineChart.options.scales.y.title.color = palette.text;
shotTimelineChart.options.scales.x.title.color = palette.text;
shotTimelineChart.update("none");
}
if (hourlyHeatmapChart) {
if (hourlyHeatmapChart.data.datasets[0]) {
hourlyHeatmapChart.data.datasets[0].backgroundColor = palette.heatmapFill;
hourlyHeatmapChart.data.datasets[0].borderColor = palette.heatmapBorder;
}
hourlyHeatmapChart.options.plugins.chartThemePlugin = { backgroundColor: palette.chartBg };
hourlyHeatmapChart.options.scales.y.ticks.color = palette.text;
hourlyHeatmapChart.options.scales.x.ticks.color = palette.text;
hourlyHeatmapChart.options.scales.y.title.color = palette.text;
hourlyHeatmapChart.options.scales.x.title.color = palette.text;
hourlyHeatmapChart.options.scales.y.grid.color = palette.gridStrong;
hourlyHeatmapChart.options.scales.x.grid.color = palette.gridSoft;
hourlyHeatmapChart.update("none");
}
}
function initMap() {
map = L.map("map").setView([46.0569, 14.5058], 13); // Ljubljana example
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "© OpenStreetMap contributors"
}).addTo(map);
markersLayer = createDetectionClusterLayer().addTo(map);
selectionLayer = L.layerGroup().addTo(map);
alertFxLayer = L.layerGroup().addTo(map);
if (typeof L.heatLayer === "function") {
mapHeatLayer = L.heatLayer([], {
radius: 26,
blur: 20,
maxZoom: 17,
gradient: {
0.2: "#1a6d5a",
0.45: "#22c55e",
0.7: "#f59e0b",
1.0: "#ef4444"
}
}).addTo(map);
}
map.on("click", clearSelectedEvent);
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/\"/g, """)
.replace(/'/g, "'");
}
function getRiskState(events) {
const now = Date.now();
const recentGunshots = events.filter((event) => {
const timestamp = toEventTimestamp(event);
if (timestamp === null || timestamp < now - (5 * 60 * 1000)) {
return false;
}
return String(event.label || "").toLowerCase().includes("gunshot");
});
if (recentGunshots.length === 0) {
return {
state: "calm",
score: 0,
count: 0,
avgConfidence: 0
};
}
const avgConfidence = recentGunshots.reduce((sum, event) => sum + Number(event.confidence || 0), 0) / recentGunshots.length;
const velocityFactor = Math.min(1, recentGunshots.length / 8);
const score = (avgConfidence * 0.75) + (velocityFactor * 0.25);
if (recentGunshots.length >= 6 || score >= 0.82) {
return { state: "critical", score, count: recentGunshots.length, avgConfidence };
}
if (recentGunshots.length >= 3 || score >= 0.56) {
return { state: "elevated", score, count: recentGunshots.length, avgConfidence };
}
return { state: "calm", score, count: recentGunshots.length, avgConfidence };
}
function updateRiskMood(events) {
const risk = getRiskState(events);
document.documentElement.setAttribute("data-risk", risk.state);
if (riskStateBadge) {
riskStateBadge.textContent = risk.state.toUpperCase();
}
if (riskStateText) {
if (risk.state === "critical") {
riskStateText.textContent = `${risk.count} detections in last 5m. Immediate attention advised.`;
}
else if (risk.state === "elevated") {
riskStateText.textContent = `${risk.count} detections in last 5m. Activity rising.`;
}
else {
riskStateText.textContent = "Area stable. Monitoring live signal.";
}
}
}
function updateLiveMetrics(events) {
const now = Date.now();
const gunshotsLast10m = events.filter((event) => {
const timestamp = toEventTimestamp(event);
if (timestamp === null || timestamp < now - (10 * 60 * 1000)) {
return false;
}
return String(event.label || "").toLowerCase().includes("gunshot");
});
const nodeCounts = new Map();
events.forEach((event) => {
const timestamp = toEventTimestamp(event);
if (timestamp === null || timestamp < now - (60 * 60 * 1000)) {
return;
}
if (!String(event.label || "").toLowerCase().includes("gunshot")) {
return;
}
const node = event.node_id || "unknown-node";
nodeCounts.set(node, (nodeCounts.get(node) || 0) + 1);
});
let hottestNode = "-";
let hottestNodeCount = 0;
nodeCounts.forEach((count, node) => {
if (count > hottestNodeCount) {
hottestNodeCount = count;
hottestNode = node;
}
});
if (streak10mDisplay) {
streak10mDisplay.textContent = String(gunshotsLast10m.length);
}
if (hottestNodeDisplay) {
hottestNodeDisplay.textContent = hottestNodeCount > 0 ? `${hottestNode} (${hottestNodeCount})` : "-";
}
}
function upsertTickerEvent(event, eventKey) {
if (!event || !eventKey) {
return;
}
if (liveTickerEntries.some((entry) => entry.key === eventKey)) {
return;
}
liveTickerEntries.unshift({
key: eventKey,
node: event.node_id || "unknown-node",
label: toDisplayLabel(event.label),
confidence: Number(event.confidence || 0),
time: new Date(event.received_at).toLocaleTimeString()
});
liveTickerEntries = liveTickerEntries.slice(0, 18);
}
function seedTickerFromEvents(events) {
if (liveTickerEntries.length > 0) {
return;
}
const latest = [...events]
.sort((a, b) => new Date(b.received_at).getTime() - new Date(a.received_at).getTime())
.slice(0, 8);
latest.forEach((event, index) => {
const key = buildEventKey(event, index);
upsertTickerEvent(event, key);
});
}
function renderLiveTicker() {
if (!liveTickerTrack) {
return;
}
if (liveTickerEntries.length === 0) {
liveTickerTrack.innerHTML = "<span class=\"ticker-item\">Waiting for live detections...</span>";
return;
}
const html = liveTickerEntries
.map((entry) => `
<button class="ticker-item" type="button" data-event-key="${escapeHtml(entry.key)}">
${escapeHtml(entry.node)} | ${escapeHtml(entry.label)} | conf ${entry.confidence.toFixed(2)} | ${escapeHtml(entry.time)}
</button>
`)
.join("");
liveTickerTrack.innerHTML = `${html}${html}`;
liveTickerTrack.style.setProperty("--ticker-duration", `${Math.max(24, liveTickerEntries.length * 4.2)}s`);
liveTickerTrack.querySelectorAll(".ticker-item").forEach((item) => {
item.addEventListener("click", () => {
const eventKey = item.dataset.eventKey;
if (eventKey) {
focusEventByKey(eventKey, true);
}
});
});
}
function triggerRadarSweep(event) {
if (!alertFxLayer || !event) {
return;
}
const lat = Number(event.latitude);
const lng = Number(event.longitude);
if (Number.isNaN(lat) || Number.isNaN(lng) || lat === 0 || lng === 0) {
return;
}
const center = L.latLng(lat, lng);
const tone = Number(event.confidence || 0) >= 0.85 ? "#ef4444" : "#fb923c";
const createPulse = (delayMs) => {
setTimeout(() => {
const pulse = L.circleMarker(center, {
radius: 8,
color: tone,
weight: 2,
fillColor: tone,
fillOpacity: 0.2,
opacity: 0.9,
interactive: false
}).addTo(alertFxLayer);
const startTime = performance.now();
const durationMs = 1100;
const animate = (now) => {
const elapsed = now - startTime;
const progress = Math.min(1, elapsed / durationMs);
pulse.setRadius(8 + (42 * progress));
pulse.setStyle({
opacity: 0.9 * (1 - progress),
fillOpacity: 0.2 * (1 - progress)
});
if (progress < 1) {
requestAnimationFrame(animate);
}
else {
alertFxLayer.removeLayer(pulse);
}
};
requestAnimationFrame(animate);
}, delayMs);
};
createPulse(0);
createPulse(180);
}
function updateMapHeatmap(events) {
if (!map || !mapHeatLayer) {
return;
}
const heatPoints = events
.map((event) => {
const lat = Number(event.latitude);
const lng = Number(event.longitude);
const confidence = Number(event.confidence || 0);
if (Number.isNaN(lat) || Number.isNaN(lng) || lat === 0 || lng === 0) {
return null;
}
const weight = Math.min(1, Math.max(0.1, confidence || 0.1));
return [lat, lng, weight];
})
.filter(Boolean);
mapHeatLayer.setLatLngs(heatPoints);
if (showMapHeatmap) {
if (!map.hasLayer(mapHeatLayer)) {
map.addLayer(mapHeatLayer);
}
}
else if (map.hasLayer(mapHeatLayer)) {
map.removeLayer(mapHeatLayer);
}
}
function initThemeToggle() {
const savedTheme = localStorage.getItem("theme") || "light";
document.documentElement.setAttribute("data-theme", savedTheme);
updateThemeToggleIcon(savedTheme);
if (themeToggle) {
themeToggle.addEventListener("click", () => {
const currentTheme = document.documentElement.getAttribute("data-theme") || "light";
const newTheme = currentTheme === "light" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
updateThemeToggleIcon(newTheme);
applyChartTheme();
});
}
}
function updateThemeToggleIcon(theme) {
if (themeToggle) {
themeToggle.textContent = theme === "dark" ? "☀️" : "🌙";
}
}
function toEventTimestamp(event) {
const value = new Date(event.received_at).getTime();
return Number.isNaN(value) ? null : value;
}
function isEventInMapWindow(event) {
if (mapWindowMinutes === null) {
return true;
}
const eventTs = toEventTimestamp(event);
if (eventTs === null) {
return false;
}
const cutoffTs = Date.now() - (mapWindowMinutes * 60 * 1000);
return eventTs >= cutoffTs;
}
function updateMapWindowButtonsActiveState() {
mapWindowButtons.forEach((button) => {
const value = button.dataset.window;
const isActive = (value === "all" && mapWindowMinutes === null)
|| (value !== "all" && Number(value) === mapWindowMinutes);
button.classList.toggle("active", isActive);
});
}
function toDisplayLabel(label) {
return String(label || "unknown").trim() || "unknown";
}
function getThreatLevel(confidence) {
if (confidence >= 0.85) {
return "threat-red";
}
if (confidence >= 0.7) {
return "threat-yellow";
}
return "threat-green";
}
function buildEventKey(event, index) {
if (event.id !== undefined && event.id !== null) {
return `id:${event.id}`;
}
return `${event.received_at || "unknown-time"}|${event.node_id || "unknown-node"}|${index}`;
}
function getWeekStart(date) {
const copy = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const day = copy.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
copy.setUTCDate(copy.getUTCDate() + diff);
return copy;
}
function formatBucketKey(date, granularity) {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
if (granularity === "year") {
return `${year}`;
}
if (granularity === "month") {
return `${year}-${month}`;
}
if (granularity === "week") {
const weekStart = getWeekStart(date);
const y = weekStart.getUTCFullYear();
const jan4 = new Date(Date.UTC(y, 0, 4));
const firstWeekStart = getWeekStart(jan4);
const diffMs = weekStart - firstWeekStart;
const weekNumber = Math.floor(diffMs / (7 * 24 * 60 * 60 * 1000)) + 1;
return `${y}-W${String(weekNumber).padStart(2, "0")}`;
}
return `${year}-${month}-${day}`;
}
function parseBucketToSortableDate(bucket, granularity) {
if (granularity === "year") {
return new Date(`${bucket}-01-01T00:00:00Z`);
}
if (granularity === "month") {
return new Date(`${bucket}-01T00:00:00Z`);
}
if (granularity === "week") {
const [yearPart, weekPart] = bucket.split("-W");
const year = Number(yearPart);
const week = Number(weekPart);
const jan4 = new Date(Date.UTC(year, 0, 4));
const firstWeekStart = getWeekStart(jan4);
firstWeekStart.setUTCDate(firstWeekStart.getUTCDate() + (week - 1) * 7);
return firstWeekStart;
}
return new Date(`${bucket}T00:00:00Z`);
}
function createChartsIfNeeded() {
const palette = getChartThemePalette();
const timelineCanvas = document.getElementById("timelineChart");
const nodeCanvas = document.getElementById("nodeChart");
const shotTimelineCanvas = document.getElementById("shotTimelineChart");
if (!timelineChart) {
timelineChart = new Chart(timelineCanvas, {
type: "line",
data: {
labels: [],
datasets: [
{
label: "Gunshot detections",
data: [],
borderColor: palette.timelineLine,
backgroundColor: palette.timelineFill,
fill: true,
tension: 0.25,
borderWidth: 3,
pointRadius: 3,
pointHoverRadius: 5
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
chartThemePlugin: {
backgroundColor: palette.chartBg
},
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
color: palette.text
},
grid: {
color: palette.gridStrong
}
},
x: {
ticks: {
color: palette.text
},
grid: {
color: palette.gridSoft
}
}
}
}
});
}
if (!nodeChart) {
nodeChart = new Chart(nodeCanvas, {
type: "bar",
data: {
labels: [],
datasets: [
{
label: "Detections",
data: [],
backgroundColor: palette.nodeFill,
borderColor: palette.nodeBorder,
borderWidth: 1.5,
borderRadius: 8
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
indexAxis: "y",
plugins: {
chartThemePlugin: {
backgroundColor: palette.chartBg
},
legend: {
display: false
}
},
scales: {
x: {
beginAtZero: true,
ticks: {
precision: 0,
color: palette.text
},
grid: {
color: palette.gridStrong
}
},
y: {
ticks: {
color: palette.text
},
grid: {
display: false
}
}
}
}
});
}
if (!shotTimelineChart) {
shotTimelineChart = new Chart(shotTimelineCanvas, {
type: "scatter",
data: {
datasets: [
{
label: "Detected shots",
data: [],
pointBackgroundColor: palette.shotPointFill,
pointBorderColor: palette.shotPointBorder,
pointBorderWidth: 1.4,
pointRadius(context) {
return context.raw?.r || 2.6;
},
pointHoverRadius(context) {
return (context.raw?.r || 2.6) + 1.2;
},
fill: false,
showLine: false
},
{
label: "Confidence trend",
type: "line",
data: [],
borderColor: palette.trendLine,
backgroundColor: "rgba(26, 109, 90, 0.2)",
borderWidth: 2,
tension: 0.25,
pointRadius: 0,
fill: false
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
chartThemePlugin: {
backgroundColor: palette.chartBg
},
legend: {
display: true,
labels: {
usePointStyle: true,
boxWidth: 8,
color: palette.text
}
},
tooltip: {
callbacks: {
title(items) {
const x = items[0]?.raw?.x;
return x ? shotAxisFormatter.format(new Date(x)) : "Detection";
},
label(context) {
if (context.datasetIndex === 1) {
return `Trend: ${Number(context.raw.y).toFixed(2)}`;
}
const raw = context.raw || {};
const node = raw.node || "unknown-node";
const confidence = Number(raw.y || 0).toFixed(2);
const peak = Number(raw.peak || 0).toFixed(2);
return `${node} | Confidence: ${confidence} | Peak: ${peak}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
max: 1,
grid: {
color: palette.gridStrong
},
ticks: {
color: palette.text
},
title: {
display: true,
text: "Confidence",
color: palette.text
}
},
x: {
type: "linear",
grid: {
color: palette.gridSoft
},
ticks: {
autoSkip: true,
maxTicksLimit: 8,
color: palette.text,
callback(value) {
return shotAxisFormatter.format(new Date(value));
}
},
title: {
display: true,
text: "Detection Time",
color: palette.text
}
}
}
}
});
}
}
function getEventSignature(event) {
if (!event) {
return "";
}
if (event.id !== undefined && event.id !== null) {
return `id:${event.id}`;
}
return `${event.received_at || "unknown-time"}|${event.node_id || "unknown-node"}|${event.label || "unknown-label"}`;
}
function getLatestEvent(events) {
let latestEvent = null;
let latestTime = -1;
events.forEach((event) => {
const eventTime = new Date(event.received_at).getTime();
if (Number.isNaN(eventTime)) {
return;
}
if (eventTime > latestTime) {
latestTime = eventTime;
latestEvent = event;
}
});
return latestEvent;
}
function showWebhookAlertForEvent(event, eventKey) {
if (!webhookAlert || !event) {
return;
}
lastAlertEventKey = eventKey || null;
if (webhookAlertText) {
webhookAlertText.textContent = `ALERT: New detection received from /tnt/webhook (${toDisplayLabel(event.label)} | ${event.node_id || "unknown-node"}) at ${new Date(event.received_at).toLocaleTimeString()}`;
}
if (alertSeeMapBtn) {
alertSeeMapBtn.style.display = lastAlertEventKey ? "inline-flex" : "none";
}
webhookAlert.classList.remove("is-shocking");
void webhookAlert.offsetWidth;
webhookAlert.classList.add("is-visible");
webhookAlert.classList.add("is-shocking");
upsertTickerEvent(event, eventKey);
renderLiveTicker();
triggerRadarSweep(event);
if (alertHideTimeout) {
clearTimeout(alertHideTimeout);
}
alertHideTimeout = setTimeout(() => {
webhookAlert.classList.remove("is-visible");
webhookAlert.classList.remove("is-shocking");
}, 8000);
}
function processIncomingAlert(events) {
const latestEvent = getLatestEvent(events);
if (!latestEvent) {
return;
}
const latestTime = new Date(latestEvent.received_at).getTime();
const latestSignature = getEventSignature(latestEvent);
const latestEventIndex = events.findIndex((event) => event === latestEvent);
const latestEventKey = latestEventIndex >= 0 ? buildEventKey(latestEvent, latestEventIndex) : null;
if (!lastSeenEventTime) {
lastSeenEventTime = latestTime;
lastSeenEventSignature = latestSignature;
return;
}
const isNewEvent = latestTime > lastSeenEventTime
|| (latestTime === lastSeenEventTime && latestSignature !== lastSeenEventSignature);
if (isNewEvent) {
showWebhookAlertForEvent(latestEvent, latestEventKey);
lastSeenEventTime = latestTime;
lastSeenEventSignature = latestSignature;
}
}
function renderTimelineVisuals() {
createChartsIfNeeded();
const granularity = timelineGranularitySelect.value;
const gunshotEvents = allEvents.filter((event) =>
String(event.label || "").toLowerCase().includes("gunshot")
);
const bucketCounts = new Map();
const nodeCounts = new Map();
gunshotEvents.forEach((event) => {
const eventDate = new Date(event.received_at);
if (Number.isNaN(eventDate.getTime())) {
return;
}
...
This file has been truncated, please download it to see its full contents.
Comments