Skip to content

Commit 2901775

Browse files
authored
feat: add NOC alert system for map markers
- Backend: compute_alert_level() classifies locations as critical/medium/ok - Critical: any device with a "core" role (core, spine, distribution, router, gateway keyword) has an offline/failed/decommissioning status - Medium: >25% of all devices at the site are offline - Backend: /api/locations/<id>/detail now includes an 'alert' key - Frontend: alert banner shown in popup when detail loads - Frontend: marker icon updated to orange (medium) or pulsing red (critical) after device details are fetched, including co-located markers - Frontend: legend updated with medium and critical alert entries
1 parent abea6d3 commit 2901775

4 files changed

Lines changed: 218 additions & 9 deletions

File tree

app.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,67 @@ def get_locations() -> list:
328328
return locations
329329

330330

331+
# ---------------------------------------------------------------------------
332+
# NOC alert helpers
333+
# ---------------------------------------------------------------------------
334+
335+
# Device statuses that count as "down" for alert purposes
336+
_DOWN_STATUSES: frozenset = frozenset({"offline", "failed", "decommissioning"})
337+
338+
# Keywords in a device role that identify core/critical infrastructure.
339+
# Matching is case-insensitive substring check.
340+
_CORE_ROLE_KEYWORDS: tuple = ("core", "spine", "distribution", "router", "gateway")
341+
342+
343+
def compute_alert_level(devices: list) -> dict:
344+
"""Return the NOC alert level for a location based on its device list.
345+
346+
Returns a dict::
347+
348+
{"level": "critical" | "medium" | "ok", "reason": "<human-readable text>"}
349+
350+
Rules:
351+
* **critical** – at least one device whose role contains a core-network
352+
keyword (core, spine, distribution, router, gateway) has a down status.
353+
* **medium** – more than 25 % of all devices have a down status.
354+
* **ok** – neither condition above is met (or no devices present).
355+
"""
356+
if not devices:
357+
return {"level": "ok", "reason": ""}
358+
359+
down_names: list = []
360+
core_down_names: list = []
361+
362+
for device in devices:
363+
status = (device.get("status") or "").lower().strip()
364+
if status not in _DOWN_STATUSES:
365+
continue
366+
name = device.get("name") or "Unknown"
367+
down_names.append(name)
368+
role = (device.get("role") or "").lower()
369+
if any(kw in role for kw in _CORE_ROLE_KEYWORDS):
370+
core_down_names.append(name)
371+
372+
if core_down_names:
373+
listed = ", ".join(core_down_names[:3])
374+
suffix = f" (+{len(core_down_names) - 3} more)" if len(core_down_names) > 3 else ""
375+
return {
376+
"level": "critical",
377+
"reason": f"Core device(s) offline: {listed}{suffix}",
378+
}
379+
380+
total = len(devices)
381+
down_count = len(down_names)
382+
if total > 0 and down_count / total > 0.25:
383+
pct = round(down_count / total * 100)
384+
return {
385+
"level": "medium",
386+
"reason": f"{down_count}/{total} devices offline ({pct}%)",
387+
}
388+
389+
return {"level": "ok", "reason": ""}
390+
391+
331392
def get_location_detail(location_id: str) -> dict:
332393
"""Fetch detailed info (devices, prefixes, ASNs) for a single location."""
333394
detail: dict = {}
@@ -398,9 +459,11 @@ def get_location_detail(location_id: str) -> dict:
398459
}
399460
)
400461
detail["devices"] = devices
462+
detail["alert"] = compute_alert_level(devices)
401463
except Exception as exc:
402464
logger.warning("Could not fetch devices for location %s: %s", location_id, exc)
403465
detail["devices"] = []
466+
detail["alert"] = {"level": "ok", "reason": ""}
404467

405468
# ASN(s) associated with this location via the ipam/asns endpoint
406469
try:

static/css/map.css

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,66 @@ html, body {
655655
display: block;
656656
}
657657

658+
/* =========================================================
659+
NOC alert banner (shown inside popup after detail loads)
660+
========================================================= */
661+
.alert-banner {
662+
display: flex;
663+
align-items: flex-start;
664+
gap: 8px;
665+
border-radius: 6px;
666+
padding: 7px 10px;
667+
margin-bottom: 8px;
668+
font-size: 0.82rem;
669+
}
670+
671+
.alert-banner.alert-critical {
672+
background: rgba(231, 76, 60, 0.18);
673+
border: 1px solid rgba(231, 76, 60, 0.5);
674+
}
675+
676+
.alert-banner.alert-medium {
677+
background: rgba(255, 140, 0, 0.18);
678+
border: 1px solid rgba(255, 140, 0, 0.5);
679+
}
680+
681+
.alert-banner-icon {
682+
font-size: 1rem;
683+
flex-shrink: 0;
684+
line-height: 1.4;
685+
}
686+
687+
.alert-banner-level {
688+
font-weight: 700;
689+
letter-spacing: 0.05em;
690+
font-size: 0.75rem;
691+
}
692+
693+
.alert-critical .alert-banner-level { color: var(--color-danger); }
694+
.alert-medium .alert-banner-level { color: #ff8c00; }
695+
696+
.alert-banner-reason {
697+
font-size: 0.76rem;
698+
color: var(--color-text-muted);
699+
margin-top: 1px;
700+
}
701+
702+
/* Pulsing ring for critical markers */
703+
@keyframes markerPulse {
704+
0% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7); }
705+
70% { box-shadow: 0 0 0 10px rgba(231, 76, 60, 0); }
706+
100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); }
707+
}
708+
709+
.marker-pulse {
710+
border-radius: 50% 50% 50% 0;
711+
animation: markerPulse 1.4s ease-out infinite;
712+
}
713+
714+
/* Alert legend dots */
715+
.dot-medium { background: #ff8c00; }
716+
.dot-critical { background: var(--color-danger); }
717+
658718
/* =========================================================
659719
Responsive (collapse sidebar on small screens)
660720
========================================================= */

static/js/map.js

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
1919
// ---------------------------------------------------------------------------
2020
// Marker icon factory
2121
// ---------------------------------------------------------------------------
22-
function makeIcon(color) {
22+
function makeIcon(color, pulse) {
2323
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36">
2424
<path d="M12 0C5.373 0 0 5.373 0 12c0 9 12 24 12 24S24 21 24 12C24 5.373 18.627 0 12 0z"
2525
fill="${color}" stroke="#fff" stroke-width="1.5"/>
2626
<circle cx="12" cy="12" r="4.5" fill="#fff"/>
2727
</svg>`;
28+
const cls = pulse ? ' class="marker-pulse"' : '';
2829
return L.divIcon({
29-
html: `<div style="width:24px;height:36px">${svg}</div>`,
30+
html: `<div${cls} style="width:24px;height:36px">${svg}</div>`,
3031
iconSize: [24, 36],
3132
iconAnchor: [12, 36],
3233
popupAnchor: [0, -36],
@@ -35,10 +36,12 @@ function makeIcon(color) {
3536
}
3637

3738
const ICONS = {
38-
active: makeIcon("#2ecc71"),
39-
planned: makeIcon("#f0a500"),
40-
other: makeIcon("#888888"),
41-
search: makeIcon("#e74c3c"),
39+
active: makeIcon("#2ecc71"),
40+
planned: makeIcon("#f0a500"),
41+
other: makeIcon("#888888"),
42+
search: makeIcon("#e74c3c"),
43+
medium: makeIcon("#ff8c00"),
44+
critical: makeIcon("#e74c3c", true),
4245
};
4346

4447
function iconForStatus(status) {
@@ -85,6 +88,19 @@ function renderDetail(locId, detail) {
8588

8689
let html = "";
8790

91+
// NOC alert banner
92+
if (detail.alert && detail.alert.level !== "ok") {
93+
const lvl = detail.alert.level;
94+
const icon = lvl === "critical" ? "🔴" : "🟠";
95+
html += `<div class="alert-banner alert-${escHtml(lvl)}">
96+
<span class="alert-banner-icon">${icon}</span>
97+
<div>
98+
<div class="alert-banner-level">${escHtml(lvl.toUpperCase())}</div>
99+
${detail.alert.reason ? `<div class="alert-banner-reason">${escHtml(detail.alert.reason)}</div>` : ""}
100+
</div>
101+
</div>`;
102+
}
103+
88104
// ASNs
89105
if (detail.asns && detail.asns.length > 0) {
90106
const tags = detail.asns
@@ -195,15 +211,21 @@ function groupByCoords(locations) {
195211

196212
/**
197213
* Create a marker icon with a small count badge for co-located sites.
214+
* Pass alertLevel ("critical" | "medium") to colour the pin accordingly.
198215
*/
199-
function makeStackedIcon(count) {
216+
function makeStackedIcon(count, alertLevel) {
217+
const color = alertLevel === "critical" ? "#e74c3c"
218+
: alertLevel === "medium" ? "#ff8c00"
219+
: "#3388ff";
220+
const pulse = alertLevel === "critical";
200221
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36">
201222
<path d="M12 0C5.373 0 0 5.373 0 12c0 9 12 24 12 24S24 21 24 12C24 5.373 18.627 0 12 0z"
202-
fill="#3388ff" stroke="#fff" stroke-width="1.5"/>
223+
fill="${color}" stroke="#fff" stroke-width="1.5"/>
203224
<circle cx="12" cy="12" r="4.5" fill="#fff"/>
204225
</svg>`;
226+
const cls = pulse ? ' class="marker-pulse"' : '';
205227
return L.divIcon({
206-
html: `<div style="width:24px;height:36px;position:relative">${svg}<span class="colocated-badge">${count}</span></div>`,
228+
html: `<div${cls} style="width:24px;height:36px;position:relative">${svg}<span class="colocated-badge">${count}</span></div>`,
207229
iconSize: [24, 36],
208230
iconAnchor: [12, 36],
209231
popupAnchor: [0, -36],
@@ -337,6 +359,14 @@ function addColocatedMarker(locations) {
337359
});
338360

339361
bindHoverAndLock(marker);
362+
363+
// Register all location IDs so alert updates can find this marker
364+
const ids = locations.map((l) => l.id);
365+
for (const loc of locations) {
366+
markerByLocId[loc.id] = marker;
367+
colocGroupByLocId[loc.id] = ids;
368+
}
369+
340370
marker.addTo(markerLayer);
341371
}
342372

@@ -347,6 +377,49 @@ const markerLayer = L.layerGroup().addTo(map);
347377
let allLocations = [];
348378
const CLUSTER_THRESHOLD = 100; // Use clustering if more than 100 locations
349379

380+
// ---------------------------------------------------------------------------
381+
// NOC alert marker registry
382+
// Keeps track of every rendered marker so icons can be updated after
383+
// device-detail loads reveal the site's alert level.
384+
// ---------------------------------------------------------------------------
385+
/** locId → L.marker */
386+
const markerByLocId = {};
387+
/** locId → array of all locIds sharing the same co-located marker */
388+
const colocGroupByLocId = {};
389+
/** locId → "critical" | "medium" | "ok" */
390+
const locationAlerts = {};
391+
392+
/**
393+
* Called after device details are loaded for a location.
394+
* Updates the map-marker icon to reflect the computed alert level,
395+
* and (for co-located groups) promotes to the highest level across
396+
* all members that have been loaded so far.
397+
*/
398+
function updateMarkerForAlert(locId, alertLevel) {
399+
locationAlerts[locId] = alertLevel;
400+
const marker = markerByLocId[locId];
401+
if (!marker) return;
402+
403+
const groupIds = colocGroupByLocId[locId];
404+
if (groupIds) {
405+
// Co-located marker: pick the worst level across the known group
406+
const groupLevel = groupIds.reduce((highest, id) => {
407+
const lvl = locationAlerts[id] || "ok";
408+
if (lvl === "critical") return "critical";
409+
if (lvl === "medium" && highest !== "critical") return "medium";
410+
return highest;
411+
}, "ok");
412+
if (groupLevel !== "ok") {
413+
marker.setIcon(makeStackedIcon(groupIds.length, groupLevel));
414+
}
415+
return;
416+
}
417+
418+
// Single marker
419+
if (alertLevel === "critical") marker.setIcon(ICONS.critical);
420+
else if (alertLevel === "medium") marker.setIcon(ICONS.medium);
421+
}
422+
350423
async function loadLocations() {
351424
showLoading(true, "Loading locations from Nautobot…");
352425
try {
@@ -367,6 +440,9 @@ async function loadLocations() {
367440

368441
function renderMarkers(locations, searchMarker) {
369442
markerLayer.clearLayers();
443+
// Clear registry so stale references don't linger after a re-render
444+
for (const key of Object.keys(markerByLocId)) delete markerByLocId[key];
445+
for (const key of Object.keys(colocGroupByLocId)) delete colocGroupByLocId[key];
370446

371447
// Add search point marker if provided
372448
if (searchMarker) {
@@ -573,6 +649,7 @@ function addMarker(loc) {
573649
});
574650

575651
bindHoverAndLock(marker);
652+
markerByLocId[loc.id] = marker;
576653
marker.addTo(markerLayer);
577654
}
578655

@@ -589,6 +666,9 @@ async function fetchAndRenderDetail(locId) {
589666
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
590667
const detail = await resp.json();
591668
renderDetail(locId, detail);
669+
if (detail.alert) {
670+
updateMarkerForAlert(locId, detail.alert.level);
671+
}
592672
} catch (err) {
593673
const container = document.getElementById(`popup-detail-${locId}`);
594674
if (container) {

templates/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ <h1>
8888
<div class="legend-item">
8989
<span class="legend-dot dot-search"></span> Search result
9090
</div>
91+
<div class="legend-item">
92+
<span class="legend-dot dot-medium"></span> Alert: &gt;25% devices down
93+
</div>
94+
<div class="legend-item">
95+
<span class="legend-dot dot-critical"></span> Alert: Core device offline
96+
</div>
9197
</div>
9298
</div>
9399

0 commit comments

Comments
 (0)