(options = {})
| 6596 | } |
| 6597 | const [cls, label] = _IPINTEL_CLASS_STYLE[d.classification] || _IPINTEL_CLASS_STYLE.unknown; |
| 6598 | const row = (k, v) => v ? `<div class="flex gap-2"><span class="text-gray-500 w-28 shrink-0">${k}</span><span class="text-gray-200 break-all">${escapeHtml(String(v))}</span></div>` : ''; |
| 6599 | const country = d.country ? d.country + (d.country_routing ? ' (routing: ' + escapeHtml(d.country_routing) + ')' : '') : ''; |
| 6600 | out.innerHTML = ` |
| 6601 | <div class="rounded-lg border ${cls} px-3 py-2 mb-3"> |
| 6602 | <span class="text-sm font-semibold">${escapeHtml(d.ip)}</span> |
| 6603 | <span class="text-xs ml-2">${escapeHtml(label)}</span> |
| 6604 | <span class="text-xs opacity-70 ml-2">${d.confidence != null ? d.confidence + '% ownership resolved' : ''}${d.cached ? ' · cached' : ''}</span> |
| 6605 | </div> |
| 6606 | <div class="space-y-1 text-xs"> |
| 6607 | ${row('Network', (d.prefix || '') + (d.network_name ? ' ' + d.network_name : ''))} |
| 6608 | ${row('Allocated', d.allocated)} |
| 6609 | ${row('ASN', d.asn ? 'AS' + d.asn + (d.as_org ? ' ' + d.as_org : '') : '')} |
| 6610 | ${row('Country', country)} |
| 6611 | ${row('Registry', d.registry)} |
| 6612 | ${row('Reverse DNS', d.ptr)} |
| 6613 | ${row('Abuse contact', d.abuse_email || 'not published')} |
| 6614 | ${row('Org address', d.org_address)} |
| 6615 | ${row('Resolved from', d.resolved_from)} |
| 6616 | </div> |
| 6617 | ${d.country_note ? `<p class="text-xs text-amber-300/80 mt-2">${escapeHtml(d.country_note)}</p>` : ''} |
| 6618 | ${d.attribution_note ? `<p class="text-xs text-amber-300/90 mt-2">${escapeHtml(d.attribution_note)}</p>` : ''} |
| 6619 | <p class="text-xs text-gray-500 mt-2">${escapeHtml(d.location_note || '')}</p>`; |
| 6620 | } catch (e) { |
| 6621 | out.innerHTML = '<p class="text-sm text-red-400">Failed: ' + escapeHtml(e.message) + '</p>'; |
| 6622 | } finally { |
| 6623 | _ndBusy(btn, false); |
| 6624 | } |
| 6625 | } |
| 6626 | |
| 6627 | // ---- Watchtower: unified pane for the standalone watchers ------------------ |
| 6628 | const _WT_SEV_STYLE = { |
| 6629 | critical: ['bg-red-950/60 border-red-800 text-red-300', '🛑'], |
| 6630 | high: ['bg-orange-950/50 border-orange-800 text-orange-300', '⚠'], |
| 6631 | medium: ['bg-amber-950/50 border-amber-800 text-amber-300', '⚠'], |
| 6632 | low: ['bg-slate-800 border-slate-700 text-slate-300', 'ℹ'], |
| 6633 | info: ['bg-slate-800 border-slate-700 text-slate-400', 'ℹ'], |
| 6634 | }; |
| 6635 | function _wtSevChip(sev, n) { |
| 6636 | const [cls, icon] = _WT_SEV_STYLE[sev] || _WT_SEV_STYLE.info; |
| 6637 | return `<span class="px-2.5 py-1 rounded border ${cls}">${icon} ${escapeHtml(sev)}: ${n}</span>`; |
| 6638 | } |
| 6639 | // Correlated attack-chain incidents — the fused view. Rendered above the raw |
| 6640 | // alert list because an incident is the "so what" behind a cluster of alerts. |
| 6641 | function renderIncidents(incidents, elId, max) { |
| 6642 | const el = document.getElementById(elId); |
| 6643 | if (!el) return; |
| 6644 | const list = (incidents || []).slice(0, max || 4); |
| 6645 | if (!list.length) { el.innerHTML = ''; return; } |
| 6646 | el.innerHTML = list.map(inc => { |
| 6647 | const [cls] = _WT_SEV_STYLE[inc.severity] || _WT_SEV_STYLE.info; |
| 6648 | const ents = Object.entries(inc.entities || {}) |
| 6649 | .map(([k, v]) => escapeHtml(v.slice(0, 3).join(', '))).filter(Boolean).join(' · '); |
| 6650 | const conf = inc.confidence != null ? inc.confidence + '%' : ''; |
| 6651 | return `<div class="rounded-lg border ${cls} px-3 py-2"> |
| 6652 | <div class="flex items-start justify-between gap-3"> |
| 6653 | <div class="min-w-0"> |
| 6654 | <span class="text-[10px] uppercase tracking-wide opacity-70">Incident</span> |
| 6655 | <span class="text-sm font-semibold ml-1">${escapeHtml(inc.label || 'Correlated activity')}</span> |
no test coverage detected