feat(web/attackers): surface GeoIP country on list cards + detail page

- Attackers list: small country-code chip next to the IP on each card,
  title-tooltip shows the source (e.g. "rir")
- AttackerDetail: country-code tag next to the IP in the header plus an
  ORIGIN field in the TIMELINE section for always-visible origin
- TypeScript interfaces updated with country_code/country_source
This commit is contained in:
2026-04-23 21:21:21 -04:00
parent 1854f9de28
commit 21e6820714
3 changed files with 53 additions and 2 deletions

View File

@@ -59,6 +59,8 @@ interface AttackerData {
credential_count: number;
fingerprints: any[];
commands: { service: string; decky: string; command: string; timestamp: string }[];
country_code: string | null;
country_source: string | null;
updated_at: string;
behavior: AttackerBehavior | null;
}
@@ -903,6 +905,16 @@ const AttackerDetail: React.FC = () => {
<h1 className="matrix-text" style={{ fontSize: '1.8rem', letterSpacing: '2px' }}>
{attacker.ip}
</h1>
{attacker.country_code && (
<Tag color="var(--text-color)">
<span
title={attacker.country_source ? `source: ${attacker.country_source}` : undefined}
style={{ letterSpacing: '2px' }}
>
{attacker.country_code}
</span>
</Tag>
)}
{attacker.is_traversal && (
<span className="traversal-badge" style={{ fontSize: '0.8rem' }}>TRAVERSAL</span>
)}
@@ -934,7 +946,7 @@ const AttackerDetail: React.FC = () => {
{/* Timestamps */}
<Section title="TIMELINE" open={openSections.timeline} onToggle={() => toggle('timeline')}>
<div style={{ padding: '16px', display: 'flex', gap: '32px', fontSize: '0.85rem' }}>
<div style={{ padding: '16px', display: 'flex', flexWrap: 'wrap', gap: '32px', fontSize: '0.85rem' }}>
<div>
<span className="dim">FIRST SEEN: </span>
<span className="matrix-text">{new Date(attacker.first_seen).toLocaleString()}</span>
@@ -947,6 +959,21 @@ const AttackerDetail: React.FC = () => {
<span className="dim">UPDATED: </span>
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
</div>
<div>
<span className="dim">ORIGIN: </span>
{attacker.country_code ? (
<span className="matrix-text">
{attacker.country_code}
{attacker.country_source && (
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
({attacker.country_source})
</span>
)}
</span>
) : (
<span className="dim">unknown</span>
)}
</div>
</div>
</Section>

View File

@@ -72,6 +72,18 @@
color: var(--matrix);
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
display: inline-flex;
align-items: baseline;
gap: 8px;
}
.attackers-root .ak-card .ak-cc {
font-size: 0.62rem;
letter-spacing: 2px;
padding: 1px 6px;
border: 1px solid var(--border);
color: var(--matrix);
opacity: 0.75;
font-weight: 600;
}
.attackers-root .ak-meta {

View File

@@ -23,6 +23,8 @@ interface AttackerEntry {
credential_count: number;
fingerprints: any[];
commands: any[];
country_code: string | null;
country_source: string | null;
updated_at: string;
}
@@ -187,7 +189,17 @@ const Attackers: React.FC = () => {
onClick={() => navigate(`/attackers/${a.uuid}`)}
>
<div className="ak-top">
<span className="ak-ip">{a.ip}</span>
<span className="ak-ip">
{a.ip}
{a.country_code && (
<span
className="ak-cc"
title={`Origin: ${a.country_code}${a.country_source ? ` (${a.country_source})` : ''}`}
>
{a.country_code}
</span>
)}
</span>
<span className={`activity-chip ${activity}`}>
<span className="dot" />
{activity.toUpperCase()}