deploy: nightly backup (pg_dump + ClickHouse native exports, off-node copy to BHS128)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
44 changed files +2,860 −15
added
apps/web/Dockerfile
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +# InternetPressure.io — web (Next 16 standalone). Build from the REPOSITORY ROOT: | |
| 2 | +# docker build -f apps/web/Dockerfile -t internetpressure-web . | |
| 3 | +# The dev mock (apps/web/mock) is excluded via apps/web/Dockerfile.dockerignore and is never part of the image. | |
| 4 | + | |
| 5 | +FROM node:22-alpine AS deps | |
| 6 | +RUN corepack enable && corepack prepare pnpm@11 --activate | |
| 7 | +WORKDIR /repo | |
| 8 | +COPY apps/web/package.json apps/web/pnpm-lock.yaml apps/web/ | |
| 9 | +RUN cd apps/web && pnpm install --frozen-lockfile | |
| 10 | + | |
| 11 | +FROM node:22-alpine AS build | |
| 12 | +RUN corepack enable && corepack prepare pnpm@11 --activate | |
| 13 | +WORKDIR /repo | |
| 14 | +COPY --from=deps /repo/apps/web/node_modules apps/web/node_modules | |
| 15 | +COPY apps/web apps/web | |
| 16 | +# Static pages (/methodology, /api) tolerate an unreachable API at build time (they fall back to documented defaults). | |
| 17 | +ENV NEXT_TELEMETRY_DISABLED=1 | |
| 18 | +RUN cd apps/web && pnpm build | |
| 19 | + | |
| 20 | +FROM node:22-alpine AS runtime | |
| 21 | +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=8351 HOSTNAME=0.0.0.0 | |
| 22 | +WORKDIR /app | |
| 23 | +RUN addgroup -S web && adduser -S web -G web | |
| 24 | +# outputFileTracingRoot = repo root → the standalone server lives at apps/web/server.js | |
| 25 | +COPY --from=build --chown=web:web /repo/apps/web/.next/standalone ./ | |
| 26 | +COPY --from=build --chown=web:web /repo/apps/web/.next/static ./apps/web/.next/static | |
| 27 | +COPY --from=build --chown=web:web /repo/apps/web/public ./apps/web/public | |
| 28 | +USER web | |
| 29 | +EXPOSE 8351 | |
| 30 | +CMD ["node", "apps/web/server.js"] | |
added
apps/web/Dockerfile.dockerignore
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +# Applies to `docker build -f apps/web/Dockerfile .` (BuildKit picks <Dockerfile>.dockerignore). | |
| 2 | +**/node_modules | |
| 3 | +**/.next | |
| 4 | +**/.git | |
| 5 | +**/qa/screens | |
| 6 | +apps/web/mock | |
| 7 | +apps/web/qa | |
| 8 | +apps/api | |
| 9 | +services | |
| 10 | +infra | |
| 11 | +deploy | |
| 12 | +docs | |
| 13 | +data | |
| 14 | +tests | |
| 15 | +**/.env* | |
added
apps/web/README.md
+126 −0
@@ -0,0 +1,126 @@ | ||
| 1 | +# InternetPressure.io — web frontend | |
| 2 | + | |
| 3 | +Next 16 (App Router, React 19, TypeScript strict, Tailwind 4) instrument for **The real-time pressure gauge for the | |
| 4 | +Internet**. It renders the public API (`docs/API.md`) server-side, then follows the `/api/v1/live` SSE stream in small | |
| 5 | +client islands. Dark-first, tabular numerals, no motion without a real update (spec §66), visible "Instrument degraded" | |
| 6 | +state when `internal_status ≠ ok` (spec §57). | |
| 7 | + | |
| 8 | +## Structure | |
| 9 | + | |
| 10 | +``` | |
| 11 | +apps/web | |
| 12 | +├── next.config.ts env loading (../../.env), /api → API_URL rewrite, security + cache headers, standalone output | |
| 13 | +├── Dockerfile multi-stage node:22-alpine → .next/standalone (port 8351) | |
| 14 | +├── mock/server.mjs DEV-ONLY API fixtures for every endpoint of docs/API.md, incl. SSE (never shipped) | |
| 15 | +├── scripts/copy-maplibre-worker.mjs copies MapLibre 6 module worker to public/maplibre (predev/prebuild) | |
| 16 | +├── qa/screens.mjs Playwright screenshots + overflow/console checks at 1440×900 and 390×844 | |
| 17 | +├── public/logo.svg gauge glyph (also inline in components/chrome/Logo.tsx and src/app/icon.svg) | |
| 18 | +└── src | |
| 19 | + ├── app | |
| 20 | + │ ├── layout.tsx html.dark, Geist fonts, TimeProvider (UTC/local) | |
| 21 | + │ ├── (site)/ public chrome: layout (LiveProvider + header/footer), all public routes | |
| 22 | + │ ├── admin/ separate layout (token gate, no SSE) + 10 sections | |
| 23 | + │ ├── opengraph-image.tsx OG image from the live index · robots.ts · sitemap.ts · error.tsx · not-found.tsx | |
| 24 | + │ └── globals.css design tokens, pressure scale CSS variables, .tbl dense tables | |
| 25 | + ├── components | |
| 26 | + │ ├── chrome/ Header, NavLinks, Footer, Logo, LiveIndicator, DegradedBanner, TimeToggle, Search (⌘K) | |
| 27 | + │ ├── gauge/ Gauge (the number), ExplainPanel (explain rows → components → signals) | |
| 28 | + │ ├── home/ ComponentRows, Fronts, IncidentsList, Ticker, Clock, RegionsTable, ProbeStrip | |
| 29 | + │ ├── map/ MapIsland (lazy, ssr:false) → WorldMap (MapLibre), ModeSelector, modes | |
| 30 | + │ ├── charts/ echarts core registration, useEChart, HistoryChart, SeriesChart | |
| 31 | + │ ├── detail/ ScopeHeader, ComponentGrid, Tables (targets/probes/latency matrix), ScopeCharts | |
| 32 | + │ ├── incidents/ routes/ service/ history/ bgp/ targets/ admin/ | |
| 33 | + │ └── ui/ primitives (Section, LevelBadge, Delta, Bar, Sparkline, Stat…), AnimatedNumber | |
| 34 | + └── lib | |
| 35 | + ├── types.ts TypeScript mirror of docs/API.md (public + admin) | |
| 36 | + ├── api.ts server-side fetch (API_URL_INTERNAL, no-store, 404 → notFound) | |
| 37 | + ├── live.tsx one EventSource per page, backoff reconnect, sliced store (useSyncExternalStore) | |
| 38 | + ├── pressure.ts levels, colours, pressureColor(level|value), component labels | |
| 39 | + ├── format.ts numerals, deltas (true minus), durations, formatTime(ts, utc|local) | |
| 40 | + ├── time.tsx UTC/local toggle persisted in localStorage; <Time/> component | |
| 41 | + ├── geo.ts great-circle interpolation for Pressure Front arcs | |
| 42 | + ├── iso-numeric-to-alpha2.ts world-atlas numeric ids → ISO alpha-2 | |
| 43 | + └── admin-fetch.ts X-IP-Admin-Token client (token in sessionStorage) | |
| 44 | +``` | |
| 45 | + | |
| 46 | +## Routes | |
| 47 | + | |
| 48 | +| Route | Consumes | | |
| 49 | +|---|---| | |
| 50 | +| `/` | `pressure/global`, `ticker`, `pressure/regions`, `pressure/countries`, `fronts`, `incidents?status=active`, `probes`, `pressure/history?range=24h`, `latency` + live stream | | |
| 51 | +| `/internet/[region]` | `pressure/region/{id}` | | |
| 52 | +| `/country/[cc]` | `pressure/country/{cc}`, `latency` (matrix rows of its region) | | |
| 53 | +| `/asn/[asn]`, `/asns` | `pressure/asn/{asn}`, `asns` | | |
| 54 | +| `/service/[slug]`, `/services` | `service/{slug}`, `services` | | |
| 55 | +| `/routes` | `routes/pairs`, `probes`, `targets`, `routes?probe&target` (client refetch on change) | | |
| 56 | +| `/event/[slug]`, `/incidents` | `incident/{slug}`, `incidents?status=…` | | |
| 57 | +| `/history`, `/history/[year]`, `/history/[year]/[month]` | `history/summary[?year[&month]]` | | |
| 58 | +| `/probes` | `probes`, `pressure/regions`, `latency` | | |
| 59 | +| `/targets` | `targets` | | |
| 60 | +| `/bgp` | `bgp/stats` + live `bgp_stats` | | |
| 61 | +| `/methodology` (static, revalidate 300) | `methodology` + prose from SPEC §3–8, 20, 40, 57, 66 | | |
| 62 | +| `/api` (static) | none (documentation) | | |
| 63 | +| `/admin/*` | `/api/admin/*` (client-side, token header) | | |
| 64 | +| `/opengraph-image`, `/sitemap.xml`, `/robots.txt` | `pressure/global`; countries/regions/asns/services/incidents/history | | |
| 65 | + | |
| 66 | +All dynamic pages export `dynamic = 'force-dynamic'` so `next build` never bakes API data. | |
| 67 | + | |
| 68 | +## Environment variables | |
| 69 | + | |
| 70 | +| Variable | Default | Used by | | |
| 71 | +|---|---|---| | |
| 72 | +| `API_URL` | `http://127.0.0.1:8352` | `next.config.ts` rewrite of browser `/api/*` calls (dev; in production the edge Caddy routes `/api` directly) | | |
| 73 | +| `API_URL_INTERNAL` | falls back to `API_URL` | server components / OG image / sitemap fetching the API inside the network | | |
| 74 | +| `NEXT_PUBLIC_SITE_URL` | `https://www.internetpressure.io` | canonical URLs, OG, sitemap, API examples | | |
| 75 | + | |
| 76 | +A single `.env` at the repository root is loaded by `next.config.ts` (like the sibling projects). | |
| 77 | + | |
| 78 | +## Development | |
| 79 | + | |
| 80 | +```bash | |
| 81 | +pnpm install | |
| 82 | +node mock/server.mjs # dev-only fixtures on :8352 (MOCK_DEGRADED=1 to see the degraded state; admin token dev-admin-token) | |
| 83 | +pnpm dev # http://localhost:8351 (predev copies the MapLibre worker to public/maplibre) | |
| 84 | +pnpm typecheck && pnpm lint | |
| 85 | +node qa/screens.mjs # screenshots into qa/screens/ (Playwright borrowed from ~/Desktop/uqo-eval/node_modules) | |
| 86 | +``` | |
| 87 | + | |
| 88 | +The mock is **development-only**: it is excluded from the Docker image (`Dockerfile.dockerignore`) and must never be | |
| 89 | +deployed. The production site only ever talks to `apps/api`. | |
| 90 | + | |
| 91 | +## Build & Docker | |
| 92 | + | |
| 93 | +```bash | |
| 94 | +pnpm build # requires the API (or the mock) reachable at API_URL_INTERNAL for the two static pages | |
| 95 | +pnpm start # next start -p 8351 -H 0.0.0.0 | |
| 96 | + | |
| 97 | +# from the repository root | |
| 98 | +docker build -f apps/web/Dockerfile -t internetpressure-web . | |
| 99 | +docker run --rm -p 8351:8351 -e API_URL_INTERNAL=http://api:8352 internetpressure-web | |
| 100 | +``` | |
| 101 | + | |
| 102 | +`output: 'standalone'` with `outputFileTracingRoot` at the repo root puts the server at | |
| 103 | +`.next/standalone/apps/web/server.js`; the Dockerfile copies `.next/static` and `public` next to it. | |
| 104 | + | |
| 105 | +## Notes on MapLibre 6 | |
| 106 | + | |
| 107 | +MapLibre GL ≥ 6 is ESM-only and spawns a *module* worker resolved from `import.meta.url`. Bundled by Turbopack that URL | |
| 108 | +points at a chunk, the worker 404s and the map stays black. `scripts/copy-maplibre-worker.mjs` copies | |
| 109 | +`maplibre-gl-worker.mjs` + `maplibre-gl-shared.mjs` to `public/maplibre/` and `WorldMap.tsx` calls | |
| 110 | +`setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')`. Basemap: `https://tiles.openfreemap.org/styles/dark` (no key) with | |
| 111 | +an offline fallback style; countries from `world-atlas` 110m; only observed countries are coloured. | |
| 112 | + | |
| 113 | +## Contract notes (docs/API.md) | |
| 114 | + | |
| 115 | +Everything consumed is in API.md. Places where the frontend had to interpret the contract: | |
| 116 | + | |
| 117 | +- `GET /pressure/region/{id}` and `/pressure/country/{cc}`: `probes`/`targets`/`incidents` are **counts** in the list | |
| 118 | + objects and **lists** in the detail objects — typed separately (`RegionDetailResponse`, `CountryDetailResponse`). | |
| 119 | +- `GET /history/summary` without `month`: the month rows (`months`) are assumed to carry `{month, min, max, avg, events}` | |
| 120 | + (mirrors the `days` shape with `month: "YYYY-MM"`); API.md only says "per-month rows". | |
| 121 | +- `fronts[].status` values are not enumerated in API.md; the map animates the arc only for `developing`/`active`. | |
| 122 | +- The map's "Packet loss" mode derives per-region loss from `latency.matrix[].loss_pct` (source view) because there is no | |
| 123 | + loss component in `regions[].components`; countries are neutral in that mode. | |
| 124 | +- Admin `GET /incidents` rows are assumed to carry `review` and `note` (the PATCH body fields) so the review state can | |
| 125 | + be displayed; API.md does not list them on the GET response. | |
| 126 | +- Admin `GET /annotations` rows assumed `{id?, ts, author?, scope_type, scope_id, text}`. | |
modified
apps/web/mock/server.mjs
+1 −1
@@ -351,7 +351,7 @@ const allIncidents = () => [...activeIncidents(), ...RESOLVED]; | ||
| 351 | 351 | function incidentDetail(inc) { |
| 352 | 352 | const start = new Date(inc.started_at).getTime(); |
| 353 | 353 | const end = inc.ended_at ? new Date(inc.ended_at).getTime() : now(); |
| 354 | − const step = Math.max(60, Math.round((end - start + 30 * 60_000) / 180 / 60) * 60); | |
| 354 | + const step = Math.max(60, Math.round((end - start + 30 * 60_000) / 1000 / 180 / 60) * 60); | |
| 355 | 355 | const points = []; |
| 356 | 356 | for (let t = start - 30 * 60_000; t <= end; t += step * 1000) { |
| 357 | 357 | const frac = clamp((t - start) / Math.max(1, end - start), 0, 1); |
added
apps/web/src/app/(site)/api/page.tsx
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { Section } from '@/components/ui/primitives'; | |
| 3 | +import { SITE_URL } from '@/lib/site'; | |
| 4 | + | |
| 5 | +export const revalidate = 3600; | |
| 6 | +export const metadata: Metadata = { title: 'API', description: 'Public, rate-limited JSON API and Server-Sent Events stream of InternetPressure.io — the same API the site uses.' }; | |
| 7 | + | |
| 8 | +const ENDPOINTS: { path: string; desc: string; params?: string }[] = [ | |
| 9 | + { path: '/api/v1/status', desc: 'Instrument health: engine, ingest, probes, BGP, stores, internal_status.' }, | |
| 10 | + { path: '/api/v1/pressure/global', desc: 'The index: pressure, level, Δ1h/Δ24h, velocity, acceleration, volatility, confidence, coverage, components with drivers, explain rows, 1-h sparkline.' }, | |
| 11 | + { path: '/api/v1/pressure/history', desc: 'Server-side aggregated series for any scope.', params: 'scope_type=global|region|country|asn|service|component · scope_id · range=1h|6h|24h|7d|30d|1y' }, | |
| 12 | + { path: '/api/v1/pressure/regions', desc: 'All regions with pressure, level, Δ1h, component scores, probes, targets, coverage.' }, | |
| 13 | + { path: '/api/v1/pressure/region/{id}', desc: 'Region detail: 24-h history, 7-d baseline, incidents, top ASNs/services, probes, latency matrix.' }, | |
| 14 | + { path: '/api/v1/pressure/countries', desc: 'Countries where we hold at least one probe or anchored target — nothing else is pretended.' }, | |
| 15 | + { path: '/api/v1/pressure/country/{cc}', desc: 'Country detail with history, baseline, incidents, ASNs, services, probes and targets.' }, | |
| 16 | + { path: '/api/v1/asns', desc: 'Autonomous systems index.' }, | |
| 17 | + { path: '/api/v1/pressure/asn/{asn}', desc: 'ASN detail: components, BGP stats and 24-h series, regions observed, targets, incidents.' }, | |
| 18 | + { path: '/api/v1/services', desc: 'Services index with observed availability and vendor status indicator.' }, | |
| 19 | + { path: '/api/v1/service/{slug}', desc: 'Observed vs vendor, discrepancy, probe × target matrix, affected regions.' }, | |
| 20 | + { path: '/api/v1/targets', desc: 'Target registry.' }, | |
| 21 | + { path: '/api/v1/target/{id}', desc: 'Target detail: latest measurement per probe, 24-h series, DNS resolvers.' }, | |
| 22 | + { path: '/api/v1/probes', desc: 'Probe network: status, last seen, version, uptime, clock offset, capabilities.' }, | |
| 23 | + { path: '/api/v1/incidents', desc: 'Incident list.', params: 'status=active|resolved|all · limit · offset' }, | |
| 24 | + { path: '/api/v1/incident/{slug}', desc: 'Incident detail: timeline, evidence, series with global overlay, probes, targets, BGP, annotations.' }, | |
| 25 | + { path: '/api/v1/fronts', desc: 'Active Pressure Fronts (source → destination arcs).' }, | |
| 26 | + { path: '/api/v1/bgp/stats', desc: 'BGP rates vs baseline, collectors, 1-h series, top origins.' }, | |
| 27 | + { path: '/api/v1/latency', desc: 'Global latency medians, inter-region matrix, per-probe view.' }, | |
| 28 | + { path: '/api/v1/ticker', desc: 'The live ticker counters.' }, | |
| 29 | + { path: '/api/v1/routes', desc: 'Route Explorer: baseline vs current traceroute, diff, 24-h hashes, 7-d share.', params: 'probe · target' }, | |
| 30 | + { path: '/api/v1/routes/pairs', desc: 'Sampled (probe, target) pairs with 24-h change counts.' }, | |
| 31 | + { path: '/api/v1/history/summary', desc: 'History explorer summary.', params: 'year · month' }, | |
| 32 | + { path: '/api/v1/explain', desc: 'Deep explainability: components → signals with current, median, MAD, robust z, samples, stress, contribution.' }, | |
| 33 | + { path: '/api/v1/methodology', desc: 'Public copy of the scoring configuration.' }, | |
| 34 | + { path: '/api/v1/search', desc: 'Search countries, regions, ASNs, services, targets, incidents.', params: 'q' }, | |
| 35 | + { path: '/api/v1/live', desc: 'Server-Sent Events: snapshot, global_pressure_update, regional_pressure_update, ticker, bgp_stats, probe_stats, incident_created/updated, service_degradation, front_update, internal_status.' }, | |
| 36 | +]; | |
| 37 | + | |
| 38 | +export default function ApiPage() { | |
| 39 | + return ( | |
| 40 | + <div className="pb-8"> | |
| 41 | + <header className="pt-6 pb-4"> | |
| 42 | + <p className="label">Developers</p> | |
| 43 | + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Public API</h1> | |
| 44 | + <p className="mt-2 max-w-[780px] text-[14px] leading-relaxed text-ink-2">The website is built on the same API. It is free and rate-limited (120 requests per minute per IP, 4 concurrent SSE connections), returns JSON with UTC ISO-8601 timestamps and plain numbers, and is served with <span className="num">Cache-Control: no-store</span> except long-range history (30 s). Unknown scopes return <span className="num">404 {'{"error":"not_found"}'}</span>. Higher limits, webhooks and historical exports: contact@spboucher.ai.</p> | |
| 45 | + </header> | |
| 46 | + | |
| 47 | + <Section label="Quick start"> | |
| 48 | + <pre className="num overflow-x-auto rounded-[4px] border border-line bg-panel p-4 text-[12px] leading-relaxed text-ink"> | |
| 49 | + {`# the index right now | |
| 50 | +curl -s ${SITE_URL}/api/v1/pressure/global | jq '{pressure, level, delta_1h, confidence}' | |
| 51 | + | |
| 52 | +# 24 h of global pressure, 1-minute step | |
| 53 | +curl -s "${SITE_URL}/api/v1/pressure/history?scope_type=global&range=24h" | jq '.summary' | |
| 54 | + | |
| 55 | +# a country, an ASN, a service | |
| 56 | +curl -s ${SITE_URL}/api/v1/pressure/country/CA | jq '.pressure' | |
| 57 | +curl -s ${SITE_URL}/api/v1/pressure/asn/13335 | jq '.bgp' | |
| 58 | +curl -s ${SITE_URL}/api/v1/service/cloudflare | jq '{observed, vendor_status, discrepancy}' | |
| 59 | + | |
| 60 | +# live stream (SSE) | |
| 61 | +curl -N ${SITE_URL}/api/v1/live`} | |
| 62 | + </pre> | |
| 63 | + </Section> | |
| 64 | + | |
| 65 | + <Section label="Endpoints" right={<span>{ENDPOINTS.length} public routes</span>}> | |
| 66 | + <div className="scroll-x -mx-3 px-3"> | |
| 67 | + <table className="tbl min-w-[640px]"> | |
| 68 | + <thead> | |
| 69 | + <tr> | |
| 70 | + <th>GET</th> | |
| 71 | + <th>Returns</th> | |
| 72 | + <th className="hidden md:table-cell">Query</th> | |
| 73 | + </tr> | |
| 74 | + </thead> | |
| 75 | + <tbody> | |
| 76 | + {ENDPOINTS.map((e) => ( | |
| 77 | + <tr key={e.path}> | |
| 78 | + <td className="num align-top text-ink"> | |
| 79 | + <a href={e.path.includes('{') ? undefined : e.path} className={e.path.includes('{') ? '' : 'hover:text-accent'}> | |
| 80 | + {e.path} | |
| 81 | + </a> | |
| 82 | + </td> | |
| 83 | + <td className="whitespace-normal align-top text-ink-2">{e.desc}</td> | |
| 84 | + <td className="num hidden whitespace-normal align-top text-[11px] text-ink-3 md:table-cell">{e.params ?? ''}</td> | |
| 85 | + </tr> | |
| 86 | + ))} | |
| 87 | + </tbody> | |
| 88 | + </table> | |
| 89 | + </div> | |
| 90 | + </Section> | |
| 91 | + | |
| 92 | + <Section label="Levels & status"> | |
| 93 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-6 text-[13px] text-ink-2 md:grid-cols-2"> | |
| 94 | + <p> | |
| 95 | + Every <span className="num text-ink">level</span> field is one of <span className="num text-ink">calm ≤10 · normal ≤25 · elevated ≤40 · stressed ≤55 · high ≤70 · severe ≤85 · extreme ≤100</span>; <span className="num text-ink">level_label</span> is its human label. | |
| 96 | + </p> | |
| 97 | + <p> | |
| 98 | + <span className="num text-ink">internal_status</span> is <span className="num">ok</span>, <span className="num">degraded</span> (too few fresh probes, stale BGP or unhealthy stores — the score is frozen and <span className="num">stale: true</span>) or <span className="num">stale</span> (engine has not run). Clients must show a degraded state instead of interpreting a frozen number. | |
| 99 | + </p> | |
| 100 | + </div> | |
| 101 | + </Section> | |
| 102 | + </div> | |
| 103 | + ); | |
| 104 | +} | |
added
apps/web/src/app/(site)/bgp/page.tsx
+100 −0
@@ -0,0 +1,100 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { BgpLive } from '@/components/bgp/BgpLive'; | |
| 4 | +import { BgpSeriesChart } from '@/components/bgp/BgpSeriesChart'; | |
| 5 | +import { Section, StatusDot } from '@/components/ui/primitives'; | |
| 6 | +import { apiGet } from '@/lib/api'; | |
| 7 | +import { fmt, fmtInt } from '@/lib/format'; | |
| 8 | +import { Time } from '@/lib/time'; | |
| 9 | +import type { BgpStats } from '@/lib/types'; | |
| 10 | + | |
| 11 | +export const dynamic = 'force-dynamic'; | |
| 12 | +export const metadata: Metadata = { title: 'BGP', description: 'Live BGP updates and withdrawals per second versus baseline, collector health, one-hour series and top origin ASNs.' }; | |
| 13 | + | |
| 14 | +export default async function BgpPage() { | |
| 15 | + const b = await apiGet<BgpStats>('/api/v1/bgp/stats'); | |
| 16 | + return ( | |
| 17 | + <div className="pb-8"> | |
| 18 | + <header className="pt-6 pb-4"> | |
| 19 | + <p className="label">Routing component</p> | |
| 20 | + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">BGP</h1> | |
| 21 | + <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">Announcements and withdrawals from RIPE RIS Live collectors, normalised and compared with a rolling baseline. Withdrawal spikes, origin-ASN changes and collector disagreement feed the Routing component.</p> | |
| 22 | + </header> | |
| 23 | + <Section label="Live rates" right={<span>updated every 5 s from real counters</span>}> | |
| 24 | + <BgpLive initial={b} /> | |
| 25 | + </Section> | |
| 26 | + <Section label="Last hour" right={<span>per minute</span>}> | |
| 27 | + <BgpSeriesChart series={b.series_1h} /> | |
| 28 | + </Section> | |
| 29 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-2"> | |
| 30 | + <Section label="Collectors" right={<span>{fmtInt(b.collectors.length)} · {fmtInt(b.peers)} peers</span>} className="min-w-0"> | |
| 31 | + <div className="scroll-x -mx-3 px-3"> | |
| 32 | + <table className="tbl"> | |
| 33 | + <thead> | |
| 34 | + <tr> | |
| 35 | + <th>Collector</th> | |
| 36 | + <th className="hidden sm:table-cell">Location</th> | |
| 37 | + <th className="r">Ann./s</th> | |
| 38 | + <th className="r">Wd./s</th> | |
| 39 | + <th className="r hidden sm:table-cell">Peers</th> | |
| 40 | + <th className="r">Last msg</th> | |
| 41 | + <th>Fresh</th> | |
| 42 | + </tr> | |
| 43 | + </thead> | |
| 44 | + <tbody> | |
| 45 | + {b.collectors.map((c) => ( | |
| 46 | + <tr key={c.id}> | |
| 47 | + <td className="num text-ink">{c.id}</td> | |
| 48 | + <td className="hidden text-ink-2 sm:table-cell">{c.location}</td> | |
| 49 | + <td className="num r">{fmt(c.announcements_per_s)}</td> | |
| 50 | + <td className="num r">{fmt(c.withdrawals_per_s)}</td> | |
| 51 | + <td className="num r hidden text-ink-2 sm:table-cell">{fmtInt(c.peers)}</td> | |
| 52 | + <td className="num r text-ink-2"> | |
| 53 | + <Time ts={c.last_message} style="time" /> | |
| 54 | + </td> | |
| 55 | + <td> | |
| 56 | + <StatusDot status={c.fresh ? 'online' : 'stale'} /> | |
| 57 | + </td> | |
| 58 | + </tr> | |
| 59 | + ))} | |
| 60 | + </tbody> | |
| 61 | + </table> | |
| 62 | + </div> | |
| 63 | + </Section> | |
| 64 | + <Section label="Top origins · 1 h" className="min-w-0"> | |
| 65 | + <div className="scroll-x -mx-3 px-3"> | |
| 66 | + <table className="tbl"> | |
| 67 | + <thead> | |
| 68 | + <tr> | |
| 69 | + <th>Origin ASN</th> | |
| 70 | + <th className="r">Announcements</th> | |
| 71 | + <th className="r">Withdrawals</th> | |
| 72 | + <th className="r hidden sm:table-cell">Wd. share</th> | |
| 73 | + </tr> | |
| 74 | + </thead> | |
| 75 | + <tbody> | |
| 76 | + {b.top_origins_1h.map((o) => { | |
| 77 | + const share = o.announcements + o.withdrawals ? o.withdrawals / (o.announcements + o.withdrawals) : 0; | |
| 78 | + return ( | |
| 79 | + <tr key={o.asn}> | |
| 80 | + <td> | |
| 81 | + <Link href={`/asn/${o.asn}`} className="text-ink hover:text-accent"> | |
| 82 | + <span className="num">AS{o.asn}</span> {o.name} | |
| 83 | + </Link> | |
| 84 | + </td> | |
| 85 | + <td className="num r">{fmtInt(o.announcements)}</td> | |
| 86 | + <td className="num r" style={{ color: share > 0.3 ? 'var(--p-high)' : undefined }}> | |
| 87 | + {fmtInt(o.withdrawals)} | |
| 88 | + </td> | |
| 89 | + <td className="num r hidden text-ink-2 sm:table-cell">{Math.round(share * 100)} %</td> | |
| 90 | + </tr> | |
| 91 | + ); | |
| 92 | + })} | |
| 93 | + </tbody> | |
| 94 | + </table> | |
| 95 | + </div> | |
| 96 | + </Section> | |
| 97 | + </div> | |
| 98 | + </div> | |
| 99 | + ); | |
| 100 | +} | |
added
apps/web/src/app/(site)/event/[slug]/page.tsx
+244 −0
@@ -0,0 +1,244 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { IncidentSeriesChart } from '@/components/incidents/IncidentSeriesChart'; | |
| 4 | +import { TYPE_LABEL } from '@/components/incidents/IncidentRow'; | |
| 5 | +import { Bar, ConfBar, Section, Stat, StatusDot } from '@/components/ui/primitives'; | |
| 6 | +import { apiGet, apiTry } from '@/lib/api'; | |
| 7 | +import { fmt, fmtDuration, fmtInt, fmtRatio } from '@/lib/format'; | |
| 8 | +import { pressureColor } from '@/lib/pressure'; | |
| 9 | +import { Time } from '@/lib/time'; | |
| 10 | +import type { IncidentDetail } from '@/lib/types'; | |
| 11 | + | |
| 12 | +export const dynamic = 'force-dynamic'; | |
| 13 | + | |
| 14 | +type Params = Promise<{ slug: string }>; | |
| 15 | + | |
| 16 | +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> { | |
| 17 | + const { slug } = await params; | |
| 18 | + const i = await apiTry<IncidentDetail>(`/api/v1/incident/${encodeURIComponent(slug)}`); | |
| 19 | + if (!i) return { title: 'Incident' }; | |
| 20 | + return { title: `${i.title} — ${i.status}, peak ${fmt(i.peak_pressure)}`, description: i.summary, alternates: { canonical: `/event/${i.slug}` }, openGraph: { type: 'article', publishedTime: i.started_at, modifiedTime: i.updated_at } }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +function scopeHref(i: IncidentDetail): string | null { | |
| 24 | + if (i.scope_type === 'region' && i.scope_id) return `/internet/${i.scope_id}`; | |
| 25 | + if (i.scope_type === 'country' && i.scope_id) return `/country/${i.scope_id.toLowerCase()}`; | |
| 26 | + if (i.scope_type === 'asn' && i.scope_id) return `/asn/${i.scope_id}`; | |
| 27 | + if (i.scope_type === 'service' && i.scope_id) return `/service/${i.scope_id}`; | |
| 28 | + return null; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export default async function EventPage({ params }: { params: Params }) { | |
| 32 | + const { slug } = await params; | |
| 33 | + const i = await apiGet<IncidentDetail>(`/api/v1/incident/${encodeURIComponent(slug)}`); | |
| 34 | + const href = scopeHref(i); | |
| 35 | + return ( | |
| 36 | + <article className="pb-8"> | |
| 37 | + <header className="grid gap-6 pt-6 pb-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-end"> | |
| 38 | + <div className="min-w-0"> | |
| 39 | + <p className="label flex flex-wrap items-center gap-3"> | |
| 40 | + <span>{TYPE_LABEL[i.type] ?? i.type}</span> | |
| 41 | + <StatusDot status={i.status} /> | |
| 42 | + <span className="num normal-case tracking-normal text-ink-3">{i.event_id}</span> | |
| 43 | + </p> | |
| 44 | + <h1 className="mt-1 text-[26px] font-medium leading-tight tracking-tight sm:text-[32px]">{i.title}</h1> | |
| 45 | + <p className="mt-2 max-w-[760px] text-[14px] text-ink-2">{i.summary}</p> | |
| 46 | + <p className="num mt-3 flex flex-wrap gap-x-5 gap-y-1 text-[12px] text-ink-2"> | |
| 47 | + <span> | |
| 48 | + scope{' '} | |
| 49 | + {href ? ( | |
| 50 | + <Link href={href} className="text-ink hover:text-accent"> | |
| 51 | + {i.scope_label} | |
| 52 | + </Link> | |
| 53 | + ) : ( | |
| 54 | + <span className="text-ink">{i.scope_label}</span> | |
| 55 | + )} | |
| 56 | + </span> | |
| 57 | + <span> | |
| 58 | + started <Time ts={i.started_at} className="text-ink" /> | |
| 59 | + </span> | |
| 60 | + <span> | |
| 61 | + {i.ended_at ? ( | |
| 62 | + <> | |
| 63 | + ended <Time ts={i.ended_at} className="text-ink" /> | |
| 64 | + </> | |
| 65 | + ) : ( | |
| 66 | + <> | |
| 67 | + updated <Time ts={i.updated_at} className="text-ink" /> | |
| 68 | + </> | |
| 69 | + )} | |
| 70 | + </span> | |
| 71 | + <span> | |
| 72 | + duration <span className="text-ink">{fmtDuration(i.duration_s)}</span> | |
| 73 | + </span> | |
| 74 | + </p> | |
| 75 | + </div> | |
| 76 | + <div className="flex flex-wrap items-end gap-6"> | |
| 77 | + <div> | |
| 78 | + <div className="label">current</div> | |
| 79 | + <div className="num text-[56px] leading-none tracking-[-0.03em]" style={{ color: pressureColor(i.current_pressure) }}> | |
| 80 | + {fmt(i.current_pressure)} | |
| 81 | + </div> | |
| 82 | + </div> | |
| 83 | + <div> | |
| 84 | + <div className="label">peak</div> | |
| 85 | + <div className="num text-[28px] leading-none" style={{ color: pressureColor(i.peak_pressure) }}> | |
| 86 | + {fmt(i.peak_pressure)} | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + <div className="pb-1"> | |
| 90 | + <div className="label mb-1">confidence</div> | |
| 91 | + <ConfBar value={i.confidence} /> | |
| 92 | + </div> | |
| 93 | + </div> | |
| 94 | + </header> | |
| 95 | + | |
| 96 | + <Section label="Series" right={<span>incident scope vs global pressure · from 30 min before detection</span>}> | |
| 97 | + <IncidentSeriesChart series={i.series} timeline={i.timeline} /> | |
| 98 | + </Section> | |
| 99 | + | |
| 100 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> | |
| 101 | + <Section label="Timeline" className="min-w-0"> | |
| 102 | + <ol className="relative ml-2 border-l border-line"> | |
| 103 | + {i.timeline.map((t, idx) => ( | |
| 104 | + <li key={idx} className="relative mb-4 pl-5 last:mb-0"> | |
| 105 | + <span className="absolute -left-[5px] top-1.5 size-2 rounded-full border border-bg" style={{ background: pressureColor(t.pressure) }} aria-hidden="true" /> | |
| 106 | + <div className="flex flex-wrap items-baseline gap-x-3"> | |
| 107 | + <StatusDot status={t.status} /> | |
| 108 | + <Time ts={t.ts} className="num text-[11.5px] text-ink-2" /> | |
| 109 | + <span className="num text-[12px]" style={{ color: pressureColor(t.pressure) }}> | |
| 110 | + {fmt(t.pressure)} | |
| 111 | + </span> | |
| 112 | + </div> | |
| 113 | + <p className="mt-0.5 text-[12.5px] text-ink-2">{t.note}</p> | |
| 114 | + </li> | |
| 115 | + ))} | |
| 116 | + </ol> | |
| 117 | + </Section> | |
| 118 | + <Section label="Hypotheses" right={<span>never overstated · confidence-weighted</span>} className="min-w-0"> | |
| 119 | + {i.hypotheses.length ? ( | |
| 120 | + <ul className="space-y-4"> | |
| 121 | + {i.hypotheses.map((h, idx) => ( | |
| 122 | + <li key={idx}> | |
| 123 | + <div className="flex items-baseline justify-between gap-4"> | |
| 124 | + <p className="text-[14px] text-ink">{h.text}</p> | |
| 125 | + <span className="num shrink-0 text-[12px] text-ink-2">{Math.round(h.confidence * 100)} %</span> | |
| 126 | + </div> | |
| 127 | + <Bar value={h.confidence * 100} color="var(--accent)" className="mt-1.5" /> | |
| 128 | + <ul className="mt-2 space-y-0.5 text-[12.5px] text-ink-2"> | |
| 129 | + {h.evidence.map((e, k) => ( | |
| 130 | + <li key={k}>· {e}</li> | |
| 131 | + ))} | |
| 132 | + </ul> | |
| 133 | + </li> | |
| 134 | + ))} | |
| 135 | + </ul> | |
| 136 | + ) : ( | |
| 137 | + <p className="text-[12.5px] text-ink-3">No causal hypothesis reached the minimum confidence.</p> | |
| 138 | + )} | |
| 139 | + </Section> | |
| 140 | + </div> | |
| 141 | + | |
| 142 | + <Section label="Evidence" right={<span>signal · scope · current vs baseline · robust z</span>}> | |
| 143 | + <div className="scroll-x -mx-3 px-3"> | |
| 144 | + <table className="tbl"> | |
| 145 | + <thead> | |
| 146 | + <tr> | |
| 147 | + <th>Signal</th> | |
| 148 | + <th className="hidden sm:table-cell">Scope</th> | |
| 149 | + <th className="r">Current</th> | |
| 150 | + <th className="r">Baseline</th> | |
| 151 | + <th className="r">z</th> | |
| 152 | + <th className="r hidden sm:table-cell">Samples</th> | |
| 153 | + <th className="r hidden md:table-cell">At</th> | |
| 154 | + </tr> | |
| 155 | + </thead> | |
| 156 | + <tbody> | |
| 157 | + {i.evidence.map((e, idx) => ( | |
| 158 | + <tr key={idx}> | |
| 159 | + <td> | |
| 160 | + <span className="text-ink">{e.label}</span> <span className="num text-[10.5px] text-ink-3">{e.signal_id}</span> | |
| 161 | + </td> | |
| 162 | + <td className="hidden text-ink-2 sm:table-cell">{e.scope_id ? `${e.scope_type}:${e.scope_id}` : e.scope_type}</td> | |
| 163 | + <td className="num r">{fmt(e.current, Math.abs(e.current) < 1 ? 3 : 1)}</td> | |
| 164 | + <td className="num r text-ink-2">{fmt(e.baseline, Math.abs(e.baseline) < 1 ? 3 : 1)}</td> | |
| 165 | + <td className="num r" style={{ color: Math.abs(e.robust_z) >= 3 ? 'var(--p-high)' : 'var(--ink)' }}> | |
| 166 | + {fmt(e.robust_z)} | |
| 167 | + </td> | |
| 168 | + <td className="num r hidden text-ink-2 sm:table-cell">{fmtInt(e.samples)}</td> | |
| 169 | + <td className="num r hidden text-ink-2 md:table-cell"> | |
| 170 | + <Time ts={e.ts} style="time" /> | |
| 171 | + </td> | |
| 172 | + </tr> | |
| 173 | + ))} | |
| 174 | + </tbody> | |
| 175 | + </table> | |
| 176 | + </div> | |
| 177 | + {i.bgp && ( | |
| 178 | + <div className="mt-4 grid grid-cols-3 gap-4 sm:max-w-[480px]"> | |
| 179 | + <Stat label="BGP withdrawals" value={fmtRatio(i.bgp.withdrawals_ratio)} sub="vs baseline" /> | |
| 180 | + <Stat label="BGP announcements" value={fmtRatio(i.bgp.announcements_ratio)} sub="vs baseline" /> | |
| 181 | + <Stat label="origin changes" value={fmtInt(i.bgp.origin_changes)} /> | |
| 182 | + </div> | |
| 183 | + )} | |
| 184 | + </Section> | |
| 185 | + | |
| 186 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-2"> | |
| 187 | + <Section label={`Affected probes · ${fmtInt(i.affected_probes)}`} className="min-w-0"> | |
| 188 | + <ul className="divide-y divide-line text-[12.5px]"> | |
| 189 | + {i.probes.map((p) => ( | |
| 190 | + <li key={p.probe_id} className="flex flex-wrap gap-x-3 py-1.5"> | |
| 191 | + <span className="num w-24 text-ink">{p.probe_id}</span> | |
| 192 | + <Link href={`/internet/${p.region}`} className="w-24 text-ink-2 hover:text-accent"> | |
| 193 | + {p.region} | |
| 194 | + </Link> | |
| 195 | + <span className="flex-1 text-ink-2">{p.observation}</span> | |
| 196 | + </li> | |
| 197 | + ))} | |
| 198 | + </ul> | |
| 199 | + </Section> | |
| 200 | + <Section label={`Affected targets · ${fmtInt(i.affected_targets)}`} className="min-w-0"> | |
| 201 | + <ul className="divide-y divide-line text-[12.5px]"> | |
| 202 | + {i.targets.map((t) => ( | |
| 203 | + <li key={t.target_id} className="flex flex-wrap gap-x-3 py-1.5"> | |
| 204 | + <Link href={`/service/${t.service_id}`} className="w-40 truncate text-ink hover:text-accent"> | |
| 205 | + {t.name} | |
| 206 | + </Link> | |
| 207 | + <span className="flex-1 text-ink-2">{t.observation}</span> | |
| 208 | + </li> | |
| 209 | + ))} | |
| 210 | + </ul> | |
| 211 | + <div className="mt-3 flex flex-wrap gap-1.5"> | |
| 212 | + {i.affected_asns.map((a) => ( | |
| 213 | + <Link key={a} href={`/asn/${a}`} className="num rounded-[3px] border border-line px-1.5 py-0.5 text-[11px] text-ink hover:border-line-2"> | |
| 214 | + AS{a} | |
| 215 | + </Link> | |
| 216 | + ))} | |
| 217 | + {i.affected_services.map((s) => ( | |
| 218 | + <Link key={s} href={`/service/${s}`} className="rounded-[3px] border border-line px-1.5 py-0.5 text-[11px] text-ink hover:border-line-2"> | |
| 219 | + {s} | |
| 220 | + </Link> | |
| 221 | + ))} | |
| 222 | + </div> | |
| 223 | + </Section> | |
| 224 | + </div> | |
| 225 | + | |
| 226 | + <Section label="Annotations"> | |
| 227 | + {i.annotations.length ? ( | |
| 228 | + <ul className="divide-y divide-line text-[13px]"> | |
| 229 | + {i.annotations.map((a, idx) => ( | |
| 230 | + <li key={idx} className="py-2"> | |
| 231 | + <p className="text-ink">{a.text}</p> | |
| 232 | + <p className="num mt-0.5 text-[11px] text-ink-3"> | |
| 233 | + {a.author} · <Time ts={a.ts} /> | |
| 234 | + </p> | |
| 235 | + </li> | |
| 236 | + ))} | |
| 237 | + </ul> | |
| 238 | + ) : ( | |
| 239 | + <p className="text-[12.5px] text-ink-3">No human annotation yet.</p> | |
| 240 | + )} | |
| 241 | + </Section> | |
| 242 | + </article> | |
| 243 | + ); | |
| 244 | +} | |
added
apps/web/src/app/(site)/history/[year]/[month]/page.tsx
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { DayStrip, LargestGrid, TopEvents, TopLists } from '@/components/history/HistoryViews'; | |
| 5 | +import { Section, Stat } from '@/components/ui/primitives'; | |
| 6 | +import { apiGet } from '@/lib/api'; | |
| 7 | +import { MONTH_NAMES, fmt, fmtInt } from '@/lib/format'; | |
| 8 | +import type { HistorySummary } from '@/lib/types'; | |
| 9 | + | |
| 10 | +export const dynamic = 'force-dynamic'; | |
| 11 | + | |
| 12 | +type Params = Promise<{ year: string; month: string }>; | |
| 13 | + | |
| 14 | +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> { | |
| 15 | + const { year, month } = await params; | |
| 16 | + const name = MONTH_NAMES[Number(month) - 1] ?? month; | |
| 17 | + return { title: `History ${name} ${year}`, description: `Daily Global Internet Pressure for ${name} ${year} with the largest events and most affected networks.` }; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export default async function HistoryMonthPage({ params }: { params: Params }) { | |
| 21 | + const { year, month } = await params; | |
| 22 | + const y = Number(year); | |
| 23 | + const m = Number(month); | |
| 24 | + if (!/^\d{4}$/.test(year) || !(m >= 1 && m <= 12)) notFound(); | |
| 25 | + const s = await apiGet<HistorySummary>(`/api/v1/history/summary?year=${y}&month=${m}`); | |
| 26 | + const days = s.days ?? []; | |
| 27 | + const max = days.length ? Math.max(...days.map((d) => d.max)) : null; | |
| 28 | + const min = days.length ? Math.min(...days.map((d) => d.min)) : null; | |
| 29 | + const avg = days.length ? days.reduce((a, d) => a + d.avg, 0) / days.length : null; | |
| 30 | + const events = days.reduce((a, d) => a + d.events, 0); | |
| 31 | + const idx = s.available_months.indexOf(`${year}-${String(m).padStart(2, '0')}`); | |
| 32 | + const prev = idx > 0 ? s.available_months[idx - 1] : null; | |
| 33 | + const next = idx >= 0 && idx < s.available_months.length - 1 ? s.available_months[idx + 1] : null; | |
| 34 | + const toHref = (ym: string) => `/history/${ym.split('-')[0]}/${Number(ym.split('-')[1])}`; | |
| 35 | + return ( | |
| 36 | + <div className="pb-8"> | |
| 37 | + <header className="pt-6 pb-4"> | |
| 38 | + <p className="label"> | |
| 39 | + <Link href="/history" className="hover:text-ink"> | |
| 40 | + History | |
| 41 | + </Link>{' '} | |
| 42 | + ·{' '} | |
| 43 | + <Link href={`/history/${year}`} className="hover:text-ink"> | |
| 44 | + {year} | |
| 45 | + </Link> | |
| 46 | + </p> | |
| 47 | + <div className="mt-1 flex flex-wrap items-baseline gap-4"> | |
| 48 | + <h1 className="text-[26px] font-medium tracking-tight sm:text-[32px]"> | |
| 49 | + {MONTH_NAMES[m - 1]} <span className="num">{year}</span> | |
| 50 | + </h1> | |
| 51 | + <nav className="num flex gap-2 text-[12px]" aria-label="Adjacent months"> | |
| 52 | + {prev && ( | |
| 53 | + <Link href={toHref(prev)} className="text-ink-2 hover:text-ink"> | |
| 54 | + ← {prev} | |
| 55 | + </Link> | |
| 56 | + )} | |
| 57 | + {next && ( | |
| 58 | + <Link href={toHref(next)} className="text-ink-2 hover:text-ink"> | |
| 59 | + {next} → | |
| 60 | + </Link> | |
| 61 | + )} | |
| 62 | + </nav> | |
| 63 | + </div> | |
| 64 | + <div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-5"> | |
| 65 | + <Stat label="days observed" value={fmtInt(days.length)} /> | |
| 66 | + <Stat label="max" value={<span style={{ color: max == null ? undefined : `var(--p-${max <= 10 ? 'calm' : max <= 25 ? 'normal' : max <= 40 ? 'elevated' : max <= 55 ? 'stressed' : max <= 70 ? 'high' : max <= 85 ? 'severe' : 'extreme'})` }}>{fmt(max)}</span>} /> | |
| 67 | + <Stat label="avg" value={fmt(avg)} /> | |
| 68 | + <Stat label="min" value={fmt(min)} /> | |
| 69 | + <Stat label="events" value={fmtInt(events)} /> | |
| 70 | + </div> | |
| 71 | + </header> | |
| 72 | + <Section label="Calendar" right={<span>daily max pressure</span>}> | |
| 73 | + <DayStrip days={days} year={y} month={m} /> | |
| 74 | + </Section> | |
| 75 | + <Section label="Largest events"> | |
| 76 | + <LargestGrid largest={s.largest} /> | |
| 77 | + </Section> | |
| 78 | + <TopLists s={s} /> | |
| 79 | + <Section label="Events this month"> | |
| 80 | + <TopEvents events={s.top_events.filter((e) => e.started_at.startsWith(`${year}-${String(m).padStart(2, '0')}`))} /> | |
| 81 | + </Section> | |
| 82 | + </div> | |
| 83 | + ); | |
| 84 | +} | |
added
apps/web/src/app/(site)/history/[year]/page.tsx
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { LargestGrid, MonthStrip, TopEvents, TopLists } from '@/components/history/HistoryViews'; | |
| 5 | +import { Section } from '@/components/ui/primitives'; | |
| 6 | +import { apiGet } from '@/lib/api'; | |
| 7 | +import type { HistorySummary } from '@/lib/types'; | |
| 8 | + | |
| 9 | +export const dynamic = 'force-dynamic'; | |
| 10 | + | |
| 11 | +type Params = Promise<{ year: string }>; | |
| 12 | + | |
| 13 | +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> { | |
| 14 | + const { year } = await params; | |
| 15 | + return { title: `History ${year}`, description: `Global Internet Pressure in ${year}: months, largest events, most affected networks and regions.` }; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export default async function HistoryYearPage({ params }: { params: Params }) { | |
| 19 | + const { year } = await params; | |
| 20 | + if (!/^\d{4}$/.test(year)) notFound(); | |
| 21 | + const s = await apiGet<HistorySummary>(`/api/v1/history/summary?year=${year}`); | |
| 22 | + return ( | |
| 23 | + <div className="pb-8"> | |
| 24 | + <header className="pt-6 pb-4"> | |
| 25 | + <p className="label"> | |
| 26 | + <Link href="/history" className="hover:text-ink"> | |
| 27 | + History | |
| 28 | + </Link>{' '} | |
| 29 | + · year | |
| 30 | + </p> | |
| 31 | + <h1 className="num mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">{year}</h1> | |
| 32 | + </header> | |
| 33 | + <Section label="Months"> | |
| 34 | + <MonthStrip months={s.months ?? []} /> | |
| 35 | + </Section> | |
| 36 | + <Section label="Largest events"> | |
| 37 | + <LargestGrid largest={s.largest} /> | |
| 38 | + </Section> | |
| 39 | + <TopLists s={s} /> | |
| 40 | + <Section label="Top events"> | |
| 41 | + <TopEvents events={s.top_events} /> | |
| 42 | + </Section> | |
| 43 | + </div> | |
| 44 | + ); | |
| 45 | +} | |
added
apps/web/src/app/(site)/history/page.tsx
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { LargestGrid, MonthStrip, TopEvents, TopLists } from '@/components/history/HistoryViews'; | |
| 4 | +import { Section } from '@/components/ui/primitives'; | |
| 5 | +import { apiGet } from '@/lib/api'; | |
| 6 | +import type { HistorySummary } from '@/lib/types'; | |
| 7 | + | |
| 8 | +export const dynamic = 'force-dynamic'; | |
| 9 | +export const metadata: Metadata = { title: 'History', description: 'Explore the proprietary history of Global Internet Pressure: months, days, largest events, most affected networks and regions.' }; | |
| 10 | + | |
| 11 | +export default async function HistoryPage() { | |
| 12 | + const s = await apiGet<HistorySummary>('/api/v1/history/summary'); | |
| 13 | + return ( | |
| 14 | + <div className="pb-8"> | |
| 15 | + <header className="pt-6 pb-4"> | |
| 16 | + <p className="label">History explorer</p> | |
| 17 | + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">All time</h1> | |
| 18 | + <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">Pressure history is retained indefinitely (1-minute for a year, 5-minute for three, hourly forever). Every incident page is kept. Browse by month, then by day.</p> | |
| 19 | + <nav className="num mt-3 flex flex-wrap gap-2 text-[12px]" aria-label="Available months"> | |
| 20 | + {s.available_months.map((m) => { | |
| 21 | + const [y, mo] = m.split('-'); | |
| 22 | + return ( | |
| 23 | + <Link key={m} href={`/history/${y}/${Number(mo)}`} className="rounded-[3px] border border-line px-2 py-1 text-ink-2 hover:text-ink"> | |
| 24 | + {m} | |
| 25 | + </Link> | |
| 26 | + ); | |
| 27 | + })} | |
| 28 | + </nav> | |
| 29 | + </header> | |
| 30 | + <Section label="Months"> | |
| 31 | + <MonthStrip months={s.months ?? []} /> | |
| 32 | + </Section> | |
| 33 | + <Section label="Largest events"> | |
| 34 | + <LargestGrid largest={s.largest} /> | |
| 35 | + </Section> | |
| 36 | + <TopLists s={s} /> | |
| 37 | + <Section label="Top events" right={<Link href="/incidents" className="hover:text-ink">all incidents →</Link>}> | |
| 38 | + <TopEvents events={s.top_events} /> | |
| 39 | + </Section> | |
| 40 | + </div> | |
| 41 | + ); | |
| 42 | +} | |
added
apps/web/src/app/(site)/methodology/page.tsx
+169 −0
@@ -0,0 +1,169 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Bar, LevelLegend, Section } from '@/components/ui/primitives'; | |
| 4 | +import { apiTry } from '@/lib/api'; | |
| 5 | +import { fmt } from '@/lib/format'; | |
| 6 | +import { COMPONENT_LABEL, LEVELS } from '@/lib/pressure'; | |
| 7 | +import type { ComponentId, Methodology } from '@/lib/types'; | |
| 8 | + | |
| 9 | +export const revalidate = 300; | |
| 10 | +export const metadata: Metadata = { title: 'Methodology', description: 'How the Global Internet Pressure Index is computed: components, weights, baselines, robust z, saturation, levels, confidence, Pressure Fronts and self-exclusion.' }; | |
| 11 | + | |
| 12 | +export default async function MethodologyPage() { | |
| 13 | + const m = await apiTry<Methodology>('/api/v1/methodology'); | |
| 14 | + const weights = m?.weights ?? {}; | |
| 15 | + const levels = m?.levels ?? LEVELS.map((l) => ({ max: l.max, id: l.id, label: l.label })); | |
| 16 | + const eng = m?.engine ?? {}; | |
| 17 | + return ( | |
| 18 | + <div className="pb-8"> | |
| 19 | + <header className="pt-6 pb-4"> | |
| 20 | + <p className="label">Methodology {m ? <span className="num normal-case tracking-normal text-ink-3">· config v{m.version} · updated {m.updated_at.slice(0, 10)}</span> : <span className="text-warn">· live config unavailable, showing documented defaults</span>}</p> | |
| 21 | + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">How the pressure is computed</h1> | |
| 22 | + <p className="mt-2 max-w-[780px] text-[14px] leading-relaxed text-ink-2">The Global Internet Pressure Index is a composite observability index between 0 and 100 describing how stressed, unstable, congested, degraded or abnormal the public Internet currently is. It is not scientific truth and it is not an uptime percentage: it is a baseline-relative synthesis of independent telemetry, and every number on this site decomposes into the signals that produced it.</p> | |
| 23 | + </header> | |
| 24 | + | |
| 25 | + <Section label="1 · Data hierarchy" title="Our own measurements first"> | |
| 26 | + <ol className="max-w-[780px] list-decimal space-y-1 pl-5 text-[13.5px] leading-relaxed text-ink-2"> | |
| 27 | + <li>Direct measurements from the InternetPressure Observability Network — our probes (HTTP, TCP, TLS, ICMP, DNS, traceroute) toward a registry of representative targets.</li> | |
| 28 | + <li>Open public Internet telemetry — BGP messages from RIPE RIS Live and RouteViews collectors.</li> | |
| 29 | + <li>Public raw feeds and polled public infrastructure information.</li> | |
| 30 | + <li>External APIs and vendor status pages only as optional corroboration (weight 0.05) — the instrument must remain useful if every commercial API disappears.</li> | |
| 31 | + </ol> | |
| 32 | + </Section> | |
| 33 | + | |
| 34 | + <Section label="2 · Components & weights" title="Configuration-driven, never hard-coded"> | |
| 35 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> | |
| 36 | + <table className="tbl self-start"> | |
| 37 | + <thead> | |
| 38 | + <tr> | |
| 39 | + <th>Component</th> | |
| 40 | + <th className="r">Weight</th> | |
| 41 | + <th className="w-[40%]"></th> | |
| 42 | + </tr> | |
| 43 | + </thead> | |
| 44 | + <tbody> | |
| 45 | + {Object.entries(weights).map(([id, w]) => ( | |
| 46 | + <tr key={id}> | |
| 47 | + <td className="text-ink">{COMPONENT_LABEL[id as ComponentId] ?? id}</td> | |
| 48 | + <td className="num r">{fmt(w, 2)}</td> | |
| 49 | + <td> | |
| 50 | + <Bar value={w * 100} max={30} color="var(--accent)" /> | |
| 51 | + </td> | |
| 52 | + </tr> | |
| 53 | + ))} | |
| 54 | + <tr> | |
| 55 | + <td className="text-ink-2">Σ</td> | |
| 56 | + <td className="num r text-ink-2">{fmt(Object.values(weights).reduce((a, b) => a + b, 0), 2)}</td> | |
| 57 | + <td></td> | |
| 58 | + </tr> | |
| 59 | + </tbody> | |
| 60 | + </table> | |
| 61 | + <div className="space-y-3 text-[13.5px] leading-relaxed text-ink-2"> | |
| 62 | + <p> | |
| 63 | + <span className="text-ink">Routing</span> — BGP announcements and withdrawals per second, churn, origin-ASN changes, visibility loss, path instability and collector disagreement. <span className="text-ink">Latency</span> — ICMP RTT, TCP connect, TLS handshake, HTTP time-to-first-byte, inter-region latency, packet loss and jitter, always relative to baseline (20 → 100 ms matters; 100 ms alone does not). <span className="text-ink">DNS</span> — lookup latency, SERVFAIL and timeouts, NXDOMAIN anomalies, resolver disagreement across local, Google, Cloudflare and Quad9 resolvers. <span className="text-ink">Availability</span> — representative endpoints failing from at least two probe regions (single-probe failures are the probe's problem, not the Internet's). <span className="text-ink">HTTP/TLS</span> — 5xx, certificate and handshake failures, resets and timeouts. <span className="text-ink">Path</span> — traceroute fingerprints, hop-count deviation and latency shift on changed paths. <span className="text-ink">Corroboration</span> — incidents publicly declared by major providers. | |
| 64 | + </p> | |
| 65 | + {m?.components && ( | |
| 66 | + <details className="rounded-[4px] border border-line p-3"> | |
| 67 | + <summary className="cursor-pointer text-[12px] text-ink">Signal recipe per component (from the live configuration)</summary> | |
| 68 | + <div className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 69 | + {Object.entries(m.components).map(([cid, c]) => ( | |
| 70 | + <div key={cid}> | |
| 71 | + <p className="label">{COMPONENT_LABEL[cid as ComponentId] ?? cid}</p> | |
| 72 | + <ul className="mt-1 space-y-0.5 text-[12px]"> | |
| 73 | + {c.signals.map((s) => ( | |
| 74 | + <li key={s.id} className="flex justify-between gap-3"> | |
| 75 | + <span> | |
| 76 | + {s.label} <span className="num text-ink-3">{s.id}</span> | |
| 77 | + </span> | |
| 78 | + <span className="num text-ink">{fmt(s.weight, 2)}</span> | |
| 79 | + </li> | |
| 80 | + ))} | |
| 81 | + </ul> | |
| 82 | + </div> | |
| 83 | + ))} | |
| 84 | + </div> | |
| 85 | + </details> | |
| 86 | + )} | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + </Section> | |
| 90 | + | |
| 91 | + <Section label="3 · Baselines & anomaly" title="Robust z against a rolling, seasonal baseline"> | |
| 92 | + <div className="max-w-[780px] space-y-3 text-[13.5px] leading-relaxed text-ink-2"> | |
| 93 | + <p> | |
| 94 | + For every signal and scope we keep a trailing baseline of <span className="num text-ink">{String(eng.baseline_days ?? 7)} days</span>, restricted to the same hour of day ±1 h once at least <span className="num text-ink">{String(eng.seasonality_min_days ?? 3)}</span> days of history exist (normal daily patterns are not pressure). The most recent <span className="num text-ink">{String(eng.baseline_exclude_seconds ?? 600)} s</span> are excluded so an incident cannot baseline itself. Anomaly strength is the robust z-score | |
| 95 | + </p> | |
| 96 | + <p className="num rounded-[4px] border border-line bg-panel px-4 py-3 text-center text-[14px] text-ink">z = (x − median) / MAD, clipped to [{String(eng.z_clip_low ?? -3)}, {String(eng.z_clip_high ?? 8)}]</p> | |
| 97 | + <p> | |
| 98 | + A (probe, target) pair is abnormal above z = <span className="num text-ink">{String(eng.z_anomaly ?? 3)}</span>. Signals with fewer than <span className="num text-ink">{String(eng.baseline_min_samples ?? 24)}</span> samples are “weak coverage” and their weight is damped: no strong conclusions from thin data. Each component sums its weighted signal stresses and saturates into a 0–100 score with <span className="num text-ink">score = 100 × (1 − e^(−{String(eng.saturation_k ?? 0.35)} × stress))</span>, then the index is the weighted sum of component scores. The engine runs every <span className="num text-ink">{String(eng.cycle_seconds ?? 10)} s</span> over a <span className="num text-ink">{String(eng.window_seconds ?? 120)} s</span> window ({String(eng.bgp_window_seconds ?? 60)} s for BGP rates). | |
| 99 | + </p> | |
| 100 | + </div> | |
| 101 | + </Section> | |
| 102 | + | |
| 103 | + <Section label="4 · Levels" title="Seven levels, one colour each"> | |
| 104 | + <table className="tbl max-w-[560px]"> | |
| 105 | + <thead> | |
| 106 | + <tr> | |
| 107 | + <th>Level</th> | |
| 108 | + <th className="r">Up to</th> | |
| 109 | + <th>Meaning</th> | |
| 110 | + </tr> | |
| 111 | + </thead> | |
| 112 | + <tbody> | |
| 113 | + {levels.map((l) => { | |
| 114 | + const c = LEVELS.find((x) => x.id === l.id); | |
| 115 | + return ( | |
| 116 | + <tr key={l.id}> | |
| 117 | + <td> | |
| 118 | + <span className="inline-flex items-center gap-2 text-ink"> | |
| 119 | + <span className="size-2 rounded-[1px]" style={{ background: c?.color }} aria-hidden="true" /> | |
| 120 | + {c?.short ?? l.id} | |
| 121 | + </span> | |
| 122 | + </td> | |
| 123 | + <td className="num r">{l.max}</td> | |
| 124 | + <td className="text-ink-2">{l.label}</td> | |
| 125 | + </tr> | |
| 126 | + ); | |
| 127 | + })} | |
| 128 | + </tbody> | |
| 129 | + </table> | |
| 130 | + <div className="mt-3"> | |
| 131 | + <LevelLegend /> | |
| 132 | + </div> | |
| 133 | + </Section> | |
| 134 | + | |
| 135 | + <Section label="5 · Importance, confidence & velocity" title="Not all networks weigh the same"> | |
| 136 | + <div className="max-w-[780px] space-y-3 text-[13.5px] leading-relaxed text-ink-2"> | |
| 137 | + <p>Each target and ASN carries a Network Importance Score (1–5) reflecting centrality, prefixes, downstream dependency and known services; aggregates weight importance 5 about five times more than importance 1. Event pressure is anomaly strength × affected scope × network importance × confidence. Confidence combines probe count, geographic diversity, signal agreement, BGP corroboration, magnitude, duration and external corroboration. Alongside the index we publish velocity (points per hour), acceleration (per hour²) and volatility so that “high but recovering” is distinguishable from “moderate but worsening”.</p> | |
| 138 | + </div> | |
| 139 | + </Section> | |
| 140 | + | |
| 141 | + <Section id="fronts" label="6 · Pressure Fronts & incidents" title="Storm systems for the Internet"> | |
| 142 | + <div className="max-w-[780px] space-y-3 text-[13.5px] leading-relaxed text-ink-2"> | |
| 143 | + <p> | |
| 144 | + A Pressure Front is declared when at least <span className="num text-ink">{String(m?.fronts?.min_pairs ?? 3)}</span> source-region → destination-region pairs rise together above z = <span className="num text-ink">{String(m?.fronts?.z_threshold ?? 2.5)}</span> with intensity ≥ <span className="num text-ink">{String(m?.fronts?.min_intensity ?? 35)}</span>; it is drawn on the map as a great-circle arc with a direction. Incidents open when a component or regional score exceeds <span className="num text-ink">{String(m?.events?.detect_threshold ?? 45)}</span> for <span className="num text-ink">{String(m?.events?.confirm_cycles ?? 2)}</span> consecutive cycles (detected → developing), become active after <span className="num text-ink">{String(m?.events?.active_cycles ?? 6)}</span>, recover below <span className="num text-ink">{String(m?.events?.recover_threshold ?? 30)}</span> and resolve after <span className="num text-ink">{String(m?.events?.resolve_after_seconds ?? 600)} s</span> continuously below. Correlation is rule-based first — BGP spike + latency + path change + failures raise confidence together — and hypotheses are always phrased as “possible”, “probable” or “high-confidence”. | |
| 145 | + </p> | |
| 146 | + </div> | |
| 147 | + </Section> | |
| 148 | + | |
| 149 | + <Section label="7 · Self-exclusion & no fake real-time" title="Our own failure is never an Internet event"> | |
| 150 | + <div className="max-w-[780px] space-y-3 text-[13.5px] leading-relaxed text-ink-2"> | |
| 151 | + <p> | |
| 152 | + If fewer than <span className="num text-ink">{String(eng.min_probes_for_scoring ?? 2)}</span> probes are fresh (a probe is fresh if it reported within <span className="num text-ink">{String(eng.probe_fresh_seconds ?? 180)} s</span>), if the BGP feed is older than <span className="num text-ink">{String(eng.bgp_fresh_seconds ?? 120)} s</span>, or if our stores are unhealthy, the engine freezes the index at its last value and flags <span className="num text-ink">internal_status = degraded</span>. The interface then shows a visible “Instrument degraded” state with the freeze time instead of interpreting a frozen number. A probe whose targets fail at ≥ {String(eng.probe_local_failure_ratio ?? 0.8)} at once is excluded — its own uplink is down, not the Internet. | |
| 153 | + </p> | |
| 154 | + <p>Nothing on this site is animated without a measurement behind it: if one update arrives every 10 s, the numbers move every 10 s. We never randomise values or synthesise events, and pages for countries, ASNs, services and incidents exist only where we hold real data.</p> | |
| 155 | + </div> | |
| 156 | + </Section> | |
| 157 | + | |
| 158 | + <Section label="8 · Ethics" title="Ordinary lightweight client traffic"> | |
| 159 | + <p className="max-w-[780px] text-[13.5px] leading-relaxed text-ink-2"> | |
| 160 | + No scanning, no authentication bypass, no private infrastructure, no exploitation, no excessive traffic, no rate-limit evasion and no personal data. Probes fetch a handful of public endpoints on an adaptive schedule that resembles a normal user, and reveal only an approximate position, provider and ASN. Questions: see the{' '} | |
| 161 | + <Link href="/api" className="text-accent hover:underline"> | |
| 162 | + API page | |
| 163 | + </Link>{' '} | |
| 164 | + or write to contact@spboucher.ai. | |
| 165 | + </p> | |
| 166 | + </Section> | |
| 167 | + </div> | |
| 168 | + ); | |
| 169 | +} | |
added
apps/web/src/app/(site)/probes/page.tsx
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { ProbesTable } from '@/components/detail/Tables'; | |
| 3 | +import { MapIsland } from '@/components/map/MapIsland'; | |
| 4 | +import { Section, Stat } from '@/components/ui/primitives'; | |
| 5 | +import { apiGet, apiTry } from '@/lib/api'; | |
| 6 | +import { fmtInt, fmtPct } from '@/lib/format'; | |
| 7 | +import type { Latency, Probe, Region } from '@/lib/types'; | |
| 8 | + | |
| 9 | +export const dynamic = 'force-dynamic'; | |
| 10 | +export const metadata: Metadata = { title: 'Probe network', description: 'The InternetPressure Observability Network: our own measurement probes, where they sit, who hosts them, and whether they are fresh.' }; | |
| 11 | + | |
| 12 | +export default async function ProbesPage() { | |
| 13 | + const [{ probes }, regions, latency] = await Promise.all([apiGet<{ probes: Probe[] }>('/api/v1/probes'), apiTry<{ regions: Region[] }>('/api/v1/pressure/regions'), apiTry<Latency>('/api/v1/latency')]); | |
| 14 | + const online = probes.filter((p) => p.status === 'online').length; | |
| 15 | + const regionsCovered = new Set(probes.map((p) => p.region)).size; | |
| 16 | + const countries = new Set(probes.map((p) => p.country)).size; | |
| 17 | + const meas = probes.reduce((a, p) => a + p.measurements_1h, 0); | |
| 18 | + const uptime = probes.length ? probes.reduce((a, p) => a + p.uptime_24h, 0) / probes.length : null; | |
| 19 | + const byProbe = new Map((latency?.by_probe ?? []).map((b) => [b.probe_id, b])); | |
| 20 | + return ( | |
| 21 | + <div className="pb-8"> | |
| 22 | + <header className="pt-6 pb-4"> | |
| 23 | + <p className="label">Observability network</p> | |
| 24 | + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Probes</h1> | |
| 25 | + <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">Lightweight Go agents running as ordinary clients from MacLustr (Québec), OVHcloud (Québec, France) and rented hosts abroad. They never scan, never bypass anything and never send more traffic than a normal user would. Positions are approximate on purpose.</p> | |
| 26 | + <div className="mt-4 grid grid-cols-3 gap-4 sm:grid-cols-6"> | |
| 27 | + <Stat label="online" value={<span style={{ color: online < probes.length ? 'var(--warn)' : undefined }}>{`${online}/${probes.length}`}</span>} /> | |
| 28 | + <Stat label="regions" value={fmtInt(regionsCovered)} /> | |
| 29 | + <Stat label="countries" value={fmtInt(countries)} /> | |
| 30 | + <Stat label="measurements / h" value={fmtInt(meas)} /> | |
| 31 | + <Stat label="avg uptime 24h" value={fmtPct(uptime, 2)} /> | |
| 32 | + <Stat label="capabilities" value="http · dns · ping · tr" /> | |
| 33 | + </div> | |
| 34 | + </header> | |
| 35 | + <Section label="Map"> | |
| 36 | + <MapIsland initial={{ regions: regions?.regions ?? null, countries: null, probes, fronts: null, incidents: null, matrix: latency?.matrix ?? null }} initialMode="probes" height="h-[280px] sm:h-[380px]" /> | |
| 37 | + </Section> | |
| 38 | + <Section label="Network" right={<span>{fmtInt(probes.length)} probes</span>}> | |
| 39 | + <ProbesTable probes={probes} /> | |
| 40 | + </Section> | |
| 41 | + {latency && ( | |
| 42 | + <Section label="Per-probe latency view" right={<span>medians over the current window · z vs each probe's own baseline</span>}> | |
| 43 | + <div className="scroll-x -mx-3 px-3"> | |
| 44 | + <table className="tbl"> | |
| 45 | + <thead> | |
| 46 | + <tr> | |
| 47 | + <th>Probe</th> | |
| 48 | + <th className="r">RTT p50</th> | |
| 49 | + <th className="r">TTFB p50</th> | |
| 50 | + <th className="r">Loss</th> | |
| 51 | + <th className="r">z</th> | |
| 52 | + </tr> | |
| 53 | + </thead> | |
| 54 | + <tbody> | |
| 55 | + {probes.map((p) => { | |
| 56 | + const b = byProbe.get(p.probe_id); | |
| 57 | + return ( | |
| 58 | + <tr key={p.probe_id}> | |
| 59 | + <td className="num text-ink">{p.probe_id}</td> | |
| 60 | + <td className="num r">{b ? `${b.rtt_ms_median.toFixed(1)} ms` : '—'}</td> | |
| 61 | + <td className="num r">{b ? `${b.ttfb_ms_median.toFixed(1)} ms` : '—'}</td> | |
| 62 | + <td className="num r" style={{ color: b && b.loss_pct >= 1 ? 'var(--p-high)' : undefined }}> | |
| 63 | + {b ? `${b.loss_pct.toFixed(1)} %` : '—'} | |
| 64 | + </td> | |
| 65 | + <td className="num r" style={{ color: b && Math.abs(b.z) >= 3 ? 'var(--p-high)' : undefined }}> | |
| 66 | + {b ? b.z.toFixed(1) : '—'} | |
| 67 | + </td> | |
| 68 | + </tr> | |
| 69 | + ); | |
| 70 | + })} | |
| 71 | + </tbody> | |
| 72 | + </table> | |
| 73 | + </div> | |
| 74 | + </Section> | |
| 75 | + )} | |
| 76 | + </div> | |
| 77 | + ); | |
| 78 | +} | |
modified
apps/web/src/app/(site)/service/[slug]/page.tsx
+1 −1
@@ -5,12 +5,12 @@ import { ScopePressureChart } from '@/components/detail/ScopeCharts'; | ||
| 5 | 5 | import { TargetsTable } from '@/components/detail/Tables'; |
| 6 | 6 | import { IncidentsSection } from '@/components/incidents/IncidentsSection'; |
| 7 | 7 | import { ProbeTargetMatrix } from '@/components/service/ProbeTargetMatrix'; |
| 8 | +import { VendorIndicator } from '@/components/service/VendorIndicator'; | |
| 8 | 9 | import { Section, Stat } from '@/components/ui/primitives'; |
| 9 | 10 | import { apiGet, apiTry } from '@/lib/api'; |
| 10 | 11 | import { fmt, fmtInt, fmtMs, fmtPct } from '@/lib/format'; |
| 11 | 12 | import { Time } from '@/lib/time'; |
| 12 | 13 | import type { ServiceDetail } from '@/lib/types'; |
| 13 | −import { VendorIndicator } from '../../services/page'; | |
| 14 | 14 | |
| 15 | 15 | export const dynamic = 'force-dynamic'; |
| 16 | 16 | |
modified
apps/web/src/app/(site)/services/page.tsx
+1 −12
@@ -3,23 +3,12 @@ import Link from 'next/link'; | ||
| 3 | 3 | import { LevelBadge, PNum, Section } from '@/components/ui/primitives'; |
| 4 | 4 | import { apiGet } from '@/lib/api'; |
| 5 | 5 | import { fmtInt, fmtPct } from '@/lib/format'; |
| 6 | +import { VendorIndicator } from '@/components/service/VendorIndicator'; | |
| 6 | 7 | import type { ServiceRow } from '@/lib/types'; |
| 7 | 8 | |
| 8 | 9 | export const dynamic = 'force-dynamic'; |
| 9 | 10 | export const metadata: Metadata = { title: 'Services', description: 'Independent observation of major Internet services versus what their status pages declare.' }; |
| 10 | 11 | |
| 11 | −export function VendorIndicator({ v }: { v: ServiceRow['vendor_status'] }) { | |
| 12 | − if (!v) return <span className="text-[11px] text-ink-3">no connector</span>; | |
| 13 | − const color = v.indicator === 'none' ? 'var(--ok)' : v.indicator === 'minor' ? 'var(--warn)' : 'var(--bad)'; | |
| 14 | − return ( | |
| 15 | − <span className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-[0.08em]" style={{ color }}> | |
| 16 | − <span className="size-1.5 rounded-full" style={{ background: color }} aria-hidden="true" /> | |
| 17 | − {v.indicator} | |
| 18 | − {v.incidents ? <span className="num text-ink-2">({v.incidents})</span> : null} | |
| 19 | − </span> | |
| 20 | − ); | |
| 21 | −} | |
| 22 | − | |
| 23 | 12 | export default async function ServicesPage() { |
| 24 | 13 | const { services } = await apiGet<{ services: ServiceRow[] }>('/api/v1/services'); |
| 25 | 14 | const sorted = [...services].sort((a, b) => b.pressure - a.pressure); |
added
apps/web/src/app/(site)/targets/page.tsx
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { TargetsRegistry } from '@/components/targets/TargetsRegistry'; | |
| 3 | +import { apiGet } from '@/lib/api'; | |
| 4 | +import { fmtInt } from '@/lib/format'; | |
| 5 | +import type { Target } from '@/lib/types'; | |
| 6 | + | |
| 7 | +export const dynamic = 'force-dynamic'; | |
| 8 | +export const metadata: Metadata = { title: 'Target registry', description: 'The representative endpoints we measure: cloud, CDN, DNS, developer platforms, social, finance, government, streaming and more.' }; | |
| 9 | + | |
| 10 | +export default async function TargetsPage({ searchParams }: { searchParams: Promise<{ q?: string; category?: string }> }) { | |
| 11 | + const sp = await searchParams; | |
| 12 | + const { targets } = await apiGet<{ targets: Target[] }>('/api/v1/targets'); | |
| 13 | + const categories = [...new Set(targets.map((t) => t.category))].sort(); | |
| 14 | + return ( | |
| 15 | + <div className="pb-8"> | |
| 16 | + <header className="pt-6 pb-4"> | |
| 17 | + <p className="label">Registry</p> | |
| 18 | + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Targets</h1> | |
| 19 | + <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">{fmtInt(targets.length)} endpoints across {categories.length} categories. Tier 1 is checked every 20 s, tier 2 every 45 s, tier 3 every 3 min; traceroutes every 15 min. Importance (1–5) weights each target in the aggregates.</p> | |
| 20 | + </header> | |
| 21 | + <TargetsRegistry targets={targets} categories={categories} initialQuery={sp.q ?? ''} initialCategory={sp.category ?? ''} /> | |
| 22 | + </div> | |
| 23 | + ); | |
| 24 | +} | |
added
apps/web/src/app/admin/annotations/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Annotations } from '@/components/admin/Annotations'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <Annotations />; | |
| 5 | +} | |
added
apps/web/src/app/admin/baselines/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Baselines } from '@/components/admin/Baselines'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <Baselines />; | |
| 5 | +} | |
added
apps/web/src/app/admin/boost/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Boost } from '@/components/admin/Boost'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <Boost />; | |
| 5 | +} | |
added
apps/web/src/app/admin/config/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Config } from '@/components/admin/Config'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <Config />; | |
| 5 | +} | |
added
apps/web/src/app/admin/incidents/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { IncidentsReview } from '@/components/admin/IncidentsReview'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <IncidentsReview />; | |
| 5 | +} | |
added
apps/web/src/app/admin/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Overview } from '@/components/admin/Overview'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <Overview />; | |
| 5 | +} | |
added
apps/web/src/app/admin/probes/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Probes } from '@/components/admin/Probes'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <Probes />; | |
| 5 | +} | |
added
apps/web/src/app/admin/raw/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Raw } from '@/components/admin/Raw'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <Raw />; | |
| 5 | +} | |
added
apps/web/src/app/admin/replay/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Replay } from '@/components/admin/Replay'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <Replay />; | |
| 5 | +} | |
added
apps/web/src/app/admin/targets/page.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { Targets } from '@/components/admin/Targets'; | |
| 2 | + | |
| 3 | +export default function Page() { | |
| 4 | + return <Targets />; | |
| 5 | +} | |
modified
apps/web/src/components/admin/AdminShell.tsx
+115 −1
@@ -1,2 +1,116 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | −export function AdminShell({ children }: { children: React.ReactNode }) { return <div>{children}</div>; } | |
| 2 | + | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { usePathname } from 'next/navigation'; | |
| 5 | +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; | |
| 6 | +import { Logo } from '@/components/chrome/Logo'; | |
| 7 | +import { TimeToggle } from '@/components/chrome/TimeToggle'; | |
| 8 | +import { AdminError, adminFetch, getAdminToken, setAdminToken } from '@/lib/admin-fetch'; | |
| 9 | + | |
| 10 | +const NAV: [string, string][] = [ | |
| 11 | + ['/admin', 'Overview'], | |
| 12 | + ['/admin/targets', 'Targets'], | |
| 13 | + ['/admin/probes', 'Probes'], | |
| 14 | + ['/admin/config', 'Scoring config'], | |
| 15 | + ['/admin/baselines', 'Baselines'], | |
| 16 | + ['/admin/raw', 'Raw explorer'], | |
| 17 | + ['/admin/incidents', 'Incidents review'], | |
| 18 | + ['/admin/annotations', 'Annotations'], | |
| 19 | + ['/admin/replay', 'Replay'], | |
| 20 | + ['/admin/boost', 'Boost'], | |
| 21 | +]; | |
| 22 | + | |
| 23 | +const AuthCtx = createContext<{ authed: boolean; logout: () => void }>({ authed: false, logout: () => {} }); | |
| 24 | +export const useAdminAuth = () => useContext(AuthCtx); | |
| 25 | + | |
| 26 | +/** Token gate: the token lives in sessionStorage only and is validated against /api/admin/overview. */ | |
| 27 | +export function AdminShell({ children }: { children: ReactNode }) { | |
| 28 | + const [state, setState] = useState<'checking' | 'locked' | 'ok'>('checking'); | |
| 29 | + const [input, setInput] = useState(''); | |
| 30 | + const [err, setErr] = useState<string | null>(null); | |
| 31 | + const path = usePathname(); | |
| 32 | + | |
| 33 | + const verify = useCallback(async (token: string) => { | |
| 34 | + setAdminToken(token); | |
| 35 | + try { | |
| 36 | + await adminFetch('/overview'); | |
| 37 | + setState('ok'); | |
| 38 | + setErr(null); | |
| 39 | + } catch (e) { | |
| 40 | + setAdminToken(''); | |
| 41 | + setState('locked'); | |
| 42 | + setErr(e instanceof AdminError && e.status === 401 ? 'Invalid token.' : 'Admin API unreachable.'); | |
| 43 | + } | |
| 44 | + }, []); | |
| 45 | + | |
| 46 | + useEffect(() => { | |
| 47 | + const t = getAdminToken(); | |
| 48 | + if (t) void verify(t); | |
| 49 | + else setState('locked'); | |
| 50 | + }, [verify]); | |
| 51 | + | |
| 52 | + const logout = useCallback(() => { | |
| 53 | + setAdminToken(''); | |
| 54 | + setState('locked'); | |
| 55 | + }, []); | |
| 56 | + | |
| 57 | + return ( | |
| 58 | + <AuthCtx.Provider value={{ authed: state === 'ok', logout }}> | |
| 59 | + <div className="flex min-h-screen flex-col"> | |
| 60 | + <header className="border-b border-line"> | |
| 61 | + <div className="mx-auto flex h-[var(--header-h)] max-w-[1600px] items-center gap-4 px-3 sm:px-5"> | |
| 62 | + <Link href="/admin" className="flex items-center gap-2 text-[14px] font-medium tracking-tight text-ink"> | |
| 63 | + <Logo size={20} color="var(--warn)" /> | |
| 64 | + InternetPressure <span className="label text-warn">admin</span> | |
| 65 | + </Link> | |
| 66 | + <div className="ml-auto flex items-center gap-3"> | |
| 67 | + <TimeToggle className="hidden sm:inline-flex" /> | |
| 68 | + <Link href="/" className="hidden text-[11.5px] text-ink-2 hover:text-ink sm:inline"> | |
| 69 | + public site → | |
| 70 | + </Link> | |
| 71 | + {state === 'ok' && ( | |
| 72 | + <button type="button" onClick={logout} className="rounded-[3px] border border-line px-2 py-1 text-[11px] text-ink-2 hover:text-ink"> | |
| 73 | + lock | |
| 74 | + </button> | |
| 75 | + )} | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + {state === 'ok' && ( | |
| 79 | + <nav aria-label="Admin" className="scroll-x mx-auto flex max-w-[1600px] gap-1 px-3 sm:px-5"> | |
| 80 | + {NAV.map(([href, label]) => { | |
| 81 | + const active = href === '/admin' ? path === '/admin' : path.startsWith(href); | |
| 82 | + return ( | |
| 83 | + <Link key={href} href={href} aria-current={active ? 'page' : undefined} className={`whitespace-nowrap px-2 py-2 text-[11.5px] tracking-[0.04em] ${active ? 'text-ink' : 'text-ink-2 hover:text-ink'}`} style={active ? { boxShadow: 'inset 0 -1px 0 var(--warn)' } : undefined}> | |
| 84 | + {label} | |
| 85 | + </Link> | |
| 86 | + ); | |
| 87 | + })} | |
| 88 | + </nav> | |
| 89 | + )} | |
| 90 | + </header> | |
| 91 | + <main className="mx-auto w-full max-w-[1600px] flex-1 px-3 py-5 sm:px-5"> | |
| 92 | + {state === 'checking' && <p className="text-[12px] text-ink-3">Checking token…</p>} | |
| 93 | + {state === 'locked' && ( | |
| 94 | + <form | |
| 95 | + className="mx-auto mt-[12vh] max-w-[380px]" | |
| 96 | + onSubmit={(e) => { | |
| 97 | + e.preventDefault(); | |
| 98 | + if (input.trim()) void verify(input.trim()); | |
| 99 | + }} | |
| 100 | + > | |
| 101 | + <p className="label">Restricted</p> | |
| 102 | + <h1 className="mt-1 text-[22px] font-medium tracking-tight">Admin console</h1> | |
| 103 | + <p className="mt-1 text-[12.5px] text-ink-2">Enter the admin token. It is kept in this tab's session storage only and sent as X-IP-Admin-Token.</p> | |
| 104 | + <input type="password" value={input} onChange={(e) => setInput(e.target.value)} autoFocus autoComplete="off" placeholder="token" className="num mt-4 h-10 w-full rounded-[4px] border border-line bg-panel px-3 text-[14px] text-ink placeholder:text-ink-3" aria-label="Admin token" /> | |
| 105 | + {err && <p className="mt-2 text-[12px] text-bad">{err}</p>} | |
| 106 | + <button type="submit" className="mt-3 h-9 w-full rounded-[4px] bg-warn text-[13px] font-medium text-bg hover:opacity-90"> | |
| 107 | + Unlock | |
| 108 | + </button> | |
| 109 | + </form> | |
| 110 | + )} | |
| 111 | + {state === 'ok' && children} | |
| 112 | + </main> | |
| 113 | + </div> | |
| 114 | + </AuthCtx.Provider> | |
| 115 | + ); | |
| 116 | +} | |
added
apps/web/src/components/admin/Annotations.tsx
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { adminFetch } from '@/lib/admin-fetch'; | |
| 5 | +import { Time } from '@/lib/time'; | |
| 6 | +import type { AdminAnnotation } from '@/lib/types'; | |
| 7 | +import { AdminPage, ErrorNote, Field, Panel, Toast, btnPrimary, inputCls, useAdmin } from './shared'; | |
| 8 | + | |
| 9 | +export function Annotations() { | |
| 10 | + const { data, err, reload } = useAdmin<{ annotations: AdminAnnotation[] }>('/annotations'); | |
| 11 | + const [form, setForm] = useState({ ts: new Date().toISOString().slice(0, 19) + 'Z', scope_type: 'global', scope_id: '', text: '' }); | |
| 12 | + const [msg, setMsg] = useState<string | null>(null); | |
| 13 | + const [actErr, setActErr] = useState<string | null>(null); | |
| 14 | + return ( | |
| 15 | + <AdminPage title="Annotations" desc="Human context attached to a moment and a scope (maintenance windows, confirmed causes, external news). Shown on incident pages and charts."> | |
| 16 | + <Panel title="New annotation" className="mb-3"> | |
| 17 | + <form | |
| 18 | + className="grid grid-cols-2 gap-3 md:grid-cols-[180px_130px_160px_minmax(0,1fr)_auto]" | |
| 19 | + onSubmit={(e) => { | |
| 20 | + e.preventDefault(); | |
| 21 | + setActErr(null); | |
| 22 | + adminFetch('/annotations', { method: 'POST', body: { ...form, scope_id: form.scope_id || null } }) | |
| 23 | + .then(() => { | |
| 24 | + setMsg('Annotation saved'); | |
| 25 | + setForm({ ...form, text: '' }); | |
| 26 | + reload(); | |
| 27 | + }) | |
| 28 | + .catch((er: unknown) => setActErr(String(er))) | |
| 29 | + .finally(() => setTimeout(() => setMsg(null), 3000)); | |
| 30 | + }} | |
| 31 | + > | |
| 32 | + <Field label="ts (UTC ISO)"> | |
| 33 | + <input value={form.ts} onChange={(e) => setForm({ ...form, ts: e.target.value })} className={`${inputCls} num w-full`} required /> | |
| 34 | + </Field> | |
| 35 | + <Field label="scope_type"> | |
| 36 | + <select value={form.scope_type} onChange={(e) => setForm({ ...form, scope_type: e.target.value })} className={`${inputCls} w-full`}> | |
| 37 | + {['global', 'region', 'country', 'asn', 'service', 'incident'].map((s) => ( | |
| 38 | + <option key={s}>{s}</option> | |
| 39 | + ))} | |
| 40 | + </select> | |
| 41 | + </Field> | |
| 42 | + <Field label="scope_id"> | |
| 43 | + <input value={form.scope_id} onChange={(e) => setForm({ ...form, scope_id: e.target.value })} className={`${inputCls} num w-full`} /> | |
| 44 | + </Field> | |
| 45 | + <Field label="text"> | |
| 46 | + <input value={form.text} onChange={(e) => setForm({ ...form, text: e.target.value })} className={`${inputCls} w-full`} required /> | |
| 47 | + </Field> | |
| 48 | + <div className="flex items-end"> | |
| 49 | + <button type="submit" className={btnPrimary}> | |
| 50 | + Add | |
| 51 | + </button> | |
| 52 | + </div> | |
| 53 | + </form> | |
| 54 | + </Panel> | |
| 55 | + <ErrorNote err={err ?? actErr} /> | |
| 56 | + <Toast msg={msg} /> | |
| 57 | + <ul className="divide-y divide-line"> | |
| 58 | + {(data?.annotations ?? []).map((a, i) => ( | |
| 59 | + <li key={a.id ?? i} className="py-2 text-[13px]"> | |
| 60 | + <p className="text-ink">{a.text}</p> | |
| 61 | + <p className="num mt-0.5 text-[11px] text-ink-3"> | |
| 62 | + <Time ts={a.ts} /> · {a.scope_type} | |
| 63 | + {a.scope_id ? `:${a.scope_id}` : ''} · {a.author ?? 'admin'} | |
| 64 | + </p> | |
| 65 | + </li> | |
| 66 | + ))} | |
| 67 | + </ul> | |
| 68 | + </AdminPage> | |
| 69 | + ); | |
| 70 | +} | |
added
apps/web/src/components/admin/Baselines.tsx
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { SeriesChart } from '@/components/charts/SeriesChart'; | |
| 5 | +import { fmt, fmtInt } from '@/lib/format'; | |
| 6 | +import type { AdminBaselines } from '@/lib/types'; | |
| 7 | +import { AdminPage, ErrorNote, Field, Panel, inputCls, useAdmin } from './shared'; | |
| 8 | + | |
| 9 | +const SIGNALS = ['ttfb_z', 'tcp_z', 'rtt_z', 'loss', 'dns_fail_rate', 'dns_latency_z', 'resolver_disagreement', 'target_down_corroborated', 'fail_rate_z', 'http_5xx_rate', 'tls_fail_rate', 'reset_timeout_rate', 'route_change_rate', 'hop_count_z', 'path_latency_shift', 'bgp_withdrawals_z', 'bgp_announcements_z', 'bgp_origin_changes_z', 'bgp_collector_disagreement', 'vendor_incidents']; | |
| 10 | + | |
| 11 | +export function Baselines() { | |
| 12 | + const [signal, setSignal] = useState('ttfb_z'); | |
| 13 | + const [scopeType, setScopeType] = useState('global'); | |
| 14 | + const [scopeId, setScopeId] = useState(''); | |
| 15 | + const { data, err, loading } = useAdmin<AdminBaselines>('/baselines', { signal_id: signal, scope_type: scopeType, scope_id: scopeId || undefined }); | |
| 16 | + const pts = data?.points ?? []; | |
| 17 | + return ( | |
| 18 | + <AdminPage title="Baseline diagnostics" desc="Value vs rolling median ± MAD for one signal and scope. If the median drifts with the value, the baseline window is too short or the exclusion too small."> | |
| 19 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-4"> | |
| 20 | + <Field label="signal_id"> | |
| 21 | + <select value={signal} onChange={(e) => setSignal(e.target.value)} className={`${inputCls} num w-full`}> | |
| 22 | + {SIGNALS.map((s) => ( | |
| 23 | + <option key={s}>{s}</option> | |
| 24 | + ))} | |
| 25 | + </select> | |
| 26 | + </Field> | |
| 27 | + <Field label="scope_type"> | |
| 28 | + <select value={scopeType} onChange={(e) => setScopeType(e.target.value)} className={`${inputCls} w-full`}> | |
| 29 | + {['global', 'region', 'country', 'asn', 'service', 'probe', 'target'].map((s) => ( | |
| 30 | + <option key={s}>{s}</option> | |
| 31 | + ))} | |
| 32 | + </select> | |
| 33 | + </Field> | |
| 34 | + <Field label="scope_id"> | |
| 35 | + <input value={scopeId} onChange={(e) => setScopeId(e.target.value)} placeholder="na-east · CA · 13335 · …" className={`${inputCls} num w-full`} /> | |
| 36 | + </Field> | |
| 37 | + <div className="num flex items-end text-[11px] text-ink-3">{data ? `${fmtInt(data.samples)} samples · ${data.baseline_days} d baseline` : loading ? 'loading…' : ''}</div> | |
| 38 | + </div> | |
| 39 | + <ErrorNote err={err} /> | |
| 40 | + {pts.length > 0 && ( | |
| 41 | + <Panel className="mt-3"> | |
| 42 | + <SeriesChart | |
| 43 | + lines={[ | |
| 44 | + { name: 'value', color: '#E6EDF3', points: pts.map((p) => ({ ts: p.ts, value: p.value })), width: 1.25 }, | |
| 45 | + { name: 'median', color: '#5B8DEF', points: pts.map((p) => ({ ts: p.ts, value: p.median })), dashed: true }, | |
| 46 | + { name: 'median + MAD', color: '#8B98A5', points: pts.map((p) => ({ ts: p.ts, value: p.median + p.mad })), dashed: true, width: 0.8 }, | |
| 47 | + { name: 'median − MAD', color: '#8B98A5', points: pts.map((p) => ({ ts: p.ts, value: p.median - p.mad })), dashed: true, width: 0.8 }, | |
| 48 | + { name: 'robust z', color: '#E76F51', points: pts.map((p) => ({ ts: p.ts, value: p.z })), yAxisIndex: 1 }, | |
| 49 | + ]} | |
| 50 | + yMax="auto" | |
| 51 | + bands={false} | |
| 52 | + height={300} | |
| 53 | + y2={{ max: 'auto', name: 'z' }} | |
| 54 | + /> | |
| 55 | + <p className="num mt-2 text-[11px] text-ink-3"> | |
| 56 | + latest: value {fmt(pts.at(-1)!.value)} · median {fmt(pts.at(-1)!.median)} · MAD {fmt(pts.at(-1)!.mad, 2)} · z {fmt(pts.at(-1)!.z)} | |
| 57 | + </p> | |
| 58 | + </Panel> | |
| 59 | + )} | |
| 60 | + </AdminPage> | |
| 61 | + ); | |
| 62 | +} | |
added
apps/web/src/components/admin/Boost.tsx
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { adminFetch } from '@/lib/admin-fetch'; | |
| 5 | +import type { AdminTarget } from '@/lib/types'; | |
| 6 | +import { AdminPage, ErrorNote, Field, Panel, Toast, btnPrimary, inputCls, useAdmin } from './shared'; | |
| 7 | + | |
| 8 | +export function Boost() { | |
| 9 | + const { data } = useAdmin<{ targets: AdminTarget[] }>('/targets'); | |
| 10 | + const [q, setQ] = useState(''); | |
| 11 | + const [sel, setSel] = useState<Set<string>>(new Set()); | |
| 12 | + const [factor, setFactor] = useState(0.5); | |
| 13 | + const [seconds, setSeconds] = useState(900); | |
| 14 | + const [msg, setMsg] = useState<string | null>(null); | |
| 15 | + const [err, setErr] = useState<string | null>(null); | |
| 16 | + const [busy, setBusy] = useState(false); | |
| 17 | + const list = (data?.targets ?? []).filter((t) => !q || t.hostname.includes(q) || t.name.toLowerCase().includes(q.toLowerCase()) || t.service_id.includes(q)); | |
| 18 | + const toggle = (id: string) => | |
| 19 | + setSel((s) => { | |
| 20 | + const n = new Set(s); | |
| 21 | + if (n.has(id)) n.delete(id); | |
| 22 | + else n.add(id); | |
| 23 | + return n; | |
| 24 | + }); | |
| 25 | + return ( | |
| 26 | + <AdminPage title="Sampling boost" desc="Temporarily multiply the check interval of selected targets by a factor < 1 (0.5 = twice as often) for a limited time. Pushed to probes at their next config refresh; never exceeds the ethical traffic budget."> | |
| 27 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-[minmax(0,1fr)_300px]"> | |
| 28 | + <Panel title="Targets" right={<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="filter…" className={inputCls} aria-label="Filter" />}> | |
| 29 | + <div className="flex gap-2 text-[11px]"> | |
| 30 | + <button type="button" className="text-ink-2 hover:text-ink" onClick={() => setSel(new Set(list.map((t) => t.target_id)))}> | |
| 31 | + select shown ({list.length}) | |
| 32 | + </button> | |
| 33 | + <button type="button" className="text-ink-2 hover:text-ink" onClick={() => setSel(new Set())}> | |
| 34 | + clear | |
| 35 | + </button> | |
| 36 | + </div> | |
| 37 | + <ul className="mt-2 max-h-[60vh] overflow-auto divide-y divide-line"> | |
| 38 | + {list.map((t) => ( | |
| 39 | + <li key={t.target_id}> | |
| 40 | + <label className="flex cursor-pointer items-center gap-3 py-1.5 text-[12.5px]"> | |
| 41 | + <input type="checkbox" checked={sel.has(t.target_id)} onChange={() => toggle(t.target_id)} className="accent-[var(--accent)]" /> | |
| 42 | + <span className="text-ink">{t.name}</span> | |
| 43 | + <span className="num text-ink-3">{t.hostname}</span> | |
| 44 | + <span className="num ml-auto text-ink-3">tier {t.tier}</span> | |
| 45 | + </label> | |
| 46 | + </li> | |
| 47 | + ))} | |
| 48 | + </ul> | |
| 49 | + </Panel> | |
| 50 | + <Panel title="Boost"> | |
| 51 | + <Field label="factor (interval multiplier)"> | |
| 52 | + <input type="number" step="0.1" min={0.1} max={1} value={factor} onChange={(e) => setFactor(Number(e.target.value))} className={`${inputCls} num w-full`} /> | |
| 53 | + </Field> | |
| 54 | + <div className="mt-3"> | |
| 55 | + <Field label="duration (seconds)"> | |
| 56 | + <input type="number" step="60" min={60} max={7200} value={seconds} onChange={(e) => setSeconds(Number(e.target.value))} className={`${inputCls} num w-full`} /> | |
| 57 | + </Field> | |
| 58 | + </div> | |
| 59 | + <p className="num mt-3 text-[11px] text-ink-3"> | |
| 60 | + {sel.size} targets · ×{factor} for {Math.round(seconds / 60)} min | |
| 61 | + </p> | |
| 62 | + <button | |
| 63 | + type="button" | |
| 64 | + className={`${btnPrimary} mt-3 w-full`} | |
| 65 | + disabled={!sel.size || busy || factor <= 0 || factor > 1} | |
| 66 | + onClick={() => { | |
| 67 | + setBusy(true); | |
| 68 | + setErr(null); | |
| 69 | + adminFetch('/boost', { method: 'POST', body: { targets: [...sel], factor, seconds } }) | |
| 70 | + .then(() => setMsg(`Boost pushed for ${sel.size} targets`)) | |
| 71 | + .catch((e: unknown) => setErr(String(e))) | |
| 72 | + .finally(() => { | |
| 73 | + setBusy(false); | |
| 74 | + setTimeout(() => setMsg(null), 4000); | |
| 75 | + }); | |
| 76 | + }} | |
| 77 | + > | |
| 78 | + Push boost | |
| 79 | + </button> | |
| 80 | + <Toast msg={msg} /> | |
| 81 | + <ErrorNote err={err} /> | |
| 82 | + </Panel> | |
| 83 | + </div> | |
| 84 | + </AdminPage> | |
| 85 | + ); | |
| 86 | +} | |
added
apps/web/src/components/admin/Config.tsx
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect, useState } from 'react'; | |
| 4 | +import { Bar } from '@/components/ui/primitives'; | |
| 5 | +import { adminFetch } from '@/lib/admin-fetch'; | |
| 6 | +import { fmt } from '@/lib/format'; | |
| 7 | +import { COMPONENT_LABEL } from '@/lib/pressure'; | |
| 8 | +import type { AdminConfig, ComponentId } from '@/lib/types'; | |
| 9 | +import { AdminPage, ErrorNote, Panel, Toast, btnCls, btnPrimary, inputCls, useAdmin } from './shared'; | |
| 10 | + | |
| 11 | +export function Config() { | |
| 12 | + const { data, err, reload } = useAdmin<AdminConfig>('/config'); | |
| 13 | + const [cfg, setCfg] = useState<AdminConfig | null>(null); | |
| 14 | + const [msg, setMsg] = useState<string | null>(null); | |
| 15 | + const [saveErr, setSaveErr] = useState<string | null>(null); | |
| 16 | + const [busy, setBusy] = useState(false); | |
| 17 | + useEffect(() => { | |
| 18 | + if (data) setCfg(JSON.parse(JSON.stringify(data)) as AdminConfig); | |
| 19 | + }, [data]); | |
| 20 | + | |
| 21 | + if (!cfg) return <AdminPage title="Scoring config">{err ? <ErrorNote err={err} /> : <p className="text-[12px] text-ink-3">Loading…</p>}</AdminPage>; | |
| 22 | + | |
| 23 | + const sum = Object.values(cfg.pressure_weights).reduce((a, b) => a + Number(b || 0), 0); | |
| 24 | + const valid = Math.abs(sum - 1) <= 0.001; | |
| 25 | + const levelsOk = cfg.levels.every((l, i) => i === 0 || l.max > cfg.levels[i - 1]!.max) && cfg.levels.at(-1)?.max === 100; | |
| 26 | + const dirty = JSON.stringify(cfg) !== JSON.stringify(data); | |
| 27 | + | |
| 28 | + const save = async () => { | |
| 29 | + setBusy(true); | |
| 30 | + setSaveErr(null); | |
| 31 | + try { | |
| 32 | + await adminFetch('/config', { method: 'PUT', body: cfg }); | |
| 33 | + setMsg('Configuration stored — applied on the next engine cycle.'); | |
| 34 | + reload(); | |
| 35 | + } catch (e) { | |
| 36 | + setSaveErr(String(e)); | |
| 37 | + } finally { | |
| 38 | + setBusy(false); | |
| 39 | + setTimeout(() => setMsg(null), 4000); | |
| 40 | + } | |
| 41 | + }; | |
| 42 | + | |
| 43 | + const setEngine = (k: string, v: string) => setCfg({ ...cfg, engine: { ...cfg.engine, [k]: v === '' ? '' : Number.isNaN(Number(v)) ? v : Number(v) } }); | |
| 44 | + | |
| 45 | + return ( | |
| 46 | + <AdminPage | |
| 47 | + title={`Scoring config · v${cfg.version}`} | |
| 48 | + desc="Weights, levels and engine parameters (pressure.yaml). Stored in Postgres and hot-reloaded by the engine. Weights must sum to 1 ± 0.001." | |
| 49 | + right={ | |
| 50 | + <div className="flex gap-2"> | |
| 51 | + <button type="button" className={btnCls} disabled={!dirty} onClick={() => setCfg(JSON.parse(JSON.stringify(data)) as AdminConfig)}> | |
| 52 | + reset | |
| 53 | + </button> | |
| 54 | + <button type="button" className={btnPrimary} disabled={!valid || !levelsOk || !dirty || busy} onClick={() => void save()}> | |
| 55 | + PUT config | |
| 56 | + </button> | |
| 57 | + </div> | |
| 58 | + } | |
| 59 | + > | |
| 60 | + <ErrorNote err={err ?? saveErr} /> | |
| 61 | + <Toast msg={msg} /> | |
| 62 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-3"> | |
| 63 | + <Panel title="Component weights" right={<span className="num" style={{ color: valid ? 'var(--ok)' : 'var(--bad)' }}>Σ {fmt(sum, 3)} {valid ? '✓' : '≠ 1.000'}</span>}> | |
| 64 | + <ul className="space-y-2"> | |
| 65 | + {Object.entries(cfg.pressure_weights).map(([id, w]) => ( | |
| 66 | + <li key={id} className="grid grid-cols-[110px_72px_minmax(0,1fr)] items-center gap-3 text-[12.5px]"> | |
| 67 | + <span className="text-ink">{COMPONENT_LABEL[id as ComponentId] ?? id}</span> | |
| 68 | + <input type="number" step="0.01" min={0} max={1} value={w} onChange={(e) => setCfg({ ...cfg, pressure_weights: { ...cfg.pressure_weights, [id]: Number(e.target.value) } })} className={`${inputCls} num w-full`} aria-label={`${id} weight`} /> | |
| 69 | + <Bar value={Number(w) * 100} max={40} color="var(--accent)" /> | |
| 70 | + </li> | |
| 71 | + ))} | |
| 72 | + </ul> | |
| 73 | + </Panel> | |
| 74 | + <Panel title="Levels" right={<span style={{ color: levelsOk ? 'var(--ok)' : 'var(--bad)' }}>{levelsOk ? 'monotonic, ends at 100' : 'must increase and end at 100'}</span>}> | |
| 75 | + <ul className="space-y-2"> | |
| 76 | + {cfg.levels.map((l, i) => ( | |
| 77 | + <li key={l.id} className="grid grid-cols-[80px_72px_minmax(0,1fr)] items-center gap-3 text-[12.5px]"> | |
| 78 | + <span className="text-ink" style={{ color: `var(--p-${l.id})` }}> | |
| 79 | + {l.id} | |
| 80 | + </span> | |
| 81 | + <input type="number" min={0} max={100} value={l.max} onChange={(e) => setCfg({ ...cfg, levels: cfg.levels.map((x, j) => (j === i ? { ...x, max: Number(e.target.value) } : x)) })} className={`${inputCls} num w-full`} aria-label={`${l.id} max`} /> | |
| 82 | + <input value={l.label} onChange={(e) => setCfg({ ...cfg, levels: cfg.levels.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)) })} className={`${inputCls} w-full`} aria-label={`${l.id} label`} /> | |
| 83 | + </li> | |
| 84 | + ))} | |
| 85 | + </ul> | |
| 86 | + </Panel> | |
| 87 | + <Panel title="Engine parameters"> | |
| 88 | + <ul className="space-y-2"> | |
| 89 | + {Object.entries(cfg.engine).map(([k, v]) => ( | |
| 90 | + <li key={k} className="grid grid-cols-[minmax(0,1fr)_110px] items-center gap-3 text-[12px]"> | |
| 91 | + <span className="num truncate text-ink-2" title={k}> | |
| 92 | + {k} | |
| 93 | + </span> | |
| 94 | + <input value={String(v)} onChange={(e) => setEngine(k, e.target.value)} className={`${inputCls} num w-full`} aria-label={k} /> | |
| 95 | + </li> | |
| 96 | + ))} | |
| 97 | + </ul> | |
| 98 | + </Panel> | |
| 99 | + </div> | |
| 100 | + <details className="mt-3"> | |
| 101 | + <summary className="cursor-pointer text-[12px] text-ink-2">Full JSON (events, fronts, scheduler, component signal recipes)</summary> | |
| 102 | + <pre className="num mt-2 max-h-[420px] overflow-auto rounded-[4px] border border-line bg-panel p-3 text-[11px] text-ink-2">{JSON.stringify(cfg, null, 2)}</pre> | |
| 103 | + </details> | |
| 104 | + </AdminPage> | |
| 105 | + ); | |
| 106 | +} | |
added
apps/web/src/components/admin/IncidentsReview.tsx
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useState } from 'react'; | |
| 5 | +import { PNum, StatusDot } from '@/components/ui/primitives'; | |
| 6 | +import { adminFetch } from '@/lib/admin-fetch'; | |
| 7 | +import { fmtDuration } from '@/lib/format'; | |
| 8 | +import { Time } from '@/lib/time'; | |
| 9 | +import type { AdminIncident } from '@/lib/types'; | |
| 10 | +import { AdminPage, ErrorNote, Toast, btnCls, inputCls, useAdmin } from './shared'; | |
| 11 | + | |
| 12 | +export function IncidentsReview() { | |
| 13 | + const [status, setStatus] = useState('all'); | |
| 14 | + const { data, err, reload } = useAdmin<{ incidents: AdminIncident[] }>('/incidents', { status }); | |
| 15 | + const [notes, setNotes] = useState<Record<string, string>>({}); | |
| 16 | + const [msg, setMsg] = useState<string | null>(null); | |
| 17 | + const [actErr, setActErr] = useState<string | null>(null); | |
| 18 | + | |
| 19 | + const review = async (i: AdminIncident, verdict: 'confirmed' | 'dismissed' | 'unreviewed') => { | |
| 20 | + setActErr(null); | |
| 21 | + try { | |
| 22 | + await adminFetch(`/incidents/${encodeURIComponent(i.event_id)}`, { method: 'PATCH', body: { review: verdict, note: notes[i.event_id] ?? i.note ?? '' } }); | |
| 23 | + setMsg(`${i.title}: ${verdict}`); | |
| 24 | + reload(); | |
| 25 | + } catch (e) { | |
| 26 | + setActErr(String(e)); | |
| 27 | + } finally { | |
| 28 | + setTimeout(() => setMsg(null), 3000); | |
| 29 | + } | |
| 30 | + }; | |
| 31 | + | |
| 32 | + return ( | |
| 33 | + <AdminPage | |
| 34 | + title="Incidents review" | |
| 35 | + desc="Confirm or dismiss detected incidents and leave a note. Reviews feed the replay/validation dataset; they never alter the public record of what the engine detected." | |
| 36 | + right={ | |
| 37 | + <select value={status} onChange={(e) => setStatus(e.target.value)} className={inputCls} aria-label="Status filter"> | |
| 38 | + {['all', 'active', 'resolved'].map((s) => ( | |
| 39 | + <option key={s}>{s}</option> | |
| 40 | + ))} | |
| 41 | + </select> | |
| 42 | + } | |
| 43 | + > | |
| 44 | + <ErrorNote err={err ?? actErr} /> | |
| 45 | + <Toast msg={msg} /> | |
| 46 | + <ul className="divide-y divide-line"> | |
| 47 | + {(data?.incidents ?? []).map((i) => ( | |
| 48 | + <li key={i.event_id} className="grid gap-3 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"> | |
| 49 | + <div className="min-w-0"> | |
| 50 | + <div className="flex flex-wrap items-baseline gap-x-3"> | |
| 51 | + <PNum value={i.peak_pressure} className="text-[18px]" /> | |
| 52 | + <Link href={`/event/${i.slug}`} className="text-[13.5px] text-ink hover:text-accent"> | |
| 53 | + {i.title} | |
| 54 | + </Link> | |
| 55 | + <StatusDot status={i.status} /> | |
| 56 | + <span className="label" style={{ color: i.review === 'confirmed' ? 'var(--ok)' : i.review === 'dismissed' ? 'var(--bad)' : 'var(--ink-3)' }}> | |
| 57 | + {i.review ?? 'unreviewed'} | |
| 58 | + </span> | |
| 59 | + </div> | |
| 60 | + <p className="num mt-0.5 text-[11px] text-ink-3"> | |
| 61 | + {i.type} · {i.scope_label} · <Time ts={i.started_at} /> · {fmtDuration(i.duration_s)} · conf {Math.round(i.confidence * 100)} % | |
| 62 | + </p> | |
| 63 | + <p className="mt-1 text-[12px] text-ink-2">{i.summary}</p> | |
| 64 | + </div> | |
| 65 | + <textarea value={notes[i.event_id] ?? i.note ?? ''} onChange={(e) => setNotes({ ...notes, [i.event_id]: e.target.value })} placeholder="review note…" rows={2} className={`${inputCls} h-auto w-full py-1.5`} aria-label={`Note for ${i.title}`} /> | |
| 66 | + <div className="flex gap-2 lg:flex-col"> | |
| 67 | + <button type="button" className={`${btnCls} border-ok/60 text-ok`} onClick={() => void review(i, 'confirmed')}> | |
| 68 | + confirm | |
| 69 | + </button> | |
| 70 | + <button type="button" className={`${btnCls} border-bad/60 text-bad`} onClick={() => void review(i, 'dismissed')}> | |
| 71 | + dismiss | |
| 72 | + </button> | |
| 73 | + <button type="button" className={btnCls} onClick={() => void review(i, 'unreviewed')}> | |
| 74 | + save note | |
| 75 | + </button> | |
| 76 | + </div> | |
| 77 | + </li> | |
| 78 | + ))} | |
| 79 | + </ul> | |
| 80 | + </AdminPage> | |
| 81 | + ); | |
| 82 | +} | |
added
apps/web/src/components/admin/Overview.tsx
+179 −0
@@ -0,0 +1,179 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { Stat, StatusDot } from '@/components/ui/primitives'; | |
| 4 | +import { fmt, fmtBytes, fmtInt, fmtPct } from '@/lib/format'; | |
| 5 | +import { Time } from '@/lib/time'; | |
| 6 | +import type { AdminOverview } from '@/lib/types'; | |
| 7 | +import { AdminPage, ErrorNote, Ok, Panel, useAdmin } from './shared'; | |
| 8 | + | |
| 9 | +export function Overview() { | |
| 10 | + const { data: o, err, loading, reload } = useAdmin<AdminOverview>('/overview'); | |
| 11 | + return ( | |
| 12 | + <AdminPage | |
| 13 | + title="Pipeline health" | |
| 14 | + desc="Probes, ingest, stores, BGP collectors, engine timings and corroboration connectors. Everything here is about US, not about the Internet." | |
| 15 | + right={ | |
| 16 | + <button type="button" onClick={reload} className="h-8 rounded-[4px] border border-line px-3 text-[12px] text-ink-2 hover:text-ink" disabled={loading}> | |
| 17 | + {loading ? 'refreshing…' : 'refresh'} | |
| 18 | + </button> | |
| 19 | + } | |
| 20 | + > | |
| 21 | + <ErrorNote err={err} /> | |
| 22 | + {o && ( | |
| 23 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-2"> | |
| 24 | + <Panel title="Engine" right={<StatusDot status={o.engine.internal_status} />} className="lg:col-span-2"> | |
| 25 | + <div className="grid grid-cols-3 gap-4 sm:grid-cols-6"> | |
| 26 | + <Stat label="last run" value={<Time ts={o.engine.last_run} style="time" />} /> | |
| 27 | + <Stat label="cycle p50" value={`${fmtInt(o.engine.cycle_ms_p50)} ms`} /> | |
| 28 | + <Stat label="cycle max" value={<span style={{ color: o.engine.cycle_ms_max > 5000 ? 'var(--warn)' : undefined }}>{`${fmtInt(o.engine.cycle_ms_max)} ms`}</span>} /> | |
| 29 | + <Stat label="runs 1h" value={fmtInt(o.engine.runs_1h)} /> | |
| 30 | + <Stat label="errors 1h" value={<span style={{ color: o.engine.errors_1h ? 'var(--bad)' : undefined }}>{fmtInt(o.engine.errors_1h)}</span>} /> | |
| 31 | + <Stat label="excluded probes" value={o.engine.excluded_probes.length ? o.engine.excluded_probes.join(', ') : 'none'} /> | |
| 32 | + </div> | |
| 33 | + </Panel> | |
| 34 | + | |
| 35 | + <Panel title="Probes" right={<span>{o.probes.filter((p) => p.status === 'online').length}/{o.probes.length} online</span>} className="lg:col-span-2"> | |
| 36 | + <div className="scroll-x"> | |
| 37 | + <table className="tbl"> | |
| 38 | + <thead> | |
| 39 | + <tr> | |
| 40 | + <th>Probe</th> | |
| 41 | + <th>Status</th> | |
| 42 | + <th className="r">Uptime 24h</th> | |
| 43 | + <th className="r">Clock</th> | |
| 44 | + <th className="r">Missing 1h</th> | |
| 45 | + <th className="r">Error rate 1h</th> | |
| 46 | + <th className="r">Buffered</th> | |
| 47 | + <th className="r">Spool</th> | |
| 48 | + <th>Version</th> | |
| 49 | + <th className="r">Last health</th> | |
| 50 | + </tr> | |
| 51 | + </thead> | |
| 52 | + <tbody> | |
| 53 | + {o.probes.map((p) => ( | |
| 54 | + <tr key={p.probe_id}> | |
| 55 | + <td className="num text-ink"> | |
| 56 | + {p.probe_id} <span className="text-ink-3">{p.region}</span> | |
| 57 | + </td> | |
| 58 | + <td> | |
| 59 | + <StatusDot status={p.status} /> | |
| 60 | + </td> | |
| 61 | + <td className="num r" style={{ color: (p.health?.uptime_24h ?? 1) < 0.99 ? 'var(--warn)' : undefined }}> | |
| 62 | + {fmtPct(p.health?.uptime_24h, 2)} | |
| 63 | + </td> | |
| 64 | + <td className="num r" style={{ color: Math.abs(p.health?.clock_offset_ms ?? 0) > 50 ? 'var(--warn)' : undefined }}> | |
| 65 | + {p.health ? `${p.health.clock_offset_ms > 0 ? '+' : ''}${p.health.clock_offset_ms} ms` : '—'} | |
| 66 | + </td> | |
| 67 | + <td className="num r">{fmtPct(p.health?.missing_ratio_1h, 2)}</td> | |
| 68 | + <td className="num r" style={{ color: (p.health?.error_rate_1h ?? 0) > 0.05 ? 'var(--warn)' : undefined }}> | |
| 69 | + {fmtPct(p.health?.error_rate_1h, 2)} | |
| 70 | + </td> | |
| 71 | + <td className="num r" style={{ color: p.health?.buffered ? 'var(--warn)' : undefined }}> | |
| 72 | + {fmtInt(p.health?.buffered)} | |
| 73 | + </td> | |
| 74 | + <td className="num r text-ink-2">{fmtBytes(p.health?.spool_bytes)}</td> | |
| 75 | + <td className="num text-ink-2">{p.health?.version ?? p.version}</td> | |
| 76 | + <td className="num r text-ink-2"> | |
| 77 | + <Time ts={p.health?.last_health ?? p.last_seen} style="time" /> | |
| 78 | + </td> | |
| 79 | + </tr> | |
| 80 | + ))} | |
| 81 | + </tbody> | |
| 82 | + </table> | |
| 83 | + </div> | |
| 84 | + </Panel> | |
| 85 | + | |
| 86 | + <Panel title="Ingest"> | |
| 87 | + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> | |
| 88 | + <Stat label="batches / min" value={fmtInt(o.ingest.batches_per_min)} /> | |
| 89 | + <Stat label="measurements / min" value={fmtInt(o.ingest.measurements_per_min)} /> | |
| 90 | + <Stat label="rejected / min" value={<span style={{ color: o.ingest.rejected_per_min ? 'var(--bad)' : undefined }}>{fmtInt(o.ingest.rejected_per_min)}</span>} /> | |
| 91 | + <Stat label="last batch" value={<Time ts={o.ingest.last_batch} style="time" />} /> | |
| 92 | + </div> | |
| 93 | + </Panel> | |
| 94 | + | |
| 95 | + <Panel title="BGP" right={<Ok ok={o.bgp.fresh} label={o.bgp.fresh ? 'fresh' : 'stale'} />}> | |
| 96 | + <div className="grid grid-cols-3 gap-4"> | |
| 97 | + <Stat label="messages / s" value={fmt(o.bgp.messages_per_s)} /> | |
| 98 | + <Stat label="collectors fresh" value={`${o.bgp.collectors.filter((c) => c.fresh).length}/${o.bgp.collectors.length}`} /> | |
| 99 | + <Stat label="reconnects 24h" value={fmtInt(o.bgp.reconnects_24h)} /> | |
| 100 | + </div> | |
| 101 | + <ul className="mt-3 flex flex-wrap gap-1.5"> | |
| 102 | + {o.bgp.collectors.map((c) => ( | |
| 103 | + <li key={c.id} className="num rounded-[3px] border border-line px-1.5 py-0.5 text-[11px]" style={{ color: c.fresh ? 'var(--ink)' : 'var(--warn)' }} title={`${c.location} · ${c.peers} peers`}> | |
| 104 | + {c.id} | |
| 105 | + </li> | |
| 106 | + ))} | |
| 107 | + </ul> | |
| 108 | + </Panel> | |
| 109 | + | |
| 110 | + <Panel title="Stores" className="lg:col-span-2"> | |
| 111 | + <div className="grid grid-cols-3 gap-4"> | |
| 112 | + <Stat label="ClickHouse" value={<Ok ok={o.stores.clickhouse.ok} />} sub={`${fmt(o.stores.clickhouse.inserts_per_s)} inserts/s`} /> | |
| 113 | + <Stat label="Postgres" value={<Ok ok={o.stores.postgres.ok} />} sub={fmtBytes(o.stores.postgres.size_bytes)} /> | |
| 114 | + <Stat label="Redis" value={<Ok ok={o.stores.redis.ok} />} sub={`${fmtBytes(o.stores.redis.used_memory_bytes)} · ${fmtInt(o.stores.redis.keys)} keys`} /> | |
| 115 | + </div> | |
| 116 | + <div className="scroll-x mt-3"><table className="tbl"> | |
| 117 | + <thead> | |
| 118 | + <tr> | |
| 119 | + <th>ClickHouse table</th> | |
| 120 | + <th className="r">Rows</th> | |
| 121 | + <th className="r">Bytes</th> | |
| 122 | + <th className="r">Oldest</th> | |
| 123 | + <th className="r">Newest</th> | |
| 124 | + </tr> | |
| 125 | + </thead> | |
| 126 | + <tbody> | |
| 127 | + {o.stores.clickhouse.tables.map((t) => ( | |
| 128 | + <tr key={t.name}> | |
| 129 | + <td className="num text-ink">{t.name}</td> | |
| 130 | + <td className="num r">{fmtInt(t.rows)}</td> | |
| 131 | + <td className="num r text-ink-2">{fmtBytes(t.bytes)}</td> | |
| 132 | + <td className="num r text-ink-2"> | |
| 133 | + <Time ts={t.oldest} style="date" /> | |
| 134 | + </td> | |
| 135 | + <td className="num r text-ink-2"> | |
| 136 | + <Time ts={t.newest} style="time" /> | |
| 137 | + </td> | |
| 138 | + </tr> | |
| 139 | + ))} | |
| 140 | + </tbody> | |
| 141 | + </table> | |
| 142 | + </div> | |
| 143 | + </Panel> | |
| 144 | + | |
| 145 | + <Panel title="Corroboration connectors" className="lg:col-span-2"> | |
| 146 | + <div className="scroll-x"> | |
| 147 | + <table className="tbl"> | |
| 148 | + <thead> | |
| 149 | + <tr> | |
| 150 | + <th>Connector</th> | |
| 151 | + <th>State</th> | |
| 152 | + <th className="r">Last fetch</th> | |
| 153 | + <th className="r">Incidents declared</th> | |
| 154 | + </tr> | |
| 155 | + </thead> | |
| 156 | + <tbody> | |
| 157 | + {o.corroboration.map((c) => ( | |
| 158 | + <tr key={c.id}> | |
| 159 | + <td className="text-ink"> | |
| 160 | + {c.name} <span className="num text-ink-3">{c.id}</span> | |
| 161 | + </td> | |
| 162 | + <td> | |
| 163 | + <Ok ok={c.ok} /> | |
| 164 | + </td> | |
| 165 | + <td className="num r text-ink-2"> | |
| 166 | + <Time ts={c.last_fetch} style="time" /> | |
| 167 | + </td> | |
| 168 | + <td className="num r">{fmtInt(c.incidents)}</td> | |
| 169 | + </tr> | |
| 170 | + ))} | |
| 171 | + </tbody> | |
| 172 | + </table> | |
| 173 | + </div> | |
| 174 | + </Panel> | |
| 175 | + </div> | |
| 176 | + )} | |
| 177 | + </AdminPage> | |
| 178 | + ); | |
| 179 | +} | |
added
apps/web/src/components/admin/Probes.tsx
+175 −0
@@ -0,0 +1,175 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { StatusDot } from '@/components/ui/primitives'; | |
| 5 | +import { adminFetch } from '@/lib/admin-fetch'; | |
| 6 | +import { fmtInt, fmtPct } from '@/lib/format'; | |
| 7 | +import { Time } from '@/lib/time'; | |
| 8 | +import type { AdminProbe } from '@/lib/types'; | |
| 9 | +import { AdminPage, ErrorNote, Field, Panel, Toast, btnCls, btnPrimary, inputCls, useAdmin } from './shared'; | |
| 10 | + | |
| 11 | +const EMPTY = { probe_id: '', name: '', region: 'na-east', country: 'CA', city: '', provider: '', asn: 0, lat: 0, lon: 0 }; | |
| 12 | + | |
| 13 | +export function Probes() { | |
| 14 | + const { data, err, reload } = useAdmin<{ probes: AdminProbe[] }>('/probes'); | |
| 15 | + const [form, setForm] = useState<typeof EMPTY | null>(null); | |
| 16 | + const [key, setKey] = useState<{ probe_id: string; key: string } | null>(null); | |
| 17 | + const [msg, setMsg] = useState<string | null>(null); | |
| 18 | + const [actErr, setActErr] = useState<string | null>(null); | |
| 19 | + const [busy, setBusy] = useState(false); | |
| 20 | + | |
| 21 | + const run = async (fn: () => Promise<unknown>, ok: string) => { | |
| 22 | + setBusy(true); | |
| 23 | + setActErr(null); | |
| 24 | + try { | |
| 25 | + await fn(); | |
| 26 | + setMsg(ok); | |
| 27 | + reload(); | |
| 28 | + } catch (e) { | |
| 29 | + setActErr(String(e)); | |
| 30 | + } finally { | |
| 31 | + setBusy(false); | |
| 32 | + setTimeout(() => setMsg(null), 3000); | |
| 33 | + } | |
| 34 | + }; | |
| 35 | + | |
| 36 | + return ( | |
| 37 | + <AdminPage | |
| 38 | + title="Probes" | |
| 39 | + desc="Register probes and manage their keys. A key is shown exactly once at creation or rotation — copy it into the agent configuration." | |
| 40 | + right={ | |
| 41 | + <button type="button" className={btnPrimary} onClick={() => setForm({ ...EMPTY })}> | |
| 42 | + + probe | |
| 43 | + </button> | |
| 44 | + } | |
| 45 | + > | |
| 46 | + <ErrorNote err={err ?? actErr} /> | |
| 47 | + <Toast msg={msg} /> | |
| 48 | + {key && ( | |
| 49 | + <Panel title={`Key for ${key.probe_id} — shown once`} className="mb-3 border-warn/60"> | |
| 50 | + <div className="flex flex-wrap items-center gap-2"> | |
| 51 | + <code className="num break-all rounded-[3px] bg-panel-2 px-2 py-1 text-[12px] text-ink">{key.key}</code> | |
| 52 | + <button | |
| 53 | + type="button" | |
| 54 | + className={btnCls} | |
| 55 | + onClick={() => { | |
| 56 | + void navigator.clipboard.writeText(key.key); | |
| 57 | + setMsg('Key copied'); | |
| 58 | + setTimeout(() => setMsg(null), 2000); | |
| 59 | + }} | |
| 60 | + > | |
| 61 | + copy | |
| 62 | + </button> | |
| 63 | + <button type="button" className={btnCls} onClick={() => setKey(null)}> | |
| 64 | + dismiss | |
| 65 | + </button> | |
| 66 | + </div> | |
| 67 | + </Panel> | |
| 68 | + )} | |
| 69 | + {form && ( | |
| 70 | + <Panel title="Register probe" className="mb-3"> | |
| 71 | + <form | |
| 72 | + className="grid grid-cols-2 gap-3 md:grid-cols-5" | |
| 73 | + onSubmit={(e) => { | |
| 74 | + e.preventDefault(); | |
| 75 | + void run(async () => { | |
| 76 | + const res = await adminFetch<AdminProbe & { key: string }>('/probes', { method: 'POST', body: { ...form, asn: Number(form.asn), lat: Number(form.lat), lon: Number(form.lon) } }); | |
| 77 | + setKey({ probe_id: res.probe_id, key: res.key }); | |
| 78 | + setForm(null); | |
| 79 | + }, 'Probe registered'); | |
| 80 | + }} | |
| 81 | + > | |
| 82 | + {( | |
| 83 | + [ | |
| 84 | + ['probe_id', 'text'], | |
| 85 | + ['name', 'text'], | |
| 86 | + ['region', 'text'], | |
| 87 | + ['country', 'text'], | |
| 88 | + ['city', 'text'], | |
| 89 | + ['provider', 'text'], | |
| 90 | + ['asn', 'number'], | |
| 91 | + ['lat', 'number'], | |
| 92 | + ['lon', 'number'], | |
| 93 | + ] as [keyof typeof EMPTY, string][] | |
| 94 | + ).map(([k, type]) => ( | |
| 95 | + <Field key={k} label={k}> | |
| 96 | + <input required={k !== 'city'} type={type} step="any" value={form[k]} onChange={(e) => setForm({ ...form, [k]: type === 'number' ? Number(e.target.value) : e.target.value })} className={`${inputCls} num w-full`} /> | |
| 97 | + </Field> | |
| 98 | + ))} | |
| 99 | + <div className="col-span-2 flex items-end gap-2 md:col-span-1"> | |
| 100 | + <button type="submit" className={btnPrimary} disabled={busy}> | |
| 101 | + Create | |
| 102 | + </button> | |
| 103 | + <button type="button" className={btnCls} onClick={() => setForm(null)}> | |
| 104 | + Cancel | |
| 105 | + </button> | |
| 106 | + </div> | |
| 107 | + </form> | |
| 108 | + </Panel> | |
| 109 | + )} | |
| 110 | + <div className="scroll-x"> | |
| 111 | + <table className="tbl"> | |
| 112 | + <thead> | |
| 113 | + <tr> | |
| 114 | + <th>Probe</th> | |
| 115 | + <th>Status</th> | |
| 116 | + <th>Region</th> | |
| 117 | + <th>Location</th> | |
| 118 | + <th>Provider</th> | |
| 119 | + <th className="r">ASN</th> | |
| 120 | + <th className="r">Meas./h</th> | |
| 121 | + <th className="r">Uptime</th> | |
| 122 | + <th>Version</th> | |
| 123 | + <th className="r">Last seen</th> | |
| 124 | + <th>Enabled</th> | |
| 125 | + <th></th> | |
| 126 | + </tr> | |
| 127 | + </thead> | |
| 128 | + <tbody> | |
| 129 | + {(data?.probes ?? []).map((p) => ( | |
| 130 | + <tr key={p.probe_id} className={p.enabled === false ? 'opacity-50' : ''}> | |
| 131 | + <td> | |
| 132 | + <span className="num text-ink">{p.probe_id}</span> <span className="text-ink-2">{p.name}</span> | |
| 133 | + </td> | |
| 134 | + <td> | |
| 135 | + <StatusDot status={p.status} /> | |
| 136 | + </td> | |
| 137 | + <td className="num text-ink-2">{p.region}</td> | |
| 138 | + <td className="text-ink-2"> | |
| 139 | + {p.city}, {p.country} <span className="num text-ink-3">{p.lat}, {p.lon}</span> | |
| 140 | + </td> | |
| 141 | + <td className="text-ink-2">{p.provider}</td> | |
| 142 | + <td className="num r">{p.asn}</td> | |
| 143 | + <td className="num r">{fmtInt(p.measurements_1h)}</td> | |
| 144 | + <td className="num r">{fmtPct(p.uptime_24h, 1)}</td> | |
| 145 | + <td className="num text-ink-2">{p.version ?? '—'}</td> | |
| 146 | + <td className="num r text-ink-2">{p.last_seen ? <Time ts={p.last_seen} style="time" /> : '—'}</td> | |
| 147 | + <td> | |
| 148 | + <button type="button" className="text-[11px] uppercase tracking-[0.1em]" style={{ color: p.enabled === false ? 'var(--ink-3)' : 'var(--ok)' }} onClick={() => void run(() => adminFetch(`/probes/${encodeURIComponent(p.probe_id)}`, { method: 'PATCH', body: { enabled: p.enabled === false } }), p.enabled === false ? 'Probe enabled' : 'Probe disabled')}> | |
| 149 | + {p.enabled === false ? 'disabled' : 'enabled'} | |
| 150 | + </button> | |
| 151 | + </td> | |
| 152 | + <td className="r"> | |
| 153 | + <button | |
| 154 | + type="button" | |
| 155 | + className="text-[11px] text-ink-2 hover:text-ink" | |
| 156 | + disabled={busy} | |
| 157 | + onClick={() => { | |
| 158 | + if (confirm(`Rotate the key of ${p.probe_id}? The agent must be reconfigured with the new key.`)) | |
| 159 | + void run(async () => { | |
| 160 | + const res = await adminFetch<AdminProbe & { key: string }>(`/probes/${encodeURIComponent(p.probe_id)}/rotate-key`, { method: 'POST' }); | |
| 161 | + setKey({ probe_id: p.probe_id, key: res.key }); | |
| 162 | + }, 'Key rotated'); | |
| 163 | + }} | |
| 164 | + > | |
| 165 | + rotate key | |
| 166 | + </button> | |
| 167 | + </td> | |
| 168 | + </tr> | |
| 169 | + ))} | |
| 170 | + </tbody> | |
| 171 | + </table> | |
| 172 | + </div> | |
| 173 | + </AdminPage> | |
| 174 | + ); | |
| 175 | +} | |
added
apps/web/src/components/admin/Raw.tsx
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { fmtInt } from '@/lib/format'; | |
| 5 | +import type { AdminRaw } from '@/lib/types'; | |
| 6 | +import { AdminPage, ErrorNote, Field, inputCls, useAdmin } from './shared'; | |
| 7 | + | |
| 8 | +const TABLES = ['measurements', 'traceroutes', 'bgp_events', 'bgp_stats', 'pressure_history', 'signal_features', 'probe_health']; | |
| 9 | + | |
| 10 | +export function Raw() { | |
| 11 | + const [table, setTable] = useState('measurements'); | |
| 12 | + const [probe, setProbe] = useState(''); | |
| 13 | + const [target, setTarget] = useState(''); | |
| 14 | + const [limit, setLimit] = useState(100); | |
| 15 | + const { data, err, loading } = useAdmin<AdminRaw>('/raw', { table, probe_id: probe || undefined, target_id: target || undefined, limit }); | |
| 16 | + return ( | |
| 17 | + <AdminPage title="Raw explorer" desc="Latest rows of a ClickHouse table, newest first. Read-only, capped at 200 rows."> | |
| 18 | + <div className="grid grid-cols-2 gap-3 md:grid-cols-5"> | |
| 19 | + <Field label="table"> | |
| 20 | + <select value={table} onChange={(e) => setTable(e.target.value)} className={`${inputCls} num w-full`}> | |
| 21 | + {TABLES.map((t) => ( | |
| 22 | + <option key={t}>{t}</option> | |
| 23 | + ))} | |
| 24 | + </select> | |
| 25 | + </Field> | |
| 26 | + <Field label="probe_id"> | |
| 27 | + <input value={probe} onChange={(e) => setProbe(e.target.value)} className={`${inputCls} num w-full`} /> | |
| 28 | + </Field> | |
| 29 | + <Field label="target_id"> | |
| 30 | + <input value={target} onChange={(e) => setTarget(e.target.value)} className={`${inputCls} num w-full`} /> | |
| 31 | + </Field> | |
| 32 | + <Field label="limit"> | |
| 33 | + <input type="number" min={1} max={200} value={limit} onChange={(e) => setLimit(Math.min(200, Math.max(1, Number(e.target.value) || 1)))} className={`${inputCls} num w-full`} /> | |
| 34 | + </Field> | |
| 35 | + <div className="num flex items-end text-[11px] text-ink-3">{loading ? 'loading…' : data ? `${fmtInt(data.rows.length)} rows` : ''}</div> | |
| 36 | + </div> | |
| 37 | + <ErrorNote err={err} /> | |
| 38 | + {data && ( | |
| 39 | + <div className="scroll-x mt-3 max-h-[70vh] overflow-auto rounded-[4px] border border-line"> | |
| 40 | + <table className="tbl text-[11.5px]"> | |
| 41 | + <thead className="sticky top-0 bg-bg"> | |
| 42 | + <tr> | |
| 43 | + {data.columns.map((c) => ( | |
| 44 | + <th key={c} className="num"> | |
| 45 | + {c} | |
| 46 | + </th> | |
| 47 | + ))} | |
| 48 | + </tr> | |
| 49 | + </thead> | |
| 50 | + <tbody> | |
| 51 | + {data.rows.map((r, i) => ( | |
| 52 | + <tr key={i}> | |
| 53 | + {r.map((v, j) => ( | |
| 54 | + <td key={j} className="num max-w-[280px] truncate" title={String(v)}> | |
| 55 | + {v === null || v === undefined ? <span className="text-ink-3">∅</span> : typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v)} | |
| 56 | + </td> | |
| 57 | + ))} | |
| 58 | + </tr> | |
| 59 | + ))} | |
| 60 | + </tbody> | |
| 61 | + </table> | |
| 62 | + </div> | |
| 63 | + )} | |
| 64 | + </AdminPage> | |
| 65 | + ); | |
| 66 | +} | |
added
apps/web/src/components/admin/Replay.tsx
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { SeriesChart } from '@/components/charts/SeriesChart'; | |
| 5 | +import { adminFetch } from '@/lib/admin-fetch'; | |
| 6 | +import { fmt } from '@/lib/format'; | |
| 7 | +import { COMPONENT_LABEL, COMPONENT_ORDER } from '@/lib/pressure'; | |
| 8 | +import type { AdminConfig, AdminReplay } from '@/lib/types'; | |
| 9 | +import { AdminPage, ErrorNote, Field, Panel, btnPrimary, inputCls, useAdmin } from './shared'; | |
| 10 | + | |
| 11 | +export function Replay() { | |
| 12 | + const { data: cfg } = useAdmin<AdminConfig>('/config'); | |
| 13 | + const [from, setFrom] = useState(new Date(Date.now() - 86400_000).toISOString().slice(0, 16)); | |
| 14 | + const [to, setTo] = useState(new Date().toISOString().slice(0, 16)); | |
| 15 | + const [weights, setWeights] = useState<Record<string, number> | null>(null); | |
| 16 | + const [res, setRes] = useState<AdminReplay | null>(null); | |
| 17 | + const [err, setErr] = useState<string | null>(null); | |
| 18 | + const [busy, setBusy] = useState(false); | |
| 19 | + const w = weights ?? cfg?.pressure_weights ?? Object.fromEntries(COMPONENT_ORDER.map((c) => [c, 0])); | |
| 20 | + const sum = Object.values(w).reduce((a, b) => a + Number(b || 0), 0); | |
| 21 | + const valid = Math.abs(sum - 1) <= 0.001; | |
| 22 | + | |
| 23 | + const run = async () => { | |
| 24 | + setBusy(true); | |
| 25 | + setErr(null); | |
| 26 | + try { | |
| 27 | + setRes(await adminFetch<AdminReplay>('/replay', { method: 'POST', body: { from: new Date(from + 'Z').toISOString(), to: new Date(to + 'Z').toISOString(), weights: w } })); | |
| 28 | + } catch (e) { | |
| 29 | + setErr(String(e)); | |
| 30 | + } finally { | |
| 31 | + setBusy(false); | |
| 32 | + } | |
| 33 | + }; | |
| 34 | + | |
| 35 | + const diffs = res ? res.points.map((p) => p.pressure_replayed - p.pressure_original) : []; | |
| 36 | + const maxDiff = diffs.length ? Math.max(...diffs.map(Math.abs)) : 0; | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <AdminPage title="Replay" desc="Recompute the index over a past window with alternative weights, from stored signal features. Validates scoring changes against real incidents before applying them."> | |
| 40 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-[320px_minmax(0,1fr)]"> | |
| 41 | + <Panel title="Window & weights" right={<span className="num" style={{ color: valid ? 'var(--ok)' : 'var(--bad)' }}>Σ {fmt(sum, 3)}</span>}> | |
| 42 | + <div className="grid grid-cols-2 gap-3"> | |
| 43 | + <Field label="from (UTC)"> | |
| 44 | + <input type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} className={`${inputCls} num w-full`} /> | |
| 45 | + </Field> | |
| 46 | + <Field label="to (UTC)"> | |
| 47 | + <input type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} className={`${inputCls} num w-full`} /> | |
| 48 | + </Field> | |
| 49 | + </div> | |
| 50 | + <ul className="mt-3 space-y-2"> | |
| 51 | + {Object.entries(w).map(([id, v]) => ( | |
| 52 | + <li key={id} className="grid grid-cols-[minmax(0,1fr)_80px] items-center gap-3 text-[12.5px]"> | |
| 53 | + <span className="text-ink">{COMPONENT_LABEL[id as keyof typeof COMPONENT_LABEL] ?? id}</span> | |
| 54 | + <input type="number" step="0.01" min={0} max={1} value={v} onChange={(e) => setWeights({ ...w, [id]: Number(e.target.value) })} className={`${inputCls} num w-full`} aria-label={`${id} weight`} /> | |
| 55 | + </li> | |
| 56 | + ))} | |
| 57 | + </ul> | |
| 58 | + <button type="button" className={`${btnPrimary} mt-3 w-full`} disabled={!valid || busy} onClick={() => void run()}> | |
| 59 | + {busy ? 'replaying…' : 'Replay'} | |
| 60 | + </button> | |
| 61 | + <ErrorNote err={err} /> | |
| 62 | + </Panel> | |
| 63 | + <Panel title="Original vs replayed" right={res ? <span className="num">step {res.step_seconds} s · max |Δ| {fmt(maxDiff)}</span> : undefined}> | |
| 64 | + {res ? ( | |
| 65 | + <SeriesChart | |
| 66 | + lines={[ | |
| 67 | + { name: 'original', color: '#8B98A5', points: res.points.map((p) => ({ ts: p.ts, value: p.pressure_original })), area: true }, | |
| 68 | + { name: 'replayed', color: '#5B8DEF', points: res.points.map((p) => ({ ts: p.ts, value: p.pressure_replayed })), width: 1.5 }, | |
| 69 | + ]} | |
| 70 | + height={340} | |
| 71 | + /> | |
| 72 | + ) : ( | |
| 73 | + <p className="py-10 text-center text-[12px] text-ink-3">Choose a window and weights, then replay.</p> | |
| 74 | + )} | |
| 75 | + </Panel> | |
| 76 | + </div> | |
| 77 | + </AdminPage> | |
| 78 | + ); | |
| 79 | +} | |
added
apps/web/src/components/admin/Targets.tsx
+187 −0
@@ -0,0 +1,187 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { PNum } from '@/components/ui/primitives'; | |
| 5 | +import { adminFetch } from '@/lib/admin-fetch'; | |
| 6 | +import { fmtInt, fmtPct } from '@/lib/format'; | |
| 7 | +import type { AdminTarget } from '@/lib/types'; | |
| 8 | +import { AdminPage, ErrorNote, Field, Panel, Toast, btnCls, btnDanger, btnPrimary, inputCls, useAdmin } from './shared'; | |
| 9 | + | |
| 10 | +const EMPTY: Partial<AdminTarget> = { target_id: '', name: '', hostname: '', category: 'cloud', provider: '', service_id: '', country: 'US', region: 'na-east', importance: 3, tier: 2 }; | |
| 11 | +const CATEGORIES = ['dns', 'cdn', 'cloud', 'search', 'messaging', 'social', 'finance', 'government', 'news', 'developer', 'ai', 'streaming', 'commerce', 'infrastructure']; | |
| 12 | + | |
| 13 | +export function Targets() { | |
| 14 | + const { data, err, reload } = useAdmin<{ targets: AdminTarget[] }>('/targets'); | |
| 15 | + const [q, setQ] = useState(''); | |
| 16 | + const [editing, setEditing] = useState<Partial<AdminTarget> | null>(null); | |
| 17 | + const [isNew, setIsNew] = useState(false); | |
| 18 | + const [msg, setMsg] = useState<string | null>(null); | |
| 19 | + const [busy, setBusy] = useState(false); | |
| 20 | + const [actErr, setActErr] = useState<string | null>(null); | |
| 21 | + | |
| 22 | + const rows = (data?.targets ?? []).filter((t) => !q || t.hostname.includes(q) || t.name.toLowerCase().includes(q.toLowerCase()) || t.target_id.includes(q)); | |
| 23 | + | |
| 24 | + const run = async (fn: () => Promise<unknown>, ok: string) => { | |
| 25 | + setBusy(true); | |
| 26 | + setActErr(null); | |
| 27 | + try { | |
| 28 | + await fn(); | |
| 29 | + setMsg(ok); | |
| 30 | + setEditing(null); | |
| 31 | + reload(); | |
| 32 | + } catch (e) { | |
| 33 | + setActErr(String(e)); | |
| 34 | + } finally { | |
| 35 | + setBusy(false); | |
| 36 | + setTimeout(() => setMsg(null), 3000); | |
| 37 | + } | |
| 38 | + }; | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <AdminPage | |
| 42 | + title="Targets" | |
| 43 | + desc="Registry of measured endpoints. Frequencies come from tiers (scheduler); importance weights the aggregates. Changes apply on the next config refresh (≤ 5 min on probes)." | |
| 44 | + right={ | |
| 45 | + <div className="flex gap-2"> | |
| 46 | + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="filter…" className={inputCls} aria-label="Filter" /> | |
| 47 | + <button | |
| 48 | + type="button" | |
| 49 | + className={btnPrimary} | |
| 50 | + onClick={() => { | |
| 51 | + setEditing({ ...EMPTY }); | |
| 52 | + setIsNew(true); | |
| 53 | + }} | |
| 54 | + > | |
| 55 | + + target | |
| 56 | + </button> | |
| 57 | + </div> | |
| 58 | + } | |
| 59 | + > | |
| 60 | + <ErrorNote err={err ?? actErr} /> | |
| 61 | + <Toast msg={msg} /> | |
| 62 | + {editing && ( | |
| 63 | + <Panel title={isNew ? 'Create target' : `Edit ${editing.target_id}`} className="mb-3"> | |
| 64 | + <form | |
| 65 | + className="grid grid-cols-2 gap-3 md:grid-cols-5" | |
| 66 | + onSubmit={(e) => { | |
| 67 | + e.preventDefault(); | |
| 68 | + const body = { ...editing, importance: Number(editing.importance), tier: Number(editing.tier) }; | |
| 69 | + void run(() => (isNew ? adminFetch('/targets', { method: 'POST', body }) : adminFetch(`/targets/${encodeURIComponent(editing.target_id!)}`, { method: 'PATCH', body })), isNew ? 'Target created' : 'Target updated'); | |
| 70 | + }} | |
| 71 | + > | |
| 72 | + <Field label="target_id"> | |
| 73 | + <input required disabled={!isNew} value={editing.target_id ?? ''} onChange={(e) => setEditing({ ...editing, target_id: e.target.value })} className={`${inputCls} num w-full`} /> | |
| 74 | + </Field> | |
| 75 | + <Field label="name"> | |
| 76 | + <input required value={editing.name ?? ''} onChange={(e) => setEditing({ ...editing, name: e.target.value })} className={`${inputCls} w-full`} /> | |
| 77 | + </Field> | |
| 78 | + <Field label="hostname"> | |
| 79 | + <input required value={editing.hostname ?? ''} onChange={(e) => setEditing({ ...editing, hostname: e.target.value })} className={`${inputCls} num w-full`} /> | |
| 80 | + </Field> | |
| 81 | + <Field label="category"> | |
| 82 | + <select value={editing.category ?? ''} onChange={(e) => setEditing({ ...editing, category: e.target.value })} className={`${inputCls} w-full`}> | |
| 83 | + {CATEGORIES.map((c) => ( | |
| 84 | + <option key={c}>{c}</option> | |
| 85 | + ))} | |
| 86 | + </select> | |
| 87 | + </Field> | |
| 88 | + <Field label="provider"> | |
| 89 | + <input value={editing.provider ?? ''} onChange={(e) => setEditing({ ...editing, provider: e.target.value })} className={`${inputCls} w-full`} /> | |
| 90 | + </Field> | |
| 91 | + <Field label="service_id"> | |
| 92 | + <input value={editing.service_id ?? ''} onChange={(e) => setEditing({ ...editing, service_id: e.target.value })} className={`${inputCls} num w-full`} /> | |
| 93 | + </Field> | |
| 94 | + <Field label="country"> | |
| 95 | + <input value={editing.country ?? ''} maxLength={2} onChange={(e) => setEditing({ ...editing, country: e.target.value.toUpperCase() })} className={`${inputCls} num w-full`} /> | |
| 96 | + </Field> | |
| 97 | + <Field label="region"> | |
| 98 | + <input value={editing.region ?? ''} onChange={(e) => setEditing({ ...editing, region: e.target.value })} className={`${inputCls} num w-full`} /> | |
| 99 | + </Field> | |
| 100 | + <Field label="importance 1–5"> | |
| 101 | + <input type="number" min={1} max={5} value={editing.importance ?? 3} onChange={(e) => setEditing({ ...editing, importance: Number(e.target.value) })} className={`${inputCls} num w-full`} /> | |
| 102 | + </Field> | |
| 103 | + <Field label="tier 1–3"> | |
| 104 | + <input type="number" min={1} max={3} value={editing.tier ?? 2} onChange={(e) => setEditing({ ...editing, tier: Number(e.target.value) })} className={`${inputCls} num w-full`} /> | |
| 105 | + </Field> | |
| 106 | + <div className="col-span-2 flex gap-2 md:col-span-5"> | |
| 107 | + <button type="submit" className={btnPrimary} disabled={busy}> | |
| 108 | + {isNew ? 'Create' : 'Save'} | |
| 109 | + </button> | |
| 110 | + <button type="button" className={btnCls} onClick={() => setEditing(null)}> | |
| 111 | + Cancel | |
| 112 | + </button> | |
| 113 | + </div> | |
| 114 | + </form> | |
| 115 | + </Panel> | |
| 116 | + )} | |
| 117 | + <div className="scroll-x"> | |
| 118 | + <table className="tbl"> | |
| 119 | + <thead> | |
| 120 | + <tr> | |
| 121 | + <th>Target</th> | |
| 122 | + <th>Hostname</th> | |
| 123 | + <th>Category</th> | |
| 124 | + <th>Anchor</th> | |
| 125 | + <th className="r">Imp.</th> | |
| 126 | + <th className="r">Tier</th> | |
| 127 | + <th className="r">Pressure</th> | |
| 128 | + <th className="r">OK 1h</th> | |
| 129 | + <th>Enabled</th> | |
| 130 | + <th></th> | |
| 131 | + </tr> | |
| 132 | + </thead> | |
| 133 | + <tbody> | |
| 134 | + {rows.map((t) => ( | |
| 135 | + <tr key={t.target_id} className={t.enabled === false ? 'opacity-50' : ''}> | |
| 136 | + <td> | |
| 137 | + <span className="text-ink">{t.name}</span> <span className="num text-[10.5px] text-ink-3">{t.target_id}</span> | |
| 138 | + </td> | |
| 139 | + <td className="num text-ink-2">{t.hostname}</td> | |
| 140 | + <td className="text-ink-2">{t.category}</td> | |
| 141 | + <td className="num text-ink-2"> | |
| 142 | + {t.country} · {t.region} | |
| 143 | + </td> | |
| 144 | + <td className="num r">{t.importance}</td> | |
| 145 | + <td className="num r">{t.tier}</td> | |
| 146 | + <td className="r"> | |
| 147 | + <PNum value={t.pressure} /> | |
| 148 | + </td> | |
| 149 | + <td className="num r">{fmtPct(t.ok_ratio_1h, 1)}</td> | |
| 150 | + <td> | |
| 151 | + <button type="button" className="text-[11px] uppercase tracking-[0.1em]" style={{ color: t.enabled === false ? 'var(--ink-3)' : 'var(--ok)' }} onClick={() => void run(() => adminFetch(`/targets/${encodeURIComponent(t.target_id)}`, { method: 'PATCH', body: { enabled: t.enabled === false } }), t.enabled === false ? 'Enabled' : 'Disabled')}> | |
| 152 | + {t.enabled === false ? 'disabled' : 'enabled'} | |
| 153 | + </button> | |
| 154 | + </td> | |
| 155 | + <td className="r"> | |
| 156 | + <div className="flex justify-end gap-1"> | |
| 157 | + <button | |
| 158 | + type="button" | |
| 159 | + className="text-[11px] text-ink-2 hover:text-ink" | |
| 160 | + onClick={() => { | |
| 161 | + setEditing({ ...t }); | |
| 162 | + setIsNew(false); | |
| 163 | + }} | |
| 164 | + > | |
| 165 | + edit | |
| 166 | + </button> | |
| 167 | + <button | |
| 168 | + type="button" | |
| 169 | + className="text-[11px] text-bad/80 hover:text-bad" | |
| 170 | + onClick={() => { | |
| 171 | + if (confirm(`Delete target ${t.target_id}? History is kept; the target stops being measured.`)) void run(() => adminFetch(`/targets/${encodeURIComponent(t.target_id)}`, { method: 'DELETE' }), 'Target deleted'); | |
| 172 | + }} | |
| 173 | + > | |
| 174 | + delete | |
| 175 | + </button> | |
| 176 | + </div> | |
| 177 | + </td> | |
| 178 | + </tr> | |
| 179 | + ))} | |
| 180 | + </tbody> | |
| 181 | + </table> | |
| 182 | + </div> | |
| 183 | + <p className="num mt-2 text-[11px] text-ink-3">{fmtInt(rows.length)} targets</p> | |
| 184 | + <span className="hidden">{btnDanger}</span> | |
| 185 | + </AdminPage> | |
| 186 | + ); | |
| 187 | +} | |
added
apps/web/src/components/admin/shared.tsx
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useCallback, useEffect, useState, type ReactNode } from 'react'; | |
| 4 | +import { AdminError, adminFetch } from '@/lib/admin-fetch'; | |
| 5 | + | |
| 6 | +/** Fetch an admin resource with loading/error state and a `reload()` handle. */ | |
| 7 | +export function useAdmin<T>(path: string | null, params?: Record<string, string | number | undefined>) { | |
| 8 | + const [data, setData] = useState<T | null>(null); | |
| 9 | + const [err, setErr] = useState<string | null>(null); | |
| 10 | + const [loading, setLoading] = useState(false); | |
| 11 | + const key = JSON.stringify(params ?? {}); | |
| 12 | + const reload = useCallback(() => { | |
| 13 | + if (!path) return; | |
| 14 | + setLoading(true); | |
| 15 | + adminFetch<T>(path, { params: JSON.parse(key) as Record<string, string | number | undefined> }) | |
| 16 | + .then((d) => { | |
| 17 | + setData(d); | |
| 18 | + setErr(null); | |
| 19 | + }) | |
| 20 | + .catch((e: unknown) => setErr(e instanceof AdminError ? `${e.status} ${JSON.stringify(e.body ?? '')}` : String(e))) | |
| 21 | + .finally(() => setLoading(false)); | |
| 22 | + }, [path, key]); | |
| 23 | + useEffect(() => { | |
| 24 | + reload(); | |
| 25 | + }, [reload]); | |
| 26 | + return { data, err, loading, reload }; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function AdminPage({ title, desc, right, children }: { title: string; desc?: string; right?: ReactNode; children: ReactNode }) { | |
| 30 | + return ( | |
| 31 | + <div> | |
| 32 | + <header className="mb-4 flex flex-wrap items-end justify-between gap-3"> | |
| 33 | + <div> | |
| 34 | + <h1 className="text-[22px] font-medium tracking-tight">{title}</h1> | |
| 35 | + {desc && <p className="mt-0.5 max-w-[760px] text-[12.5px] text-ink-2">{desc}</p>} | |
| 36 | + </div> | |
| 37 | + {right} | |
| 38 | + </header> | |
| 39 | + {children} | |
| 40 | + </div> | |
| 41 | + ); | |
| 42 | +} | |
| 43 | + | |
| 44 | +export function Panel({ title, right, children, className = '' }: { title?: string; right?: ReactNode; children: ReactNode; className?: string }) { | |
| 45 | + return ( | |
| 46 | + <section className={`panel p-3 ${className}`}> | |
| 47 | + {(title || right) && ( | |
| 48 | + <header className="mb-2 flex items-baseline justify-between gap-3"> | |
| 49 | + {title && <h2 className="label">{title}</h2>} | |
| 50 | + {right && <div className="text-[11px] text-ink-2">{right}</div>} | |
| 51 | + </header> | |
| 52 | + )} | |
| 53 | + {children} | |
| 54 | + </section> | |
| 55 | + ); | |
| 56 | +} | |
| 57 | + | |
| 58 | +export function ErrorNote({ err }: { err: string | null }) { | |
| 59 | + if (!err) return null; | |
| 60 | + return <p className="my-2 text-[12px] text-bad">Admin API error: {err}</p>; | |
| 61 | +} | |
| 62 | + | |
| 63 | +export function Ok({ ok, label }: { ok: boolean; label?: string }) { | |
| 64 | + return ( | |
| 65 | + <span className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-[0.1em]" style={{ color: ok ? 'var(--ok)' : 'var(--bad)' }}> | |
| 66 | + <span className="size-1.5 rounded-full" style={{ background: ok ? 'var(--ok)' : 'var(--bad)' }} aria-hidden="true" /> | |
| 67 | + {label ?? (ok ? 'ok' : 'down')} | |
| 68 | + </span> | |
| 69 | + ); | |
| 70 | +} | |
| 71 | + | |
| 72 | +export const inputCls = 'h-8 rounded-[4px] border border-line bg-panel px-2 text-[12.5px] text-ink placeholder:text-ink-3'; | |
| 73 | +export const btnCls = 'h-8 rounded-[4px] border border-line px-3 text-[12px] text-ink hover:border-line-2 disabled:opacity-40'; | |
| 74 | +export const btnPrimary = 'h-8 rounded-[4px] bg-accent px-3 text-[12px] font-medium text-bg hover:opacity-90 disabled:opacity-40'; | |
| 75 | +export const btnDanger = 'h-8 rounded-[4px] border border-bad/60 px-3 text-[12px] text-bad hover:bg-bad/10 disabled:opacity-40'; | |
| 76 | + | |
| 77 | +export function Field({ label, children }: { label: string; children: ReactNode }) { | |
| 78 | + return ( | |
| 79 | + <label className="block min-w-0"> | |
| 80 | + <span className="label">{label}</span> | |
| 81 | + <div className="mt-1">{children}</div> | |
| 82 | + </label> | |
| 83 | + ); | |
| 84 | +} | |
| 85 | + | |
| 86 | +export function Toast({ msg }: { msg: string | null }) { | |
| 87 | + if (!msg) return null; | |
| 88 | + return ( | |
| 89 | + <p role="status" className="my-2 text-[12px] text-ok"> | |
| 90 | + {msg} | |
| 91 | + </p> | |
| 92 | + ); | |
| 93 | +} | |
added
apps/web/src/components/bgp/BgpLive.tsx
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { AnimatedNumber } from '@/components/ui/AnimatedNumber'; | |
| 4 | +import { fmt, fmtInt, fmtRatio } from '@/lib/format'; | |
| 5 | +import { useLive } from '@/lib/live'; | |
| 6 | +import { Time } from '@/lib/time'; | |
| 7 | +import type { BgpStats } from '@/lib/types'; | |
| 8 | + | |
| 9 | +export function BgpLive({ initial }: { initial: BgpStats }) { | |
| 10 | + const live = useLive((s) => s.bgp); | |
| 11 | + const updates = useLive((s) => s.updates); | |
| 12 | + const cur = live ?? initial; | |
| 13 | + const ratioColor = (r: number) => (r >= 3 ? 'var(--p-high)' : r >= 1.5 ? 'var(--p-elevated)' : 'var(--ink)'); | |
| 14 | + return ( | |
| 15 | + <div> | |
| 16 | + <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-[4px] border border-line bg-line sm:grid-cols-3 lg:grid-cols-6"> | |
| 17 | + {[ | |
| 18 | + ['updates / s', cur.updates_per_s, 1, undefined], | |
| 19 | + ['announcements / s', cur.announcements_per_s, 1, `baseline ${fmt(initial.baseline.announcements_per_s, 0)}`], | |
| 20 | + ['withdrawals / s', cur.withdrawals_per_s, 1, `baseline ${fmt(initial.baseline.withdrawals_per_s, 1)}`], | |
| 21 | + ['unique prefixes / min', initial.unique_prefixes_1m, 0, undefined], | |
| 22 | + ['unique origins / min', initial.unique_origins_1m, 0, undefined], | |
| 23 | + ['origin changes / min', initial.origin_changes_1m, 0, undefined], | |
| 24 | + ].map(([label, v, d, sub]) => ( | |
| 25 | + <div key={label as string} className="bg-panel px-3 py-2.5"> | |
| 26 | + <dt className="label truncate">{label as string}</dt> | |
| 27 | + <dd className="num mt-0.5 text-[22px] leading-none text-ink"> | |
| 28 | + <AnimatedNumber value={v as number} digits={d as number} duration={updates ? 500 : 0} /> | |
| 29 | + </dd> | |
| 30 | + {sub ? <dd className="num mt-0.5 text-[10.5px] text-ink-3">{sub as string}</dd> : null} | |
| 31 | + </div> | |
| 32 | + ))} | |
| 33 | + </dl> | |
| 34 | + <div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-1 text-[12.5px] text-ink-2"> | |
| 35 | + <span> | |
| 36 | + announcements vs baseline <span className="num" style={{ color: ratioColor(cur.ratio.announcements) }}>{fmtRatio(cur.ratio.announcements)}</span> | |
| 37 | + </span> | |
| 38 | + <span> | |
| 39 | + withdrawals vs baseline <span className="num" style={{ color: ratioColor(cur.ratio.withdrawals) }}>{fmtRatio(cur.ratio.withdrawals)}</span> | |
| 40 | + </span> | |
| 41 | + <span className={cur.fresh ? 'text-ok' : 'text-warn'}>{cur.fresh ? 'feed fresh' : 'feed stale — routing component frozen'}</span> | |
| 42 | + <span className="num text-ink-3"> | |
| 43 | + {fmtInt(initial.peers)} peers · <Time ts={cur.ts} style="time" /> | |
| 44 | + </span> | |
| 45 | + </div> | |
| 46 | + </div> | |
| 47 | + ); | |
| 48 | +} | |
added
apps/web/src/components/bgp/BgpSeriesChart.tsx
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { SeriesChart } from '@/components/charts/SeriesChart'; | |
| 4 | + | |
| 5 | +export function BgpSeriesChart({ series }: { series: { ts: string; announcements: number; withdrawals: number }[] }) { | |
| 6 | + return ( | |
| 7 | + <SeriesChart | |
| 8 | + lines={[ | |
| 9 | + { name: 'Announcements / min', color: '#5B8DEF', points: series.map((p) => ({ ts: p.ts, value: p.announcements })), area: true }, | |
| 10 | + { name: 'Withdrawals / min', color: '#E76F51', points: series.map((p) => ({ ts: p.ts, value: p.withdrawals })), yAxisIndex: 1, width: 1.5 }, | |
| 11 | + ]} | |
| 12 | + height={240} | |
| 13 | + yMax="auto" | |
| 14 | + bands={false} | |
| 15 | + y2={{ max: 'auto', name: 'withdrawals' }} | |
| 16 | + /> | |
| 17 | + ); | |
| 18 | +} | |
added
apps/web/src/components/history/HistoryViews.tsx
+185 −0
@@ -0,0 +1,185 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { IncidentRow, TYPE_LABEL } from '@/components/incidents/IncidentRow'; | |
| 3 | +import { Empty, PNum, Section } from '@/components/ui/primitives'; | |
| 4 | +import { MONTH_NAMES, fmt, fmtInt } from '@/lib/format'; | |
| 5 | +import { pressureColor } from '@/lib/pressure'; | |
| 6 | +import type { HistoryDay, HistoryMonthRow, HistorySummary, Incident } from '@/lib/types'; | |
| 7 | + | |
| 8 | +/** Calendar heat strip: one cell per day coloured by max pressure. */ | |
| 9 | +export function DayStrip({ days, year, month }: { days: HistoryDay[]; year: number; month: number }) { | |
| 10 | + const dim = new Date(Date.UTC(year, month, 0)).getUTCDate(); | |
| 11 | + const byDate = new Map(days.map((d) => [d.date, d])); | |
| 12 | + const firstDow = (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() + 6) % 7; // Monday first | |
| 13 | + return ( | |
| 14 | + <div> | |
| 15 | + <div className="grid grid-cols-7 gap-1 text-center text-[10px] text-ink-3"> | |
| 16 | + {['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((d, i) => ( | |
| 17 | + <span key={i}>{d}</span> | |
| 18 | + ))} | |
| 19 | + </div> | |
| 20 | + <div className="mt-1 grid grid-cols-7 gap-0.5 sm:gap-1"> | |
| 21 | + {Array.from({ length: firstDow }).map((_, i) => ( | |
| 22 | + <span key={`e${i}`} /> | |
| 23 | + ))} | |
| 24 | + {Array.from({ length: dim }, (_, i) => i + 1).map((n) => { | |
| 25 | + const date = `${year}-${String(month).padStart(2, '0')}-${String(n).padStart(2, '0')}`; | |
| 26 | + const d = byDate.get(date); | |
| 27 | + return ( | |
| 28 | + <div key={date} className="min-h-[44px] min-w-0 overflow-hidden rounded-[3px] border border-line p-1 sm:p-1.5" style={{ background: d ? pressureColor(d.max) + '33' : 'var(--neutral)' }} title={d ? `${date}: min ${fmt(d.min)} · avg ${fmt(d.avg)} · max ${fmt(d.max)} · ${d.events} events` : `${date}: not observed`}> | |
| 29 | + <div className="flex items-baseline justify-between"> | |
| 30 | + <span className="num text-[10.5px] text-ink-2">{n}</span> | |
| 31 | + {d && d.events > 0 && <span className="num text-[9.5px] text-high">{d.events}</span>} | |
| 32 | + </div> | |
| 33 | + {d && ( | |
| 34 | + <div className="num text-[13px] leading-tight" style={{ color: pressureColor(d.max) }}> | |
| 35 | + {fmt(d.max, 0)} | |
| 36 | + </div> | |
| 37 | + )} | |
| 38 | + </div> | |
| 39 | + ); | |
| 40 | + })} | |
| 41 | + </div> | |
| 42 | + <p className="mt-2 text-[10.5px] text-ink-3">Cell number = daily max pressure · red count = events opened that day · grey = not observed (observatory started 2026-09)</p> | |
| 43 | + </div> | |
| 44 | + ); | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function MonthStrip({ months }: { months: HistoryMonthRow[] }) { | |
| 48 | + if (!months.length) return <Empty>No month observed yet.</Empty>; | |
| 49 | + return ( | |
| 50 | + <ul className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6"> | |
| 51 | + {months.map((m) => { | |
| 52 | + const [y, mo] = m.month.split('-'); | |
| 53 | + return ( | |
| 54 | + <li key={m.month}> | |
| 55 | + <Link href={`/history/${y}/${Number(mo)}`} className="block rounded-[3px] border border-line p-3 hover:border-line-2" style={{ background: pressureColor(m.max) + '22' }}> | |
| 56 | + <div className="text-[12px] text-ink"> | |
| 57 | + {MONTH_NAMES[Number(mo) - 1]} {y} | |
| 58 | + </div> | |
| 59 | + <div className="num mt-1 text-[22px] leading-none" style={{ color: pressureColor(m.max) }}> | |
| 60 | + {fmt(m.max)} | |
| 61 | + </div> | |
| 62 | + <div className="num mt-1 text-[10.5px] text-ink-2"> | |
| 63 | + avg {fmt(m.avg)} · min {fmt(m.min)} · {fmtInt(m.events)} events | |
| 64 | + </div> | |
| 65 | + </Link> | |
| 66 | + </li> | |
| 67 | + ); | |
| 68 | + })} | |
| 69 | + </ul> | |
| 70 | + ); | |
| 71 | +} | |
| 72 | + | |
| 73 | +export function LargestGrid({ largest }: { largest: HistorySummary['largest'] }) { | |
| 74 | + const cats: [keyof HistorySummary['largest'], string][] = [ | |
| 75 | + ['pressure', 'Largest by pressure'], | |
| 76 | + ['routing', 'Largest routing event'], | |
| 77 | + ['dns', 'Largest DNS event'], | |
| 78 | + ['latency', 'Largest latency event'], | |
| 79 | + ]; | |
| 80 | + return ( | |
| 81 | + <ul className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-[4px] border border-line bg-line sm:grid-cols-2 lg:grid-cols-4"> | |
| 82 | + {cats.map(([k, label]) => { | |
| 83 | + const inc = largest[k]; | |
| 84 | + return ( | |
| 85 | + <li key={k} className="bg-panel p-3"> | |
| 86 | + <div className="label">{label}</div> | |
| 87 | + {inc ? ( | |
| 88 | + <> | |
| 89 | + <Link href={`/event/${inc.slug}`} className="mt-1 block text-[13px] text-ink hover:text-accent"> | |
| 90 | + {inc.title} | |
| 91 | + </Link> | |
| 92 | + <div className="num mt-1 flex items-baseline gap-2 text-[11px] text-ink-2"> | |
| 93 | + <PNum value={inc.peak_pressure} className="text-[18px]" /> | |
| 94 | + <span>{TYPE_LABEL[inc.type]}</span> | |
| 95 | + </div> | |
| 96 | + </> | |
| 97 | + ) : ( | |
| 98 | + <p className="mt-1 text-[12px] text-ink-3">none in range</p> | |
| 99 | + )} | |
| 100 | + </li> | |
| 101 | + ); | |
| 102 | + })} | |
| 103 | + </ul> | |
| 104 | + ); | |
| 105 | +} | |
| 106 | + | |
| 107 | +export function TopLists({ s }: { s: HistorySummary }) { | |
| 108 | + return ( | |
| 109 | + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-2"> | |
| 110 | + <Section label="Most affected ASNs" className="min-w-0"> | |
| 111 | + {s.top_asns.length ? ( | |
| 112 | + <table className="tbl"> | |
| 113 | + <thead> | |
| 114 | + <tr> | |
| 115 | + <th>ASN</th> | |
| 116 | + <th className="r">Events</th> | |
| 117 | + <th className="r">Max pressure</th> | |
| 118 | + </tr> | |
| 119 | + </thead> | |
| 120 | + <tbody> | |
| 121 | + {s.top_asns.map((a) => ( | |
| 122 | + <tr key={a.asn}> | |
| 123 | + <td> | |
| 124 | + <Link href={`/asn/${a.asn}`} className="text-ink hover:text-accent"> | |
| 125 | + <span className="num">AS{a.asn}</span> {a.name} | |
| 126 | + </Link> | |
| 127 | + </td> | |
| 128 | + <td className="num r">{fmtInt(a.events)}</td> | |
| 129 | + <td className="r"> | |
| 130 | + <PNum value={a.max_pressure} /> | |
| 131 | + </td> | |
| 132 | + </tr> | |
| 133 | + ))} | |
| 134 | + </tbody> | |
| 135 | + </table> | |
| 136 | + ) : ( | |
| 137 | + <Empty /> | |
| 138 | + )} | |
| 139 | + </Section> | |
| 140 | + <Section label="Most affected regions" className="min-w-0"> | |
| 141 | + {s.top_regions.length ? ( | |
| 142 | + <table className="tbl"> | |
| 143 | + <thead> | |
| 144 | + <tr> | |
| 145 | + <th>Region</th> | |
| 146 | + <th className="r">Events</th> | |
| 147 | + <th className="r">Max</th> | |
| 148 | + <th className="r">Hours elevated</th> | |
| 149 | + </tr> | |
| 150 | + </thead> | |
| 151 | + <tbody> | |
| 152 | + {s.top_regions.map((r) => ( | |
| 153 | + <tr key={r.id}> | |
| 154 | + <td> | |
| 155 | + <Link href={`/internet/${r.id}`} className="text-ink hover:text-accent"> | |
| 156 | + {r.name} | |
| 157 | + </Link> | |
| 158 | + </td> | |
| 159 | + <td className="num r">{fmtInt(r.events)}</td> | |
| 160 | + <td className="r"> | |
| 161 | + <PNum value={r.max_pressure} /> | |
| 162 | + </td> | |
| 163 | + <td className="num r text-ink-2">{fmt(r.hours_elevated)}</td> | |
| 164 | + </tr> | |
| 165 | + ))} | |
| 166 | + </tbody> | |
| 167 | + </table> | |
| 168 | + ) : ( | |
| 169 | + <Empty /> | |
| 170 | + )} | |
| 171 | + </Section> | |
| 172 | + </div> | |
| 173 | + ); | |
| 174 | +} | |
| 175 | + | |
| 176 | +export function TopEvents({ events }: { events: Incident[] }) { | |
| 177 | + if (!events.length) return <Empty>No event in this range.</Empty>; | |
| 178 | + return ( | |
| 179 | + <ul className="divide-y divide-line"> | |
| 180 | + {events.map((e) => ( | |
| 181 | + <IncidentRow key={e.event_id} inc={e} compact /> | |
| 182 | + ))} | |
| 183 | + </ul> | |
| 184 | + ); | |
| 185 | +} | |
added
apps/web/src/components/incidents/IncidentSeriesChart.tsx
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { SeriesChart } from '@/components/charts/SeriesChart'; | |
| 4 | +import type { IncidentDetail } from '@/lib/types'; | |
| 5 | + | |
| 6 | +export function IncidentSeriesChart({ series, timeline }: { series: IncidentDetail['series']; timeline: IncidentDetail['timeline'] }) { | |
| 7 | + return ( | |
| 8 | + <SeriesChart | |
| 9 | + lines={[ | |
| 10 | + { name: 'Incident scope', color: '#E76F51', points: series.points.map((p) => ({ ts: p.ts, value: p.pressure })), area: true, width: 1.5 }, | |
| 11 | + { name: 'Global pressure', color: '#8B98A5', points: series.points.map((p) => ({ ts: p.ts, value: p.global_pressure })), dashed: true }, | |
| 12 | + ]} | |
| 13 | + height={260} | |
| 14 | + markLines={timeline.map((t) => ({ ts: t.ts, label: t.status }))} | |
| 15 | + /> | |
| 16 | + ); | |
| 17 | +} | |
added
apps/web/src/components/service/VendorIndicator.tsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +import type { ServiceRow } from '@/lib/types'; | |
| 2 | + | |
| 3 | +/** Vendor status-page indicator (none/minor/major/critical) or "no connector" when we have no source for that provider. */ | |
| 4 | +export function VendorIndicator({ v }: { v: ServiceRow['vendor_status'] }) { | |
| 5 | + if (!v) return <span className="text-[11px] text-ink-3">no connector</span>; | |
| 6 | + const color = v.indicator === 'none' ? 'var(--ok)' : v.indicator === 'minor' ? 'var(--warn)' : 'var(--bad)'; | |
| 7 | + return ( | |
| 8 | + <span className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-[0.08em]" style={{ color }}> | |
| 9 | + <span className="size-1.5 rounded-full" style={{ background: color }} aria-hidden="true" /> | |
| 10 | + {v.indicator} | |
| 11 | + {v.incidents ? <span className="num text-ink-2">({v.incidents})</span> : null} | |
| 12 | + </span> | |
| 13 | + ); | |
| 14 | +} | |
added
apps/web/src/components/targets/TargetsRegistry.tsx
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useMemo, useState } from 'react'; | |
| 5 | +import { PNum } from '@/components/ui/primitives'; | |
| 6 | +import { fmtInt, fmtMs, fmtPct } from '@/lib/format'; | |
| 7 | +import type { Target } from '@/lib/types'; | |
| 8 | + | |
| 9 | +type SortKey = 'pressure' | 'name' | 'ok_ratio_1h' | 'ttfb_ms_median_1h' | 'importance'; | |
| 10 | + | |
| 11 | +export function TargetsRegistry({ targets, categories, initialQuery, initialCategory }: { targets: Target[]; categories: string[]; initialQuery: string; initialCategory: string }) { | |
| 12 | + const [q, setQ] = useState(initialQuery); | |
| 13 | + const [cat, setCat] = useState(initialCategory); | |
| 14 | + const [sort, setSort] = useState<SortKey>('pressure'); | |
| 15 | + const [dir, setDir] = useState<1 | -1>(-1); | |
| 16 | + | |
| 17 | + const rows = useMemo(() => { | |
| 18 | + const s = q.trim().toLowerCase(); | |
| 19 | + return targets | |
| 20 | + .filter((t) => (!cat || t.category === cat) && (!s || t.hostname.toLowerCase().includes(s) || t.name.toLowerCase().includes(s) || t.provider.toLowerCase().includes(s) || t.target_id.includes(s))) | |
| 21 | + .sort((a, b) => { | |
| 22 | + const av = a[sort]; | |
| 23 | + const bv = b[sort]; | |
| 24 | + if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir; | |
| 25 | + return ((av as number) - (bv as number)) * dir; | |
| 26 | + }); | |
| 27 | + }, [targets, q, cat, sort, dir]); | |
| 28 | + | |
| 29 | + const th = (key: SortKey, label: string, right = false) => ( | |
| 30 | + <th className={right ? 'r' : ''}> | |
| 31 | + <button | |
| 32 | + type="button" | |
| 33 | + onClick={() => { | |
| 34 | + if (sort === key) setDir((d) => (d === 1 ? -1 : 1)); | |
| 35 | + else { | |
| 36 | + setSort(key); | |
| 37 | + setDir(key === 'name' ? 1 : -1); | |
| 38 | + } | |
| 39 | + }} | |
| 40 | + className={`uppercase tracking-[0.1em] hover:text-ink ${sort === key ? 'text-ink' : ''}`} | |
| 41 | + aria-sort={sort === key ? (dir === 1 ? 'ascending' : 'descending') : 'none'} | |
| 42 | + > | |
| 43 | + {label} | |
| 44 | + {sort === key ? (dir === 1 ? ' ↑' : ' ↓') : ''} | |
| 45 | + </button> | |
| 46 | + </th> | |
| 47 | + ); | |
| 48 | + | |
| 49 | + return ( | |
| 50 | + <div> | |
| 51 | + <div className="flex flex-wrap items-center gap-2"> | |
| 52 | + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="hostname, provider, id…" className="h-8 w-full max-w-[320px] rounded-[4px] border border-line bg-panel px-2 text-[13px] text-ink placeholder:text-ink-3" aria-label="Filter targets" /> | |
| 53 | + <div className="scroll-x flex gap-1" role="radiogroup" aria-label="Category"> | |
| 54 | + <button type="button" role="radio" aria-checked={!cat} onClick={() => setCat('')} className={`whitespace-nowrap rounded-[3px] border px-2 py-1 text-[11px] ${!cat ? 'border-line-2 bg-panel-2 text-ink' : 'border-line text-ink-2 hover:text-ink'}`}> | |
| 55 | + all | |
| 56 | + </button> | |
| 57 | + {categories.map((c) => ( | |
| 58 | + <button key={c} type="button" role="radio" aria-checked={cat === c} onClick={() => setCat(c)} className={`whitespace-nowrap rounded-[3px] border px-2 py-1 text-[11px] ${cat === c ? 'border-line-2 bg-panel-2 text-ink' : 'border-line text-ink-2 hover:text-ink'}`}> | |
| 59 | + {c} | |
| 60 | + </button> | |
| 61 | + ))} | |
| 62 | + </div> | |
| 63 | + <span className="num ml-auto text-[11px] text-ink-3">{fmtInt(rows.length)} shown</span> | |
| 64 | + </div> | |
| 65 | + <div className="scroll-x -mx-3 mt-3 px-3"> | |
| 66 | + <table className="tbl"> | |
| 67 | + <thead> | |
| 68 | + <tr> | |
| 69 | + {th('name', 'Target')} | |
| 70 | + <th className="hidden md:table-cell">Hostname</th> | |
| 71 | + <th className="hidden sm:table-cell">Category</th> | |
| 72 | + <th className="hidden lg:table-cell">Service</th> | |
| 73 | + <th className="hidden sm:table-cell">Anchor</th> | |
| 74 | + {th('importance', 'Imp.', true)} | |
| 75 | + <th className="r hidden md:table-cell">Tier</th> | |
| 76 | + {th('pressure', 'Pressure', true)} | |
| 77 | + {th('ok_ratio_1h', 'OK 1h', true)} | |
| 78 | + {th('ttfb_ms_median_1h', 'TTFB', true)} | |
| 79 | + </tr> | |
| 80 | + </thead> | |
| 81 | + <tbody> | |
| 82 | + {rows.map((t) => ( | |
| 83 | + <tr key={t.target_id}> | |
| 84 | + <td> | |
| 85 | + <span className="text-ink">{t.name}</span> | |
| 86 | + </td> | |
| 87 | + <td className="num hidden text-ink-2 md:table-cell">{t.hostname}</td> | |
| 88 | + <td className="hidden text-ink-2 sm:table-cell">{t.category}</td> | |
| 89 | + <td className="hidden lg:table-cell"> | |
| 90 | + <Link href={`/service/${t.service_id}`} className="text-ink-2 hover:text-accent"> | |
| 91 | + {t.service_id} | |
| 92 | + </Link> | |
| 93 | + </td> | |
| 94 | + <td className="hidden sm:table-cell"> | |
| 95 | + <Link href={`/country/${t.country.toLowerCase()}`} className="text-ink-2 hover:text-accent"> | |
| 96 | + {t.country} | |
| 97 | + </Link>{' '} | |
| 98 | + <Link href={`/internet/${t.region}`} className="text-ink-3 hover:text-accent"> | |
| 99 | + {t.region} | |
| 100 | + </Link> | |
| 101 | + </td> | |
| 102 | + <td className="num r text-ink-2">{t.importance}</td> | |
| 103 | + <td className="num r hidden text-ink-2 md:table-cell">{t.tier}</td> | |
| 104 | + <td className="r"> | |
| 105 | + <PNum value={t.pressure} /> | |
| 106 | + </td> | |
| 107 | + <td className="num r" style={{ color: t.ok_ratio_1h < 0.98 ? 'var(--p-high)' : undefined }}> | |
| 108 | + {fmtPct(t.ok_ratio_1h, 1)} | |
| 109 | + </td> | |
| 110 | + <td className="num r text-ink-2">{fmtMs(t.ttfb_ms_median_1h)}</td> | |
| 111 | + </tr> | |
| 112 | + ))} | |
| 113 | + </tbody> | |
| 114 | + </table> | |
| 115 | + </div> | |
| 116 | + </div> | |
| 117 | + ); | |
| 118 | +} | |
added
deploy/bin/backup.sh
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Nightly backup, runs ON BHS64b (installed as a cron by deploy/bin/backup.sh install from the laptop): | |
| 3 | +# - Postgres: pg_dump (registry, config, events, annotations) | |
| 4 | +# - ClickHouse: the irreplaceable tables (pressure_history, bgp_stats_10s, signal_features last 7 d) as Native+zstd | |
| 5 | +# - copies to BHS128:/srv/backups/internetpressure (off-node, same DC), keeps 14 days locally | |
| 6 | +# | |
| 7 | +# deploy/bin/backup.sh install (from the laptop) copy this script to the server + cron 04:10 UTC | |
| 8 | +# deploy/bin/backup.sh run (on the server) perform a backup now | |
| 9 | +set -euo pipefail | |
| 10 | + | |
| 11 | +if [[ "${1:-}" == "install" ]]; then | |
| 12 | + source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" | |
| 13 | + scp -q "$REPO_ROOT/deploy/bin/backup.sh" "$IP_SERVER:/opt/internetpressure/deploy/bin/backup.sh" | |
| 14 | + rssh 'chmod +x /opt/internetpressure/deploy/bin/backup.sh; sudo mkdir -p /var/backups/internetpressure && sudo chown $(id -un) /var/backups/internetpressure; | |
| 15 | + (crontab -l 2>/dev/null | grep -v internetpressure/deploy/bin/backup.sh; echo "10 4 * * * /opt/internetpressure/deploy/bin/backup.sh run >> /var/backups/internetpressure/backup.log 2>&1") | crontab -; crontab -l | grep backup' | |
| 16 | + ssh -n BHS128 'sudo mkdir -p /srv/backups/internetpressure && sudo chown ubuntu /srv/backups/internetpressure' | |
| 17 | + ok "backup cron installed" | |
| 18 | + exit 0 | |
| 19 | +fi | |
| 20 | + | |
| 21 | +[[ "${1:-}" == "run" ]] || { echo "usage: backup.sh install|run" >&2; exit 2; } | |
| 22 | +cd /opt/internetpressure | |
| 23 | +DEST=/var/backups/internetpressure | |
| 24 | +STAMP=$(date -u +%Y%m%d) | |
| 25 | +mkdir -p "$DEST/$STAMP" | |
| 26 | +COMPOSE="docker compose -f infra/compose.yml --env-file deploy/.env" | |
| 27 | +CHPW=$(grep '^CLICKHOUSE_PASSWORD=' deploy/.env | cut -d= -f2) | |
| 28 | + | |
| 29 | +echo "[$(date -u +%FT%TZ)] postgres" | |
| 30 | +$COMPOSE exec -T postgres pg_dump -U ip -d ip --no-owner | gzip -6 > "$DEST/$STAMP/postgres.sql.gz" | |
| 31 | + | |
| 32 | +for t in pressure_history bgp_stats_10s bgp_origin_1m events_placeholder; do | |
| 33 | + [[ "$t" == "events_placeholder" ]] && continue | |
| 34 | + echo "[$(date -u +%FT%TZ)] clickhouse $t" | |
| 35 | + $COMPOSE exec -T clickhouse clickhouse-client -u ip --password "$CHPW" -d ip \ | |
| 36 | + -q "SELECT * FROM $t FORMAT Native" | zstd -q -3 -o "$DEST/$STAMP/$t.native.zst" -f | |
| 37 | +done | |
| 38 | +echo "[$(date -u +%FT%TZ)] clickhouse signal_features (7 d)" | |
| 39 | +$COMPOSE exec -T clickhouse clickhouse-client -u ip --password "$CHPW" -d ip \ | |
| 40 | + -q "SELECT * FROM signal_features WHERE ts >= now() - INTERVAL 7 DAY FORMAT Native" | zstd -q -3 -o "$DEST/$STAMP/signal_features_7d.native.zst" -f | |
| 41 | + | |
| 42 | +du -sh "$DEST/$STAMP" | |
| 43 | +rsync -a --delete-after "$DEST/" BHS128:/srv/backups/internetpressure/ 2>/dev/null \ | |
| 44 | + || rsync -a "$DEST/" ubuntu@51.161.112.69:/srv/backups/internetpressure/ || echo "off-node copy failed" | |
| 45 | +find "$DEST" -maxdepth 1 -type d -name '20*' -mtime +14 -exec rm -rf {} + | |
| 46 | +echo "[$(date -u +%FT%TZ)] done" | |
| 47 | ||