Skip to content

Commit aa7ca71

Browse files
committed
feat(fw): add traffic-origins country ranking to dashboard
- New GET /api/v1/origins endpoint aggregating tracked-source pps by origin country (with dropped pps + blocked counts), bounded to one row per country. - Dashboard: 'Traffic origins' card (ranked flag list, fixed height) shown beside the traffic meters; Protected prefixes moved into the left column. - Detection limits moved behind a settings-cog modal. - Line-rate saturation bar stacked under each traffic meter. - Inline Heroicons (icons.tsx) replacing emoji/unicode glyphs throughout. - SharedState geo overrides so the serve_api simulator can inject mock country/ASN data (botnet spread) without a MaxMind DB.
1 parent 3ad2821 commit aa7ca71

11 files changed

Lines changed: 627 additions & 65 deletions

File tree

lnvps_fw/lnvps_fw_service/dashboard/dist/index.html

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

lnvps_fw/lnvps_fw_service/dashboard/src/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export interface SourceBlock { cidr: string; age_secs: number; pps: number; manu
3434
/** A row in the unified source list: tracked sources (any state) + manual blocks. */
3535
export interface TrackedSource { ip: string; pps: number; state: "normal" | "dropping"; manual: boolean; age_secs: number; asn?: number; org?: string; country?: string }
3636
export interface LearnedPort { ip: string; port: number; proto: string; age_secs: number }
37+
/** One origin-country row in the traffic-origins ranking. */
38+
export interface OriginStat { country: string; sources: number; pps: number; drop_pps: number; dropping: number }
39+
export interface OriginsResponse { items: OriginStat[]; total_pps: number; total_sources: number }
3740
export interface FwEvent {
3841
seq: number; kind: string; cidr: string; flags: number; ts_unix: number;
3942
pps: number; bps: number; syn_pps: number;

lnvps_fw/lnvps_fw_service/dashboard/src/app.css

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
.grow{ flex:1; }
2121
/* posture indicator */
2222
.chip{ font-size:.64rem; letter-spacing:.14em; text-transform:uppercase;
23-
padding:.24rem .6rem; border:1px solid var(--line2); border-radius:2px; color:var(--muted); white-space:nowrap; }
23+
padding:.24rem .6rem; border:1px solid var(--line2); border-radius:2px; color:var(--muted); white-space:nowrap;
24+
display:inline-flex; align-items:center; gap:.4em; }
25+
/* inline heroicons: sized in em so they track the surrounding text */
26+
.hi{ width:1em; height:1em; flex:none; vertical-align:-0.15em; }
2427
.chip.arm{ color:var(--rx); border-color:#1c4a45; background:#0b1c1b; }
2528
.chip.atk{ color:var(--alarm); border-color:#4a1c22; background:#1c0c0e; animation:pulse 1.4s ease-in-out infinite; }
2629
@keyframes pulse{ 50%{ opacity:.5; } }
@@ -33,11 +36,20 @@
3336
button:disabled{ opacity:.4; cursor:default; filter:none; }
3437
button.ghost{ background:transparent; color:var(--muted); border:1px solid var(--line2); font-weight:500; }
3538
button.ghost:hover{ color:var(--text); border-color:var(--dim); filter:none; }
39+
/* buttons that carry a leading icon + label, and icon-only ghost buttons */
40+
button{ display:inline-flex; align-items:center; gap:.4em; }
41+
button.icon{ padding:.32rem .45rem; }
42+
button.icon .hi{ font-size:1.05rem; }
3643
main{ padding:1.1rem; display:grid; gap:1.1rem;
3744
grid-template-columns:repeat(auto-fit, minmax(340px, 1fr)); }
3845
section{ background:var(--panel); border:1px solid var(--line2); border-radius:3px;
3946
padding:.8rem 1rem .85rem; min-width:0; }
4047
.wide{ grid-column:1 / -1; }
48+
/* two-column split: left stack (traffic meters + protected prefixes) and the
49+
traffic-origins map on the right */
50+
.split2{ display:grid; gap:1.1rem; grid-template-columns:3fr 2fr; align-items:start; }
51+
.split2-col{ display:grid; gap:1.1rem; align-content:start; }
52+
@media (max-width:820px){ .split2{ grid-template-columns:1fr; } }
4153
section h2{ font-size:.66rem; text-transform:uppercase; letter-spacing:.18em;
4254
margin:0 0 .7rem; color:var(--muted); display:flex; gap:.5rem; align-items:center;
4355
padding-left:.65rem; position:relative; }
@@ -62,27 +74,45 @@
6274
.modal h3{ margin:0; font-size:.8rem; letter-spacing:.06em; text-transform:uppercase; color:var(--rx); }
6375
.modal .chk{ display:inline-flex; gap:.3rem; align-items:center; margin-right:.8rem; color:var(--text); font-size:.85rem; }
6476
.modal .act{ display:flex; gap:.5rem; align-items:center; margin-top:.3rem; }
77+
.modal .limits{ max-width:min(80vw, 34rem); }
6578
.limits{ display:flex; flex-wrap:wrap; gap:.6rem 1rem; align-items:flex-end; }
6679
.limits label{ display:inline-flex; flex-direction:column; font-size:.66rem; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); gap:.25rem; }
6780
.limits input{ width:7.5rem; }
6881
.limits .act{ display:flex; gap:.5rem; align-items:center; width:100%; margin-top:.2rem; }
6982
/* traffic overview */
70-
.traffic{ display:flex; flex-wrap:wrap; gap:.7rem 2.4rem; align-items:center; padding-left:.65rem; }
71-
.meter{ display:flex; align-items:center; gap:.7rem; }
83+
.traffic{ display:flex; flex-wrap:wrap; gap:.9rem 2.4rem; align-items:flex-start; padding-left:.65rem; }
84+
/* each meter is a column: the numbers row on top, the line-rate bar stacked
85+
full-width beneath it (rather than crammed inline to the right) */
86+
.meter{ display:flex; flex-direction:column; gap:.4rem; align-items:stretch; }
87+
.meter-main{ display:flex; align-items:center; gap:.7rem; }
7288
.meter .dir{ font-size:1.2rem; font-weight:700; line-height:1; }
7389
.meter .stack{ display:flex; flex-direction:column; line-height:1.16; }
7490
.meter .lbl{ font-size:.6rem; text-transform:uppercase; letter-spacing:.16em; color:var(--dim); }
7591
.meter .big{ font-size:1.45rem; font-weight:700; letter-spacing:-.01em; font-variant-numeric:tabular-nums; }
7692
.meter .sub{ font-size:.72rem; color:var(--muted); font-variant-numeric:tabular-nums; }
7793
.spark{ display:block; align-self:center; filter:drop-shadow(0 0 3px currentColor); }
78-
.sat{ display:flex; flex-direction:column; gap:.28rem; min-width:118px; }
94+
.sat{ display:flex; flex-direction:column; gap:.28rem; width:100%; }
7995
.sat .satbar{ height:5px; border-radius:2px; background:var(--inset); border:1px solid var(--line); overflow:hidden; }
8096
.sat .satbar span{ display:block; height:100%; }
8197
.sat .satlbl{ font-size:.66rem; color:var(--muted); font-variant-numeric:tabular-nums; }
8298
/* load/drop cell bars, responsive to the fixed column width */
8399
.barwrap{ display:flex; align-items:center; gap:.4rem; width:100%; min-width:0; }
84100
.bar{ background:var(--inset); border:1px solid var(--line); border-radius:2px; height:9px; flex:1 1 auto; min-width:20px; max-width:120px; overflow:hidden; }
85101
.bar .fill{ display:block; height:100%; }
102+
/* traffic origins: ranked source-country list (flags, no geographic map).
103+
fixed-height box so it never resizes as countries appear/disappear. */
104+
.origins-box{ height:16rem; overflow:auto; }
105+
.origins{ display:flex; flex-direction:column; gap:.35rem; }
106+
.origin{ display:grid; grid-template-columns:1.4rem 3rem 1fr auto auto; align-items:center; gap:.5rem;
107+
font-variant-numeric:tabular-nums; }
108+
.origin .oflag{ font-size:1.05rem; line-height:1; }
109+
.origin .occ{ font-weight:600; letter-spacing:.02em; }
110+
.origin .obar{ background:var(--inset); border:1px solid var(--line); border-radius:2px; height:9px; overflow:hidden; }
111+
/* outer fill = this country's total pps vs the busiest; inner = dropped share */
112+
.origin .ofill{ display:block; height:100%; background:var(--dim); }
113+
.origin .odrop{ display:block; height:100%; }
114+
.origin .opps{ min-width:8rem; text-align:right; }
115+
.origin .osrc{ min-width:6rem; text-align:right; font-size:.72rem; }
86116
/* connect screen (shown until the first successful load) */
87117
.connect-wrap{ min-height:100vh; display:flex; align-items:center; justify-content:center; padding:2rem; }
88118
.connect{ width:340px; background:linear-gradient(#0f161e,#0c1219); border:1px solid var(--line2);

lnvps_fw/lnvps_fw_service/dashboard/src/app.tsx

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ import type { ComponentChildren } from "preact";
33
import { api } from "./api";
44
import type { Status, TrackedIp, PrefixLoad, Mitigation, FwEvent, RuleSet, UpgradeStatus } from "./api";
55
import { fmtn, fmtbps, timeStr, loadColor, dropColor } from "./format";
6-
import { LoadBar, Sparkline, PagedTable, Section, flagCell } from "./ui";
7-
import { Login, LimitsCard, MitigationsCard, SourcesCard, PortsCard, geoCell } from "./cards";
6+
import { LoadBar, Sparkline, PagedTable, Section, Modal, flagCell } from "./ui";
7+
import { Login, LimitsCard, MitigationsCard, SourcesCard, PortsCard, OriginsCard, geoCell } from "./cards";
8+
import {
9+
IconUpgrade, IconRefresh, IconSettings, IconUpdateCheck, IconDisconnect,
10+
IconArrowDown, IconArrowUp, IconNoSymbol, IconShieldCheck, IconShieldAlert,
11+
} from "./icons";
812

913
interface Data {
1014
status: Status | null;
@@ -30,6 +34,7 @@ const DEMO_TOKEN = (import.meta.env.VITE_DEMO_TOKEN as string | undefined) || ""
3034
export function App() {
3135
const [token, setToken] = useState(localStorage.getItem("fwtoken") || DEMO_TOKEN);
3236
const [auto, setAuto] = useState(true);
37+
const [showLimits, setShowLimits] = useState(false);
3338
const [d, setD] = useState<Data>(EMPTY);
3439
const [upMsg, setUpMsg] = useState("");
3540
const [events, setEvents] = useState<FwEvent[]>([]);
@@ -118,11 +123,14 @@ export function App() {
118123
</div>
119124
);
120125
};
121-
const meter = (dir: string, lbl: string, color: string, big: string, sub: string, spark: ComponentChildren, sat: ComponentChildren) => (
126+
const meter = (dir: ComponentChildren, lbl: string, color: string, big: string, sub: string, spark: ComponentChildren, sat: ComponentChildren) => (
122127
<div class="meter">
123-
<span class="dir" style={{ color }}>{dir}</span>
124-
<div class="stack"><span class="lbl">{lbl}</span><span class="big" style={{ color }}>{big}</span><span class="sub">{sub}</span></div>
125-
{spark}{sat}
128+
<div class="meter-main">
129+
<span class="dir" style={{ color }}>{dir}</span>
130+
<div class="stack"><span class="lbl">{lbl}</span><span class="big" style={{ color }}>{big}</span><span class="sub">{sub}</span></div>
131+
{spark}
132+
</div>
133+
{sat}
126134
</div>
127135
);
128136

@@ -143,8 +151,8 @@ export function App() {
143151
const evRows = events.map((e) => [e.seq, timeStr(e.ts_unix), e.kind, e.cidr, flagCell(e.flags), fmtn(e.pps), fmtbps(e.bps), fmtn(e.syn_pps)]);
144152

145153
const posture = s.active_mitigations > 0
146-
? <span class="chip atk"> under attack</span>
147-
: <span class="chip arm"> armed</span>;
154+
? <span class="chip atk"><IconShieldAlert /> under attack</span>
155+
: <span class="chip arm"><IconShieldCheck /> armed</span>;
148156

149157
return (
150158
<>
@@ -154,36 +162,46 @@ export function App() {
154162
<span class="muted">up {s.uptime_secs}s · {s.active_mitigations} active · {s.learned_ports} ports{nicStr ? " · " + nicStr : ""}</span>
155163
{d.err ? <span class="err">{d.err}</span> : null}
156164
{d.upgrade && d.upgrade.available
157-
? <button title={"download & install " + d.upgrade.latest + ", then restart"} onClick={doUpgrade}> upgrade {d.upgrade.latest}</button>
165+
? <button title={"download & install " + d.upgrade.latest + ", then restart"} onClick={doUpgrade}><IconUpgrade /> upgrade {d.upgrade.latest}</button>
158166
: null}
159167
{upMsg ? <span class="muted">{upMsg}</span> : null}
160168
<span class="grow" />
161-
<button class="ghost" title="Check for updates now" onClick={checkUpdates}></button>
169+
<button class="ghost icon" title="Detection limits" onClick={() => setShowLimits(true)}><IconSettings /></button>
170+
<button class="ghost icon" title="Check for updates now" onClick={checkUpdates}><IconUpdateCheck /></button>
162171
<label class="muted"><input type="checkbox" checked={auto} onChange={(e) => setAuto((e.target as HTMLInputElement).checked)} /> auto</label>
163-
<button class="ghost" onClick={refresh}>refresh</button>
164-
<button class="ghost" onClick={disconnect}>disconnect</button>
172+
<button class="ghost icon" title="Refresh now" onClick={refresh}><IconRefresh /></button>
173+
<button class="ghost icon" title="Disconnect" onClick={disconnect}><IconDisconnect /></button>
165174
</header>
166175
<main>
167176
{t0 && (
168-
<section class="wide"><div class="traffic">
169-
{meter("↓", "rx · ingress", "#2fd4c4", fmtbps(t0.rx_bps), fmtn(t0.rx_pps) + " pps · " + fmtn(t0.rx_syn_pps) + " syn/s",
170-
<Sparkline data={hist.rx} color="#2fd4c4" />, satBar(t0.rx_bps))}
171-
{meter("↑", "tx · egress", "#9a86ff", fmtbps(t0.tx_bps), fmtn(t0.tx_pps) + " pps",
172-
<Sparkline data={hist.tx} color="#9a86ff" />, satBar(t0.tx_bps))}
173-
{meter("●", "dropped", dropColor(t0.rx_drop_pct), t0.rx_drop_pct + "%", fmtn(t0.rx_drop_pps) + " pps",
174-
<Sparkline data={hist.drop} color={dropColor(t0.rx_drop_pct)} max={100} />, null)}
175-
</div></section>
177+
<div class="wide split2">
178+
<div class="split2-col">
179+
<section><div class="traffic">
180+
{meter(<IconArrowDown />, "rx · ingress", "#2fd4c4", fmtbps(t0.rx_bps), fmtn(t0.rx_pps) + " pps · " + fmtn(t0.rx_syn_pps) + " syn/s",
181+
<Sparkline data={hist.rx} color="#2fd4c4" />, satBar(t0.rx_bps))}
182+
{meter(<IconArrowUp />, "tx · egress", "#9a86ff", fmtbps(t0.tx_bps), fmtn(t0.tx_pps) + " pps",
183+
<Sparkline data={hist.tx} color="#9a86ff" />, satBar(t0.tx_bps))}
184+
{meter(<IconNoSymbol />, "dropped", dropColor(t0.rx_drop_pct), t0.rx_drop_pct + "%", fmtn(t0.rx_drop_pps) + " pps",
185+
<Sparkline data={hist.drop} color={dropColor(t0.rx_drop_pct)} max={100} />, null)}
186+
</div></section>
187+
<Section title="Protected prefixes" extra={"(" + d.prefixes.length + ")"}>
188+
<PagedTable cols={["prefix", "origin", "rx pps", "rx bps", "tx pps", "tx bps", "syn/s", "drop/s", "drop%", "load", "state"]} rows={prefixRows} />
189+
</Section>
190+
</div>
191+
<Section title="Traffic origins" extra="source countries by pps"><OriginsCard token={token} /></Section>
192+
</div>
193+
)}
194+
{showLimits && (
195+
<Modal title="Detection limits" onClose={() => setShowLimits(false)}>
196+
<LimitsCard token={token} nics={s.nics} />
197+
</Modal>
176198
)}
177-
<Section wide title="Detection limits"><LimitsCard token={token} nics={s.nics} /></Section>
178199
<Section wide title="Active mitigations" extra={"(" + d.mitigations.length + ")"}>
179200
<MitigationsCard token={token} mitigations={d.mitigations} onChange={refresh} />
180201
</Section>
181202
<Section wide title="Live tracked IPs" extra={"(" + d.tracked.length + ")"}>
182203
<PagedTable cols={["ip", "origin", "rx pps", "rx bps", "tx pps", "tx bps", "syn/s", "drop/s", "drop%", "load", "state"]} rows={trackedRows} />
183204
</Section>
184-
<Section wide title="Protected prefixes" extra={"(" + d.prefixes.length + ")"}>
185-
<PagedTable cols={["prefix", "origin", "rx pps", "rx bps", "tx pps", "tx bps", "syn/s", "drop/s", "drop%", "load", "state"]} rows={prefixRows} />
186-
</Section>
187205
<Section wide title="Sources"><SourcesCard token={token} /></Section>
188206
<PortsCard token={token} />
189207
<Section wide title="Events" extra={"(" + events.length + ")"}>

lnvps_fw/lnvps_fw_service/dashboard/src/cards.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { useState, useEffect, useCallback } from "preact/hooks";
22
import { api, authHeaders } from "./api";
3-
import type { InterfaceInfo, Limits, Mitigation, TrackedSource, SourcesPage, PortsPage } from "./api";
4-
import { fmtn, fmtbps, fmtUnit, parseUnit, timeStr, dropColor } from "./format";
3+
import type { InterfaceInfo, Limits, Mitigation, TrackedSource, SourcesPage, PortsPage, OriginsResponse } from "./api";
4+
import { fmtn, fmtbps, fmtUnit, parseUnit, timeStr, dropColor, flagEmoji } from "./format";
55
import { LoadBar, Table, Pager, PagedTable, Section, Modal, flagCell } from "./ui";
6+
import { IconTrash } from "./icons";
67

78
const FLAGS: [number, string][] = [[1, "PORT_FILTER"], [2, "SYN_PROXY"], [4, "RATE_CAPS"], [8, "SOURCE_BLOCK"]];
89

@@ -124,7 +125,7 @@ export function MitigationsCard({ token, mitigations, onChange }: {
124125
} catch (e) { setMsg((e as Error).message); }
125126
};
126127
const del = async (c: string) => { try { await fetch("/api/v1/mitigations?cidr=" + encodeURIComponent(c), { method: "DELETE", headers: hdr }); onChange(); } catch { /* */ } };
127-
const bin = (c: string) => <button class="binbtn" title="remove override" onClick={() => del(c)}>🗑</button>;
128+
const bin = (c: string) => <button class="binbtn" title="remove override" onClick={() => del(c)}><IconTrash /></button>;
128129
const meta = (m: Mitigation) => m.manual ? <span><span class="tag">manual</span> {timeStr(m.since_unix)}</span> : timeStr(m.since_unix);
129130
const rows = mitigations.map((m) => [
130131
m.cidr, geoCell(m), fmtn(m.rx_pps), fmtbps(m.rx_bps), fmtn(m.tx_pps), fmtbps(m.tx_bps),
@@ -188,7 +189,7 @@ export function SourcesCard({ token }: { token: string }) {
188189
} catch (e) { setMsg((e as Error).message); }
189190
};
190191
const del = async (c: string) => { try { await fetch("/api/v1/blocks?cidr=" + encodeURIComponent(c), { method: "DELETE", headers: hdr }); load(); } catch { /* */ } };
191-
const bin = (c: string) => <button class="binbtn" title="remove block" onClick={() => del(c)}>🗑</button>;
192+
const bin = (c: string) => <button class="binbtn" title="remove block" onClick={() => del(c)}><IconTrash /></button>;
192193
const stateCell = (s: TrackedSource) => s.manual
193194
? <span class="tag">pinned</span>
194195
: s.state === "dropping" ? <span style={{ color: "#ff5d6c" }}>dropping</span>
@@ -220,6 +221,43 @@ export function SourcesCard({ token }: { token: string }) {
220221
);
221222
}
222223

224+
// --- Traffic origins: attack-source pps aggregated by origin country, ranked
225+
// as a compact flags list (no geographic map). Server-side aggregation keeps
226+
// the payload bounded to one row per country even under a large flood. ---
227+
export function OriginsCard({ token }: { token: string }) {
228+
const [data, setData] = useState<OriginsResponse | null>(null);
229+
const load = useCallback(async () => {
230+
try { setData(await api<OriginsResponse>("/api/v1/origins", token)); } catch { /* surfaced by the main poller */ }
231+
}, [token]);
232+
useEffect(() => { load(); const id = setInterval(load, 3000); return () => clearInterval(id); }, [load]);
233+
const top = data?.items.slice(0, 12) ?? [];
234+
const max = Math.max(...top.map((o) => o.pps), 1);
235+
// Fixed-height box so the card doesn't resize as countries come and go.
236+
return (
237+
<div class="origins-box">
238+
{!data ? <div class="muted"></div>
239+
: !top.length ? <div class="muted">no rate-tracked sources — origins appear here during a mitigation</div>
240+
: <div class="origins">{top.map((o) => {
241+
const share = data.total_pps ? Math.round((o.pps / data.total_pps) * 100) : 0;
242+
const dropPct = o.pps ? Math.round((o.drop_pps / o.pps) * 100) : 0;
243+
const name = o.country === "??" ? "unknown" : o.country;
244+
return (
245+
<div class="origin" key={o.country}>
246+
<span class="oflag">{flagEmoji(o.country)}</span>
247+
<span class="occ" title={name + " · " + share + "% of tracked source pps · " + dropPct + "% dropped"}>{name}</span>
248+
{/* full bar = total pps vs the busiest country; inner fill = dropped share */}
249+
<span class="obar"><span class="ofill" style={{ width: (o.pps / max) * 100 + "%" }}>
250+
<span class="odrop" style={{ width: dropPct + "%", background: dropColor(dropPct) }} />
251+
</span></span>
252+
<span class="opps"><b style={{ color: dropColor(dropPct) }}>{fmtn(o.drop_pps)}</b> / {fmtn(o.pps)} pps</span>
253+
<span class="muted osrc">{o.sources} src{o.dropping ? " · " + o.dropping + " blk" : ""}</span>
254+
</div>
255+
);
256+
})}</div>}
257+
</div>
258+
);
259+
}
260+
223261
// --- Learned open ports: server-paginated + filtered ---
224262
export function PortsCard({ token }: { token: string }) {
225263
const PAGE = 50;

lnvps_fw/lnvps_fw_service/dashboard/src/format.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ export const fmtUnit = (n: number): string => {
5454

5555
export const timeStr = (t: number): string => new Date(t * 1000).toLocaleTimeString();
5656

57+
/** ISO 3166-1 alpha-2 code -> regional-indicator flag emoji; globe for unknown
58+
* or malformed codes (e.g. the "??" bucket for un-geolocated sources). */
59+
export const flagEmoji = (cc?: string): string => {
60+
if (!cc || !/^[A-Za-z]{2}$/.test(cc)) return "\u{1F310}"; // globe
61+
const A = 0x1f1e6, base = "A".charCodeAt(0);
62+
const u = cc.toUpperCase();
63+
return String.fromCodePoint(A + (u.charCodeAt(0) - base), A + (u.charCodeAt(1) - base));
64+
};
65+
5766
// Scope palette: teal (calm) -> cyan -> amber (load) -> coral (alarm).
5867
export const loadColor = (p: number): string =>
5968
p >= 100 ? "#ff5d6c" : p >= 80 ? "#f5b13d" : p >= 50 ? "#5fc9e0" : "#2fd4c4";

0 commit comments

Comments
 (0)